mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-30 17:53:31 -04:00 
			
		
		
		
	feat: ✨ add bulk actions service and routes (WIP) (#747)
* feat(frontend): ✨ Group level recipe data management * feat(backend): ✨ add bulk actions service and routes Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
		
							
								
								
									
										59
									
								
								frontend/api/class-interfaces/recipe-bulk-actions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								frontend/api/class-interfaces/recipe-bulk-actions.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| import { BaseAPI } from "./_base"; | ||||
|  | ||||
| interface BasePayload { | ||||
|   recipes: string[]; | ||||
| } | ||||
|  | ||||
| type exportType = "json"; | ||||
|  | ||||
| interface RecipeBulkDelete extends BasePayload {} | ||||
|  | ||||
| interface RecipeBulkExport extends BasePayload { | ||||
|   exportType: exportType; | ||||
| } | ||||
|  | ||||
| interface RecipeBulkCategorize extends BasePayload { | ||||
|   categories: string[]; | ||||
| } | ||||
|  | ||||
| interface RecipeBulkTag extends BasePayload { | ||||
|   tags: string[]; | ||||
| } | ||||
|  | ||||
| interface BulkActionError { | ||||
|   recipe: string; | ||||
|   error: string; | ||||
| } | ||||
|  | ||||
| interface BulkActionResponse { | ||||
|   success: boolean; | ||||
|   message: string; | ||||
|   errors: BulkActionError[]; | ||||
| } | ||||
|  | ||||
| const prefix = "/api"; | ||||
|  | ||||
| const routes = { | ||||
|   bulkExport: prefix + "/recipes/bulk-actions/export", | ||||
|   bulkCategorize: prefix + "/recipes/bulk-actions/categorize", | ||||
|   bulkTag: prefix + "/recipes/bulk-actions/tag", | ||||
|   bulkDelete: prefix + "/recipes/bulk-actions/delete", | ||||
| }; | ||||
|  | ||||
| export class BulkActionsAPI extends BaseAPI { | ||||
|   async bulkExport(payload: RecipeBulkExport) { | ||||
|     return await this.requests.post<BulkActionResponse>(routes.bulkExport, payload); | ||||
|   } | ||||
|  | ||||
|   async bulkCategorize(payload: RecipeBulkCategorize) { | ||||
|     return await this.requests.post<BulkActionResponse>(routes.bulkCategorize, payload); | ||||
|   } | ||||
|  | ||||
|   async bulkTag(payload: RecipeBulkTag) { | ||||
|     return await this.requests.post<BulkActionResponse>(routes.bulkTag, payload); | ||||
|   } | ||||
|  | ||||
|   async bulkDelete(payload: RecipeBulkDelete) { | ||||
|     return await this.requests.post<BulkActionResponse>(routes.bulkDelete, payload); | ||||
|   } | ||||
| } | ||||
| @@ -16,6 +16,7 @@ import { AdminAboutAPI } from "./class-interfaces/admin-about"; | ||||
| import { RegisterAPI } from "./class-interfaces/user-registration"; | ||||
| import { MealPlanAPI } from "./class-interfaces/group-mealplan"; | ||||
| import { EmailAPI } from "./class-interfaces/email"; | ||||
| import { BulkActionsAPI } from "./class-interfaces/recipe-bulk-actions"; | ||||
| import { ApiRequestInstance } from "~/types/api"; | ||||
|  | ||||
| class AdminAPI { | ||||
| @@ -52,6 +53,7 @@ class Api { | ||||
|   public register: RegisterAPI; | ||||
|   public mealplans: MealPlanAPI; | ||||
|   public email: EmailAPI; | ||||
|   public bulk: BulkActionsAPI; | ||||
|  | ||||
|   // Utils | ||||
|   public upload: UploadFile; | ||||
| @@ -86,6 +88,7 @@ class Api { | ||||
|     this.utils = new UtilsAPI(requests); | ||||
|  | ||||
|     this.email = new EmailAPI(requests); | ||||
|     this.bulk = new BulkActionsAPI(requests); | ||||
|  | ||||
|     Object.freeze(this); | ||||
|     Api.instance = this; | ||||
|   | ||||
							
								
								
									
										154
									
								
								frontend/components/Domain/Recipe/RecipeDataTable.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								frontend/components/Domain/Recipe/RecipeDataTable.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | ||||
| <template> | ||||
|   <v-data-table | ||||
|     v-model="selected" | ||||
|     item-key="id" | ||||
|     show-select | ||||
|     :headers="headers" | ||||
|     :items="recipes" | ||||
|     :items-per-page="15" | ||||
|     class="elevation-0" | ||||
|     @input="setValue(selected)" | ||||
|   > | ||||
|     <template #body.preappend> | ||||
|       <tr> | ||||
|         <td></td> | ||||
|         <td>Hello</td> | ||||
|         <td colspan="4"></td> | ||||
|       </tr> | ||||
|     </template> | ||||
|     <template #item.tags="{ item }"> | ||||
|       <RecipeChip small :items="item.tags" /> | ||||
|     </template> | ||||
|     <template #item.recipeCategory="{ item }"> | ||||
|       <RecipeChip small :items="item.recipeCategory" /> | ||||
|     </template> | ||||
|     <template #item.userId="{ item }"> | ||||
|       <v-list-item class="justify-start"> | ||||
|         <v-list-item-avatar> | ||||
|           <img src="https://i.pravatar.cc/300" alt="John" /> | ||||
|         </v-list-item-avatar> | ||||
|         <v-list-item-content> | ||||
|           <v-list-item-title v-text="getMember(item.userId)"></v-list-item-title> | ||||
|         </v-list-item-content> | ||||
|       </v-list-item> | ||||
|     </template> | ||||
|   </v-data-table> | ||||
| </template> | ||||
|      | ||||
| <script lang="ts"> | ||||
| import { computed, defineComponent, onMounted, ref } from "@nuxtjs/composition-api"; | ||||
| import RecipeChip from "./RecipeChips.vue"; | ||||
| import { Recipe } from "~/types/api-types/recipe"; | ||||
| import { useApiSingleton } from "~/composables/use-api"; | ||||
| import { UserOut } from "~/types/api-types/user"; | ||||
|  | ||||
| const INPUT_EVENT = "input"; | ||||
|  | ||||
| interface ShowHeaders { | ||||
|   id: Boolean; | ||||
|   owner: Boolean; | ||||
|   tags: Boolean; | ||||
|   categories: Boolean; | ||||
|   recipeYield: Boolean; | ||||
|   dateAdded: Boolean; | ||||
| } | ||||
|  | ||||
| export default defineComponent({ | ||||
|   components: { RecipeChip }, | ||||
|   props: { | ||||
|     value: { | ||||
|       type: Array, | ||||
|       required: false, | ||||
|       default: () => [], | ||||
|     }, | ||||
|     recipes: { | ||||
|       type: Array as () => Recipe[], | ||||
|       default: () => [], | ||||
|     }, | ||||
|     showHeaders: { | ||||
|       type: Object as () => ShowHeaders, | ||||
|       required: false, | ||||
|       default: () => { | ||||
|         return { | ||||
|           id: true, | ||||
|           owner: false, | ||||
|           tags: true, | ||||
|           categories: true, | ||||
|           recipeYield: true, | ||||
|           dateAdded: true, | ||||
|         }; | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|   setup(props, context) { | ||||
|     function setValue(value: Recipe[]) { | ||||
|       context.emit(INPUT_EVENT, value); | ||||
|     } | ||||
|  | ||||
|     const show = props.showHeaders; | ||||
|     const headers = computed(() => { | ||||
|       const hdrs = []; | ||||
|  | ||||
|       if (show.id) { | ||||
|         hdrs.push({ text: "Id", value: "id" }); | ||||
|       } | ||||
|       if (show.owner) { | ||||
|         hdrs.push({ text: "Owner", value: "userId", align: "center" }); | ||||
|       } | ||||
|       hdrs.push({ text: "Name", value: "name" }); | ||||
|       if (show.categories) { | ||||
|         hdrs.push({ text: "Categories", value: "recipeCategory" }); | ||||
|       } | ||||
|  | ||||
|       if (show.tags) { | ||||
|         hdrs.push({ text: "Tags", value: "tags" }); | ||||
|       } | ||||
|       if (show.recipeYield) { | ||||
|         hdrs.push({ text: "Yield", value: "recipeYield" }); | ||||
|       } | ||||
|       if (show.dateAdded) { | ||||
|         hdrs.push({ text: "Date Added", value: "dateAdded" }); | ||||
|       } | ||||
|       return hdrs; | ||||
|     }); | ||||
|  | ||||
|     // ============ | ||||
|     // Group Members | ||||
|     const api = useApiSingleton(); | ||||
|     const members = ref<UserOut[] | null[]>([]); | ||||
|  | ||||
|     async function refreshMembers() { | ||||
|       const { data } = await api.groups.fetchMembers(); | ||||
|       if (data) { | ||||
|         members.value = data; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     onMounted(() => { | ||||
|       refreshMembers(); | ||||
|     }); | ||||
|  | ||||
|     function getMember(id: number) { | ||||
|       if (members.value[0]) { | ||||
|         // @ts-ignore | ||||
|         return members.value.find((m) => m.id === id).username; | ||||
|       } | ||||
|  | ||||
|       return "None"; | ||||
|     } | ||||
|  | ||||
|     return { setValue, headers, members, getMember }; | ||||
|   }, | ||||
|  | ||||
|   data() { | ||||
|     return { | ||||
|       selected: [], | ||||
|     }; | ||||
|   }, | ||||
|   watch: { | ||||
|     value(val) { | ||||
|       this.selected = val; | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
| @@ -132,7 +132,6 @@ export default defineComponent({ | ||||
|       this.submitted = true; | ||||
|     }, | ||||
|     open() { | ||||
|       console.log("Open Dialog"); | ||||
|       this.dialog = true; | ||||
|     }, | ||||
|     close() { | ||||
|   | ||||
| @@ -1,17 +1,18 @@ | ||||
|   <template> | ||||
|   <v-menu offset-y> | ||||
|     <template #activator="{ on, attrs }"> | ||||
|       <v-btn color="primary" v-bind="attrs" :class="btnClass" v-on="on"> | ||||
|       <v-btn color="primary" v-bind="attrs" :class="btnClass" :disabled="disabled" v-on="on"> | ||||
|         <v-icon v-if="activeObj.icon" left> | ||||
|           {{ activeObj.icon }} | ||||
|         </v-icon> | ||||
|         {{ activeObj.text }} | ||||
|         {{ mode === MODES.model ? activeObj.text : btnText }} | ||||
|         <v-icon right> | ||||
|           {{ $globals.icons.chevronDown }} | ||||
|         </v-icon> | ||||
|       </v-btn> | ||||
|     </template> | ||||
|     <v-list> | ||||
|     <!--  Model --> | ||||
|     <v-list v-if="mode === MODES.model" dense> | ||||
|       <v-list-item-group v-model="itemGroup"> | ||||
|         <v-list-item v-for="(item, index) in items" :key="index" @click="setValue(item)"> | ||||
|           <v-list-item-icon v-if="item.icon"> | ||||
| @@ -21,6 +22,26 @@ | ||||
|         </v-list-item> | ||||
|       </v-list-item-group> | ||||
|     </v-list> | ||||
|     <!--  Event --> | ||||
|     <v-list v-else-if="mode === MODES.link" dense> | ||||
|       <v-list-item-group v-model="itemGroup"> | ||||
|         <v-list-item v-for="(item, index) in items" :key="index" :to="item.to"> | ||||
|           <v-list-item-icon v-if="item.icon"> | ||||
|             <v-icon>{{ item.icon }}</v-icon> | ||||
|           </v-list-item-icon> | ||||
|           <v-list-item-title>{{ item.text }}</v-list-item-title> | ||||
|         </v-list-item> | ||||
|       </v-list-item-group> | ||||
|     </v-list> | ||||
|     <!--  Event --> | ||||
|     <v-list v-else-if="mode === MODES.event" dense> | ||||
|       <v-list-item v-for="(item, index) in items" :key="index" @click="$emit(item.event)"> | ||||
|         <v-list-item-icon v-if="item.icon"> | ||||
|           <v-icon>{{ item.icon }}</v-icon> | ||||
|         </v-list-item-icon> | ||||
|         <v-list-item-title>{{ item.text }}</v-list-item-title> | ||||
|       </v-list-item> | ||||
|     </v-list> | ||||
|   </v-menu> | ||||
| </template> | ||||
|  | ||||
| @@ -29,12 +50,28 @@ import { defineComponent, ref } from "@nuxtjs/composition-api"; | ||||
|  | ||||
| const INPUT_EVENT = "input"; | ||||
|  | ||||
| type modes = "model" | "link" | "event"; | ||||
|  | ||||
| const MODES = { | ||||
|   model: "model", | ||||
|   link: "link", | ||||
|   event: "event", | ||||
| }; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   props: { | ||||
|     mode: { | ||||
|       type: String as () => modes, | ||||
|       default: "model", | ||||
|     }, | ||||
|     items: { | ||||
|       type: Array, | ||||
|       required: true, | ||||
|     }, | ||||
|     disabled: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|     }, | ||||
|     value: { | ||||
|       type: String, | ||||
|       required: false, | ||||
| @@ -45,6 +82,11 @@ export default defineComponent({ | ||||
|       required: false, | ||||
|       default: "", | ||||
|     }, | ||||
|     btnText: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: "Actions", | ||||
|     }, | ||||
|   }, | ||||
|   setup(props, context) { | ||||
|     const activeObj = ref({ | ||||
| @@ -70,6 +112,7 @@ export default defineComponent({ | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       MODES, | ||||
|       activeObj, | ||||
|       itemGroup, | ||||
|       setValue, | ||||
|   | ||||
| @@ -118,5 +118,5 @@ export const useRecipes = (all = false, fetchRecipes = true) => { | ||||
|     getAllRecipes(); | ||||
|   } | ||||
|  | ||||
|   return { getAllRecipes, assignSorted }; | ||||
|   return { getAllRecipes, assignSorted, refreshRecipes }; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										275
									
								
								frontend/pages/user/group/recipe-data/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										275
									
								
								frontend/pages/user/group/recipe-data/index.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,275 @@ | ||||
| <template> | ||||
|   <v-container fluid> | ||||
|     <!-- Dialog Object --> | ||||
|     <BaseDialog | ||||
|       ref="domDialog" | ||||
|       width="650px" | ||||
|       :icon="dialog.icon" | ||||
|       :title="dialog.title" | ||||
|       submit-text="Submit" | ||||
|       @submit="dialog.callback" | ||||
|     > | ||||
|       <v-card-text v-if="dialog.mode == MODES.tag"> | ||||
|         <RecipeCategoryTagSelector v-model="toSetTags" :tag-selector="true" /> | ||||
|       </v-card-text> | ||||
|       <v-card-text v-else-if="dialog.mode == MODES.category"> | ||||
|         <RecipeCategoryTagSelector v-model="toSetCategories" /> | ||||
|       </v-card-text> | ||||
|       <v-card-text v-else-if="dialog.mode == MODES.delete"> | ||||
|         Are you sure you want to delete the following recipes? | ||||
|         <ul class="pt-5"> | ||||
|           <li v-for="recipe in selected" :key="recipe.slug">{{ recipe.name }}</li> | ||||
|         </ul> | ||||
|       </v-card-text> | ||||
|       <v-card-text v-else-if="dialog.mode == MODES.export"> TODO: Export Stuff Here </v-card-text> | ||||
|     </BaseDialog> | ||||
|     <BasePageTitle divider> | ||||
|       <template #header> | ||||
|         <v-img max-height="125" max-width="125" :src="require('~/static/svgs/manage-recipes.svg')"></v-img> | ||||
|       </template> | ||||
|       <template #title> Recipe Data Management </template> | ||||
|       Lorem ipsum dolor, sit amet consectetur adipisicing elit. Saepe quidem repudiandae consequatur laboriosam maxime | ||||
|       perferendis nemo asperiores ipsum est, tenetur ratione dolorum sapiente recusandae | ||||
|     </BasePageTitle> | ||||
|     <v-card-actions> | ||||
|       <v-menu offset-y bottom nudge-bottom="6" :close-on-content-click="false"> | ||||
|         <template #activator="{ on, attrs }"> | ||||
|           <v-btn color="accent" class="mr-1" dark v-bind="attrs" v-on="on"> | ||||
|             <v-icon left> | ||||
|               {{ $globals.icons.cog }} | ||||
|             </v-icon> | ||||
|             Columns | ||||
|           </v-btn> | ||||
|         </template> | ||||
|         <v-card> | ||||
|           <v-card-title class="py-2"> | ||||
|             <div>Recipe Columns</div> | ||||
|           </v-card-title> | ||||
|           <v-divider class="mx-2"></v-divider> | ||||
|           <v-card-text class="mt-n5"> | ||||
|             <v-checkbox | ||||
|               v-for="(itemValue, key) in headers" | ||||
|               :key="key" | ||||
|               v-model="headers[key]" | ||||
|               dense | ||||
|               flat | ||||
|               inset | ||||
|               :label="headerLabels[key]" | ||||
|               hide-details | ||||
|             ></v-checkbox> | ||||
|           </v-card-text> | ||||
|         </v-card> | ||||
|       </v-menu> | ||||
|       <BaseOverflowButton | ||||
|         :disabled="selected.length < 1" | ||||
|         mode="event" | ||||
|         color="info" | ||||
|         :items="actions" | ||||
|         @export-selected="openDialog(MODES.export)" | ||||
|         @tag-selected="openDialog(MODES.tag)" | ||||
|         @categorize-selected="openDialog(MODES.category)" | ||||
|         @delete-selected="openDialog(MODES.delete)" | ||||
|       > | ||||
|       </BaseOverflowButton> | ||||
|  | ||||
|       <p v-if="selected.length > 0" class="text-caption my-auto ml-5">Selected: {{ selected.length }}</p> | ||||
|     </v-card-actions> | ||||
|     <RecipeDataTable v-model="selected" :recipes="allRecipes" :show-headers="headers" /> | ||||
|     <v-card-actions class="justify-end"> | ||||
|       <BaseButton color="info"> | ||||
|         <template #icon> | ||||
|           {{ $globals.icons.database }} | ||||
|         </template> | ||||
|         Import | ||||
|       </BaseButton> | ||||
|       <BaseButton color="info"> | ||||
|         <template #icon> | ||||
|           {{ $globals.icons.database }} | ||||
|         </template> | ||||
|         Export All | ||||
|       </BaseButton> | ||||
|     </v-card-actions> | ||||
|   </v-container> | ||||
| </template> | ||||
|      | ||||
| <script lang="ts"> | ||||
| import { defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api"; | ||||
| import RecipeDataTable from "~/components/Domain/Recipe/RecipeDataTable.vue"; | ||||
| import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue"; | ||||
| import { useApiSingleton } from "~/composables/use-api"; | ||||
| import { useRecipes, allRecipes } from "~/composables/use-recipes"; | ||||
| import { Recipe } from "~/types/api-types/recipe"; | ||||
|  | ||||
| const MODES = { | ||||
|   tag: "tag", | ||||
|   category: "category", | ||||
|   export: "export", | ||||
|   delete: "delete", | ||||
| }; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   components: { RecipeDataTable, RecipeCategoryTagSelector }, | ||||
|   scrollToTop: true, | ||||
|   setup() { | ||||
|     const { getAllRecipes, refreshRecipes } = useRecipes(true, true); | ||||
|  | ||||
|     // @ts-ignore | ||||
|     const { $globals } = useContext(); | ||||
|  | ||||
|     const selected = ref([]); | ||||
|  | ||||
|     function resetAll() { | ||||
|       selected.value = []; | ||||
|       toSetTags.value = []; | ||||
|       toSetCategories.value = []; | ||||
|     } | ||||
|  | ||||
|     const headers = reactive({ | ||||
|       id: false, | ||||
|       owner: true, | ||||
|       tags: true, | ||||
|       categories: true, | ||||
|       recipeYield: false, | ||||
|       dateAdded: false, | ||||
|     }); | ||||
|  | ||||
|     const headerLabels = { | ||||
|       id: "Id", | ||||
|       owner: "Owner", | ||||
|       tags: "Tags", | ||||
|       categories: "Categories", | ||||
|       recipeYield: "Recipe Yield", | ||||
|       dateAdded: "Date Added", | ||||
|     }; | ||||
|  | ||||
|     const actions = [ | ||||
|       { | ||||
|         icon: $globals.icons.database, | ||||
|         text: "Export", | ||||
|         value: 0, | ||||
|         event: "export-selected", | ||||
|       }, | ||||
|       { | ||||
|         icon: $globals.icons.tags, | ||||
|         text: "Tag", | ||||
|         value: 1, | ||||
|         event: "tag-selected", | ||||
|       }, | ||||
|       { | ||||
|         icon: $globals.icons.tags, | ||||
|         text: "Categorize", | ||||
|         value: 2, | ||||
|         event: "categorize-selected", | ||||
|       }, | ||||
|       { | ||||
|         icon: $globals.icons.delete, | ||||
|         text: "Delete", | ||||
|         value: 3, | ||||
|         event: "delete-selected", | ||||
|       }, | ||||
|     ]; | ||||
|  | ||||
|     const api = useApiSingleton(); | ||||
|  | ||||
|     function exportSelected() { | ||||
|       console.log("Export Selected"); | ||||
|     } | ||||
|  | ||||
|     const toSetTags = ref([]); | ||||
|  | ||||
|     async function tagSelected() { | ||||
|       const recipes = selected.value.map((x: Recipe) => x.slug); | ||||
|       await api.bulk.bulkTag({ recipes, tags: toSetTags.value }); | ||||
|       await refreshRecipes(); | ||||
|       resetAll(); | ||||
|     } | ||||
|  | ||||
|     const toSetCategories = ref([]); | ||||
|  | ||||
|     async function categorizeSelected() { | ||||
|       const recipes = selected.value.map((x: Recipe) => x.slug); | ||||
|       await api.bulk.bulkCategorize({ recipes, categories: toSetCategories.value }); | ||||
|       await refreshRecipes(); | ||||
|       resetAll(); | ||||
|     } | ||||
|  | ||||
|     async function deleteSelected() { | ||||
|       const recipes = selected.value.map((x: Recipe) => x.slug); | ||||
|  | ||||
|       const { response, data } = await api.bulk.bulkDelete({ recipes }); | ||||
|  | ||||
|       console.log(response, data); | ||||
|  | ||||
|       await refreshRecipes(); | ||||
|       resetAll(); | ||||
|     } | ||||
|  | ||||
|     // ============================================================ | ||||
|     // Dialog Management | ||||
|  | ||||
|     const domDialog = ref(null); | ||||
|  | ||||
|     const dialog = reactive({ | ||||
|       title: "Tag Recipes", | ||||
|       mode: MODES.tag, | ||||
|       tag: "", | ||||
|       callback: () => {}, | ||||
|       icon: $globals.icons.tags, | ||||
|     }); | ||||
|  | ||||
|     function openDialog(mode: string) { | ||||
|       const titles = { | ||||
|         [MODES.tag]: "Tag Recipes", | ||||
|         [MODES.category]: "Categorize Recipes", | ||||
|         [MODES.export]: "Export Recipes", | ||||
|         [MODES.delete]: "Delete Recipes", | ||||
|       }; | ||||
|  | ||||
|       const callbacks = { | ||||
|         [MODES.tag]: tagSelected, | ||||
|         [MODES.category]: categorizeSelected, | ||||
|         [MODES.export]: exportSelected, | ||||
|         [MODES.delete]: deleteSelected, | ||||
|       }; | ||||
|  | ||||
|       const icons = { | ||||
|         [MODES.tag]: $globals.icons.tags, | ||||
|         [MODES.category]: $globals.icons.tags, | ||||
|         [MODES.export]: $globals.icons.database, | ||||
|         [MODES.delete]: $globals.icons.delete, | ||||
|       }; | ||||
|  | ||||
|       dialog.mode = mode; | ||||
|       dialog.title = titles[mode]; | ||||
|       dialog.callback = callbacks[mode]; | ||||
|       dialog.icon = icons[mode]; | ||||
|       // @ts-ignore | ||||
|       domDialog.value.open(); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       toSetTags, | ||||
|       toSetCategories, | ||||
|       openDialog, | ||||
|       domDialog, | ||||
|       dialog, | ||||
|       MODES, | ||||
|       headers, | ||||
|       headerLabels, | ||||
|       exportSelected, | ||||
|       tagSelected, | ||||
|       categorizeSelected, | ||||
|       deleteSelected, | ||||
|       actions, | ||||
|       selected, | ||||
|       allRecipes, | ||||
|       getAllRecipes, | ||||
|     }; | ||||
|   }, | ||||
|   head() { | ||||
|     return { | ||||
|       title: "Recipe Data", | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
| @@ -54,9 +54,8 @@ | ||||
|             Manage your preferences, change your password, and update your email | ||||
|           </UserProfileLinkCard> | ||||
|         </v-col> | ||||
|         <v-col cols="12" sm="12" md="6"> | ||||
|         <v-col v-if="user.advanced" cols="12" sm="12" md="6"> | ||||
|           <UserProfileLinkCard | ||||
|             v-if="user.advanced" | ||||
|             :link="{ text: 'Manage Your API Tokens', to: '/user/profile/api-tokens' }" | ||||
|             :image="require('~/static/svgs/manage-api-tokens.svg')" | ||||
|           > | ||||
| @@ -91,9 +90,8 @@ | ||||
|             Manage a collection of recipe categories and generate pages for them. | ||||
|           </UserProfileLinkCard> | ||||
|         </v-col> | ||||
|         <v-col cols="12" sm="12" md="6"> | ||||
|         <v-col v-if="user.advanced" cols="12" sm="12" md="6"> | ||||
|           <UserProfileLinkCard | ||||
|             v-if="user.advanced" | ||||
|             :link="{ text: 'Manage Webhooks', to: '/user/group/webhooks' }" | ||||
|             :image="require('~/static/svgs/manage-webhooks.svg')" | ||||
|           > | ||||
| @@ -101,9 +99,8 @@ | ||||
|             Setup webhooks that trigger on days that you have have mealplan scheduled. | ||||
|           </UserProfileLinkCard> | ||||
|         </v-col> | ||||
|         <v-col cols="12" sm="12" md="6"> | ||||
|         <v-col v-if="user.canManage" cols="12" sm="12" md="6"> | ||||
|           <UserProfileLinkCard | ||||
|             v-if="user.canManage" | ||||
|             :link="{ text: 'Manage Members', to: '/user/group/members' }" | ||||
|             :image="require('~/static/svgs/manage-members.svg')" | ||||
|           > | ||||
| @@ -111,6 +108,15 @@ | ||||
|             See who's in your group and manage their permissions. | ||||
|           </UserProfileLinkCard> | ||||
|         </v-col> | ||||
|         <v-col cols="12" sm="12" md="6"> | ||||
|           <UserProfileLinkCard | ||||
|             :link="{ text: 'Manage Recipe Data', to: '/user/group/recipe-data' }" | ||||
|             :image="require('~/static/svgs/manage-recipes.svg')" | ||||
|           > | ||||
|             <template #title> Recipe Data </template> | ||||
|             Manage your recipe data and make bulk changes | ||||
|           </UserProfileLinkCard> | ||||
|         </v-col> | ||||
|       </v-row> | ||||
|     </section> | ||||
|   </v-container> | ||||
| @@ -127,6 +133,7 @@ export default defineComponent({ | ||||
|   components: { | ||||
|     UserProfileLinkCard, | ||||
|   }, | ||||
|   scrollToTop: true, | ||||
|   setup() { | ||||
|     const { $auth } = useContext(); | ||||
|  | ||||
|   | ||||
							
								
								
									
										1
									
								
								frontend/static/svgs/manage-recipes.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/static/svgs/manage-recipes.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 9.3 KiB | 
| @@ -1,6 +1,6 @@ | ||||
| from fastapi import APIRouter | ||||
|  | ||||
| from . import all_recipe_routes, comments, image_and_assets, recipe_crud_routes, recipe_export | ||||
| from . import all_recipe_routes, bulk_actions, comments, image_and_assets, recipe_crud_routes, recipe_export | ||||
|  | ||||
| prefix = "/recipes" | ||||
|  | ||||
| @@ -11,3 +11,4 @@ router.include_router(recipe_export.user_router, prefix=prefix, tags=["Recipe: E | ||||
| router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"]) | ||||
| router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"]) | ||||
| router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"]) | ||||
| router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Actions"]) | ||||
|   | ||||
							
								
								
									
										49
									
								
								mealie/routes/recipe/bulk_actions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								mealie/routes/recipe/bulk_actions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| from fastapi import APIRouter, Depends | ||||
| from fastapi.responses import FileResponse | ||||
|  | ||||
| from mealie.core.dependencies.dependencies import temporary_zip_path | ||||
| from mealie.schema.recipe.recipe_bulk_actions import ( | ||||
|     AssignCategories, | ||||
|     AssignTags, | ||||
|     BulkActionsResponse, | ||||
|     DeleteRecipes, | ||||
|     ExportRecipes, | ||||
| ) | ||||
| from mealie.services.recipe.recipe_bulk_service import RecipeBulkActions | ||||
|  | ||||
| router = APIRouter(prefix="/bulk-actions") | ||||
|  | ||||
|  | ||||
| @router.post("/tag", response_model=BulkActionsResponse) | ||||
| def bulk_tag_recipes( | ||||
|     tag_data: AssignTags, | ||||
|     bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private), | ||||
| ): | ||||
|     bulk_service.assign_tags(tag_data.recipes, tag_data.tags) | ||||
|  | ||||
|  | ||||
| @router.post("/categorize", response_model=BulkActionsResponse) | ||||
| def bulk_categorize_recipes( | ||||
|     assign_cats: AssignCategories, | ||||
|     bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private), | ||||
| ): | ||||
|     bulk_service.assign_categories(assign_cats.recipes, assign_cats.categories) | ||||
|  | ||||
|  | ||||
| @router.post("/delete", response_model=BulkActionsResponse) | ||||
| def bulk_delete_recipes( | ||||
|     delete_recipes: DeleteRecipes, | ||||
|     bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private), | ||||
| ): | ||||
|     bulk_service.delete_recipes(delete_recipes.recipes) | ||||
|  | ||||
|  | ||||
| @router.post("/export", response_class=FileResponse) | ||||
| def bulk_export_recipes( | ||||
|     export_recipes: ExportRecipes, | ||||
|     temp_path=Depends(temporary_zip_path), | ||||
|     bulk_service: RecipeBulkActions = Depends(RecipeBulkActions.private), | ||||
| ): | ||||
|     bulk_service.export_recipes(temp_path, export_recipes.recipes) | ||||
|  | ||||
|     return FileResponse(temp_path, filename="recipes.zip") | ||||
							
								
								
									
										40
									
								
								mealie/schema/recipe/recipe_bulk_actions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								mealie/schema/recipe/recipe_bulk_actions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| import enum | ||||
|  | ||||
| from fastapi_camelcase import CamelModel | ||||
|  | ||||
| from . import CategoryBase, TagBase | ||||
|  | ||||
|  | ||||
| class ExportTypes(str, enum.Enum): | ||||
|     JSON = "json" | ||||
|  | ||||
|  | ||||
| class _ExportBase(CamelModel): | ||||
|     recipes: list[str] | ||||
|  | ||||
|  | ||||
| class ExportRecipes(_ExportBase): | ||||
|     export_type: ExportTypes = ExportTypes.JSON | ||||
|  | ||||
|  | ||||
| class AssignCategories(_ExportBase): | ||||
|     categories: list[CategoryBase] | ||||
|  | ||||
|  | ||||
| class AssignTags(_ExportBase): | ||||
|     tags: list[TagBase] | ||||
|  | ||||
|  | ||||
| class DeleteRecipes(_ExportBase): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class BulkActionError(CamelModel): | ||||
|     recipe: str | ||||
|     error: str | ||||
|  | ||||
|  | ||||
| class BulkActionsResponse(CamelModel): | ||||
|     success: bool | ||||
|     message: str | ||||
|     errors: list[BulkActionError] = [] | ||||
							
								
								
									
										60
									
								
								mealie/services/recipe/recipe_bulk_service.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								mealie/services/recipe/recipe_bulk_service.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from pathlib import Path | ||||
|  | ||||
| from mealie.core.root_logger import get_logger | ||||
| from mealie.schema.recipe import CategoryBase, Recipe | ||||
| from mealie.schema.recipe.recipe_category import TagBase | ||||
| from mealie.services._base_http_service.http_services import UserHttpService | ||||
| from mealie.services.events import create_recipe_event | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
|  | ||||
| class RecipeBulkActions(UserHttpService[int, Recipe]): | ||||
|     event_func = create_recipe_event | ||||
|     _restrict_by_group = True | ||||
|  | ||||
|     def populate_item(self, _: int) -> Recipe: | ||||
|         return | ||||
|  | ||||
|     def export_recipes(self, temp_path: Path, recipes: list[str]) -> None: | ||||
|         return | ||||
|  | ||||
|     def assign_tags(self, recipes: list[str], tags: list[TagBase]) -> None: | ||||
|         for slug in recipes: | ||||
|             recipe = self.db.recipes.get_one(slug) | ||||
|  | ||||
|             if recipe is None: | ||||
|                 logger.error(f"Failed to tag recipe {slug}, no recipe found") | ||||
|  | ||||
|             recipe.tags += tags | ||||
|  | ||||
|             try: | ||||
|                 self.db.recipes.update(slug, recipe) | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to tag recipe {slug}") | ||||
|                 logger.error(e) | ||||
|  | ||||
|     def assign_categories(self, recipes: list[str], categories: list[CategoryBase]) -> None: | ||||
|         for slug in recipes: | ||||
|             recipe = self.db.recipes.get_one(slug) | ||||
|  | ||||
|             if recipe is None: | ||||
|                 logger.error(f"Failed to categorize recipe {slug}, no recipe found") | ||||
|  | ||||
|             recipe.recipe_category += categories | ||||
|  | ||||
|             try: | ||||
|                 self.db.recipes.update(slug, recipe) | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to categorize recipe {slug}") | ||||
|                 logger.error(e) | ||||
|  | ||||
|     def delete_recipes(self, recipes: list[str]) -> None: | ||||
|         for slug in recipes: | ||||
|             try: | ||||
|                 self.db.recipes.delete(slug) | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to delete recipe {slug}") | ||||
|                 logger.error(e) | ||||
		Reference in New Issue
	
	Block a user