Repository: mealie-recipes/mealie
Pull request: feat: Allow Cookbooks To Share Names
## What type of PR is this?

_(REQUIRED)_

- feature

## What this PR does / why we need it:

_(REQUIRED)_

This PR allows cookbooks to share the same name. We want this since cookbooks are per-household, and multiple households may want the same cookbook name (e.g. "Sides").

When multiple cookbooks share the same name, they are assigned different slugs (so slugs are still unique, which we enforce at the DB layer [in a previous PR](https://github.com/mealie-recipes/mealie/pull/3970)). The logic is similar to how duplicate recipes are handled (although recipes de-dupe name, too). This PR also makes it so when a cookbook's name is updated, so is its slug (so it matches the name).

## Which issue(s) this PR fixes:

_(REQUIRED)_

Closes https://github.com/mealie-recipes/mealie/issues/4083

## Special notes for your reviewer:

Technically we're not stopping users from creating the same cookbook name in one household, so it is possible to do this:
![image](https://github.com/user-attachments/assets/aca57e76-eadc-43ba-bec7-fef949b0c54a)

I don't really see this as a problem, just figured I'd point it out.

## Testing

_(fill-in or delete this section)_

Pytest for the new cookbooks repo with custom dedupe logic. I also confirmed the frontend handles this fine with cookbook CRUD operations (both in the same household and on two different households).


Problem statement:
[Task] - Allow Cookbooks to Share the Same Name
### What is the problem this task addresses?

Currently cookbooks will throw an error if there's another cookbook with the same name. Similar to recipes, we should allow this as long as the slug is different.

We want this since two different households might have the same common cookbook name (e.g. Drinks). Since cookbooks are tied to households, they may not know a cookbook name is taken already.

### Proposed/Possible Solution(s)?

- Make sure the API handles duplicate names correctly (e.g. incrementing the slug, like it does for recipes)
- Make sure the frontend handles this fine

Hints from issue discussion:

Commit message: fix duplicate key in sidebar

Patch to review:
diff --git a/frontend/components/Layout/DefaultLayout.vue b/frontend/components/Layout/DefaultLayout.vue
index cc2aa5c00bc..062a70ed350 100644
--- a/frontend/components/Layout/DefaultLayout.vue
+++ b/frontend/components/Layout/DefaultLayout.vue
@@ -117,6 +117,7 @@ export default defineComponent({
       if (!cookbooks.value) return [];
       return cookbooks.value.map((cookbook) => {
         return {
+          key: cookbook.slug,
           icon: $globals.icons.pages,
           title: cookbook.name,
           to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`,
diff --git a/frontend/components/Layout/LayoutParts/AppSidebar.vue b/frontend/components/Layout/LayoutParts/AppSidebar.vue
index 049b71e4b8f..44d86f6d586 100644
--- a/frontend/components/Layout/LayoutParts/AppSidebar.vue
+++ b/frontend/components/Layout/LayoutParts/AppSidebar.vue
@@ -26,11 +26,11 @@
     <template v-if="topLink">
       <v-list nav dense>
         <template v-for="nav in topLink">
-          <div v-if="!nav.restricted || isOwnGroup" :key="nav.title">
+          <div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
             <!-- Multi Items -->
             <v-list-group
               v-if="nav.children"
-              :key="nav.title + 'multi-item'"
+              :key="(nav.key || nav.title) + 'multi-item'"
               v-model="dropDowns[nav.title]"
               color="primary"
               :prepend-icon="nav.icon"
@@ -39,7 +39,7 @@
                 <v-list-item-title>{{ nav.title }}</v-list-item-title>
               </template>
 
-              <v-list-item v-for="child in nav.children" :key="child.title" exact :to="child.to" class="ml-2">
+              <v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to" class="ml-2">
                 <v-list-item-icon>
                   <v-icon>{{ child.icon }}</v-icon>
                 </v-list-item-icon>
@@ -50,7 +50,7 @@
             <!-- Single Item -->
             <v-list-item-group
               v-else
-              :key="nav.title + 'single-item'"
+              :key="(nav.key || nav.title) + 'single-item'"
               v-model="secondarySelected"
               color="primary"
             >
@@ -71,11 +71,11 @@
       <v-divider class="mt-2"></v-divider>
       <v-list nav dense exact>
         <template v-for="nav in secondaryLinks">
-          <div v-if="!nav.restricted || isOwnGroup" :key="nav.title">
+          <div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
             <!-- Multi Items -->
             <v-list-group
               v-if="nav.children"
-              :key="nav.title + 'multi-item'"
+              :key="(nav.key || nav.title) + 'multi-item'"
               v-model="dropDowns[nav.title]"
               color="primary"
               :prepend-icon="nav.icon"
@@ -84,7 +84,7 @@
                 <v-list-item-title>{{ nav.title }}</v-list-item-title>
               </template>
 
-              <v-list-item v-for="child in nav.children" :key="child.title" exact :to="child.to">
+              <v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to">
                 <v-list-item-icon>
                   <v-icon>{{ child.icon }}</v-icon>
                 </v-list-item-icon>
@@ -94,7 +94,7 @@
             </v-list-group>
 
             <!-- Single Item -->
-            <v-list-item-group v-else :key="nav.title + 'single-item'" v-model="secondarySelected" color="primary">
+            <v-list-item-group v-else :key="(nav.key || nav.title) + 'single-item'" v-model="secondarySelected" color="primary">
               <v-list-item exact link :to="nav.to">
                 <v-list-item-icon>
                   <v-icon>{{ nav.icon }}</v-icon>
@@ -112,9 +112,9 @@
       <v-list nav dense>
         <v-list-item-group v-model="bottomSelected" color="primary">
           <template v-for="nav in bottomLinks">
-            <div v-if="!nav.restricted || isOwnGroup" :key="nav.title">
+            <div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
               <v-list-item
-                :key="nav.title"
+                :key="nav.key || nav.title"
                 exact
                 link
                 :to="nav.to || null"
diff --git a/frontend/types/application-types.ts b/frontend/types/application-types.ts
index 060ce2d9e81..a2f2032c261 100644
--- a/frontend/types/application-types.ts
+++ b/frontend/types/application-types.ts
@@ -1,4 +1,5 @@
 export interface SideBarLink {
+  key?: string;
   icon: string;
   to?: string;
   href?: string;
diff --git a/mealie/repos/repository_cookbooks.py b/mealie/repos/repository_cookbooks.py
new file mode 100644
index 00000000000..5469c8de459
--- /dev/null
+++ b/mealie/repos/repository_cookbooks.py
@@ -0,0 +1,65 @@
+from collections.abc import Iterable
+
+from fastapi import HTTPException, status
+from pydantic import UUID4
+from slugify import slugify
+from sqlalchemy.exc import IntegrityError
+
+from mealie.db.models.household.cookbook import CookBook
+from mealie.repos.repository_generic import HouseholdRepositoryGeneric
+from mealie.schema.cookbook.cookbook import ReadCookBook, SaveCookBook
+from mealie.schema.response.responses import ErrorResponse
+
+
+class RepositoryCookbooks(HouseholdRepositoryGeneric[ReadCookBook, CookBook]):
+    def create(self, data: SaveCookBook | dict) -> ReadCookBook:
+        if isinstance(data, dict):
+            data = SaveCookBook(**data)
+        data.slug = slugify(data.name)
+
+        max_retries = 10
+        for i in range(max_retries):
+            try:
+                return super().create(data)
+            except IntegrityError:
+                self.session.rollback()
+                data.slug = slugify(f"{data.name} ({i+1})")
+
+        raise  # raise the last IntegrityError
+
+    def create_many(self, data: Iterable[ReadCookBook | dict]) -> list[ReadCookBook]:
+        return [self.create(entry) for entry in data]
+
+    def update(self, match_value: str | int | UUID4, data: SaveCookBook | dict) -> ReadCookBook:
+        if isinstance(data, dict):
+            data = SaveCookBook(**data)
+
+        new_slug = slugify(data.name)
+        if not (data.slug and data.slug.startswith(new_slug)):
+            data.slug = slugify(data.name)
+
+        max_retries = 10
+        for i in range(max_retries):
+            try:
+                return super().update(match_value, data)
+            except IntegrityError:
+                self.session.rollback()
+                data.slug = slugify(f"{data.name} ({i+1})")
+
+        raise  # raise the last IntegrityError
+
+    def update_many(self, data: Iterable[ReadCookBook | dict]) -> list[ReadCookBook]:
+        return [self.update(entry.id if isinstance(entry, ReadCookBook) else entry["id"], entry) for entry in data]
+
+    def patch(self, match_value: str | int | UUID4, data: SaveCookBook | dict) -> ReadCookBook:
+        cookbook = self.get_one(match_value)
+        if not cookbook:
+            raise HTTPException(
+                status.HTTP_404_NOT_FOUND,
+                detail=ErrorResponse.respond(message="Not found."),
+            )
+        cookbook_data = cookbook.model_dump()
+
+        if not isinstance(data, dict):
+            data = data.model_dump()
+        return self.update(match_value, cookbook_data | data)
diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py
index bc9b4d67c42..341d62bf930 100644
--- a/mealie/repos/repository_factory.py
+++ b/mealie/repos/repository_factory.py
@@ -35,6 +35,7 @@
 from mealie.db.models.users import LongLiveToken, User
 from mealie.db.models.users.password_reset import PasswordResetModel
 from mealie.db.models.users.user_to_recipe import UserToRecipe
+from mealie.repos.repository_cookbooks import RepositoryCookbooks
 from mealie.repos.repository_foods import RepositoryFood
 from mealie.repos.repository_household import RepositoryHousehold
 from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
@@ -231,8 +232,8 @@ def household_preferences(self) -> HouseholdRepositoryGeneric[ReadHouseholdPrefe
         )
 
     @cached_property
-    def cookbooks(self) -> HouseholdRepositoryGeneric[ReadCookBook, CookBook]:
-        return HouseholdRepositoryGeneric(
+    def cookbooks(self) -> RepositoryCookbooks:
+        return RepositoryCookbooks(
             self.session, PK_ID, CookBook, ReadCookBook, group_id=self.group_id, household_id=self.household_id
         )
 
diff --git a/mealie/schema/cookbook/cookbook.py b/mealie/schema/cookbook/cookbook.py
index 910bd1937bc..00ef3805e96 100644
--- a/mealie/schema/cookbook/cookbook.py
+++ b/mealie/schema/cookbook/cookbook.py
@@ -1,8 +1,6 @@
 from typing import Annotated
 
 from pydantic import UUID4, ConfigDict, Field, field_validator
-from pydantic_core.core_schema import ValidationInfo
-from slugify import slugify
 from sqlalchemy.orm import joinedload
 from sqlalchemy.orm.interfaces import LoaderOption
 
@@ -31,16 +29,6 @@ class CreateCookBook(MealieModel):
     def validate_public(public: bool | None) -> bool:
         return False if public is None else public
 
-    @field_validator("slug", mode="before")
-    def validate_slug(slug: str, info: ValidationInfo):
-        name: str = info.data["name"]
-        calc_slug: str = slugify(name)
-
-        if slug != calc_slug:
-            slug = calc_slug
-
-        return slug
-
 
 class SaveCookBook(CreateCookBook):
     group_id: UUID4
diff --git a/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py b/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py
index b273ee4f31f..4e401785ee6 100644
--- a/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py
+++ b/tests/integration_tests/public_explorer_tests/test_public_cookbooks.py
@@ -34,11 +34,12 @@ def test_get_all_cookbooks(
     household_private_map: dict[UUID4, bool] = {}
     public_cookbooks: list[ReadCookBook] = []
     private_cookbooks: list[ReadCookBook] = []
-    for database, is_private_household in [
-        (unique_user.repos, is_household_1_private),
-        (h2_user.repos, is_household_2_private),
+    for user, is_private_household in [
+        (unique_user, is_household_1_private),
+        (h2_user, is_household_2_private),
     ]:
-        household = database.households.get_one(unique_user.household_id)
+        database = user.repos
+        household = database.households.get_one(user.household_id)
         assert household and household.preferences
 
         household_private_map[household.id] = is_private_household
@@ -49,7 +50,7 @@ def test_get_all_cookbooks(
         ## Set Up Cookbooks
         default_cookbooks = database.cookbooks.create_many(
             [
-                SaveCookBook(name=random_string(), group_id=unique_user.group_id, household_id=unique_user.household_id)
+                SaveCookBook(name=random_string(), group_id=user.group_id, household_id=user.household_id)
                 for _ in range(random_int(15, 20))
             ]
         )
diff --git a/tests/unit_tests/repository_tests/test_cookbook_repository.py b/tests/unit_tests/repository_tests/test_cookbook_repository.py
new file mode 100644
index 00000000000..abec4d12aaf
--- /dev/null
+++ b/tests/unit_tests/repository_tests/test_cookbook_repository.py
@@ -0,0 +1,98 @@
+from uuid import UUID
+
+import pytest
+from slugify import slugify
+
+from mealie.schema.cookbook.cookbook import SaveCookBook
+from tests.utils.factories import random_string
+from tests.utils.fixture_schemas import TestUser
+
+
+def cookbook_data(user: TestUser, **kwargs):
+    data = {
+        "name": random_string(),
+        "group_id": UUID(user.group_id),
+        "household_id": UUID(user.household_id),
+    } | kwargs
+
+    return SaveCookBook(**data)
+
+
+@pytest.mark.parametrize("use_create_many", [True, False])
+def test_create_cookbook_ignores_slug(unique_user: TestUser, use_create_many: bool):
+    bad_slug = random_string()
+    cb_data = cookbook_data(unique_user, slug=bad_slug)
+    if use_create_many:
+        result = unique_user.repos.cookbooks.create_many([cb_data])
+        assert len(result) == 1
+        cb = result[0]
+    else:
+        cb = unique_user.repos.cookbooks.create(cb_data)
+    assert cb.slug == slugify(cb.name) != bad_slug
+
+
+@pytest.mark.parametrize("use_create_many", [True, False])
+def test_create_cookbook_duplicate_name(unique_user: TestUser, use_create_many: bool):
+    cb_1_data = cookbook_data(unique_user)
+    cb_2_data = cookbook_data(unique_user, name=cb_1_data.name)
+
+    cb_1 = unique_user.repos.cookbooks.create(cb_1_data)
+    unique_user.repos.session.commit()
+
+    if use_create_many:
+        result = unique_user.repos.cookbooks.create_many([cb_2_data])
+        assert len(result) == 1
+        cb_2 = result[0]
+    else:
+        cb_2 = unique_user.repos.cookbooks.create(cb_2_data)
+
+    assert cb_1.id != cb_2.id
+    assert cb_1.name == cb_2.name
+    assert cb_1.slug != cb_2.slug
+
+
+@pytest.mark.parametrize("method", ["update", "update_many", "patch"])
+def test_update_cookbook_updates_slug(unique_user: TestUser, method: str):
+    cb_data = cookbook_data(unique_user)
+    cb = unique_user.repos.cookbooks.create(cb_data)
+    unique_user.repos.session.commit()
+
+    new_name = random_string()
+    cb.name = new_name
+
+    if method == "update":
+        cb = unique_user.repos.cookbooks.update(cb.id, cb)
+    if method == "update_many":
+        result = unique_user.repos.cookbooks.update_many([cb])
+        assert len(result) == 1
+        cb = result[0]
+    else:
+        cb = unique_user.repos.cookbooks.patch(cb.id, cb)
+
+    assert cb.name == new_name
+    assert cb.slug == slugify(new_name)
+
+
+@pytest.mark.parametrize("method", ["update", "update_many", "patch"])
+def test_update_cookbook_duplicate_name(unique_user: TestUser, method: str):
+    cb_1_data = cookbook_data(unique_user)
+    cb_2_data = cookbook_data(unique_user)
+
+    cb_1 = unique_user.repos.cookbooks.create(cb_1_data)
+    unique_user.repos.session.commit()
+    cb_2 = unique_user.repos.cookbooks.create(cb_2_data)
+    unique_user.repos.session.commit()
+
+    cb_2.name = cb_1.name
+    if method == "update":
+        cb_2 = unique_user.repos.cookbooks.update(cb_2.id, cb_2)
+    if method == "update_many":
+        result = unique_user.repos.cookbooks.update_many([cb_2])
+        assert len(result) == 1
+        cb_2 = result[0]
+    else:
+        cb_2 = unique_user.repos.cookbooks.patch(cb_2.id, cb_2)
+
+    assert cb_1.id != cb_2.id
+    assert cb_1.name == cb_2.name
+    assert cb_1.slug != cb_2.slug


Code review comments:
diff_hunk: @@ -0,0 +1,65 @@
+from collections.abc import Iterable
+
+from fastapi import HTTPException, status
+from pydantic import UUID4
+from slugify import slugify
+from sqlalchemy.exc import IntegrityError
+
+from mealie.db.models.household.cookbook import CookBook
+from mealie.repos.repository_generic import HouseholdRepositoryGeneric
+from mealie.schema.cookbook.cookbook import ReadCookBook, SaveCookBook
+from mealie.schema.response.responses import ErrorResponse
+
+
+class RepositoryCookbooks(HouseholdRepositoryGeneric[ReadCookBook, CookBook]):
+    def create(self, data: SaveCookBook | dict) -> ReadCookBook:
+        if isinstance(data, dict):
+            data = SaveCookBook(**data)
+        data.slug = slugify(data.name)
+
+        max_retries = 10
+        for i in range(max_retries):
+            try:
+                return super().create(data)
+            except IntegrityError:
+                self.session.rollback()
+                data.slug = slugify(f"{data.name} ({i+1})")
+
+        raise  # raise the last IntegrityError
+
+    def create_many(self, data: Iterable[ReadCookBook | dict]) -> list[ReadCookBook]:
+        return [self.create(entry) for entry in data]
+
+    def update(self, match_value: str | int | UUID4, data: SaveCookBook | dict) -> ReadCookBook:
+        if isinstance(data, dict):
+            data = SaveCookBook(**data)
+
+        new_slug = slugify(data.name)
+        if not (data.slug and data.slug.startswith(new_slug)):
+            data.slug = slugify(data.name)
path: mealie/repos/repository_cookbooks.py
text: @user1:
[question] Because this is `startswith` rather than an exact match, could that lead to problems?

@author:
I was going to answer why this logic makes sense, but then I realized using regex makes way more sense here, so I updated it.

We can still run into weird behavior if the cookbook name actually contains a number in the title (e.g. if you rename a cookbook from "My Cookbook" to "My Cookbook 1" the slug won't update) but this is a tiny edge case, and it doesn't really cause any problems anyway besides the slug being slightly different than the name