mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-21 14:03:32 -07:00
Merge branch 'mealie-next' into fix-500-error-on-recipe-share-link
This commit is contained in:
commit
5f576dc2c4
66 changed files with 47035 additions and 29218 deletions
114
.github/workflows/locale-sync.yml
vendored
Normal file
114
.github/workflows/locale-sync.yml
vendored
Normal 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"
|
|
@ -70,7 +70,7 @@ tasks:
|
|||
dev:generate:
|
||||
desc: run code generators
|
||||
cmds:
|
||||
- poetry run python dev/code-generation/main.py
|
||||
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||
- task: py:format
|
||||
|
||||
dev:services:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import os
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
@ -13,7 +14,7 @@ from mealie.schema._mealie import MealieModel
|
|||
|
||||
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
|
||||
|
@ -23,19 +24,22 @@ class LocaleData:
|
|||
|
||||
|
||||
LOCALE_DATA: dict[str, LocaleData] = {
|
||||
"en-US": LocaleData(name="American English"),
|
||||
"en-GB": LocaleData(name="British English"),
|
||||
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
|
||||
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
|
||||
"bg-BG": LocaleData(name="Български (Bulgarian)"),
|
||||
"ca-ES": LocaleData(name="Català (Catalan)"),
|
||||
"cs-CZ": LocaleData(name="Čeština (Czech)"),
|
||||
"da-DK": LocaleData(name="Dansk (Danish)"),
|
||||
"de-DE": LocaleData(name="Deutsch (German)"),
|
||||
"el-GR": LocaleData(name="Ελληνικά (Greek)"),
|
||||
"en-GB": LocaleData(name="British English"),
|
||||
"en-US": LocaleData(name="American English"),
|
||||
"es-ES": LocaleData(name="Español (Spanish)"),
|
||||
"et-EE": LocaleData(name="Eesti (Estonian)"),
|
||||
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
||||
"fr-FR": LocaleData(name="Français (French)"),
|
||||
"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)"),
|
||||
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
||||
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
|
||||
|
@ -53,6 +57,7 @@ LOCALE_DATA: dict[str, LocaleData] = {
|
|||
"pt-PT": LocaleData(name="Português (Portuguese)"),
|
||||
"ro-RO": LocaleData(name="Română (Romanian)"),
|
||||
"ru-RU": LocaleData(name="Pусский (Russian)"),
|
||||
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
|
||||
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
|
||||
"sr-SP": LocaleData(name="српски (Serbian)"),
|
||||
"sv-SE": LocaleData(name="Svenska (Swedish)"),
|
||||
|
@ -93,8 +98,8 @@ class CrowdinApi:
|
|||
project_id = "451976"
|
||||
api_key = API_KEY
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
api_key = api_key
|
||||
def __init__(self, api_key: str | None):
|
||||
self.api_key = api_key or API_KEY
|
||||
|
||||
@property
|
||||
def headers(self) -> dict:
|
||||
|
@ -196,7 +201,7 @@ def inject_registration_validation_values():
|
|||
|
||||
|
||||
def generate_locales_ts_file():
|
||||
api = CrowdinApi("")
|
||||
api = CrowdinApi(None)
|
||||
models = api.get_languages()
|
||||
tmpl = Template(LOCALE_TEMPLATE)
|
||||
rendered = tmpl.render(locales=models)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import gen_py_pytest_data_paths
|
||||
|
@ -11,15 +12,39 @@ CWD = Path(__file__).parent
|
|||
|
||||
|
||||
def main():
|
||||
items = [
|
||||
(gen_py_schema_exports.main, "schema exports"),
|
||||
(gen_ts_types.main, "frontend types"),
|
||||
(gen_ts_locales.main, "locales"),
|
||||
(gen_py_pytest_data_paths.main, "test data paths"),
|
||||
(gen_py_pytest_routes.main, "pytest routes"),
|
||||
]
|
||||
parser = argparse.ArgumentParser(description="Run code generators")
|
||||
parser.add_argument(
|
||||
"generators",
|
||||
nargs="*",
|
||||
help="Specific generators to run (schema, types, locales, data-paths, routes). If none specified, all will run.", # noqa: E501 - long line
|
||||
)
|
||||
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}...")
|
||||
func()
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
@ -35,7 +34,7 @@ class CodeSlicer:
|
|||
start: int
|
||||
end: int
|
||||
|
||||
indentation: str
|
||||
indentation: str | None
|
||||
text: list[str]
|
||||
|
||||
_next_line = None
|
||||
|
@ -47,15 +46,24 @@ class CodeSlicer:
|
|||
|
||||
def push_line(self, string: str) -> None:
|
||||
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
|
||||
|
||||
|
||||
def get_indentation_of_string(line: str, comment_char: str = "//|#") -> str:
|
||||
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n")
|
||||
def get_indentation_of_string(line: str) -> str:
|
||||
# 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
|
||||
end = None
|
||||
indentation = None
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
|
||||
|
||||

|
||||
|
||||
# {{ 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 }}
|
75
dev/scripts/convert_seed_files_to_new_format.py
Normal file
75
dev/scripts/convert_seed_files_to_new_format.py
Normal 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()
|
|
@ -1052,8 +1052,8 @@
|
|||
"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-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-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-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 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",
|
||||
"source-food": "Source Food",
|
||||
"target-food": "Target Food",
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
|
||||
recipes_markdown = CWD / "recipes.md"
|
|
@ -1,24 +0,0 @@
|
|||
|
||||
|
||||

|
||||
|
||||
# {{ 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 }}
|
|
@ -1,8 +1,5 @@
|
|||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from mealie.assets import templates
|
||||
|
||||
|
||||
class AppDirectories:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
|
@ -38,9 +35,3 @@ class AppDirectories:
|
|||
|
||||
for dir in required_dirs:
|
||||
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)
|
||||
|
|
|
@ -13,7 +13,7 @@ TRANSLATIONS = CWD / "messages"
|
|||
|
||||
class Translator(Protocol):
|
||||
@abstractmethod
|
||||
def t(self, key, default=None, **kwargs):
|
||||
def t(self, key, default=None, **kwargs) -> str:
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ from uuid import UUID
|
|||
import sqlalchemy as sa
|
||||
from fastapi import HTTPException
|
||||
from pydantic import UUID4
|
||||
from slugify import slugify
|
||||
from sqlalchemy import orm
|
||||
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.schema.cookbook.cookbook import ReadCookBook
|
||||
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_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
|
||||
from mealie.schema.recipe.recipe_tool import RecipeToolOut
|
||||
|
@ -98,7 +97,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
|||
except IntegrityError:
|
||||
self.session.rollback()
|
||||
document.name = f"{original_name} ({i})"
|
||||
document.slug = slugify(document.name)
|
||||
document.slug = create_recipe_slug(document.name)
|
||||
|
||||
if i >= max_retries:
|
||||
raise
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,692 +1,62 @@
|
|||
{
|
||||
"acorn-squash": {
|
||||
"name": "acorn squash"
|
||||
},
|
||||
"alfalfa-sprouts": {
|
||||
"name": "alfalfa sprouts"
|
||||
},
|
||||
"anchovies": {
|
||||
"name": "anchovies"
|
||||
},
|
||||
"apples": {
|
||||
"name": "apple",
|
||||
"plural_name": "apples"
|
||||
},
|
||||
"artichoke": {
|
||||
"name": "artichoke"
|
||||
},
|
||||
"arugula": {
|
||||
"name": "arugula"
|
||||
},
|
||||
"asparagus": {
|
||||
"name": "asparagus"
|
||||
},
|
||||
"avocado": {
|
||||
"name": "avocado",
|
||||
"plural_name": "avocado"
|
||||
},
|
||||
"bacon": {
|
||||
"name": "bacon"
|
||||
},
|
||||
"baking-powder": {
|
||||
"name": "baking powder"
|
||||
},
|
||||
"baking-soda": {
|
||||
"name": "baking soda"
|
||||
},
|
||||
"baking-sugar": {
|
||||
"name": "baking sugar"
|
||||
},
|
||||
"bar-sugar": {
|
||||
"name": "bar sugar"
|
||||
},
|
||||
"basil": {
|
||||
"name": "basil"
|
||||
},
|
||||
"beans": {
|
||||
"name": "beans"
|
||||
},
|
||||
"bell-peppers": {
|
||||
"name": "bell peppers",
|
||||
"plural_name": "bell peppers"
|
||||
},
|
||||
"blackberries": {
|
||||
"name": "blackberries"
|
||||
},
|
||||
"bok-choy": {
|
||||
"name": "bok choy"
|
||||
},
|
||||
"brassicas": {
|
||||
"name": "brassicas"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
"": {
|
||||
"foods": {}
|
||||
},
|
||||
"خَضْراوات وفواكه": {
|
||||
"foods": {}
|
||||
},
|
||||
"الحبوب": {
|
||||
"foods": {}
|
||||
},
|
||||
"آلفواكه": {
|
||||
"foods": {}
|
||||
},
|
||||
"الخضراوات": {
|
||||
"foods": {}
|
||||
},
|
||||
"اللحوم": {
|
||||
"foods": {}
|
||||
},
|
||||
"المأكولات البحرية": {
|
||||
"foods": {}
|
||||
},
|
||||
"المشروبات": {
|
||||
"foods": {}
|
||||
},
|
||||
"المخبوزات": {
|
||||
"foods": {}
|
||||
},
|
||||
"المعلبات": {
|
||||
"foods": {}
|
||||
},
|
||||
"الباهرات": {
|
||||
"foods": {}
|
||||
},
|
||||
"الحَلْوَيَات": {
|
||||
"foods": {}
|
||||
},
|
||||
"منتجات الألبان": {
|
||||
"foods": {}
|
||||
},
|
||||
"الأطعمة المجمدة": {
|
||||
"foods": {}
|
||||
},
|
||||
"الأغذية الصحية": {
|
||||
"foods": {}
|
||||
},
|
||||
"المنزل": {
|
||||
"foods": {}
|
||||
},
|
||||
"منتجات اللحوم": {
|
||||
"foods": {}
|
||||
},
|
||||
"الوجبات الخفيفة": {
|
||||
"foods": {}
|
||||
},
|
||||
"التوابل": {
|
||||
"foods": {}
|
||||
},
|
||||
"أخرى": {
|
||||
"foods": {}
|
||||
}
|
||||
}
|
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
|
@ -3,12 +3,17 @@ import pathlib
|
|||
from collections.abc import Generator
|
||||
from functools import cached_property
|
||||
|
||||
from mealie.schema.labels import MultiPurposeLabelSave
|
||||
from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood, SaveIngredientUnit
|
||||
from mealie.schema.labels import MultiPurposeLabelOut, MultiPurposeLabelSave
|
||||
from mealie.schema.recipe.recipe_ingredient import (
|
||||
IngredientFood,
|
||||
IngredientUnit,
|
||||
SaveIngredientFood,
|
||||
SaveIngredientUnit,
|
||||
)
|
||||
from mealie.services.group_services.labels_service import MultiPurposeLabelService
|
||||
|
||||
from ._abstract_seeder import AbstractSeeder
|
||||
from .resources import foods, labels, units
|
||||
from .resources import foods, units
|
||||
|
||||
|
||||
class MultiPurposeLabelSeeder(AbstractSeeder):
|
||||
|
@ -17,20 +22,24 @@ class MultiPurposeLabelSeeder(AbstractSeeder):
|
|||
return MultiPurposeLabelService(self.repos)
|
||||
|
||||
def get_file(self, locale: str | None = None) -> pathlib.Path:
|
||||
locale_path = self.resources / "labels" / "locales" / f"{locale}.json"
|
||||
return locale_path if locale_path.exists() else labels.en_US
|
||||
# Get the labels from the foods seed file now
|
||||
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]:
|
||||
file = self.get_file(locale)
|
||||
|
||||
seen_label_names = set()
|
||||
for label in json.loads(file.read_text(encoding="utf-8")):
|
||||
if label["name"] in seen_label_names:
|
||||
continue
|
||||
|
||||
seen_label_names.add(label["name"])
|
||||
current_label_names = {label.name for label in self.get_all_labels()}
|
||||
# load from the foods locale file and remove any empty strings
|
||||
seed_label_names = set(filter(None, json.loads(file.read_text(encoding="utf-8")).keys())) # type: set[str]
|
||||
# only seed new labels
|
||||
to_seed_labels = seed_label_names - current_label_names
|
||||
for label in to_seed_labels:
|
||||
yield MultiPurposeLabelSave(
|
||||
name=label["name"],
|
||||
name=label,
|
||||
group_id=self.repos.group_id,
|
||||
)
|
||||
|
||||
|
@ -48,10 +57,13 @@ class IngredientUnitsSeeder(AbstractSeeder):
|
|||
locale_path = self.resources / "units" / "locales" / f"{locale}.json"
|
||||
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]:
|
||||
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():
|
||||
if unit["name"] in seen_unit_names:
|
||||
continue
|
||||
|
@ -80,21 +92,32 @@ class IngredientFoodsSeeder(AbstractSeeder):
|
|||
locale_path = self.resources / "foods" / "locales" / f"{locale}.json"
|
||||
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]:
|
||||
file = self.get_file(locale)
|
||||
|
||||
seed_foods_names = set()
|
||||
for food in json.loads(file.read_text(encoding="utf-8")).values():
|
||||
if food["name"] in seed_foods_names:
|
||||
continue
|
||||
# get all current unique foods
|
||||
seen_foods_names = {food.name for food in self.get_all_foods()}
|
||||
for label, values in json.loads(file.read_text(encoding="utf-8")).items():
|
||||
label_out = self.get_label(label)
|
||||
|
||||
seed_foods_names.add(food["name"])
|
||||
yield SaveIngredientFood(
|
||||
group_id=self.repos.group_id,
|
||||
name=food["name"],
|
||||
plural_name=food.get("plural_name"),
|
||||
description="",
|
||||
)
|
||||
for food_name, attributes in values["foods"].items():
|
||||
if food_name in seen_foods_names:
|
||||
continue
|
||||
|
||||
seen_foods_names.add(food_name)
|
||||
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:
|
||||
self.logger.info("Seeding Ingredient Foods")
|
||||
|
|
|
@ -32,7 +32,6 @@ class JSONBytes(JSONResponse):
|
|||
class FormatResponse(BaseModel):
|
||||
jjson: list[str] = Field(..., alias="json")
|
||||
zip: list[str]
|
||||
jinja2: list[str]
|
||||
|
||||
|
||||
class BaseRecipeController(BaseCrudController):
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import shutil
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import File, HTTPException, UploadFile, status
|
||||
from pydantic import UUID4
|
||||
|
@ -24,7 +25,10 @@ class UserImageController(BaseUserController):
|
|||
"""Updates a User Image"""
|
||||
with get_temporary_path() as temp_path:
|
||||
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:
|
||||
shutil.copyfileobj(profile.file, buffer)
|
||||
|
|
|
@ -36,6 +36,22 @@ from .recipe_step import RecipeStep
|
|||
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):
|
||||
id: UUID4 | None = None
|
||||
group_id: UUID4 | None = None
|
||||
|
@ -229,7 +245,7 @@ class Recipe(RecipeSummary):
|
|||
if not info.data.get("name"):
|
||||
return slug
|
||||
|
||||
return slugify(info.data["name"])
|
||||
return create_recipe_slug(info.data["name"])
|
||||
|
||||
@field_validator("recipe_ingredient", mode="before")
|
||||
def validate_ingredients(recipe_ingredient):
|
||||
|
|
|
@ -9,7 +9,6 @@ from uuid import UUID, uuid4
|
|||
from zipfile import ZipFile
|
||||
|
||||
from fastapi import UploadFile
|
||||
from slugify import slugify
|
||||
|
||||
from mealie.core import exceptions
|
||||
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.schema.household.household import HouseholdInDB, HouseholdRecipeUpdate
|
||||
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_notes import RecipeNote
|
||||
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_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.recipe_instructions = (
|
||||
None
|
||||
|
@ -447,7 +446,7 @@ class OpenAIRecipeService(RecipeServiceBase):
|
|||
group_id=self.user.group_id,
|
||||
household_id=self.household.id,
|
||||
name=openai_recipe.name,
|
||||
slug=slugify(openai_recipe.name),
|
||||
slug=create_recipe_slug(openai_recipe.name),
|
||||
description=openai_recipe.description,
|
||||
recipe_yield=openai_recipe.recipe_yield,
|
||||
total_time=openai_recipe.total_time,
|
||||
|
|
|
@ -2,8 +2,6 @@ import enum
|
|||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_image_types import RecipeImageTypes
|
||||
from mealie.services._base_service import BaseService
|
||||
|
@ -11,7 +9,6 @@ from mealie.services._base_service import BaseService
|
|||
|
||||
class TemplateType(str, enum.Enum):
|
||||
json = "json"
|
||||
jinja2 = "jinja2"
|
||||
zip = "zip"
|
||||
|
||||
|
||||
|
@ -32,7 +29,6 @@ class TemplateService(BaseService):
|
|||
Returns a list of all templates available to render.
|
||||
"""
|
||||
return {
|
||||
TemplateType.jinja2.value: [x.name for x in self.directories.TEMPLATE_DIR.iterdir() if x.is_file()],
|
||||
TemplateType.json.value: ["raw"],
|
||||
TemplateType.zip.value: ["zip"],
|
||||
}
|
||||
|
@ -65,16 +61,13 @@ class TemplateService(BaseService):
|
|||
Args:
|
||||
t_type (TemplateType): The type of template 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)
|
||||
|
||||
if t_type == TemplateType.json:
|
||||
return self._render_json(recipe)
|
||||
|
||||
if t_type == TemplateType.jinja2:
|
||||
return self._render_jinja2(recipe, template)
|
||||
|
||||
if t_type == TemplateType.zip:
|
||||
return self._render_zip(recipe)
|
||||
|
||||
|
@ -96,41 +89,8 @@ class TemplateService(BaseService):
|
|||
|
||||
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:
|
||||
self.__check_temp(self._render_jinja2)
|
||||
self.__check_temp(self._render_zip)
|
||||
|
||||
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
|
||||
|
||||
|
|
|
@ -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):
|
||||
CREATED_FOODS = 214
|
||||
CREATED_FOODS = 2687
|
||||
database = unique_user.repos
|
||||
|
||||
# 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):
|
||||
CREATED_LABELS = 21
|
||||
CREATED_LABELS = 32
|
||||
database = unique_user.repos
|
||||
|
||||
# Check that the foods was created
|
||||
|
|
|
@ -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):
|
||||
CREATED_LABELS = 21
|
||||
CREATED_LABELS = 32
|
||||
database = unique_user.repos
|
||||
|
||||
# create a list with some labels
|
||||
|
|
|
@ -900,3 +900,50 @@ def test_get_cookbook_recipes(api_client: TestClient, unique_user: utils.TestUse
|
|||
assert recipe.id in fetched_recipe_ids
|
||||
for recipe in other_recipes:
|
||||
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
|
||||
|
|
|
@ -18,28 +18,9 @@ def test_get_available_exports(api_client: TestClient, unique_user: TestUser) ->
|
|||
|
||||
as_json = response.json()
|
||||
|
||||
assert "recipes.md" in as_json["jinja2"]
|
||||
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:
|
||||
# Create Recipe
|
||||
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 zip_fp.open(f"{slug}.json") as json_fp:
|
||||
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
|
||||
|
|
|
@ -3,6 +3,5 @@ from mealie.services.recipe.template_service import TemplateService, TemplateTyp
|
|||
|
||||
def test_recipe_export_types() -> None:
|
||||
ts = TemplateService()
|
||||
assert ts.template_type("recipes.md") == TemplateType.jinja2.value
|
||||
assert ts.template_type("raw") == TemplateType.json.value
|
||||
assert ts.template_type("zip") == TemplateType.zip.value
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue