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"