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 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()
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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"]>({
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
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 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()
|
||||||
|
|||||||
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.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
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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__()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user