Merge branch 'mealie-next' into fix-500-error-on-recipe-share-link

This commit is contained in:
Michael Genson 2025-07-04 20:27:48 -05:00 committed by GitHub
commit 5f576dc2c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 47035 additions and 29218 deletions

114
.github/workflows/locale-sync.yml vendored Normal file
View file

@ -0,0 +1,114 @@
name: Automatic Locale Sync
on:
schedule:
# Run every Sunday at 2 AM UTC
- cron: "0 2 * * 0"
workflow_dispatch:
# Allow manual triggering from the GitHub UI
permissions:
contents: write # To checkout, commit, and push changes
pull-requests: write # To create pull requests
jobs:
sync-locales:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
- name: Check venv cache
id: cache-validate
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
run: |
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
rm test.py
continue-on-error: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
poetry install
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
- name: Run locale generation
run: |
cd dev/code-generation
poetry run python main.py locales
env:
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
- name: Check for changes
id: changes
run: |
if git diff --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
- name: Commit and create PR
if: steps.changes.outputs.has_changes == 'true'
run: |
# Configure git
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
# Use the current branch as the base
BASE_BRANCH="${{ github.ref_name }}"
echo "Using base branch: $BASE_BRANCH"
# Create a new branch from the base branch
BRANCH_NAME="auto-locale-sync-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH_NAME"
# Add and commit changes
git add .
git commit -m "chore: automatic locale sync"
# Push the branch
git push origin "$BRANCH_NAME"
sleep 2
# Create PR using GitHub CLI with explicit repository
gh pr create \
--repo "${{ github.repository }}" \
--title "chore: automatic locale sync" \
--base "$BASE_BRANCH" \
--head "$BRANCH_NAME" \
--body "## Summary
Automatically generated locale updates from the weekly sync job.
## Changes
- Updated frontend locale files
- Generated from latest translation sources" \
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: No changes detected
if: steps.changes.outputs.has_changes == 'false'
run: echo "No locale changes detected, skipping PR creation"

View file

@ -70,7 +70,7 @@ tasks:
dev:generate: dev:generate:
desc: run code generators desc: run code generators
cmds: cmds:
- poetry run python dev/code-generation/main.py - poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
- task: py:format - task: py:format
dev:services: dev:services:

View file

@ -1,3 +1,4 @@
import os
import pathlib import pathlib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -13,7 +14,7 @@ from mealie.schema._mealie import MealieModel
BASE = pathlib.Path(__file__).parent.parent.parent BASE = pathlib.Path(__file__).parent.parent.parent
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
@dataclass @dataclass
@ -23,19 +24,22 @@ class LocaleData:
LOCALE_DATA: dict[str, LocaleData] = { LOCALE_DATA: dict[str, LocaleData] = {
"en-US": LocaleData(name="American English"),
"en-GB": LocaleData(name="British English"),
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"), "af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"), "ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
"bg-BG": LocaleData(name="Български (Bulgarian)"),
"ca-ES": LocaleData(name="Català (Catalan)"), "ca-ES": LocaleData(name="Català (Catalan)"),
"cs-CZ": LocaleData(name="Čeština (Czech)"), "cs-CZ": LocaleData(name="Čeština (Czech)"),
"da-DK": LocaleData(name="Dansk (Danish)"), "da-DK": LocaleData(name="Dansk (Danish)"),
"de-DE": LocaleData(name="Deutsch (German)"), "de-DE": LocaleData(name="Deutsch (German)"),
"el-GR": LocaleData(name="Ελληνικά (Greek)"), "el-GR": LocaleData(name="Ελληνικά (Greek)"),
"en-GB": LocaleData(name="British English"),
"en-US": LocaleData(name="American English"),
"es-ES": LocaleData(name="Español (Spanish)"), "es-ES": LocaleData(name="Español (Spanish)"),
"et-EE": LocaleData(name="Eesti (Estonian)"),
"fi-FI": LocaleData(name="Suomi (Finnish)"), "fi-FI": LocaleData(name="Suomi (Finnish)"),
"fr-FR": LocaleData(name="Français (French)"),
"fr-BE": LocaleData(name="Belge (Belgian)"), "fr-BE": LocaleData(name="Belge (Belgian)"),
"fr-CA": LocaleData(name="Français canadien (Canadian French)"),
"fr-FR": LocaleData(name="Français (French)"),
"gl-ES": LocaleData(name="Galego (Galician)"), "gl-ES": LocaleData(name="Galego (Galician)"),
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"), "he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
"hr-HR": LocaleData(name="Hrvatski (Croatian)"), "hr-HR": LocaleData(name="Hrvatski (Croatian)"),
@ -53,6 +57,7 @@ LOCALE_DATA: dict[str, LocaleData] = {
"pt-PT": LocaleData(name="Português (Portuguese)"), "pt-PT": LocaleData(name="Português (Portuguese)"),
"ro-RO": LocaleData(name="Română (Romanian)"), "ro-RO": LocaleData(name="Română (Romanian)"),
"ru-RU": LocaleData(name="Pусский (Russian)"), "ru-RU": LocaleData(name="Pусский (Russian)"),
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"), "sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
"sr-SP": LocaleData(name="српски (Serbian)"), "sr-SP": LocaleData(name="српски (Serbian)"),
"sv-SE": LocaleData(name="Svenska (Swedish)"), "sv-SE": LocaleData(name="Svenska (Swedish)"),
@ -93,8 +98,8 @@ class CrowdinApi:
project_id = "451976" project_id = "451976"
api_key = API_KEY api_key = API_KEY
def __init__(self, api_key: str): def __init__(self, api_key: str | None):
api_key = api_key self.api_key = api_key or API_KEY
@property @property
def headers(self) -> dict: def headers(self) -> dict:
@ -196,7 +201,7 @@ def inject_registration_validation_values():
def generate_locales_ts_file(): def generate_locales_ts_file():
api = CrowdinApi("") api = CrowdinApi(None)
models = api.get_languages() models = api.get_languages()
tmpl = Template(LOCALE_TEMPLATE) tmpl = Template(LOCALE_TEMPLATE)
rendered = tmpl.render(locales=models) rendered = tmpl.render(locales=models)

View file

@ -1,3 +1,4 @@
import argparse
from pathlib import Path from pathlib import Path
import gen_py_pytest_data_paths import gen_py_pytest_data_paths
@ -11,15 +12,39 @@ CWD = Path(__file__).parent
def main(): def main():
items = [ parser = argparse.ArgumentParser(description="Run code generators")
(gen_py_schema_exports.main, "schema exports"), parser.add_argument(
(gen_ts_types.main, "frontend types"), "generators",
(gen_ts_locales.main, "locales"), nargs="*",
(gen_py_pytest_data_paths.main, "test data paths"), help="Specific generators to run (schema, types, locales, data-paths, routes). If none specified, all will run.", # noqa: E501 - long line
(gen_py_pytest_routes.main, "pytest routes"), )
] args = parser.parse_args()
for func, name in items: # Define all available generators
all_generators = {
"schema": (gen_py_schema_exports.main, "schema exports"),
"types": (gen_ts_types.main, "frontend types"),
"locales": (gen_ts_locales.main, "locales"),
"data-paths": (gen_py_pytest_data_paths.main, "test data paths"),
"routes": (gen_py_pytest_routes.main, "pytest routes"),
}
# Determine which generators to run
if args.generators:
# Validate requested generators
invalid_generators = [g for g in args.generators if g not in all_generators]
if invalid_generators:
log.error(f"Invalid generator(s): {', '.join(invalid_generators)}")
log.info(f"Available generators: {', '.join(all_generators.keys())}")
return
generators_to_run = [(all_generators[g][0], all_generators[g][1]) for g in args.generators]
else:
# Run all generators (default behavior)
generators_to_run = list(all_generators.values())
# Run the selected generators
for func, name in generators_to_run:
log.info(f"Generating {name}...") log.info(f"Generating {name}...")
func() func()

View file

@ -1,5 +1,4 @@
import logging import logging
import re
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -35,7 +34,7 @@ class CodeSlicer:
start: int start: int
end: int end: int
indentation: str indentation: str | None
text: list[str] text: list[str]
_next_line = None _next_line = None
@ -47,15 +46,24 @@ class CodeSlicer:
def push_line(self, string: str) -> None: def push_line(self, string: str) -> None:
self._next_line = self._next_line or self.start + 1 self._next_line = self._next_line or self.start + 1
self.text.insert(self._next_line, self.indentation + string + "\n") self.text.insert(self._next_line, (self.indentation or "") + string + "\n")
self._next_line += 1 self._next_line += 1
def get_indentation_of_string(line: str, comment_char: str = "//|#") -> str: def get_indentation_of_string(line: str) -> str:
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n") # Extract everything before the comment
if "//" in line:
indentation = line.split("//")[0]
elif "#" in line:
indentation = line.split("#")[0]
else:
indentation = line
# Keep only the whitespace, remove any non-whitespace characters
return "".join(c for c in indentation if c.isspace())
def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str]: def find_start_end(file_text: list[str], gen_id: str) -> tuple[int, int, str | None]:
start = None start = None
end = None end = None
indentation = None indentation = None

View file

@ -1,24 +0,0 @@
![Recipe Image](../../images/{{ recipe.slug }}/original.jpg)
# {{ recipe.name }}
{{ recipe.description }}
## Ingredients
{% for ingredient in recipe.recipeIngredient %}
- [ ] {{ ingredient }} {% endfor %}
## Instructions
{% for step in recipe.recipeInstructions %}
- [ ] {{ step.text }} {% endfor %}
{% for note in recipe.notes %}
**{{ note.title }}:** {{ note.text }}
{% endfor %}
---
Tags: {{ recipe.tags }}
Categories: {{ recipe.categories }}
Original URL: {{ recipe.orgURL }}

View file

@ -0,0 +1,75 @@
import glob
import json
import pathlib
def get_seed_locale_names() -> set[str]:
"""Find all locales in the seed/resources/ folder
Returns:
A set of every file name where there's both a seed label and seed food file
"""
LABELS_PATH = "/workspaces/mealie/mealie/repos/seed/resources/labels/locales/"
FOODS_PATH = "/workspaces/mealie/mealie/repos/seed/resources/foods/locales/"
label_locales = glob.glob("*.json", root_dir=LABELS_PATH)
foods_locales = glob.glob("*.json", root_dir=FOODS_PATH)
# ensure that a locale has both a label and a food seed file
return set(label_locales).intersection(foods_locales)
def get_labels_from_file(locale: str) -> list[str]:
"""Query a locale to get all of the labels so that they can be added to the new foods seed format
Returns:
All of the labels found within the seed file for a given locale
"""
locale_path = pathlib.Path("/workspaces/mealie/mealie/repos/seed/resources/labels/locales/" + locale)
label_names = [label["name"] for label in json.loads(locale_path.read_text(encoding="utf-8"))]
return label_names
def transform_foods(locale: str):
"""
Convert the current food seed file for a locale into a new format which maps each food to a label
Existing format of foods seed file is a dictionary where each key is a food name and the values are a dictionary
of attributes such as name and plural_name
New format maps each food to a label. The top-level dictionary has each key as a label e.g. "Fruits".
Each label key as a value that is a dictionary with an element called "foods"
"Foods" is a dictionary of each food for that label, with a key of the english food name e.g. "baking-soda"
and a value of attributes, including the translated name of the item e.g. "bicarbonate of soda" for en-GB.
"""
locale_path = pathlib.Path("/workspaces/mealie/mealie/repos/seed/resources/foods/locales/" + locale)
with open(locale_path, encoding="utf-8") as infile:
data = json.load(infile)
first_value = next(iter(data.values()))
if isinstance(first_value, dict) and "foods" in first_value:
# Locale is already in the new format, skipping transformation
return
transformed_data = {"": {"foods": dict(data.items())}}
# Seeding for labels now pulls from the foods file and parses the labels from there (as top-level keys),
# thus we need to add all of the existing labels to the new food seed file and give them an empty foods dictionary
label_names = get_labels_from_file(locale)
for label in label_names:
transformed_data[label] = {"foods": {}}
with open(locale_path, "w", encoding="utf-8") as outfile:
json.dump(transformed_data, outfile, indent=4, ensure_ascii=False)
def main():
for locale in get_seed_locale_names():
transform_foods(locale)
if __name__ == "__main__":
main()

View file

@ -1052,8 +1052,8 @@
"foods": { "foods": {
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.", "merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
"merge-food-example": "Merging {food1} into {food2}", "merge-food-example": "Merging {food1} into {food2}",
"seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.", "seed-dialog-text": "Seed the database with foods based on your local language. This will create ~2700 common foods that can be used to organize your database. Foods are translated via a community effort.",
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually.", "seed-dialog-warning": "You already have some items in your database. A new item will not be added if an item with the same name already exists.",
"combine-food": "Combine Food", "combine-food": "Combine Food",
"source-food": "Source Food", "source-food": "Source Food",
"target-food": "Target Food", "target-food": "Target Food",

View file

@ -1,5 +0,0 @@
from pathlib import Path
CWD = Path(__file__).parent
recipes_markdown = CWD / "recipes.md"

View file

@ -1,24 +0,0 @@
![Recipe Image](../../images/{{ recipe.slug }}/original.jpg)
# {{ recipe.name }}
{{ recipe.description }}
## Ingredients
{% for ingredient in recipe.recipeIngredient %}
- [ ] {{ ingredient }} {% endfor %}
## Instructions
{% for step in recipe.recipeInstructions %}
- [ ] {{ step.text }} {% endfor %}
{% for note in recipe.notes %}
**{{ note.title }}:** {{ note.text }}
{% endfor %}
---
Tags: {{ recipe.tags }}
Categories: {{ recipe.categories }}
Original URL: {{ recipe.orgURL }}

View file

@ -1,8 +1,5 @@
import shutil
from pathlib import Path from pathlib import Path
from mealie.assets import templates
class AppDirectories: class AppDirectories:
def __init__(self, data_dir: Path) -> None: def __init__(self, data_dir: Path) -> None:
@ -38,9 +35,3 @@ class AppDirectories:
for dir in required_dirs: for dir in required_dirs:
dir.mkdir(parents=True, exist_ok=True) dir.mkdir(parents=True, exist_ok=True)
# Bootstrap Templates
markdown_template = self.TEMPLATE_DIR.joinpath("recipes.md")
if not markdown_template.exists():
shutil.copyfile(templates.recipes_markdown, markdown_template)

View file

@ -13,7 +13,7 @@ TRANSLATIONS = CWD / "messages"
class Translator(Protocol): class Translator(Protocol):
@abstractmethod @abstractmethod
def t(self, key, default=None, **kwargs): def t(self, key, default=None, **kwargs) -> str:
pass pass

View file

@ -7,7 +7,6 @@ from uuid import UUID
import sqlalchemy as sa import sqlalchemy as sa
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import UUID4 from pydantic import UUID4
from slugify import slugify
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -22,7 +21,7 @@ from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.db.models.users.users import User from mealie.db.models.users.users import User
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, create_recipe_slug
from mealie.schema.recipe.recipe_ingredient import IngredientFood from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
from mealie.schema.recipe.recipe_tool import RecipeToolOut from mealie.schema.recipe.recipe_tool import RecipeToolOut
@ -98,7 +97,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
except IntegrityError: except IntegrityError:
self.session.rollback() self.session.rollback()
document.name = f"{original_name} ({i})" document.name = f"{original_name} ({i})"
document.slug = slugify(document.name) document.slug = create_recipe_slug(document.name)
if i >= max_retries: if i >= max_retries:
raise raise

File diff suppressed because it is too large Load diff

View file

@ -1,692 +1,62 @@
{ {
"acorn-squash": { "": {
"name": "acorn squash" "foods": {}
}, },
"alfalfa-sprouts": { "خَضْراوات وفواكه": {
"name": "alfalfa sprouts" "foods": {}
}, },
"anchovies": { "الحبوب": {
"name": "anchovies" "foods": {}
}, },
"apples": { "آلفواكه": {
"name": "apple", "foods": {}
"plural_name": "apples" },
}, "الخضراوات": {
"artichoke": { "foods": {}
"name": "artichoke" },
}, "اللحوم": {
"arugula": { "foods": {}
"name": "arugula" },
}, "المأكولات البحرية": {
"asparagus": { "foods": {}
"name": "asparagus" },
}, "المشروبات": {
"avocado": { "foods": {}
"name": "avocado", },
"plural_name": "avocado" "المخبوزات": {
}, "foods": {}
"bacon": { },
"name": "bacon" "المعلبات": {
}, "foods": {}
"baking-powder": { },
"name": "baking powder" "الباهرات": {
}, "foods": {}
"baking-soda": { },
"name": "baking soda" "الحَلْوَيَات": {
}, "foods": {}
"baking-sugar": { },
"name": "baking sugar" "منتجات الألبان": {
}, "foods": {}
"bar-sugar": { },
"name": "bar sugar" "الأطعمة المجمدة": {
}, "foods": {}
"basil": { },
"name": "basil" "الأغذية الصحية": {
}, "foods": {}
"beans": { },
"name": "beans" "المنزل": {
}, "foods": {}
"bell-peppers": { },
"name": "bell peppers", "منتجات اللحوم": {
"plural_name": "bell peppers" "foods": {}
}, },
"blackberries": { "الوجبات الخفيفة": {
"name": "blackberries" "foods": {}
}, },
"bok-choy": { "التوابل": {
"name": "bok choy" "foods": {}
}, },
"brassicas": { "أخرى": {
"name": "brassicas" "foods": {}
}, }
"bread": { }
"name": "bread"
},
"breadfruit": {
"name": "breadfruit"
},
"broccoflower": {
"name": "broccoflower"
},
"broccoli": {
"name": "broccoli"
},
"broccoli-rabe": {
"name": "broccoli rabe"
},
"broccolini": {
"name": "broccolini"
},
"brown-sugar": {
"name": "brown sugar"
},
"brussels-sprouts": {
"name": "brussels sprouts"
},
"butter": {
"name": "butter"
},
"butternut-pumpkin": {
"name": "butternut pumpkin"
},
"butternut-squash": {
"name": "butternut squash"
},
"cabbage": {
"name": "cabbage",
"plural_name": "cabbages"
},
"cactus-edible": {
"name": "cactus, edible"
},
"calabrese": {
"name": "calabrese"
},
"cane-sugar": {
"name": "cane sugar"
},
"cannabis": {
"name": "cannabis"
},
"capsicum": {
"name": "capsicum"
},
"caraway": {
"name": "caraway"
},
"carrot": {
"name": "carrot",
"plural_name": "carrots"
},
"caster-sugar": {
"name": "caster sugar"
},
"castor-sugar": {
"name": "castor sugar"
},
"catfish": {
"name": "catfish"
},
"cauliflower": {
"name": "cauliflower",
"plural_name": "cauliflowers"
},
"cayenne-pepper": {
"name": "cayenne pepper"
},
"celeriac": {
"name": "celery root"
},
"celery": {
"name": "celery"
},
"cereal-grains": {
"name": "cereal grains"
},
"chard": {
"name": "chard"
},
"cheese": {
"name": "cheese"
},
"chicory": {
"name": "chicory"
},
"chilli-peppers": {
"name": "chilli pepper",
"plural_name": "chilli peppers"
},
"chinese-leaves": {
"name": "chinese leaves"
},
"chives": {
"name": "chives"
},
"chocolate": {
"name": "chocolate"
},
"cilantro": {
"name": "cilantro"
},
"cinnamon": {
"name": "cinnamon"
},
"clarified-butter": {
"name": "clarified butter"
},
"coconut": {
"name": "coconut",
"plural_name": "coconuts"
},
"coconut-milk": {
"name": "coconut milk"
},
"cod": {
"name": "cod"
},
"coffee": {
"name": "coffee"
},
"collard-greens": {
"name": "collard greens"
},
"confectioners-sugar": {
"name": "confectioners' sugar"
},
"coriander": {
"name": "coriander"
},
"corn": {
"name": "corn",
"plural_name": "corns"
},
"corn-syrup": {
"name": "corn syrup"
},
"cottonseed-oil": {
"name": "cottonseed oil"
},
"courgette": {
"name": "courgette"
},
"cream-of-tartar": {
"name": "cream of tartar"
},
"cucumber": {
"name": "cucumber",
"plural_name": "cucumbers"
},
"cumin": {
"name": "cumin"
},
"daikon": {
"name": "daikon",
"plural_name": "daikons"
},
"dairy-products-and-dairy-substitutes": {
"name": "dairy products and dairy substitutes"
},
"dandelion": {
"name": "dandelion"
},
"demerara-sugar": {
"name": "demerara sugar"
},
"dough": {
"name": "dough"
},
"edible-cactus": {
"name": "edible cactus"
},
"eggplant": {
"name": "eggplant",
"plural_name": "eggplants"
},
"eggs": {
"name": "egg",
"plural_name": "eggs"
},
"endive": {
"name": "endive",
"plural_name": "endives"
},
"fats": {
"name": "fats"
},
"fava-beans": {
"name": "fava beans"
},
"fiddlehead": {
"name": "fiddlehead"
},
"fiddlehead-fern": {
"name": "fiddlehead fern",
"plural_name": "fiddlehead ferns"
},
"fish": {
"name": "fish"
},
"five-spice-powder": {
"name": "five spice powder"
},
"flour": {
"name": "flour"
},
"frisee": {
"name": "frisee"
},
"fructose": {
"name": "fructose"
},
"fruit": {
"name": "fruit"
},
"fruit-sugar": {
"name": "fruit sugar"
},
"ful": {
"name": "ful"
},
"garam-masala": {
"name": "garam masala"
},
"garlic": {
"name": "garlic",
"plural_name": "garlics"
},
"gem-squash": {
"name": "gem squash"
},
"ghee": {
"name": "ghee"
},
"giblets": {
"name": "giblets"
},
"ginger": {
"name": "ginger"
},
"grains": {
"name": "grains"
},
"granulated-sugar": {
"name": "granulated sugar"
},
"grape-seed-oil": {
"name": "grape seed oil"
},
"green-onion": {
"name": "green onion",
"plural_name": "green onions"
},
"heart-of-palm": {
"name": "heart of palm",
"plural_name": "heart of palms"
},
"hemp": {
"name": "قنب والعياذ بالله"
},
"herbs": {
"name": "herbs"
},
"honey": {
"name": "honey"
},
"isomalt": {
"name": "isomalt"
},
"jackfruit": {
"name": "jackfruit",
"plural_name": "jackfruits"
},
"jaggery": {
"name": "jaggery"
},
"jams": {
"name": "jams"
},
"jellies": {
"name": "jellies"
},
"jerusalem-artichoke": {
"name": "jerusalem artichoke"
},
"jicama": {
"name": "jicama"
},
"kale": {
"name": "kale"
},
"kohlrabi": {
"name": "kohlrabi"
},
"kumara": {
"name": "kumara"
},
"leavening-agents": {
"name": "leavening agents"
},
"leek": {
"name": "leek",
"plural_name": "leeks"
},
"legumes": {
"name": "legumes"
},
"lemongrass": {
"name": "lemongrass"
},
"lentils": {
"name": "lentils"
},
"lettuce": {
"name": "lettuce"
},
"liver": {
"name": "liver",
"plural_name": "livers"
},
"maize": {
"name": "maize"
},
"maple-syrup": {
"name": "maple syrup"
},
"meat": {
"name": "meat"
},
"milk": {
"name": "milk"
},
"mortadella": {
"name": "mortadella"
},
"mushroom": {
"name": "mushroom",
"plural_name": "mushrooms"
},
"mussels": {
"name": "mussels"
},
"nanaimo-bar-mix": {
"name": "nanaimo bar mix"
},
"nori": {
"name": "nori"
},
"nutmeg": {
"name": "nutmeg"
},
"nutritional-yeast-flakes": {
"name": "nutritional yeast flakes"
},
"nuts": {
"name": "nuts"
},
"octopuses": {
"name": "octopus",
"plural_name": "octopuses"
},
"oils": {
"name": "oils"
},
"okra": {
"name": "okra"
},
"olive": {
"name": "olive"
},
"olive-oil": {
"name": "olive oil"
},
"onion": {
"name": "onion"
},
"onion-family": {
"name": "onion family"
},
"orange-blossom-water": {
"name": "orange blossom water"
},
"oranges": {
"name": "orange",
"plural_name": "oranges"
},
"oregano": {
"name": "oregano"
},
"oysters": {
"name": "oysters"
},
"panch-puran": {
"name": "panch puran"
},
"paprika": {
"name": "paprika"
},
"parsley": {
"name": "parsley"
},
"parsnip": {
"name": "parsnip",
"plural_name": "parsnips"
},
"pear": {
"name": "pear",
"plural_name": "pears"
},
"peas": {
"name": "peas"
},
"pepper": {
"name": "pepper",
"plural_name": "peppers"
},
"pineapple": {
"name": "pineapple",
"plural_name": "pineapples"
},
"plantain": {
"name": "plantain",
"plural_name": "plantains"
},
"poppy-seeds": {
"name": "poppy seeds"
},
"potato": {
"name": "potato",
"plural_name": "potatoes"
},
"poultry": {
"name": "poultry"
},
"powdered-sugar": {
"name": "powdered sugar"
},
"pumpkin": {
"name": "pumpkin",
"plural_name": "pumpkins"
},
"pumpkin-seeds": {
"name": "pumpkin seeds"
},
"radish": {
"name": "radish",
"plural_name": "radishes"
},
"raw-sugar": {
"name": "raw sugar"
},
"refined-sugar": {
"name": "refined sugar"
},
"rice": {
"name": "rice"
},
"rice-flour": {
"name": "rice flour"
},
"rock-sugar": {
"name": "rock sugar"
},
"rum": {
"name": "rum"
},
"salmon": {
"name": "salmon"
},
"salt": {
"name": "salt"
},
"salt-cod": {
"name": "salt cod"
},
"scallion": {
"name": "scallion",
"plural_name": "scallions"
},
"seafood": {
"name": "seafood"
},
"seeds": {
"name": "seeds"
},
"sesame-seeds": {
"name": "sesame seeds"
},
"shallot": {
"name": "shallot",
"plural_name": "shallots"
},
"skate": {
"name": "skate"
},
"soda": {
"name": "soda"
},
"soda-baking": {
"name": "soda, baking"
},
"soybean": {
"name": "soybean"
},
"spaghetti-squash": {
"name": "spaghetti squash",
"plural_name": "spaghetti squashes"
},
"speck": {
"name": "speck"
},
"spices": {
"name": "spices"
},
"spinach": {
"name": "spinach"
},
"spring-onion": {
"name": "spring onion",
"plural_name": "spring onions"
},
"squash": {
"name": "squash",
"plural_name": "squashes"
},
"squash-family": {
"name": "squash family"
},
"stockfish": {
"name": "stockfish"
},
"sugar": {
"name": "sugar"
},
"sunchoke": {
"name": "sunchoke",
"plural_name": "sunchokes"
},
"sunflower-seeds": {
"name": "sunflower seeds"
},
"superfine-sugar": {
"name": "superfine sugar"
},
"sweet-potato": {
"name": "sweet potato",
"plural_name": "sweet potatoes"
},
"sweetcorn": {
"name": "sweetcorn",
"plural_name": "sweetcorns"
},
"sweeteners": {
"name": "sweeteners"
},
"tahini": {
"name": "tahini"
},
"taro": {
"name": "taro",
"plural_name": "taroes"
},
"teff": {
"name": "teff"
},
"tomato": {
"name": "tomato",
"plural_name": "tomatoes"
},
"trout": {
"name": "trout"
},
"tubers": {
"name": "tuber",
"plural_name": "tubers"
},
"tuna": {
"name": "tuna"
},
"turbanado-sugar": {
"name": "turbanado sugar"
},
"turnip": {
"name": "turnip",
"plural_name": "turnips"
},
"unrefined-sugar": {
"name": "unrefined sugar"
},
"vanilla": {
"name": "vanilla"
},
"vegetables": {
"name": "vegetables"
},
"watercress": {
"name": "watercress"
},
"watermelon": {
"name": "watermelon",
"plural_name": "watermelons"
},
"white-mushroom": {
"name": "white mushroom",
"plural_name": "white mushrooms"
},
"white-sugar": {
"name": "white sugar"
},
"xanthan-gum": {
"name": "xanthan gum"
},
"yam": {
"name": "yam",
"plural_name": "yams"
},
"yeast": {
"name": "yeast"
},
"zucchini": {
"name": "zucchini",
"plural_name": "zucchinis"
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,12 +3,17 @@ import pathlib
from collections.abc import Generator from collections.abc import Generator
from functools import cached_property from functools import cached_property
from mealie.schema.labels import MultiPurposeLabelSave from mealie.schema.labels import MultiPurposeLabelOut, MultiPurposeLabelSave
from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood, SaveIngredientUnit from mealie.schema.recipe.recipe_ingredient import (
IngredientFood,
IngredientUnit,
SaveIngredientFood,
SaveIngredientUnit,
)
from mealie.services.group_services.labels_service import MultiPurposeLabelService from mealie.services.group_services.labels_service import MultiPurposeLabelService
from ._abstract_seeder import AbstractSeeder from ._abstract_seeder import AbstractSeeder
from .resources import foods, labels, units from .resources import foods, units
class MultiPurposeLabelSeeder(AbstractSeeder): class MultiPurposeLabelSeeder(AbstractSeeder):
@ -17,20 +22,24 @@ class MultiPurposeLabelSeeder(AbstractSeeder):
return MultiPurposeLabelService(self.repos) return MultiPurposeLabelService(self.repos)
def get_file(self, locale: str | None = None) -> pathlib.Path: def get_file(self, locale: str | None = None) -> pathlib.Path:
locale_path = self.resources / "labels" / "locales" / f"{locale}.json" # Get the labels from the foods seed file now
return locale_path if locale_path.exists() else labels.en_US locale_path = self.resources / "foods" / "locales" / f"{locale}.json"
return locale_path if locale_path.exists() else foods.en_US
def get_all_labels(self) -> list[MultiPurposeLabelOut]:
return self.repos.group_multi_purpose_labels.get_all()
def load_data(self, locale: str | None = None) -> Generator[MultiPurposeLabelSave, None, None]: def load_data(self, locale: str | None = None) -> Generator[MultiPurposeLabelSave, None, None]:
file = self.get_file(locale) file = self.get_file(locale)
seen_label_names = set() current_label_names = {label.name for label in self.get_all_labels()}
for label in json.loads(file.read_text(encoding="utf-8")): # load from the foods locale file and remove any empty strings
if label["name"] in seen_label_names: seed_label_names = set(filter(None, json.loads(file.read_text(encoding="utf-8")).keys())) # type: set[str]
continue # only seed new labels
to_seed_labels = seed_label_names - current_label_names
seen_label_names.add(label["name"]) for label in to_seed_labels:
yield MultiPurposeLabelSave( yield MultiPurposeLabelSave(
name=label["name"], name=label,
group_id=self.repos.group_id, group_id=self.repos.group_id,
) )
@ -48,10 +57,13 @@ class IngredientUnitsSeeder(AbstractSeeder):
locale_path = self.resources / "units" / "locales" / f"{locale}.json" locale_path = self.resources / "units" / "locales" / f"{locale}.json"
return locale_path if locale_path.exists() else units.en_US return locale_path if locale_path.exists() else units.en_US
def get_all_units(self) -> list[IngredientUnit]:
return self.repos.ingredient_units.get_all()
def load_data(self, locale: str | None = None) -> Generator[SaveIngredientUnit, None, None]: def load_data(self, locale: str | None = None) -> Generator[SaveIngredientUnit, None, None]:
file = self.get_file(locale) file = self.get_file(locale)
seen_unit_names = set() seen_unit_names = {unit.name for unit in self.get_all_units()}
for unit in json.loads(file.read_text(encoding="utf-8")).values(): for unit in json.loads(file.read_text(encoding="utf-8")).values():
if unit["name"] in seen_unit_names: if unit["name"] in seen_unit_names:
continue continue
@ -80,21 +92,32 @@ class IngredientFoodsSeeder(AbstractSeeder):
locale_path = self.resources / "foods" / "locales" / f"{locale}.json" locale_path = self.resources / "foods" / "locales" / f"{locale}.json"
return locale_path if locale_path.exists() else foods.en_US return locale_path if locale_path.exists() else foods.en_US
def get_label(self, value: str) -> MultiPurposeLabelOut | None:
return self.repos.group_multi_purpose_labels.get_one(value, "name")
def get_all_foods(self) -> list[IngredientFood]:
return self.repos.ingredient_foods.get_all()
def load_data(self, locale: str | None = None) -> Generator[SaveIngredientFood, None, None]: def load_data(self, locale: str | None = None) -> Generator[SaveIngredientFood, None, None]:
file = self.get_file(locale) file = self.get_file(locale)
seed_foods_names = set() # get all current unique foods
for food in json.loads(file.read_text(encoding="utf-8")).values(): seen_foods_names = {food.name for food in self.get_all_foods()}
if food["name"] in seed_foods_names: for label, values in json.loads(file.read_text(encoding="utf-8")).items():
continue label_out = self.get_label(label)
seed_foods_names.add(food["name"]) for food_name, attributes in values["foods"].items():
yield SaveIngredientFood( if food_name in seen_foods_names:
group_id=self.repos.group_id, continue
name=food["name"],
plural_name=food.get("plural_name"), seen_foods_names.add(food_name)
description="", yield SaveIngredientFood(
) group_id=self.repos.group_id,
name=attributes["name"],
plural_name=attributes.get("plural_name"),
description="", # description expected to be empty string by UnitFoodBase class
label_id=label_out.id if label_out and label_out.id else None,
)
def seed(self, locale: str | None = None) -> None: def seed(self, locale: str | None = None) -> None:
self.logger.info("Seeding Ingredient Foods") self.logger.info("Seeding Ingredient Foods")

View file

@ -32,7 +32,6 @@ class JSONBytes(JSONResponse):
class FormatResponse(BaseModel): class FormatResponse(BaseModel):
jjson: list[str] = Field(..., alias="json") jjson: list[str] = Field(..., alias="json")
zip: list[str] zip: list[str]
jinja2: list[str]
class BaseRecipeController(BaseCrudController): class BaseRecipeController(BaseCrudController):

View file

@ -1,4 +1,5 @@
import shutil import shutil
from uuid import uuid4
from fastapi import File, HTTPException, UploadFile, status from fastapi import File, HTTPException, UploadFile, status
from pydantic import UUID4 from pydantic import UUID4
@ -24,7 +25,10 @@ class UserImageController(BaseUserController):
"""Updates a User Image""" """Updates a User Image"""
with get_temporary_path() as temp_path: with get_temporary_path() as temp_path:
assert_user_change_allowed(id, self.user, self.user) assert_user_change_allowed(id, self.user, self.user)
temp_img = temp_path.joinpath(profile.filename)
# use a generated uuid and ignore the filename so we don't
# need to worry about sanitizing user inputs.
temp_img = temp_path.joinpath(str(uuid4()))
with temp_img.open("wb") as buffer: with temp_img.open("wb") as buffer:
shutil.copyfileobj(profile.file, buffer) shutil.copyfileobj(profile.file, buffer)

View file

@ -36,6 +36,22 @@ from .recipe_step import RecipeStep
app_dirs = get_app_dirs() app_dirs = get_app_dirs()
def create_recipe_slug(name: str, max_length: int = 250) -> str:
"""Generate a slug from a recipe name, truncating to a reasonable length.
Args:
name: The recipe name to create a slug from
max_length: Maximum length for the slug (default: 250)
Returns:
A truncated slug string
"""
generated_slug = slugify(name)
if len(generated_slug) > max_length:
generated_slug = generated_slug[:max_length]
return generated_slug
class RecipeTag(MealieModel): class RecipeTag(MealieModel):
id: UUID4 | None = None id: UUID4 | None = None
group_id: UUID4 | None = None group_id: UUID4 | None = None
@ -229,7 +245,7 @@ class Recipe(RecipeSummary):
if not info.data.get("name"): if not info.data.get("name"):
return slug return slug
return slugify(info.data["name"]) return create_recipe_slug(info.data["name"])
@field_validator("recipe_ingredient", mode="before") @field_validator("recipe_ingredient", mode="before")
def validate_ingredients(recipe_ingredient): def validate_ingredients(recipe_ingredient):

View file

@ -9,7 +9,6 @@ from uuid import UUID, uuid4
from zipfile import ZipFile from zipfile import ZipFile
from fastapi import UploadFile from fastapi import UploadFile
from slugify import slugify
from mealie.core import exceptions from mealie.core import exceptions
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
@ -21,7 +20,7 @@ from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_generic import RepositoryGeneric from mealie.repos.repository_generic import RepositoryGeneric
from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeUpdate from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeUpdate
from mealie.schema.openai.recipe import OpenAIRecipe from mealie.schema.openai.recipe import OpenAIRecipe
from mealie.schema.recipe.recipe import CreateRecipe, Recipe from mealie.schema.recipe.recipe import CreateRecipe, Recipe, create_recipe_slug
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_notes import RecipeNote from mealie.schema.recipe.recipe_notes import RecipeNote
from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_settings import RecipeSettings
@ -332,7 +331,7 @@ class RecipeService(RecipeServiceBase):
new_name = dup_data.name if dup_data.name else old_recipe.name or "" new_name = dup_data.name if dup_data.name else old_recipe.name or ""
new_recipe.id = uuid4() new_recipe.id = uuid4()
new_recipe.slug = slugify(new_name) new_recipe.slug = create_recipe_slug(new_name)
new_recipe.image = cache.cache_key.new_key() if old_recipe.image else None new_recipe.image = cache.cache_key.new_key() if old_recipe.image else None
new_recipe.recipe_instructions = ( new_recipe.recipe_instructions = (
None None
@ -447,7 +446,7 @@ class OpenAIRecipeService(RecipeServiceBase):
group_id=self.user.group_id, group_id=self.user.group_id,
household_id=self.household.id, household_id=self.household.id,
name=openai_recipe.name, name=openai_recipe.name,
slug=slugify(openai_recipe.name), slug=create_recipe_slug(openai_recipe.name),
description=openai_recipe.description, description=openai_recipe.description,
recipe_yield=openai_recipe.recipe_yield, recipe_yield=openai_recipe.recipe_yield,
total_time=openai_recipe.total_time, total_time=openai_recipe.total_time,

View file

@ -2,8 +2,6 @@ import enum
from pathlib import Path from pathlib import Path
from zipfile import ZipFile from zipfile import ZipFile
from jinja2 import Template
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe_image_types import RecipeImageTypes from mealie.schema.recipe.recipe_image_types import RecipeImageTypes
from mealie.services._base_service import BaseService from mealie.services._base_service import BaseService
@ -11,7 +9,6 @@ from mealie.services._base_service import BaseService
class TemplateType(str, enum.Enum): class TemplateType(str, enum.Enum):
json = "json" json = "json"
jinja2 = "jinja2"
zip = "zip" zip = "zip"
@ -32,7 +29,6 @@ class TemplateService(BaseService):
Returns a list of all templates available to render. Returns a list of all templates available to render.
""" """
return { return {
TemplateType.jinja2.value: [x.name for x in self.directories.TEMPLATE_DIR.iterdir() if x.is_file()],
TemplateType.json.value: ["raw"], TemplateType.json.value: ["raw"],
TemplateType.zip.value: ["zip"], TemplateType.zip.value: ["zip"],
} }
@ -65,16 +61,13 @@ class TemplateService(BaseService):
Args: Args:
t_type (TemplateType): The type of template to render t_type (TemplateType): The type of template to render
recipe (Recipe): The recipe to render recipe (Recipe): The recipe to render
template (str): The template to render **Required for Jinja2 Templates** template (str): The template to render
""" """
t_type = self.template_type(template) t_type = self.template_type(template)
if t_type == TemplateType.json: if t_type == TemplateType.json:
return self._render_json(recipe) return self._render_json(recipe)
if t_type == TemplateType.jinja2:
return self._render_jinja2(recipe, template)
if t_type == TemplateType.zip: if t_type == TemplateType.zip:
return self._render_zip(recipe) return self._render_zip(recipe)
@ -96,41 +89,8 @@ class TemplateService(BaseService):
return save_path return save_path
def _render_jinja2(self, recipe: Recipe, j2_template: str | None = None) -> Path:
"""
Renders a Jinja2 Template in a temporary directory and returns
the path to the file.
"""
self.__check_temp(self._render_jinja2)
if j2_template is None:
raise ValueError("Template must be provided for method _render_jinja2")
j2_path: Path = self.directories.TEMPLATE_DIR / j2_template
if not j2_path.is_file():
raise FileNotFoundError(f"Template '{j2_path}' not found.")
with open(j2_path) as f:
template_text = f.read()
template = Template(template_text)
rendered_text = template.render(recipe=recipe.model_dump(by_alias=True))
save_name = f"{recipe.slug}{j2_path.suffix}"
if self.temp is None:
raise ValueError("Temporary directory must be provided for method _render_jinja2")
save_path = self.temp.joinpath(save_name)
with open(save_path, "w") as f:
f.write(rendered_text)
return save_path
def _render_zip(self, recipe: Recipe) -> Path: def _render_zip(self, recipe: Recipe) -> Path:
self.__check_temp(self._render_jinja2) self.__check_temp(self._render_zip)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value) image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)

View file

@ -12,7 +12,7 @@ def test_seed_invalid_locale(api_client: TestClient, unique_user: TestUser):
def test_seed_foods(api_client: TestClient, unique_user: TestUser): def test_seed_foods(api_client: TestClient, unique_user: TestUser):
CREATED_FOODS = 214 CREATED_FOODS = 2687
database = unique_user.repos database = unique_user.repos
# Check that the foods was created # Check that the foods was created
@ -44,7 +44,7 @@ def test_seed_units(api_client: TestClient, unique_user: TestUser):
def test_seed_labels(api_client: TestClient, unique_user: TestUser): def test_seed_labels(api_client: TestClient, unique_user: TestUser):
CREATED_LABELS = 21 CREATED_LABELS = 32
database = unique_user.repos database = unique_user.repos
# Check that the foods was created # Check that the foods was created

View file

@ -99,7 +99,7 @@ def test_new_label_creates_list_labels_in_all_households(
def test_seed_label_creates_list_labels(api_client: TestClient, unique_user: TestUser): def test_seed_label_creates_list_labels(api_client: TestClient, unique_user: TestUser):
CREATED_LABELS = 21 CREATED_LABELS = 32
database = unique_user.repos database = unique_user.repos
# create a list with some labels # create a list with some labels

View file

@ -900,3 +900,50 @@ def test_get_cookbook_recipes(api_client: TestClient, unique_user: utils.TestUse
assert recipe.id in fetched_recipe_ids assert recipe.id in fetched_recipe_ids
for recipe in other_recipes: for recipe in other_recipes:
assert recipe.id not in fetched_recipe_ids assert recipe.id not in fetched_recipe_ids
def test_create_recipe_with_extremely_long_slug(api_client: TestClient, unique_user: TestUser):
"""Test creating a recipe with an extremely long name that would generate a very long slug.
This reproduces the issue where long slugs cause 500 internal server errors.
"""
# Create a recipe name that's extremely long like the one in the GitHub issue
long_recipe_name = "giallozafferano-on-instagram-il-piatto-vincente-di-simone-barlaam-medaglia-d-oro-e-d-argento-a-parigi-2024-paccheri-tricolore-se-ve-li-siete-persi-dovete-assolutamente-rimediare-lulugargari-ingredienti-paccheri-320-gr-spinacini-500-gr-nocciole-50-gr-ricotta-350-gr-olio-evo-q-b-limone-non-trattato-con-buccia-edibile-q-b-menta-q-b-peperoncino-fresco-q-b-10-pomodorini-ciliegino-preparazione-saltiamo-gli-spinaci-in-padella-lasciamo-raffreddare-e-frulliamo-insieme-a-ricotta-olio-sale-pepe-e-peperoncino-fresco-cuociamo-la-pasta-al-dente-e-mantechiamo-fuori-dal-fuoco-con-la-crema-tostiamo-a-parte-noci-o-nocciole-e-frulliamo-con-scorza-di-limone-impiattiamo-i-paccheri-con-qualche-spinacino-fresco-ciuffetti-di-ricotta-pomodorini-tagliati-in-4-e-la-polvere-di-nocciole-e-limone-buon-appetito-dmtc-pr-finp-nuotoparalimpico-giallozafferano-ricette-olimpiadi-paralimpiadi-atleti-simonebarlaam-cucina-paccheri-pasta-spinaci" # noqa: E501
# Create the recipe
response = api_client.post(api_routes.recipes, json={"name": long_recipe_name}, headers=unique_user.token)
assert response.status_code == 201
created_slug = json.loads(response.text)
assert created_slug is not None
assert len(created_slug) > 0
new_name = "Pasta"
response = api_client.patch(
api_routes.recipes_slug(created_slug), json={"name": new_name}, headers=unique_user.token
)
assert response.status_code == 200
updated_recipe = json.loads(response.text)
assert updated_recipe["name"] == new_name
assert updated_recipe["slug"] == slugify(new_name)
def test_create_recipe_slug_length_validation(api_client: TestClient, unique_user: TestUser):
"""Test that recipe slugs are properly truncated to a reasonable length."""
very_long_name = "A" * 500 # 500 character name
response = api_client.post(api_routes.recipes, json={"name": very_long_name}, headers=unique_user.token)
assert response.status_code == 201
created_slug = json.loads(response.text)
# The slug should be truncated to a reasonable length
# Using 250 characters as a reasonable limit, leaving room for collision suffixes
assert len(created_slug) <= 250
assert created_slug is not None
assert len(created_slug) > 0
response = api_client.get(api_routes.recipes_slug(created_slug), headers=unique_user.token)
assert response.status_code == 200

View file

@ -18,28 +18,9 @@ def test_get_available_exports(api_client: TestClient, unique_user: TestUser) ->
as_json = response.json() as_json = response.json()
assert "recipes.md" in as_json["jinja2"]
assert "raw" in as_json["json"] assert "raw" in as_json["json"]
def test_render_jinja_template(api_client: TestClient, unique_user: TestUser) -> None:
# Create Recipe
recipe_name = random_string()
response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token)
assert response.status_code == 201
slug = response.json()
# Render Template
response = api_client.get(
api_routes.recipes_slug_exports(slug) + "?template_name=recipes.md", headers=unique_user.token
)
assert response.status_code == 200
# Assert Template is Rendered Correctly
# TODO: More robust test
assert f"# {recipe_name}" in response.text
def test_get_recipe_as_zip(api_client: TestClient, unique_user: TestUser) -> None: def test_get_recipe_as_zip(api_client: TestClient, unique_user: TestUser) -> None:
# Create Recipe # Create Recipe
recipe_name = random_string() recipe_name = random_string()
@ -61,13 +42,3 @@ def test_get_recipe_as_zip(api_client: TestClient, unique_user: TestUser) -> Non
with zipfile.ZipFile(zip_file, "r") as zip_fp: with zipfile.ZipFile(zip_file, "r") as zip_fp:
with zip_fp.open(f"{slug}.json") as json_fp: with zip_fp.open(f"{slug}.json") as json_fp:
assert json.loads(json_fp.read())["name"] == recipe_name assert json.loads(json_fp.read())["name"] == recipe_name
# TODO: Allow users to upload templates to their own directory
# def test_upload_template(api_client: TestClient, unique_user: TestUser) -> None:
# assert False
# # TODO: Allow users to upload templates to their own directory
# def test_delete_template(api_client: TestClient, unique_user: TestUser) -> None:
# assert False

View file

@ -3,6 +3,5 @@ from mealie.services.recipe.template_service import TemplateService, TemplateTyp
def test_recipe_export_types() -> None: def test_recipe_export_types() -> None:
ts = TemplateService() ts = TemplateService()
assert ts.template_type("recipes.md") == TemplateType.jinja2.value
assert ts.template_type("raw") == TemplateType.json.value assert ts.template_type("raw") == TemplateType.json.value
assert ts.template_type("zip") == TemplateType.zip.value assert ts.template_type("zip") == TemplateType.zip.value