diff --git a/docs/docs/overrides/api.html b/docs/docs/overrides/api.html index 41131df7b4da..f971e46984ee 100644 --- a/docs/docs/overrides/api.html +++ b/docs/docs/overrides/api.html @@ -14,7 +14,7 @@
diff --git a/frontend/components/Domain/Recipe/RecipeActionMenu.vue b/frontend/components/Domain/Recipe/RecipeActionMenu.vue index 9b5edd5d4795..f2eaf9767c40 100644 --- a/frontend/components/Domain/Recipe/RecipeActionMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeActionMenu.vue @@ -54,6 +54,7 @@ delete: false, edit: false, download: true, + duplicate: true, mealplanner: true, shoppingList: true, print: true, diff --git a/frontend/components/Domain/Recipe/RecipeContextMenu.vue b/frontend/components/Domain/Recipe/RecipeContextMenu.vue index fdbcca602a24..475c119f53a0 100644 --- a/frontend/components/Domain/Recipe/RecipeContextMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeContextMenu.vue @@ -13,6 +13,23 @@ {{ $t("recipe.delete-confirmation") }} + + + + + router.push(`/recipe/${props.slug}` + "?edit=true"), download: handleDownloadEvent, + duplicate: () => { + state.recipeDuplicateDialog = true; + }, mealplanner: () => { state.mealplannerDialog = true; }, @@ -376,6 +412,7 @@ export default defineComponent({ ...toRefs(state), shoppingLists, addRecipeToList, + duplicateRecipe, contextMenuEventHandler, deleteRecipe, addRecipeToPlan, diff --git a/frontend/composables/use-locales/available-locales.ts b/frontend/composables/use-locales/available-locales.ts index 5088c5d79184..0f0991dde89b 100644 --- a/frontend/composables/use-locales/available-locales.ts +++ b/frontend/composables/use-locales/available-locales.ts @@ -8,7 +8,7 @@ export const LOCALES = [ { name: "简体中文 (Chinese simplified)", value: "zh-CN", - progress: 57, + progress: 56, }, { name: "Tiếng Việt (Vietnamese)", @@ -23,7 +23,7 @@ export const LOCALES = [ { name: "Türkçe (Turkish)", value: "tr-TR", - progress: 32, + progress: 47, }, { name: "Svenska (Swedish)", @@ -38,12 +38,12 @@ export const LOCALES = [ { name: "Slovenian", value: "sl-SI", - progress: 95, + progress: 94, }, { name: "Slovak", value: "sk-SK", - progress: 86, + progress: 85, }, { name: "Pусский (Russian)", @@ -53,7 +53,7 @@ export const LOCALES = [ { name: "Română (Romanian)", value: "ro-RO", - progress: 4, + progress: 3, }, { name: "Português (Portuguese)", @@ -63,27 +63,27 @@ export const LOCALES = [ { name: "Português do Brasil (Brazilian Portuguese)", value: "pt-BR", - progress: 39, + progress: 40, }, { name: "Polski (Polish)", value: "pl-PL", - progress: 88, + progress: 89, }, { name: "Norsk (Norwegian)", value: "no-NO", - progress: 85, + progress: 87, }, { name: "Nederlands (Dutch)", value: "nl-NL", - progress: 91, + progress: 97, }, { name: "Lithuanian", value: "lt-LT", - progress: 0, + progress: 64, }, { name: "한국어 (Korean)", @@ -98,12 +98,12 @@ export const LOCALES = [ { name: "Italiano (Italian)", value: "it-IT", - progress: 83, + progress: 82, }, { name: "Magyar (Hungarian)", value: "hu-HU", - progress: 78, + progress: 77, }, { name: "עברית (Hebrew)", @@ -113,7 +113,7 @@ export const LOCALES = [ { name: "Français (French)", value: "fr-FR", - progress: 100, + progress: 99, }, { name: "French, Canada", @@ -123,12 +123,12 @@ export const LOCALES = [ { name: "Suomi (Finnish)", value: "fi-FI", - progress: 23, + progress: 22, }, { name: "Español (Spanish)", value: "es-ES", - progress: 95, + progress: 94, }, { name: "American English", @@ -138,7 +138,7 @@ export const LOCALES = [ { name: "British English", value: "en-GB", - progress: 32, + progress: 31, }, { name: "Ελληνικά (Greek)", @@ -148,17 +148,17 @@ export const LOCALES = [ { name: "Deutsch (German)", value: "de-DE", - progress: 100, + progress: 99, }, { name: "Dansk (Danish)", value: "da-DK", - progress: 100, + progress: 99, }, { name: "Čeština (Czech)", value: "cs-CZ", - progress: 66, + progress: 89, }, { name: "Català (Catalan)", @@ -178,6 +178,6 @@ export const LOCALES = [ { name: "Afrikaans (Afrikaans)", value: "af-ZA", - progress: 0, + progress: 9, }, ] diff --git a/frontend/lang/messages/de-DE.json b/frontend/lang/messages/de-DE.json index 401f4bd22025..1b210dd0f4fb 100644 --- a/frontend/lang/messages/de-DE.json +++ b/frontend/lang/messages/de-DE.json @@ -75,6 +75,7 @@ "delete": "Löschen", "disabled": "Deaktiviert", "download": "Herunterladen", + "duplicate": "Duplizieren", "edit": "Bearbeiten", "enabled": "Aktiviert", "exception": "Fehler", @@ -281,6 +282,8 @@ "description": "Beschreibung", "disable-amount": "Zutatenmenge deaktivieren", "disable-comments": "Kommentare deaktivieren", + "duplicate": "Rezept duplizieren", + "duplicate-name": "Name of the new recipe", "edit-scale": "Maßstab ändern", "fat-content": "Fett", "fiber-content": "Ballaststoffe", diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index b0b185cecc21..fdcee71d000b 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -75,6 +75,7 @@ "delete": "Delete", "disabled": "Disabled", "download": "Download", + "duplicate": "Duplicate", "edit": "Edit", "enabled": "Enabled", "exception": "Exception", @@ -282,6 +283,8 @@ "description": "Description", "disable-amount": "Disable Ingredient Amounts", "disable-comments": "Disable Comments", + "duplicate": "Duplicate recipe", + "duplicate-name": "Name of the new recipe", "edit-scale": "Edit Scale", "fat-content": "Fat", "fiber-content": "Fiber", diff --git a/frontend/lib/api/base/base-clients.ts b/frontend/lib/api/base/base-clients.ts index dc105cc58852..7b0f702329de 100644 --- a/frontend/lib/api/base/base-clients.ts +++ b/frontend/lib/api/base/base-clients.ts @@ -1,3 +1,4 @@ +import { Recipe } from "../types/recipe"; import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated"; export interface CrudAPIInterface { @@ -20,8 +21,7 @@ export abstract class BaseAPI { export abstract class BaseCRUDAPI extends BaseAPI - implements CrudAPIInterface -{ + implements CrudAPIInterface { abstract baseRoute: string; abstract itemRoute(itemId: string | number): string; @@ -50,4 +50,10 @@ export abstract class BaseCRUDAPI async deleteOne(itemId: string | number) { return await this.requests.delete(this.itemRoute(itemId)); } + + async duplicateOne(itemId: string | number, newName: string | undefined) { + return await this.requests.post(`${this.itemRoute(itemId)}/duplicate`, { + name: newName, + }); + } } diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index 21061eeac8c9..9bda203ae01f 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -302,6 +302,9 @@ export interface RecipeCommentUpdate { id: string; text: string; } +export interface RecipeDuplicate { + name?: string; +} export interface RecipePaginationQuery { page?: number; perPage?: number; diff --git a/frontend/lib/icons/icons.ts b/frontend/lib/icons/icons.ts index ab819a525014..c44bfbc9da7f 100644 --- a/frontend/lib/icons/icons.ts +++ b/frontend/lib/icons/icons.ts @@ -123,6 +123,7 @@ import { mdiText, mdiTextBoxOutline, mdiChefHat, + mdiContentDuplicate, } from "@mdi/js"; export const icons = { @@ -173,6 +174,7 @@ export const icons = { dotsHorizontal: mdiDotsHorizontal, dotsVertical: mdiDotsVertical, download: mdiDownload, + duplicate: mdiContentDuplicate, email: mdiEmail, externalLink: mdiLinkVariant, eye: mdiEye, diff --git a/frontend/pages/group/mealplan/planner.vue b/frontend/pages/group/mealplan/planner.vue index a85a1a491563..122c88f3397a 100644 --- a/frontend/pages/group/mealplan/planner.vue +++ b/frontend/pages/group/mealplan/planner.vue @@ -243,6 +243,7 @@ delete: false, edit: false, download: true, + duplicate: false, mealplanner: false, print: true, share: false, diff --git a/mealie/lang/messages/de-DE.json b/mealie/lang/messages/de-DE.json index 0b082c2e9281..ae220a7a86e0 100644 --- a/mealie/lang/messages/de-DE.json +++ b/mealie/lang/messages/de-DE.json @@ -17,6 +17,7 @@ "generic-updated": "{name} wurde aktualisiert", "generic-created-with-url": "{name} wurde erstellt, {url}", "generic-updated-with-url": "{name} wurde aktualisiert, {url}", + "generic-duplicated": "{name} wurde dupliziert", "generic-deleted": "{name} wurde gelöscht" } } diff --git a/mealie/lang/messages/en-US.json b/mealie/lang/messages/en-US.json index 20b0ba283154..5c879a5d1f57 100644 --- a/mealie/lang/messages/en-US.json +++ b/mealie/lang/messages/en-US.json @@ -17,6 +17,7 @@ "generic-updated": "{name} was updated", "generic-created-with-url": "{name} has been created, {url}", "generic-updated-with-url": "{name} has been updated, {url}", + "generic-duplicated": "{name} has been duplicated", "generic-deleted": "{name} has been deleted" } } diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index ebb55dadd775..8a838cb2a3d9 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -31,7 +31,7 @@ from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_step import RecipeStep -from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse +from mealie.schema.recipe.request_helpers import RecipeDuplicate, RecipeZipTokenResponse, UpdateImageResponse from mealie.schema.response.responses import ErrorResponse from mealie.services import urls from mealie.services.event_bus_service.event_types import ( @@ -298,6 +298,26 @@ class RecipeController(BaseRecipeController): return new_recipe.slug + @router.post("/{slug}/duplicate", status_code=201, response_model=Recipe) + def duplicate_one(self, slug: str, req: RecipeDuplicate) -> Recipe: + """Duplicates a recipe with a new custom name if given""" + try: + new_recipe = self.service.duplicate_one(slug, req) + except Exception as e: + self.handle_exceptions(e) + + if new_recipe: + self.publish_event( + event_type=EventTypes.recipe_created, + document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=new_recipe.slug), + message=self.t( + "notifications.generic-duplicated", + name=new_recipe.name, + ), + ) + + return new_recipe + @router.put("/{slug}") def update_one(self, slug: str, data: Recipe): """Updates a recipe by existing slug and data.""" diff --git a/mealie/schema/recipe/__init__.py b/mealie/schema/recipe/__init__.py index 8b97aa3ea85e..09a67ece16a6 100644 --- a/mealie/schema/recipe/__init__.py +++ b/mealie/schema/recipe/__init__.py @@ -76,9 +76,10 @@ from .recipe_timeline_events import ( RecipeTimelineEventOut, RecipeTimelineEventPagination, RecipeTimelineEventUpdate, + TimelineEventType, ) from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave -from .request_helpers import RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse +from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse __all__ = [ "RecipeToolCreate", @@ -90,12 +91,14 @@ __all__ = [ "RecipeTimelineEventOut", "RecipeTimelineEventPagination", "RecipeTimelineEventUpdate", + "TimelineEventType", "RecipeAsset", "RecipeSettings", "RecipeShareToken", "RecipeShareTokenCreate", "RecipeShareTokenSave", "RecipeShareTokenSummary", + "RecipeDuplicate", "RecipeSlug", "RecipeZipTokenResponse", "SlugResponse", diff --git a/mealie/schema/recipe/request_helpers.py b/mealie/schema/recipe/request_helpers.py index 960b8faae2d5..4b1a8c1a28d8 100644 --- a/mealie/schema/recipe/request_helpers.py +++ b/mealie/schema/recipe/request_helpers.py @@ -20,3 +20,7 @@ class UpdateImageResponse(BaseModel): class RecipeZipTokenResponse(BaseModel): token: str + + +class RecipeDuplicate(BaseModel): + name: str | None diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 49f481be2e09..a2fa9c9d3064 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -3,17 +3,21 @@ import shutil from datetime import datetime from pathlib import Path from shutil import copytree, rmtree +from uuid import uuid4 from zipfile import ZipFile from fastapi import UploadFile +from slugify import slugify from mealie.core import exceptions +from mealie.pkgs import cache from mealie.repos.repository_factory import AllRepositories from mealie.schema.recipe.recipe import CreateRecipe, Recipe from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_step import RecipeStep from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType +from mealie.schema.recipe.request_helpers import RecipeDuplicate from mealie.schema.user.user import GroupInDB, PrivateUser from mealie.services._base_service import BaseService from mealie.services.recipe.recipe_data_service import RecipeDataService @@ -174,6 +178,64 @@ class RecipeService(BaseService): return recipe + def duplicate_one(self, old_slug: str, dup_data: RecipeDuplicate) -> Recipe: + """Duplicates a recipe and returns the new recipe.""" + + old_recipe = self._get_recipe(old_slug) + new_recipe = old_recipe.copy(exclude={"id", "name", "slug", "image", "comments"}) + + # Asset images in steps directly link to the original recipe, so we + # need to update them to references to the assets we copy below + def replace_recipe_step(step: RecipeStep) -> RecipeStep: + new_step = step.copy(exclude={"id", "text"}) + new_step.id = uuid4() + new_step.text = step.text.replace(str(old_recipe.id), str(new_recipe.id)) + return new_step + + # Copy ingredients to make them independent of the original + def copy_recipe_ingredient(ingredient: RecipeIngredient): + new_ingredient = ingredient.copy(exclude={"reference_id"}) + new_ingredient.reference_id = uuid4() + return new_ingredient + + new_name = dup_data.name if dup_data.name else old_recipe.name or "" + new_recipe.id = uuid4() + new_recipe.slug = slugify(new_name) + new_recipe.image = cache.cache_key.new_key() if old_recipe.image else None + new_recipe.recipe_instructions = ( + None + if old_recipe.recipe_instructions is None + else list(map(replace_recipe_step, old_recipe.recipe_instructions)) + ) + new_recipe.recipe_ingredient = ( + None + if old_recipe.recipe_ingredient is None + else list(map(copy_recipe_ingredient, old_recipe.recipe_ingredient)) + ) + + new_recipe = self._recipe_creation_factory( + self.user, + new_name, + additional_attrs=new_recipe.dict(), + ) + + new_recipe = self.repos.recipes.create(new_recipe) + + # Copy all assets (including images) to the new recipe directory + # This assures that replaced links in recipe steps continue to work when the old recipe is deleted + try: + new_service = RecipeDataService(new_recipe.id, group_id=old_recipe.group_id) + old_service = RecipeDataService(old_recipe.id, group_id=old_recipe.group_id) + copytree( + old_service.dir_data, + new_service.dir_data, + dirs_exist_ok=True, + ) + except Exception as e: + self.logger.error(f"Failed to copy assets from {old_recipe.slug} to {new_recipe.slug}: {e}") + + return new_recipe + def _pre_update_check(self, slug: str, new_data: Recipe) -> Recipe: """ gets the recipe from the database and performs a check to see if the user can update the recipe. diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py index a671753cc6fb..a85da206b5f3 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -192,6 +192,87 @@ def test_read_update( assert cats[0]["name"] in test_name +@pytest.mark.parametrize("recipe_data", recipe_test_data) +def test_duplicate(api_client: TestClient, recipe_data: RecipeSiteTestCase, unique_user: TestUser): + # Initial get of the original recipe + original_recipe_url = api_routes.recipes_slug(recipe_data.expected_slug) + response = api_client.get(original_recipe_url, headers=unique_user.token) + assert response.status_code == 200 + initial_recipe = json.loads(response.text) + + # Duplicate the recipe + recipe_duplicate_url = api_routes.recipes_slug_duplicate(recipe_data.expected_slug) + response = api_client.post( + recipe_duplicate_url, + headers=unique_user.token, + json={ + "name": "Test Duplicate", + }, + ) + assert response.status_code == 201 + + duplicate_recipe = json.loads(response.text) + assert duplicate_recipe["id"] != initial_recipe["id"] + assert duplicate_recipe["slug"].startswith("test-duplicate") + assert duplicate_recipe["name"].startswith("Test Duplicate") + + # Image should be copied (if it exists) + assert ( + duplicate_recipe["image"] is None + and initial_recipe["image"] is None + or duplicate_recipe["image"] != initial_recipe["image"] + ) + + # Number of steps should be the same, but the text may have changed (link replacements) + assert len(duplicate_recipe["recipeInstructions"]) == len(initial_recipe["recipeInstructions"]) + + # Ingredients should have the same texts, but different ids + assert duplicate_recipe["recipeIngredient"] != initial_recipe["recipeIngredient"] + assert list(map(lambda i: i["note"], duplicate_recipe["recipeIngredient"])) == list( + map(lambda i: i["note"], initial_recipe["recipeIngredient"]) + ) + + previous_categories = initial_recipe["recipeCategory"] + assert duplicate_recipe["recipeCategory"] == previous_categories + + # Edit the duplicated recipe to make sure it doesn't affect the original + dup_notes = duplicate_recipe["notes"] or [] + dup_notes.append({"title": "Test", "text": "Test"}) + duplicate_recipe["notes"] = dup_notes + duplicate_recipe["recipeIngredient"][0]["note"] = "Different Ingredient" + new_recipe_url = api_routes.recipes_slug(duplicate_recipe.get("slug")) + response = api_client.put(new_recipe_url, json=duplicate_recipe, headers=unique_user.token) + assert response.status_code == 200 + edited_recipe = json.loads(response.text) + + # reload original + response = api_client.get(original_recipe_url, headers=unique_user.token) + assert response.status_code == 200 + original_recipe = json.loads(response.text) + + assert edited_recipe["notes"] == dup_notes + assert original_recipe.get("notes") != edited_recipe.get("notes") + assert original_recipe.get("recipeCategory") == previous_categories + + # Make sure ingredient edits don't affect the original + original_ingredients = original_recipe.get("recipeIngredient") + edited_ingredients = edited_recipe.get("recipeIngredient") + + assert len(original_ingredients) == len(edited_ingredients) + + assert original_ingredients[0]["note"] != edited_ingredients[0]["note"] + assert edited_ingredients[0]["note"] == "Different Ingredient" + assert original_ingredients[0]["referenceId"] != edited_ingredients[1]["referenceId"] + + for i in range(1, len(original_ingredients)): + assert original_ingredients[i]["referenceId"] != edited_ingredients[i]["referenceId"] + + def copy_info(ing): + return {k: v for k, v in ing.items() if k != "referenceId"} + + assert copy_info(original_ingredients[i]) == copy_info(edited_ingredients[i]) + + @pytest.mark.parametrize("recipe_data", recipe_test_data) def test_rename(api_client: TestClient, recipe_data: RecipeSiteTestCase, unique_user: TestUser): recipe_url = api_routes.recipes_slug(recipe_data.expected_slug) diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index c256e0f8144c..e28d3fa9fe5e 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -349,6 +349,11 @@ def recipes_slug_comments(slug): return f"{prefix}/recipes/{slug}/comments" +def recipes_slug_duplicate(slug): + """`/api/recipes/{slug}/duplicate`""" + return f"{prefix}/recipes/{slug}/duplicate" + + def recipes_slug_exports(slug): """`/api/recipes/{slug}/exports`""" return f"{prefix}/recipes/{slug}/exports"