From 23c7bd7e3d51afc72aff08fd843481cb0d31354e Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:07:23 -0600 Subject: [PATCH] feat: Customize Ingredient Plural Handling (#7057) --- dev/code-generation/gen_ts_locales.py | 182 +++++++++--------- .../use-extract-ingredient-references.test.ts | 33 ++-- .../recipes/use-recipe-ingredients.test.ts | 73 ++++++- .../recipes/use-recipe-ingredients.ts | 26 ++- .../use-locales/available-locales.ts | 44 ++++- .../composables/use-locales/use-locales.ts | 3 +- mealie/app.py | 2 + mealie/lang/locale_config.py | 66 +++++++ mealie/lang/providers.py | 24 ++- mealie/middleware/__init__.py | 0 mealie/middleware/locale_context.py | 22 +++ mealie/routes/_base/base_controllers.py | 10 +- mealie/routes/auth/auth.py | 4 +- mealie/schema/recipe/recipe_ingredient.py | 30 ++- mealie/services/email/email_service.py | 4 +- .../test_recipe_ingredients.py | 45 ++++- .../scraper_tests/test_cleaner.py | 6 +- .../scraper_tests/test_cleaner_parts.py | 6 +- tests/unit_tests/test_exceptions.py | 4 +- tests/unit_tests/test_recipe_parser.py | 4 +- 20 files changed, 449 insertions(+), 139 deletions(-) create mode 100644 mealie/lang/locale_config.py create mode 100644 mealie/middleware/__init__.py create mode 100644 mealie/middleware/locale_context.py diff --git a/dev/code-generation/gen_ts_locales.py b/dev/code-generation/gen_ts_locales.py index 4208ca844..b2d3ab3e4 100644 --- a/dev/code-generation/gen_ts_locales.py +++ b/dev/code-generation/gen_ts_locales.py @@ -1,6 +1,7 @@ +import json import os import pathlib -from dataclasses import dataclass +import re from pathlib import Path import dotenv @@ -10,6 +11,7 @@ from pydantic import ConfigDict from requests import Response from utils import CodeDest, CodeKeys, inject_inline, log +from mealie.lang.locale_config import LOCALE_CONFIG, LocalePluralFoodHandling, LocaleTextDirection from mealie.schema._mealie import MealieModel BASE = pathlib.Path(__file__).parent.parent.parent @@ -17,57 +19,6 @@ BASE = pathlib.Path(__file__).parent.parent.parent API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "") -@dataclass -class LocaleData: - name: str - dir: str = "ltr" - - -LOCALE_DATA: dict[str, LocaleData] = { - "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-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)"), - "hu-HU": LocaleData(name="Magyar (Hungarian)"), - "is-IS": LocaleData(name="Íslenska (Icelandic)"), - "it-IT": LocaleData(name="Italiano (Italian)"), - "ja-JP": LocaleData(name="日本語 (Japanese)"), - "ko-KR": LocaleData(name="한국어 (Korean)"), - "lt-LT": LocaleData(name="Lietuvių (Lithuanian)"), - "lv-LV": LocaleData(name="Latviešu (Latvian)"), - "nl-NL": LocaleData(name="Nederlands (Dutch)"), - "no-NO": LocaleData(name="Norsk (Norwegian)"), - "pl-PL": LocaleData(name="Polski (Polish)"), - "pt-BR": LocaleData(name="Português do Brasil (Brazilian Portuguese)"), - "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)"), - "tr-TR": LocaleData(name="Türkçe (Turkish)"), - "uk-UA": LocaleData(name="Українська (Ukrainian)"), - "vi-VN": LocaleData(name="Tiếng Việt (Vietnamese)"), - "zh-CN": LocaleData(name="简体中文 (Chinese simplified)"), - "zh-TW": LocaleData(name="繁體中文 (Chinese traditional)"), -} - LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py export const LOCALES = [{% for locale in locales %} { @@ -75,6 +26,7 @@ export const LOCALES = [{% for locale in locales %} value: "{{ locale.locale }}", progress: {{ locale.progress }}, dir: "{{ locale.dir }}", + pluralFoodHandling: "{{ locale.plural_food_handling }}", },{% endfor %} ]; @@ -87,10 +39,11 @@ class TargetLanguage(MealieModel): id: str name: str locale: str - dir: str = "ltr" + dir: LocaleTextDirection = LocaleTextDirection.LTR + plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS threeLettersCode: str twoLettersCode: str - progress: float = 0.0 + progress: int = 0 class CrowdinApi: @@ -117,43 +70,15 @@ class CrowdinApi: def get_languages(self) -> list[TargetLanguage]: response = self.get_project() tls = response.json()["data"]["targetLanguages"] + return [TargetLanguage(**t) for t in tls] - models = [TargetLanguage(**t) for t in tls] - - models.insert( - 0, - TargetLanguage( - id="en-US", - name="English", - locale="en-US", - dir="ltr", - threeLettersCode="en", - twoLettersCode="en", - progress=100, - ), - ) - - progress: list[dict] = self.get_progress()["data"] - - for model in models: - if model.locale in LOCALE_DATA: - locale_data = LOCALE_DATA[model.locale] - model.name = locale_data.name - model.dir = locale_data.dir - - for p in progress: - if p["data"]["languageId"] == model.id: - model.progress = p["data"]["translationProgress"] - - models.sort(key=lambda x: x.locale, reverse=True) - return models - - def get_progress(self) -> dict: + def get_progress(self) -> dict[str, int]: response = requests.get( f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500", headers=self.headers, ) - return response.json() + data = response.json()["data"] + return {p["data"]["languageId"]: p["translationProgress"] for p in data} PROJECT_DIR = Path(__file__).parent.parent.parent @@ -195,8 +120,8 @@ def inject_nuxt_values(): all_langs = [] for match in locales_dir.glob("*.json"): - match_data = LOCALE_DATA.get(match.stem) - match_dir = match_data.dir if match_data else "ltr" + match_data = LOCALE_CONFIG.get(match.stem) + match_dir = match_data.dir if match_data else LocaleTextDirection.LTR lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},' all_langs.append(lang_string) @@ -221,9 +146,82 @@ def inject_registration_validation_values(): inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs) +def _get_local_models() -> list[TargetLanguage]: + return [ + TargetLanguage( + id=locale, + name=data.name, + locale=locale, + threeLettersCode=locale.split("-")[-1], + twoLettersCode=locale.split("-")[-1], + ) + for locale, data in LOCALE_CONFIG.items() + if locale != "en-US" # Crowdin doesn't include this, so we manually inject it later + ] + + +def _get_local_progress() -> dict[str, int]: + with open(CodeDest.use_locales) as f: + content = f.read() + + # Extract the array content between [ and ] + match = re.search(r"export const LOCALES = (\[.*?\]);", content, re.DOTALL) + if not match: + raise ValueError("Could not find LOCALES array in file") + + # Convert JS to JSON + array_content = match.group(1) + + # Replace unquoted keys with quoted keys for valid JSON + # This converts: { name: "value" } to { "name": "value" } + json_str = re.sub(r"([,\{\s])([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'\1"\2":', array_content) + + # Remove trailing commas before } and ] + json_str = re.sub(r",(\s*[}\]])", r"\1", json_str) + + locales = json.loads(json_str) + return {locale["value"]: locale["progress"] for locale in locales} + + +def get_languages() -> list[TargetLanguage]: + if API_KEY: + api = CrowdinApi(None) + models = api.get_languages() + progress = api.get_progress() + else: + log.warning("CROWDIN_API_KEY is not set, using local lanugages instead") + log.warning("DOUBLE CHECK the output!!! Do not overwrite with bad local locale data!") + models = _get_local_models() + progress = _get_local_progress() + + models.insert( + 0, + TargetLanguage( + id="en-US", + name="English", + locale="en-US", + dir=LocaleTextDirection.LTR, + plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT, + threeLettersCode="en", + twoLettersCode="en", + progress=100, + ), + ) + + for model in models: + if model.locale in LOCALE_CONFIG: + locale_data = LOCALE_CONFIG[model.locale] + model.name = locale_data.name + model.dir = locale_data.dir + model.plural_food_handling = locale_data.plural_food_handling + model.progress = progress.get(model.id, model.progress) + + models.sort(key=lambda x: x.locale, reverse=True) + return models + + def generate_locales_ts_file(): - api = CrowdinApi(None) - models = api.get_languages() + models = get_languages() tmpl = Template(LOCALE_TEMPLATE) rendered = tmpl.render(locales=models) @@ -233,10 +231,6 @@ def generate_locales_ts_file(): def main(): - if API_KEY is None or API_KEY == "": - log.error("CROWDIN_API_KEY is not set") - return - generate_locales_ts_file() inject_nuxt_values() inject_registration_validation_values() diff --git a/frontend/composables/recipe-page/use-extract-ingredient-references.test.ts b/frontend/composables/recipe-page/use-extract-ingredient-references.test.ts index 9721348ce..fa2640bb4 100644 --- a/frontend/composables/recipe-page/use-extract-ingredient-references.test.ts +++ b/frontend/composables/recipe-page/use-extract-ingredient-references.test.ts @@ -1,60 +1,71 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi, beforeEach } from "vitest"; import { useExtractIngredientReferences } from "./use-extract-ingredient-references"; +import { useLocales } from "../use-locales"; + +vi.mock("../use-locales"); const punctuationMarks = ["*", "?", "/", "!", "**", "&", "."]; describe("test use extract ingredient references", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useLocales).mockReturnValue({ + locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }], + locale: { value: "en-US", pluralFoodHandling: "without-unit" }, + } as any); + }); + test("when text empty return empty", () => { - const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "", true); + const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], ""); expect(result).toStrictEqual(new Set()); }); test("when and ingredient matches exactly and has a reference id, return the referenceId", () => { - const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion", true); + const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion"); expect(result).toEqual(new Set(["123"])); }); test.each(punctuationMarks)("when ingredient is suffixed by punctuation, return the referenceId", (suffix) => { - const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix, true); + const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix); expect(result).toEqual(new Set(["123"])); }); test.each(punctuationMarks)("when ingredient is prefixed by punctuation, return the referenceId", (prefix) => { - const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion", true); + const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion"); expect(result).toEqual(new Set(["123"])); }); test("when ingredient is first on a multiline, return the referenceId", () => { const multilineSting = "lksjdlk\nOnion"; - const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting, true); + const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting); expect(result).toEqual(new Set(["123"])); }); test("when the ingredient matches partially exactly and has a reference id, return the referenceId", () => { - const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions", true); + const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions"); expect(result).toEqual(new Set(["123"])); }); test("when the ingredient matches with different casing and has a reference id, return the referenceId", () => { - const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions", true); + const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions"); expect(result).toEqual(new Set(["123"])); }); test("when no ingredients, return empty", () => { - const result = useExtractIngredientReferences([], [], "A sentence containing oNions", true); + const result = useExtractIngredientReferences([], [], "A sentence containing oNions"); expect(result).toEqual(new Set()); }); test("when and ingredient matches but in the existing referenceIds, do not return the referenceId", () => { - const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion", true); + const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion"); expect(result).toEqual(new Set()); }); test("when an word is 2 letter of shorter, it is ignored", () => { - const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On", true); + const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On"); expect(result).toEqual(new Set()); }); diff --git a/frontend/composables/recipes/use-recipe-ingredients.test.ts b/frontend/composables/recipes/use-recipe-ingredients.test.ts index 398c42da3..5e98c7728 100644 --- a/frontend/composables/recipes/use-recipe-ingredients.test.ts +++ b/frontend/composables/recipes/use-recipe-ingredients.test.ts @@ -1,8 +1,19 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi, beforeEach } from "vitest"; import { parseIngredientText } from "./use-recipe-ingredients"; import type { RecipeIngredient } from "~/lib/api/types/recipe"; +import { useLocales } from "../use-locales"; + +vi.mock("../use-locales"); describe(parseIngredientText.name, () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useLocales).mockReturnValue({ + locales: [{ value: "en-US", pluralFoodHandling: "always" }], + locale: { value: "en-US", pluralFoodHandling: "always" }, + } as any); + }); + const createRecipeIngredient = (overrides: Partial): RecipeIngredient => ({ quantity: 1, food: { @@ -128,4 +139,64 @@ describe(parseIngredientText.name, () => { expect(parseIngredientText(ingredient, 2)).toEqual("2 tablespoons diced onions"); }); + + test("plural handling: 'always' strategy uses plural food with unit", () => { + vi.mocked(useLocales).mockReturnValue({ + locales: [{ value: "en-US", pluralFoodHandling: "always" }], + locale: { value: "en-US", pluralFoodHandling: "always" }, + } as any); + + const ingredient = createRecipeIngredient({ + quantity: 2, + unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false }, + food: { id: "1", name: "diced onion", pluralName: "diced onions" }, + }); + + expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onions"); + }); + + test("plural handling: 'never' strategy never uses plural food", () => { + vi.mocked(useLocales).mockReturnValue({ + locales: [{ value: "en-US", pluralFoodHandling: "never" }], + locale: { value: "en-US", pluralFoodHandling: "never" }, + } as any); + + const ingredient = createRecipeIngredient({ + quantity: 2, + unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false }, + food: { id: "1", name: "diced onion", pluralName: "diced onions" }, + }); + + expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion"); + }); + + test("plural handling: 'without-unit' strategy uses plural food without unit", () => { + vi.mocked(useLocales).mockReturnValue({ + locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }], + locale: { value: "en-US", pluralFoodHandling: "without-unit" }, + } as any); + + const ingredient = createRecipeIngredient({ + quantity: 2, + food: { id: "1", name: "diced onion", pluralName: "diced onions" }, + unit: undefined, + }); + + expect(parseIngredientText(ingredient)).toEqual("2 diced onions"); + }); + + test("plural handling: 'without-unit' strategy uses singular food with unit", () => { + vi.mocked(useLocales).mockReturnValue({ + locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }], + locale: { value: "en-US", pluralFoodHandling: "without-unit" }, + } as any); + + const ingredient = createRecipeIngredient({ + quantity: 2, + unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false }, + food: { id: "1", name: "diced onion", pluralName: "diced onions" }, + }); + + expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion"); + }); }); diff --git a/frontend/composables/recipes/use-recipe-ingredients.ts b/frontend/composables/recipes/use-recipe-ingredients.ts index e6557654e..bae49b538 100644 --- a/frontend/composables/recipes/use-recipe-ingredients.ts +++ b/frontend/composables/recipes/use-recipe-ingredients.ts @@ -1,5 +1,6 @@ import DOMPurify from "isomorphic-dompurify"; import { useFraction } from "./use-fraction"; +import { useLocales } from "../use-locales"; import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe"; const { frac } = useFraction(); @@ -56,10 +57,33 @@ type ParsedIngredientText = { recipeLink?: string; }; +function shouldUsePluralFood(quantity: number, hasUnit: boolean, pluralFoodHandling: string): boolean { + if (quantity && quantity <= 1) { + return false; + } + + switch (pluralFoodHandling) { + case "always": + return true; + case "without-unit": + return !(quantity && hasUnit); + case "never": + return false; + + default: + // same as without-unit + return !(quantity && hasUnit); + } +} + export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText { + const { locales, locale } = useLocales(); + const filteredLocales = locales.filter(lc => lc.value === locale.value); + const pluralFoodHandling = filteredLocales.length ? filteredLocales[0].pluralFoodHandling : "without-unit"; + const { quantity, food, unit, note, referencedRecipe } = ingredient; const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0); - const usePluralFood = (!quantity) || quantity * scale > 1; + const usePluralFood = shouldUsePluralFood((quantity || 0) * scale, !!unit, pluralFoodHandling); let returnQty = ""; diff --git a/frontend/composables/use-locales/available-locales.ts b/frontend/composables/use-locales/available-locales.ts index 748783074..54cd01d48 100644 --- a/frontend/composables/use-locales/available-locales.ts +++ b/frontend/composables/use-locales/available-locales.ts @@ -5,251 +5,293 @@ export const LOCALES = [ value: "zh-TW", progress: 9, dir: "ltr", + pluralFoodHandling: "never", }, { name: "简体中文 (Chinese simplified)", value: "zh-CN", progress: 38, dir: "ltr", + pluralFoodHandling: "never", }, { name: "Tiếng Việt (Vietnamese)", value: "vi-VN", progress: 2, dir: "ltr", + pluralFoodHandling: "never", }, { name: "Українська (Ukrainian)", value: "uk-UA", progress: 83, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Türkçe (Turkish)", value: "tr-TR", progress: 40, dir: "ltr", + pluralFoodHandling: "never", }, { name: "Svenska (Swedish)", value: "sv-SE", progress: 61, dir: "ltr", + pluralFoodHandling: "always", }, { name: "српски (Serbian)", value: "sr-SP", progress: 16, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Slovenščina (Slovenian)", value: "sl-SI", progress: 40, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Slovenčina (Slovak)", value: "sk-SK", progress: 47, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Pусский (Russian)", value: "ru-RU", progress: 44, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Română (Romanian)", value: "ro-RO", progress: 44, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Português (Portuguese)", value: "pt-PT", progress: 39, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Português do Brasil (Brazilian Portuguese)", value: "pt-BR", progress: 46, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Polski (Polish)", value: "pl-PL", progress: 49, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Norsk (Norwegian)", value: "no-NO", progress: 42, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Nederlands (Dutch)", value: "nl-NL", progress: 60, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Latviešu (Latvian)", value: "lv-LV", progress: 35, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Lietuvių (Lithuanian)", value: "lt-LT", progress: 30, dir: "ltr", + pluralFoodHandling: "always", }, { name: "한국어 (Korean)", value: "ko-KR", progress: 38, dir: "ltr", + pluralFoodHandling: "never", }, { name: "日本語 (Japanese)", value: "ja-JP", progress: 36, dir: "ltr", + pluralFoodHandling: "never", }, { name: "Italiano (Italian)", value: "it-IT", progress: 52, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Íslenska (Icelandic)", value: "is-IS", progress: 43, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Magyar (Hungarian)", value: "hu-HU", progress: 46, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Hrvatski (Croatian)", value: "hr-HR", progress: 30, dir: "ltr", + pluralFoodHandling: "always", }, { name: "עברית (Hebrew)", value: "he-IL", progress: 64, dir: "rtl", + pluralFoodHandling: "always", }, { name: "Galego (Galician)", value: "gl-ES", progress: 38, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Français (French)", value: "fr-FR", progress: 67, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Français canadien (Canadian French)", value: "fr-CA", progress: 83, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Belge (Belgian)", value: "fr-BE", progress: 39, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Suomi (Finnish)", value: "fi-FI", progress: 40, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Eesti (Estonian)", value: "et-EE", progress: 45, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Español (Spanish)", value: "es-ES", progress: 46, dir: "ltr", + pluralFoodHandling: "always", }, { name: "American English", value: "en-US", - progress: 100.0, + progress: 100, dir: "ltr", + pluralFoodHandling: "without-unit", }, { name: "British English", value: "en-GB", progress: 42, dir: "ltr", + pluralFoodHandling: "without-unit", }, { name: "Ελληνικά (Greek)", value: "el-GR", progress: 41, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Deutsch (German)", value: "de-DE", progress: 85, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Dansk (Danish)", value: "da-DK", progress: 65, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Čeština (Czech)", value: "cs-CZ", progress: 43, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Català (Catalan)", value: "ca-ES", progress: 40, dir: "ltr", + pluralFoodHandling: "always", }, { name: "Български (Bulgarian)", value: "bg-BG", progress: 49, dir: "ltr", + pluralFoodHandling: "always", }, { name: "العربية (Arabic)", value: "ar-SA", progress: 25, dir: "rtl", + pluralFoodHandling: "always", }, { name: "Afrikaans (Afrikaans)", value: "af-ZA", progress: 26, dir: "ltr", + pluralFoodHandling: "always", }, ]; diff --git a/frontend/composables/use-locales/use-locales.ts b/frontend/composables/use-locales/use-locales.ts index 1287bf047..124a136df 100644 --- a/frontend/composables/use-locales/use-locales.ts +++ b/frontend/composables/use-locales/use-locales.ts @@ -1,8 +1,9 @@ import type { LocaleObject } from "@nuxtjs/i18n"; import { LOCALES } from "./available-locales"; +import { useGlobalI18n } from "../use-global-i18n"; export const useLocales = () => { - const i18n = useI18n(); + const i18n = useGlobalI18n(); const { current: vuetifyLocale } = useLocale(); const locale = computed({ diff --git a/mealie/app.py b/mealie/app.py index 8ea6811a6..4309050a6 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -20,6 +20,7 @@ from starlette.middleware.sessions import SessionMiddleware from mealie.core.config import get_app_settings from mealie.core.root_logger import get_logger from mealie.core.settings.static import APP_VERSION +from mealie.middleware.locale_context import LocaleContextMiddleware from mealie.routes import router, spa, utility_routes from mealie.routes.handlers import register_debug_handler from mealie.routes.media import media_router @@ -107,6 +108,7 @@ app = FastAPI( app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(SessionMiddleware, secret_key=settings.SESSION_SECRET) +app.add_middleware(LocaleContextMiddleware) if not settings.PRODUCTION: allowed_origins = ["http://localhost:3000"] diff --git a/mealie/lang/locale_config.py b/mealie/lang/locale_config.py new file mode 100644 index 000000000..9ebef497f --- /dev/null +++ b/mealie/lang/locale_config.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass +from enum import StrEnum + + +class LocaleTextDirection(StrEnum): + LTR = "ltr" + RTL = "rtl" + + +class LocalePluralFoodHandling(StrEnum): + ALWAYS = "always" + WITHOUT_UNIT = "without-unit" + NEVER = "never" + + +@dataclass +class LocaleConfig: + name: str + dir: LocaleTextDirection = LocaleTextDirection.LTR + plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS + + +LOCALE_CONFIG: dict[str, LocaleConfig] = { + "af-ZA": LocaleConfig(name="Afrikaans (Afrikaans)"), + "ar-SA": LocaleConfig(name="العربية (Arabic)", dir=LocaleTextDirection.RTL), + "bg-BG": LocaleConfig(name="Български (Bulgarian)"), + "ca-ES": LocaleConfig(name="Català (Catalan)"), + "cs-CZ": LocaleConfig(name="Čeština (Czech)"), + "da-DK": LocaleConfig(name="Dansk (Danish)"), + "de-DE": LocaleConfig(name="Deutsch (German)"), + "el-GR": LocaleConfig(name="Ελληνικά (Greek)"), + "en-GB": LocaleConfig(name="British English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT), + "en-US": LocaleConfig(name="American English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT), + "es-ES": LocaleConfig(name="Español (Spanish)"), + "et-EE": LocaleConfig(name="Eesti (Estonian)"), + "fi-FI": LocaleConfig(name="Suomi (Finnish)"), + "fr-BE": LocaleConfig(name="Belge (Belgian)"), + "fr-CA": LocaleConfig(name="Français canadien (Canadian French)"), + "fr-FR": LocaleConfig(name="Français (French)"), + "gl-ES": LocaleConfig(name="Galego (Galician)"), + "he-IL": LocaleConfig(name="עברית (Hebrew)", dir=LocaleTextDirection.RTL), + "hr-HR": LocaleConfig(name="Hrvatski (Croatian)"), + "hu-HU": LocaleConfig(name="Magyar (Hungarian)"), + "is-IS": LocaleConfig(name="Íslenska (Icelandic)"), + "it-IT": LocaleConfig(name="Italiano (Italian)"), + "ja-JP": LocaleConfig(name="日本語 (Japanese)", plural_food_handling=LocalePluralFoodHandling.NEVER), + "ko-KR": LocaleConfig(name="한국어 (Korean)", plural_food_handling=LocalePluralFoodHandling.NEVER), + "lt-LT": LocaleConfig(name="Lietuvių (Lithuanian)"), + "lv-LV": LocaleConfig(name="Latviešu (Latvian)"), + "nl-NL": LocaleConfig(name="Nederlands (Dutch)"), + "no-NO": LocaleConfig(name="Norsk (Norwegian)"), + "pl-PL": LocaleConfig(name="Polski (Polish)"), + "pt-BR": LocaleConfig(name="Português do Brasil (Brazilian Portuguese)"), + "pt-PT": LocaleConfig(name="Português (Portuguese)"), + "ro-RO": LocaleConfig(name="Română (Romanian)"), + "ru-RU": LocaleConfig(name="Pусский (Russian)"), + "sk-SK": LocaleConfig(name="Slovenčina (Slovak)"), + "sl-SI": LocaleConfig(name="Slovenščina (Slovenian)"), + "sr-SP": LocaleConfig(name="српски (Serbian)"), + "sv-SE": LocaleConfig(name="Svenska (Swedish)"), + "tr-TR": LocaleConfig(name="Türkçe (Turkish)", plural_food_handling=LocalePluralFoodHandling.NEVER), + "uk-UA": LocaleConfig(name="Українська (Ukrainian)"), + "vi-VN": LocaleConfig(name="Tiếng Việt (Vietnamese)", plural_food_handling=LocalePluralFoodHandling.NEVER), + "zh-CN": LocaleConfig(name="简体中文 (Chinese simplified)", plural_food_handling=LocalePluralFoodHandling.NEVER), + "zh-TW": LocaleConfig(name="繁體中文 (Chinese traditional)", plural_food_handling=LocalePluralFoodHandling.NEVER), +} diff --git a/mealie/lang/providers.py b/mealie/lang/providers.py index 67d1471f7..ddd2f44a5 100644 --- a/mealie/lang/providers.py +++ b/mealie/lang/providers.py @@ -1,10 +1,12 @@ from abc import abstractmethod +from contextvars import ContextVar from functools import lru_cache from pathlib import Path from typing import Protocol from fastapi import Header +from mealie.lang.locale_config import LOCALE_CONFIG, LocaleConfig from mealie.pkgs import i18n CWD = Path(__file__).parent @@ -17,6 +19,19 @@ class Translator(Protocol): pass +_locale_context: ContextVar[tuple[Translator, LocaleConfig] | None] = ContextVar("locale_context", default=None) + + +def set_locale_context(translator: Translator, locale_config: LocaleConfig) -> None: + """Set the locale context for the current request""" + _locale_context.set((translator, locale_config)) + + +def get_locale_context() -> tuple[Translator, LocaleConfig] | None: + """Get the current locale context""" + return _locale_context.get() + + @lru_cache def _load_factory() -> i18n.ProviderFactory: return i18n.ProviderFactory( @@ -25,12 +40,19 @@ def _load_factory() -> i18n.ProviderFactory: ) -def local_provider(accept_language: str | None = Header(None)) -> Translator: +def get_locale_provider(accept_language: str | None = Header(None)) -> Translator: factory = _load_factory() accept_language = accept_language or "en-US" return factory.get(accept_language) +def get_locale_config(accept_language: str | None = Header(None)) -> LocaleConfig: + if accept_language and accept_language in LOCALE_CONFIG: + return LOCALE_CONFIG[accept_language] + else: + return LOCALE_CONFIG["en-US"] + + @lru_cache def get_all_translations(key: str) -> dict[str, str]: factory = _load_factory() diff --git a/mealie/middleware/__init__.py b/mealie/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mealie/middleware/locale_context.py b/mealie/middleware/locale_context.py new file mode 100644 index 000000000..35772dcbf --- /dev/null +++ b/mealie/middleware/locale_context.py @@ -0,0 +1,22 @@ +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware + +from mealie.lang.providers import get_locale_config, get_locale_provider, set_locale_context + + +class LocaleContextMiddleware(BaseHTTPMiddleware): + """ + Inject translator and locale config into context var. + This allows any part of the app to call get_locale_context, as long as it's within an HTTP request context. + """ + + async def dispatch(self, request: Request, call_next): + accept_language = request.headers.get("accept-language") + translator = get_locale_provider(accept_language) + locale_config = get_locale_config(accept_language) + + # Set context for this request + set_locale_context(translator, locale_config) + + response = await call_next(request) + return response diff --git a/mealie/routes/_base/base_controllers.py b/mealie/routes/_base/base_controllers.py index e7d4bd5e2..5476e7407 100644 --- a/mealie/routes/_base/base_controllers.py +++ b/mealie/routes/_base/base_controllers.py @@ -17,7 +17,8 @@ from mealie.core.root_logger import get_logger from mealie.core.settings.directories import AppDirectories from mealie.core.settings.settings import AppSettings from mealie.db.db_setup import generate_session -from mealie.lang import local_provider +from mealie.lang import get_locale_config, get_locale_provider +from mealie.lang.locale_config import LocaleConfig from mealie.lang.providers import Translator from mealie.repos._utils import NOT_SET, NotSet from mealie.repos.all_repositories import AllRepositories, get_repositories @@ -30,7 +31,8 @@ from mealie.services.event_bus_service.event_types import EventDocumentDataBase, class _BaseController(ABC): # noqa: B024 session: Session = Depends(generate_session) - translator: Translator = Depends(local_provider) + translator: Translator = Depends(get_locale_provider) + locale_config: LocaleConfig = Depends(get_locale_config) _repos: AllRepositories | None = None _logger: Logger | None = None @@ -39,7 +41,7 @@ class _BaseController(ABC): # noqa: B024 @property def t(self): - return self.translator.t if self.translator else local_provider().t + return self.translator.t if self.translator else get_locale_provider().t @property def repos(self): @@ -136,7 +138,7 @@ class BaseUserController(_BaseController): user: PrivateUser = Depends(get_current_user) integration_id: str = Depends(get_integration_id) - translator: Translator = Depends(local_provider) + translator: Translator = Depends(get_locale_provider) # Manual Cache _checks: OperationChecks diff --git a/mealie/routes/auth/auth.py b/mealie/routes/auth/auth.py index 91ab58976..d5d961ef2 100644 --- a/mealie/routes/auth/auth.py +++ b/mealie/routes/auth/auth.py @@ -15,7 +15,7 @@ from mealie.core.exceptions import MissingClaimException, UserLockedOut from mealie.core.security.providers.openid_provider import OpenIDProvider from mealie.core.security.security import get_auth_provider from mealie.db.db_setup import generate_session -from mealie.lang import local_provider +from mealie.lang import get_locale_provider from mealie.routes._base.routers import UserAPIRouter from mealie.schema.user import PrivateUser from mealie.schema.user.auth import CredentialsRequestForm @@ -155,5 +155,5 @@ async def logout( ): response.delete_cookie("mealie.access_token") - translator = local_provider(accept_language) + translator = get_locale_provider(accept_language) return {"message": translator.t("notifications.logged-out")} diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index aab12f88c..cafdd58d8 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -11,6 +11,8 @@ from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm.interfaces import LoaderOption from mealie.db.models.recipe import IngredientFoodModel +from mealie.lang.locale_config import LocalePluralFoodHandling +from mealie.lang.providers import get_locale_context from mealie.schema._mealie import MealieModel from mealie.schema._mealie.mealie_model import UpdatedAtField from mealie.schema._mealie.types import NoneFloat @@ -239,18 +241,38 @@ class RecipeIngredientBase(MealieModel): return unit_val - def _format_food_for_display(self) -> str: + def _format_food_for_display(self, plural_handling: LocalePluralFoodHandling) -> str: if not self.food: return "" - use_plural = (not self.quantity) or self.quantity > 1 + if self.quantity and self.quantity <= 1: + use_plural = False + else: + match plural_handling: + case LocalePluralFoodHandling.NEVER: + use_plural = False + case LocalePluralFoodHandling.WITHOUT_UNIT: + # if quantity is zero then unit is not shown even if it's set + use_plural = not (self.quantity and self.unit) + case LocalePluralFoodHandling.ALWAYS: + use_plural = True + case _: + use_plural = False + if use_plural: return self.food.plural_name or self.food.name else: return self.food.name def _format_display(self) -> str: - components = [] + locale_context = get_locale_context() + if locale_context: + _, locale_cfg = locale_context + plural_food_handling = locale_cfg.plural_food_handling + else: + plural_food_handling = LocalePluralFoodHandling.WITHOUT_UNIT + + components: list[str] = [] if self.quantity: components.append(self._format_quantity_for_display()) @@ -259,7 +281,7 @@ class RecipeIngredientBase(MealieModel): components.append(self._format_unit_for_display()) if self.food: - components.append(self._format_food_for_display()) + components.append(self._format_food_for_display(plural_food_handling)) if self.note: components.append(self.note) diff --git a/mealie/services/email/email_service.py b/mealie/services/email/email_service.py index a66b3369c..fb42c96e4 100644 --- a/mealie/services/email/email_service.py +++ b/mealie/services/email/email_service.py @@ -4,7 +4,7 @@ from jinja2 import Template from pydantic import BaseModel from mealie.core.root_logger import get_logger -from mealie.lang import local_provider +from mealie.lang import get_locale_provider from mealie.lang.providers import Translator from mealie.services._base_service import BaseService @@ -34,7 +34,7 @@ class EmailService(BaseService): self.templates_dir = CWD / "templates" self.default_template = self.templates_dir / "default.html" self.sender: ABCEmailSender = sender or DefaultEmailSender() - self.translator: Translator = local_provider(locale) + self.translator: Translator = get_locale_provider(locale) super().__init__() diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_ingredients.py b/tests/integration_tests/user_recipe_tests/test_recipe_ingredients.py index 7ab0859ba..1a810f0ea 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_ingredients.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_ingredients.py @@ -1,7 +1,10 @@ +from unittest.mock import MagicMock from uuid import uuid4 import pytest +from pytest import MonkeyPatch +from mealie.lang.locale_config import LocalePluralFoodHandling from mealie.schema.recipe.recipe_ingredient import ( IngredientFood, IngredientUnit, @@ -10,13 +13,13 @@ from mealie.schema.recipe.recipe_ingredient import ( @pytest.mark.parametrize( - ["quantity", "quantity_display_decimal", "quantity_display_fraction", "expect_plural_unit", "expect_plural_food"], + ["quantity", "quantity_display_decimal", "quantity_display_fraction", "expect_plural_unit"], [ - [0, "", "", False, True], - [0.5, "0.5", "¹/₂", False, False], - [1, "1", "1", False, False], - [1.5, "1.5", "1 ¹/₂", True, True], - [2, "2", "2", True, True], + [0, "", "", False], + [0.5, "0.5", "¹/₂", False], + [1, "1", "1", False], + [1.5, "1.5", "1 ¹/₂", True], + [2, "2", "2", True], ], ) @pytest.mark.parametrize( @@ -163,6 +166,14 @@ from mealie.schema.recipe.recipe_ingredient import ( ], ) @pytest.mark.parametrize("note", ["very thin", "", None]) +@pytest.mark.parametrize( + "plural_handling", + [ + LocalePluralFoodHandling.ALWAYS, + LocalePluralFoodHandling.NEVER, + LocalePluralFoodHandling.WITHOUT_UNIT, + ], +) def test_ingredient_display( quantity: float | None, quantity_display_decimal: str, @@ -172,12 +183,32 @@ def test_ingredient_display( note: str | None, expect_display_fraction: bool, expect_plural_unit: bool, - expect_plural_food: bool, expected_unit_singular_string: str, expected_unit_plural_string: str, expected_food_singular_string: str, expected_food_plural_string: str, + plural_handling: LocalePluralFoodHandling, + monkeypatch: MonkeyPatch, ): + + mock_locale_cfg = MagicMock() + mock_locale_cfg.plural_food_handling = plural_handling + monkeypatch.setattr("mealie.schema.recipe.recipe_ingredient.get_locale_context", lambda: ("en-US", mock_locale_cfg)) + + # Calculate expect_plural_food based on plural_handling strategy + if quantity and quantity <= 1: + expect_plural_food = False + else: + match plural_handling: + case LocalePluralFoodHandling.NEVER: + expect_plural_food = False + case LocalePluralFoodHandling.WITHOUT_UNIT: + expect_plural_food = not (quantity and unit) + case LocalePluralFoodHandling.ALWAYS: + expect_plural_food = True + case _: + expect_plural_food = False + expected_components = [] if expect_display_fraction: expected_components.append(quantity_display_fraction) diff --git a/tests/unit_tests/services_tests/scraper_tests/test_cleaner.py b/tests/unit_tests/services_tests/scraper_tests/test_cleaner.py index 8574fc707..1c761aee6 100644 --- a/tests/unit_tests/services_tests/scraper_tests/test_cleaner.py +++ b/tests/unit_tests/services_tests/scraper_tests/test_cleaner.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from mealie.lang.providers import local_provider +from mealie.lang.providers import get_locale_provider from mealie.services.scraper import cleaner from mealie.services.scraper.scraper_strategies import RecipeScraperOpenGraph from tests import data as test_data @@ -38,7 +38,7 @@ test_cleaner_data = [ @pytest.mark.parametrize("json_file,num_steps", test_cleaner_data) def test_cleaner_clean(json_file: Path, num_steps): - translator = local_provider() + translator = get_locale_provider() recipe_data = cleaner.clean(json.loads(json_file.read_text()), translator) assert len(recipe_data.recipe_instructions or []) == num_steps @@ -46,7 +46,7 @@ def test_cleaner_clean(json_file: Path, num_steps): def test_html_with_recipe_data(): path = test_data.html_healthy_pasta_bake_60759 url = "https://www.bbc.co.uk/food/recipes/healthy_pasta_bake_60759" - translator = local_provider() + translator = get_locale_provider() open_graph_strategy = RecipeScraperOpenGraph(url, translator) diff --git a/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py b/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py index 48019e98e..ee6b689f6 100644 --- a/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py +++ b/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from mealie.lang.providers import local_provider +from mealie.lang.providers import get_locale_provider from mealie.services.scraper import cleaner @@ -477,7 +477,7 @@ time_test_cases = ( @pytest.mark.parametrize("case", time_test_cases, ids=(x.test_id for x in time_test_cases)) def test_cleaner_clean_time(case: CleanerCase): - translator = local_provider() + translator = get_locale_provider() result = cleaner.clean_time(case.input, translator) assert case.expected == result @@ -681,5 +681,5 @@ def test_cleaner_clean_nutrition(case: CleanerCase): ], ) def test_pretty_print_timedelta(t, max_components, max_decimal_places, expected): - translator = local_provider() + translator = get_locale_provider() assert cleaner.pretty_print_timedelta(t, translator, max_components, max_decimal_places) == expected diff --git a/tests/unit_tests/test_exceptions.py b/tests/unit_tests/test_exceptions.py index d20353ddd..4fe0a3694 100644 --- a/tests/unit_tests/test_exceptions.py +++ b/tests/unit_tests/test_exceptions.py @@ -1,9 +1,9 @@ from mealie.core import exceptions -from mealie.lang import local_provider +from mealie.lang import get_locale_provider def test_mealie_registered_exceptions() -> None: - provider = local_provider() + provider = get_locale_provider() lookup = exceptions.mealie_registered_exceptions(provider) diff --git a/tests/unit_tests/test_recipe_parser.py b/tests/unit_tests/test_recipe_parser.py index c26623447..e19cca5a5 100644 --- a/tests/unit_tests/test_recipe_parser.py +++ b/tests/unit_tests/test_recipe_parser.py @@ -1,6 +1,6 @@ import pytest -from mealie.lang.providers import local_provider +from mealie.lang.providers import get_locale_provider from mealie.services.scraper import scraper from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases @@ -19,7 +19,7 @@ and then use this test case by removing the `@pytest.mark.skip` and than testing @pytest.mark.parametrize("recipe_test_data", test_cases) @pytest.mark.asyncio async def test_recipe_parser(recipe_test_data: RecipeSiteTestCase): - translator = local_provider() + translator = get_locale_provider() recipe, _ = await scraper.create_from_html(recipe_test_data.url, translator) assert recipe.slug == recipe_test_data.expected_slug