mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-14 11:53:11 -05:00
feat: Customize Ingredient Plural Handling (#7057)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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>): 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<LocaleObject["code"]>({
|
||||
|
||||
@@ -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"]
|
||||
|
||||
66
mealie/lang/locale_config.py
Normal file
66
mealie/lang/locale_config.py
Normal 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),
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
0
mealie/middleware/__init__.py
Normal file
0
mealie/middleware/__init__.py
Normal file
22
mealie/middleware/locale_context.py
Normal file
22
mealie/middleware/locale_context.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user