diff --git a/frontend/api/class-interfaces/recipe-bulk-actions.ts b/frontend/api/class-interfaces/recipe-bulk-actions.ts new file mode 100644 index 000000000000..036d8d547812 --- /dev/null +++ b/frontend/api/class-interfaces/recipe-bulk-actions.ts @@ -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(routes.bulkExport, payload); + } + + async bulkCategorize(payload: RecipeBulkCategorize) { + return await this.requests.post(routes.bulkCategorize, payload); + } + + async bulkTag(payload: RecipeBulkTag) { + return await this.requests.post(routes.bulkTag, payload); + } + + async bulkDelete(payload: RecipeBulkDelete) { + return await this.requests.post(routes.bulkDelete, payload); + } +} diff --git a/frontend/api/index.ts b/frontend/api/index.ts index 53e216ddc4b5..3d64b90abb25 100644 --- a/frontend/api/index.ts +++ b/frontend/api/index.ts @@ -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; diff --git a/frontend/components/Domain/Recipe/RecipeDataTable.vue b/frontend/components/Domain/Recipe/RecipeDataTable.vue new file mode 100644 index 000000000000..d0a4eacb9109 --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeDataTable.vue @@ -0,0 +1,154 @@ + + + \ No newline at end of file diff --git a/frontend/components/global/BaseDialog.vue b/frontend/components/global/BaseDialog.vue index 88cbfa60c931..da3b3133c47c 100644 --- a/frontend/components/global/BaseDialog.vue +++ b/frontend/components/global/BaseDialog.vue @@ -132,7 +132,6 @@ export default defineComponent({ this.submitted = true; }, open() { - console.log("Open Dialog"); this.dialog = true; }, close() { diff --git a/frontend/components/global/BaseOverflowButton.vue b/frontend/components/global/BaseOverflowButton.vue index c288cd4c568b..e060fdbd5388 100644 --- a/frontend/components/global/BaseOverflowButton.vue +++ b/frontend/components/global/BaseOverflowButton.vue @@ -1,17 +1,18 @@ @@ -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, diff --git a/frontend/composables/use-recipes.ts b/frontend/composables/use-recipes.ts index cc28d74ae285..0bab6a1b7908 100644 --- a/frontend/composables/use-recipes.ts +++ b/frontend/composables/use-recipes.ts @@ -118,5 +118,5 @@ export const useRecipes = (all = false, fetchRecipes = true) => { getAllRecipes(); } - return { getAllRecipes, assignSorted }; + return { getAllRecipes, assignSorted, refreshRecipes }; }; diff --git a/frontend/pages/user/group/recipe-data/index.vue b/frontend/pages/user/group/recipe-data/index.vue new file mode 100644 index 000000000000..9977ff513827 --- /dev/null +++ b/frontend/pages/user/group/recipe-data/index.vue @@ -0,0 +1,275 @@ + + + \ No newline at end of file diff --git a/frontend/pages/user/profile/index.vue b/frontend/pages/user/profile/index.vue index 0ee9ad073525..c42a3ad268c6 100644 --- a/frontend/pages/user/profile/index.vue +++ b/frontend/pages/user/profile/index.vue @@ -54,9 +54,8 @@ Manage your preferences, change your password, and update your email - + @@ -91,9 +90,8 @@ Manage a collection of recipe categories and generate pages for them. - + @@ -101,9 +99,8 @@ Setup webhooks that trigger on days that you have have mealplan scheduled. - + @@ -111,6 +108,15 @@ See who's in your group and manage their permissions. + + + + Manage your recipe data and make bulk changes + + @@ -127,6 +133,7 @@ export default defineComponent({ components: { UserProfileLinkCard, }, + scrollToTop: true, setup() { const { $auth } = useContext(); diff --git a/frontend/static/svgs/manage-recipes.svg b/frontend/static/svgs/manage-recipes.svg new file mode 100644 index 000000000000..4e0d200b5397 --- /dev/null +++ b/frontend/static/svgs/manage-recipes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mealie/routes/recipe/__init__.py b/mealie/routes/recipe/__init__.py index 8a73ce573106..4f343ac0ebff 100644 --- a/mealie/routes/recipe/__init__.py +++ b/mealie/routes/recipe/__init__.py @@ -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"]) diff --git a/mealie/routes/recipe/bulk_actions.py b/mealie/routes/recipe/bulk_actions.py new file mode 100644 index 000000000000..1d595d0498cf --- /dev/null +++ b/mealie/routes/recipe/bulk_actions.py @@ -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") diff --git a/mealie/schema/recipe/recipe_bulk_actions.py b/mealie/schema/recipe/recipe_bulk_actions.py new file mode 100644 index 000000000000..7ad4196c4008 --- /dev/null +++ b/mealie/schema/recipe/recipe_bulk_actions.py @@ -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] = [] diff --git a/mealie/services/recipe/recipe_bulk_service.py b/mealie/services/recipe/recipe_bulk_service.py new file mode 100644 index 000000000000..a661a7eb1971 --- /dev/null +++ b/mealie/services/recipe/recipe_bulk_service.py @@ -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)