feat: Customize Ingredient Plural Handling (#7057)

This commit is contained in:
Michael Genson
2026-02-12 19:07:23 -06:00
committed by GitHub
parent 9c1ee972c9
commit 23c7bd7e3d
20 changed files with 449 additions and 139 deletions

View File

@@ -1,6 +1,7 @@
import json
import os import os
import pathlib import pathlib
from dataclasses import dataclass import re
from pathlib import Path from pathlib import Path
import dotenv import dotenv
@@ -10,6 +11,7 @@ from pydantic import ConfigDict
from requests import Response from requests import Response
from utils import CodeDest, CodeKeys, inject_inline, log from utils import CodeDest, CodeKeys, inject_inline, log
from mealie.lang.locale_config import LOCALE_CONFIG, LocalePluralFoodHandling, LocaleTextDirection
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
BASE = pathlib.Path(__file__).parent.parent.parent 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", "") 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 LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py
export const LOCALES = [{% for locale in locales %} export const LOCALES = [{% for locale in locales %}
{ {
@@ -75,6 +26,7 @@ export const LOCALES = [{% for locale in locales %}
value: "{{ locale.locale }}", value: "{{ locale.locale }}",
progress: {{ locale.progress }}, progress: {{ locale.progress }},
dir: "{{ locale.dir }}", dir: "{{ locale.dir }}",
pluralFoodHandling: "{{ locale.plural_food_handling }}",
},{% endfor %} },{% endfor %}
]; ];
@@ -87,10 +39,11 @@ class TargetLanguage(MealieModel):
id: str id: str
name: str name: str
locale: str locale: str
dir: str = "ltr" dir: LocaleTextDirection = LocaleTextDirection.LTR
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
threeLettersCode: str threeLettersCode: str
twoLettersCode: str twoLettersCode: str
progress: float = 0.0 progress: int = 0
class CrowdinApi: class CrowdinApi:
@@ -117,43 +70,15 @@ class CrowdinApi:
def get_languages(self) -> list[TargetLanguage]: def get_languages(self) -> list[TargetLanguage]:
response = self.get_project() response = self.get_project()
tls = response.json()["data"]["targetLanguages"] tls = response.json()["data"]["targetLanguages"]
return [TargetLanguage(**t) for t in tls]
models = [TargetLanguage(**t) for t in tls] def get_progress(self) -> dict[str, int]:
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:
response = requests.get( response = requests.get(
f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500", f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
headers=self.headers, 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 PROJECT_DIR = Path(__file__).parent.parent.parent
@@ -195,8 +120,8 @@ def inject_nuxt_values():
all_langs = [] all_langs = []
for match in locales_dir.glob("*.json"): for match in locales_dir.glob("*.json"):
match_data = LOCALE_DATA.get(match.stem) match_data = LOCALE_CONFIG.get(match.stem)
match_dir = match_data.dir if match_data else "ltr" 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}" }},' lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
all_langs.append(lang_string) all_langs.append(lang_string)
@@ -221,9 +146,82 @@ def inject_registration_validation_values():
inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs) 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(): def generate_locales_ts_file():
api = CrowdinApi(None) models = get_languages()
models = api.get_languages()
tmpl = Template(LOCALE_TEMPLATE) tmpl = Template(LOCALE_TEMPLATE)
rendered = tmpl.render(locales=models) rendered = tmpl.render(locales=models)
@@ -233,10 +231,6 @@ def generate_locales_ts_file():
def main(): def main():
if API_KEY is None or API_KEY == "":
log.error("CROWDIN_API_KEY is not set")
return
generate_locales_ts_file() generate_locales_ts_file()
inject_nuxt_values() inject_nuxt_values()
inject_registration_validation_values() inject_registration_validation_values()

View File

@@ -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 { useExtractIngredientReferences } from "./use-extract-ingredient-references";
import { useLocales } from "../use-locales";
vi.mock("../use-locales");
const punctuationMarks = ["*", "?", "/", "!", "**", "&", "."]; const punctuationMarks = ["*", "?", "/", "!", "**", "&", "."];
describe("test use extract ingredient references", () => { 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", () => { 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()); expect(result).toStrictEqual(new Set());
}); });
test("when and ingredient matches exactly and has a reference id, return the referenceId", () => { 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test.each(punctuationMarks)("when ingredient is suffixed by punctuation, return the referenceId", (suffix) => { 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test.each(punctuationMarks)("when ingredient is prefixed by punctuation, return the referenceId", (prefix) => { 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test("when ingredient is first on a multiline, return the referenceId", () => { test("when ingredient is first on a multiline, return the referenceId", () => {
const multilineSting = "lksjdlk\nOnion"; 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test("when the ingredient matches partially exactly and has a reference id, return the referenceId", () => { 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test("when the ingredient matches with different casing and has a reference id, return the referenceId", () => { 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test("when no ingredients, return empty", () => { 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()); expect(result).toEqual(new Set());
}); });
test("when and ingredient matches but in the existing referenceIds, do not return the referenceId", () => { 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()); expect(result).toEqual(new Set());
}); });
test("when an word is 2 letter of shorter, it is ignored", () => { 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()); expect(result).toEqual(new Set());
}); });

View File

@@ -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 { parseIngredientText } from "./use-recipe-ingredients";
import type { RecipeIngredient } from "~/lib/api/types/recipe"; import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { useLocales } from "../use-locales";
vi.mock("../use-locales");
describe(parseIngredientText.name, () => { 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>): RecipeIngredient => ({ const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
quantity: 1, quantity: 1,
food: { food: {
@@ -128,4 +139,64 @@ describe(parseIngredientText.name, () => {
expect(parseIngredientText(ingredient, 2)).toEqual("2 tablespoons diced onions"); 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");
});
}); });

View File

@@ -1,5 +1,6 @@
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
import { useFraction } from "./use-fraction"; import { useFraction } from "./use-fraction";
import { useLocales } from "../use-locales";
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe"; import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
const { frac } = useFraction(); const { frac } = useFraction();
@@ -56,10 +57,33 @@ type ParsedIngredientText = {
recipeLink?: string; 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 { 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 { quantity, food, unit, note, referencedRecipe } = ingredient;
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0); 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 = ""; let returnQty = "";

View File

@@ -5,251 +5,293 @@ export const LOCALES = [
value: "zh-TW", value: "zh-TW",
progress: 9, progress: 9,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "never",
}, },
{ {
name: "简体中文 (Chinese simplified)", name: "简体中文 (Chinese simplified)",
value: "zh-CN", value: "zh-CN",
progress: 38, progress: 38,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "never",
}, },
{ {
name: "Tiếng Việt (Vietnamese)", name: "Tiếng Việt (Vietnamese)",
value: "vi-VN", value: "vi-VN",
progress: 2, progress: 2,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "never",
}, },
{ {
name: "Українська (Ukrainian)", name: "Українська (Ukrainian)",
value: "uk-UA", value: "uk-UA",
progress: 83, progress: 83,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Türkçe (Turkish)", name: "Türkçe (Turkish)",
value: "tr-TR", value: "tr-TR",
progress: 40, progress: 40,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "never",
}, },
{ {
name: "Svenska (Swedish)", name: "Svenska (Swedish)",
value: "sv-SE", value: "sv-SE",
progress: 61, progress: 61,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "српски (Serbian)", name: "српски (Serbian)",
value: "sr-SP", value: "sr-SP",
progress: 16, progress: 16,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Slovenščina (Slovenian)", name: "Slovenščina (Slovenian)",
value: "sl-SI", value: "sl-SI",
progress: 40, progress: 40,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Slovenčina (Slovak)", name: "Slovenčina (Slovak)",
value: "sk-SK", value: "sk-SK",
progress: 47, progress: 47,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Pусский (Russian)", name: "Pусский (Russian)",
value: "ru-RU", value: "ru-RU",
progress: 44, progress: 44,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Română (Romanian)", name: "Română (Romanian)",
value: "ro-RO", value: "ro-RO",
progress: 44, progress: 44,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Português (Portuguese)", name: "Português (Portuguese)",
value: "pt-PT", value: "pt-PT",
progress: 39, progress: 39,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Português do Brasil (Brazilian Portuguese)", name: "Português do Brasil (Brazilian Portuguese)",
value: "pt-BR", value: "pt-BR",
progress: 46, progress: 46,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Polski (Polish)", name: "Polski (Polish)",
value: "pl-PL", value: "pl-PL",
progress: 49, progress: 49,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Norsk (Norwegian)", name: "Norsk (Norwegian)",
value: "no-NO", value: "no-NO",
progress: 42, progress: 42,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Nederlands (Dutch)", name: "Nederlands (Dutch)",
value: "nl-NL", value: "nl-NL",
progress: 60, progress: 60,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Latviešu (Latvian)", name: "Latviešu (Latvian)",
value: "lv-LV", value: "lv-LV",
progress: 35, progress: 35,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Lietuvių (Lithuanian)", name: "Lietuvių (Lithuanian)",
value: "lt-LT", value: "lt-LT",
progress: 30, progress: 30,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "한국어 (Korean)", name: "한국어 (Korean)",
value: "ko-KR", value: "ko-KR",
progress: 38, progress: 38,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "never",
}, },
{ {
name: "日本語 (Japanese)", name: "日本語 (Japanese)",
value: "ja-JP", value: "ja-JP",
progress: 36, progress: 36,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "never",
}, },
{ {
name: "Italiano (Italian)", name: "Italiano (Italian)",
value: "it-IT", value: "it-IT",
progress: 52, progress: 52,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Íslenska (Icelandic)", name: "Íslenska (Icelandic)",
value: "is-IS", value: "is-IS",
progress: 43, progress: 43,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Magyar (Hungarian)", name: "Magyar (Hungarian)",
value: "hu-HU", value: "hu-HU",
progress: 46, progress: 46,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Hrvatski (Croatian)", name: "Hrvatski (Croatian)",
value: "hr-HR", value: "hr-HR",
progress: 30, progress: 30,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "עברית (Hebrew)", name: "עברית (Hebrew)",
value: "he-IL", value: "he-IL",
progress: 64, progress: 64,
dir: "rtl", dir: "rtl",
pluralFoodHandling: "always",
}, },
{ {
name: "Galego (Galician)", name: "Galego (Galician)",
value: "gl-ES", value: "gl-ES",
progress: 38, progress: 38,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Français (French)", name: "Français (French)",
value: "fr-FR", value: "fr-FR",
progress: 67, progress: 67,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Français canadien (Canadian French)", name: "Français canadien (Canadian French)",
value: "fr-CA", value: "fr-CA",
progress: 83, progress: 83,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Belge (Belgian)", name: "Belge (Belgian)",
value: "fr-BE", value: "fr-BE",
progress: 39, progress: 39,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Suomi (Finnish)", name: "Suomi (Finnish)",
value: "fi-FI", value: "fi-FI",
progress: 40, progress: 40,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Eesti (Estonian)", name: "Eesti (Estonian)",
value: "et-EE", value: "et-EE",
progress: 45, progress: 45,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Español (Spanish)", name: "Español (Spanish)",
value: "es-ES", value: "es-ES",
progress: 46, progress: 46,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "American English", name: "American English",
value: "en-US", value: "en-US",
progress: 100.0, progress: 100,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "without-unit",
}, },
{ {
name: "British English", name: "British English",
value: "en-GB", value: "en-GB",
progress: 42, progress: 42,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "without-unit",
}, },
{ {
name: "Ελληνικά (Greek)", name: "Ελληνικά (Greek)",
value: "el-GR", value: "el-GR",
progress: 41, progress: 41,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Deutsch (German)", name: "Deutsch (German)",
value: "de-DE", value: "de-DE",
progress: 85, progress: 85,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Dansk (Danish)", name: "Dansk (Danish)",
value: "da-DK", value: "da-DK",
progress: 65, progress: 65,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Čeština (Czech)", name: "Čeština (Czech)",
value: "cs-CZ", value: "cs-CZ",
progress: 43, progress: 43,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Català (Catalan)", name: "Català (Catalan)",
value: "ca-ES", value: "ca-ES",
progress: 40, progress: 40,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "Български (Bulgarian)", name: "Български (Bulgarian)",
value: "bg-BG", value: "bg-BG",
progress: 49, progress: 49,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
{ {
name: "العربية (Arabic)", name: "العربية (Arabic)",
value: "ar-SA", value: "ar-SA",
progress: 25, progress: 25,
dir: "rtl", dir: "rtl",
pluralFoodHandling: "always",
}, },
{ {
name: "Afrikaans (Afrikaans)", name: "Afrikaans (Afrikaans)",
value: "af-ZA", value: "af-ZA",
progress: 26, progress: 26,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always",
}, },
]; ];

View File

@@ -1,8 +1,9 @@
import type { LocaleObject } from "@nuxtjs/i18n"; import type { LocaleObject } from "@nuxtjs/i18n";
import { LOCALES } from "./available-locales"; import { LOCALES } from "./available-locales";
import { useGlobalI18n } from "../use-global-i18n";
export const useLocales = () => { export const useLocales = () => {
const i18n = useI18n(); const i18n = useGlobalI18n();
const { current: vuetifyLocale } = useLocale(); const { current: vuetifyLocale } = useLocale();
const locale = computed<LocaleObject["code"]>({ const locale = computed<LocaleObject["code"]>({

View File

@@ -20,6 +20,7 @@ from starlette.middleware.sessions import SessionMiddleware
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.core.settings.static import APP_VERSION 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 import router, spa, utility_routes
from mealie.routes.handlers import register_debug_handler from mealie.routes.handlers import register_debug_handler
from mealie.routes.media import media_router from mealie.routes.media import media_router
@@ -107,6 +108,7 @@ app = FastAPI(
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(SessionMiddleware, secret_key=settings.SESSION_SECRET) app.add_middleware(SessionMiddleware, secret_key=settings.SESSION_SECRET)
app.add_middleware(LocaleContextMiddleware)
if not settings.PRODUCTION: if not settings.PRODUCTION:
allowed_origins = ["http://localhost:3000"] allowed_origins = ["http://localhost:3000"]

View File

@@ -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),
}

View File

@@ -1,10 +1,12 @@
from abc import abstractmethod from abc import abstractmethod
from contextvars import ContextVar
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Protocol
from fastapi import Header from fastapi import Header
from mealie.lang.locale_config import LOCALE_CONFIG, LocaleConfig
from mealie.pkgs import i18n from mealie.pkgs import i18n
CWD = Path(__file__).parent CWD = Path(__file__).parent
@@ -17,6 +19,19 @@ class Translator(Protocol):
pass 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 @lru_cache
def _load_factory() -> i18n.ProviderFactory: def _load_factory() -> i18n.ProviderFactory:
return 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() factory = _load_factory()
accept_language = accept_language or "en-US" accept_language = accept_language or "en-US"
return factory.get(accept_language) 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 @lru_cache
def get_all_translations(key: str) -> dict[str, str]: def get_all_translations(key: str) -> dict[str, str]:
factory = _load_factory() factory = _load_factory()

View File

View File

@@ -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

View File

@@ -17,7 +17,8 @@ from mealie.core.root_logger import get_logger
from mealie.core.settings.directories import AppDirectories from mealie.core.settings.directories import AppDirectories
from mealie.core.settings.settings import AppSettings from mealie.core.settings.settings import AppSettings
from mealie.db.db_setup import generate_session 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.lang.providers import Translator
from mealie.repos._utils import NOT_SET, NotSet from mealie.repos._utils import NOT_SET, NotSet
from mealie.repos.all_repositories import AllRepositories, get_repositories 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 class _BaseController(ABC): # noqa: B024
session: Session = Depends(generate_session) 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 _repos: AllRepositories | None = None
_logger: Logger | None = None _logger: Logger | None = None
@@ -39,7 +41,7 @@ class _BaseController(ABC): # noqa: B024
@property @property
def t(self): 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 @property
def repos(self): def repos(self):
@@ -136,7 +138,7 @@ class BaseUserController(_BaseController):
user: PrivateUser = Depends(get_current_user) user: PrivateUser = Depends(get_current_user)
integration_id: str = Depends(get_integration_id) integration_id: str = Depends(get_integration_id)
translator: Translator = Depends(local_provider) translator: Translator = Depends(get_locale_provider)
# Manual Cache # Manual Cache
_checks: OperationChecks _checks: OperationChecks

View File

@@ -15,7 +15,7 @@ from mealie.core.exceptions import MissingClaimException, UserLockedOut
from mealie.core.security.providers.openid_provider import OpenIDProvider from mealie.core.security.providers.openid_provider import OpenIDProvider
from mealie.core.security.security import get_auth_provider from mealie.core.security.security import get_auth_provider
from mealie.db.db_setup import generate_session 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.routes._base.routers import UserAPIRouter
from mealie.schema.user import PrivateUser from mealie.schema.user import PrivateUser
from mealie.schema.user.auth import CredentialsRequestForm from mealie.schema.user.auth import CredentialsRequestForm
@@ -155,5 +155,5 @@ async def logout(
): ):
response.delete_cookie("mealie.access_token") response.delete_cookie("mealie.access_token")
translator = local_provider(accept_language) translator = get_locale_provider(accept_language)
return {"message": translator.t("notifications.logged-out")} return {"message": translator.t("notifications.logged-out")}

View File

@@ -11,6 +11,8 @@ from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.recipe import IngredientFoodModel 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 import MealieModel
from mealie.schema._mealie.mealie_model import UpdatedAtField from mealie.schema._mealie.mealie_model import UpdatedAtField
from mealie.schema._mealie.types import NoneFloat from mealie.schema._mealie.types import NoneFloat
@@ -239,18 +241,38 @@ class RecipeIngredientBase(MealieModel):
return unit_val return unit_val
def _format_food_for_display(self) -> str: def _format_food_for_display(self, plural_handling: LocalePluralFoodHandling) -> str:
if not self.food: if not self.food:
return "" 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: if use_plural:
return self.food.plural_name or self.food.name return self.food.plural_name or self.food.name
else: else:
return self.food.name return self.food.name
def _format_display(self) -> str: 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: if self.quantity:
components.append(self._format_quantity_for_display()) components.append(self._format_quantity_for_display())
@@ -259,7 +281,7 @@ class RecipeIngredientBase(MealieModel):
components.append(self._format_unit_for_display()) components.append(self._format_unit_for_display())
if self.food: if self.food:
components.append(self._format_food_for_display()) components.append(self._format_food_for_display(plural_food_handling))
if self.note: if self.note:
components.append(self.note) components.append(self.note)

View File

@@ -4,7 +4,7 @@ from jinja2 import Template
from pydantic import BaseModel from pydantic import BaseModel
from mealie.core.root_logger import get_logger 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.lang.providers import Translator
from mealie.services._base_service import BaseService from mealie.services._base_service import BaseService
@@ -34,7 +34,7 @@ class EmailService(BaseService):
self.templates_dir = CWD / "templates" self.templates_dir = CWD / "templates"
self.default_template = self.templates_dir / "default.html" self.default_template = self.templates_dir / "default.html"
self.sender: ABCEmailSender = sender or DefaultEmailSender() self.sender: ABCEmailSender = sender or DefaultEmailSender()
self.translator: Translator = local_provider(locale) self.translator: Translator = get_locale_provider(locale)
super().__init__() super().__init__()

View File

@@ -1,7 +1,10 @@
from unittest.mock import MagicMock
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from pytest import MonkeyPatch
from mealie.lang.locale_config import LocalePluralFoodHandling
from mealie.schema.recipe.recipe_ingredient import ( from mealie.schema.recipe.recipe_ingredient import (
IngredientFood, IngredientFood,
IngredientUnit, IngredientUnit,
@@ -10,13 +13,13 @@ from mealie.schema.recipe.recipe_ingredient import (
@pytest.mark.parametrize( @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, "", "", False],
[0.5, "0.5", "¹/₂", False, False], [0.5, "0.5", "¹/₂", False],
[1, "1", "1", False, False], [1, "1", "1", False],
[1.5, "1.5", "1 ¹/₂", True, True], [1.5, "1.5", "1 ¹/₂", True],
[2, "2", "2", True, True], [2, "2", "2", True],
], ],
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -163,6 +166,14 @@ from mealie.schema.recipe.recipe_ingredient import (
], ],
) )
@pytest.mark.parametrize("note", ["very thin", "", None]) @pytest.mark.parametrize("note", ["very thin", "", None])
@pytest.mark.parametrize(
"plural_handling",
[
LocalePluralFoodHandling.ALWAYS,
LocalePluralFoodHandling.NEVER,
LocalePluralFoodHandling.WITHOUT_UNIT,
],
)
def test_ingredient_display( def test_ingredient_display(
quantity: float | None, quantity: float | None,
quantity_display_decimal: str, quantity_display_decimal: str,
@@ -172,12 +183,32 @@ def test_ingredient_display(
note: str | None, note: str | None,
expect_display_fraction: bool, expect_display_fraction: bool,
expect_plural_unit: bool, expect_plural_unit: bool,
expect_plural_food: bool,
expected_unit_singular_string: str, expected_unit_singular_string: str,
expected_unit_plural_string: str, expected_unit_plural_string: str,
expected_food_singular_string: str, expected_food_singular_string: str,
expected_food_plural_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 = [] expected_components = []
if expect_display_fraction: if expect_display_fraction:
expected_components.append(quantity_display_fraction) expected_components.append(quantity_display_fraction)

View File

@@ -4,7 +4,7 @@ from pathlib import Path
import pytest 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 import cleaner
from mealie.services.scraper.scraper_strategies import RecipeScraperOpenGraph from mealie.services.scraper.scraper_strategies import RecipeScraperOpenGraph
from tests import data as test_data from tests import data as test_data
@@ -38,7 +38,7 @@ test_cleaner_data = [
@pytest.mark.parametrize("json_file,num_steps", test_cleaner_data) @pytest.mark.parametrize("json_file,num_steps", test_cleaner_data)
def test_cleaner_clean(json_file: Path, num_steps): 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) recipe_data = cleaner.clean(json.loads(json_file.read_text()), translator)
assert len(recipe_data.recipe_instructions or []) == num_steps 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(): def test_html_with_recipe_data():
path = test_data.html_healthy_pasta_bake_60759 path = test_data.html_healthy_pasta_bake_60759
url = "https://www.bbc.co.uk/food/recipes/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) open_graph_strategy = RecipeScraperOpenGraph(url, translator)

View File

@@ -4,7 +4,7 @@ from typing import Any
import pytest 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 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)) @pytest.mark.parametrize("case", time_test_cases, ids=(x.test_id for x in time_test_cases))
def test_cleaner_clean_time(case: CleanerCase): def test_cleaner_clean_time(case: CleanerCase):
translator = local_provider() translator = get_locale_provider()
result = cleaner.clean_time(case.input, translator) result = cleaner.clean_time(case.input, translator)
assert case.expected == result 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): 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 assert cleaner.pretty_print_timedelta(t, translator, max_components, max_decimal_places) == expected

View File

@@ -1,9 +1,9 @@
from mealie.core import exceptions from mealie.core import exceptions
from mealie.lang import local_provider from mealie.lang import get_locale_provider
def test_mealie_registered_exceptions() -> None: def test_mealie_registered_exceptions() -> None:
provider = local_provider() provider = get_locale_provider()
lookup = exceptions.mealie_registered_exceptions(provider) lookup = exceptions.mealie_registered_exceptions(provider)

View File

@@ -1,6 +1,6 @@
import pytest import pytest
from mealie.lang.providers import local_provider from mealie.lang.providers import get_locale_provider
from mealie.services.scraper import scraper from mealie.services.scraper import scraper
from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases 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.parametrize("recipe_test_data", test_cases)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recipe_parser(recipe_test_data: RecipeSiteTestCase): 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) recipe, _ = await scraper.create_from_html(recipe_test_data.url, translator)
assert recipe.slug == recipe_test_data.expected_slug assert recipe.slug == recipe_test_data.expected_slug