feat: Customize Ingredient Plural Handling (#7057)

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

View File

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

View File

@@ -0,0 +1,66 @@
from dataclasses import dataclass
from enum import StrEnum
class LocaleTextDirection(StrEnum):
LTR = "ltr"
RTL = "rtl"
class LocalePluralFoodHandling(StrEnum):
ALWAYS = "always"
WITHOUT_UNIT = "without-unit"
NEVER = "never"
@dataclass
class LocaleConfig:
name: str
dir: LocaleTextDirection = LocaleTextDirection.LTR
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
LOCALE_CONFIG: dict[str, LocaleConfig] = {
"af-ZA": LocaleConfig(name="Afrikaans (Afrikaans)"),
"ar-SA": LocaleConfig(name="العربية (Arabic)", dir=LocaleTextDirection.RTL),
"bg-BG": LocaleConfig(name="Български (Bulgarian)"),
"ca-ES": LocaleConfig(name="Català (Catalan)"),
"cs-CZ": LocaleConfig(name="Čeština (Czech)"),
"da-DK": LocaleConfig(name="Dansk (Danish)"),
"de-DE": LocaleConfig(name="Deutsch (German)"),
"el-GR": LocaleConfig(name="Ελληνικά (Greek)"),
"en-GB": LocaleConfig(name="British English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT),
"en-US": LocaleConfig(name="American English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT),
"es-ES": LocaleConfig(name="Español (Spanish)"),
"et-EE": LocaleConfig(name="Eesti (Estonian)"),
"fi-FI": LocaleConfig(name="Suomi (Finnish)"),
"fr-BE": LocaleConfig(name="Belge (Belgian)"),
"fr-CA": LocaleConfig(name="Français canadien (Canadian French)"),
"fr-FR": LocaleConfig(name="Français (French)"),
"gl-ES": LocaleConfig(name="Galego (Galician)"),
"he-IL": LocaleConfig(name="עברית (Hebrew)", dir=LocaleTextDirection.RTL),
"hr-HR": LocaleConfig(name="Hrvatski (Croatian)"),
"hu-HU": LocaleConfig(name="Magyar (Hungarian)"),
"is-IS": LocaleConfig(name="Íslenska (Icelandic)"),
"it-IT": LocaleConfig(name="Italiano (Italian)"),
"ja-JP": LocaleConfig(name="日本語 (Japanese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"ko-KR": LocaleConfig(name="한국어 (Korean)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"lt-LT": LocaleConfig(name="Lietuvių (Lithuanian)"),
"lv-LV": LocaleConfig(name="Latviešu (Latvian)"),
"nl-NL": LocaleConfig(name="Nederlands (Dutch)"),
"no-NO": LocaleConfig(name="Norsk (Norwegian)"),
"pl-PL": LocaleConfig(name="Polski (Polish)"),
"pt-BR": LocaleConfig(name="Português do Brasil (Brazilian Portuguese)"),
"pt-PT": LocaleConfig(name="Português (Portuguese)"),
"ro-RO": LocaleConfig(name="Română (Romanian)"),
"ru-RU": LocaleConfig(name="Pусский (Russian)"),
"sk-SK": LocaleConfig(name="Slovenčina (Slovak)"),
"sl-SI": LocaleConfig(name="Slovenščina (Slovenian)"),
"sr-SP": LocaleConfig(name="српски (Serbian)"),
"sv-SE": LocaleConfig(name="Svenska (Swedish)"),
"tr-TR": LocaleConfig(name="Türkçe (Turkish)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"uk-UA": LocaleConfig(name="Українська (Ukrainian)"),
"vi-VN": LocaleConfig(name="Tiếng Việt (Vietnamese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"zh-CN": LocaleConfig(name="简体中文 (Chinese simplified)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"zh-TW": LocaleConfig(name="繁體中文 (Chinese traditional)", plural_food_handling=LocalePluralFoodHandling.NEVER),
}

View File

@@ -1,10 +1,12 @@
from abc import abstractmethod
from 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()

View File

View File

@@ -0,0 +1,22 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from mealie.lang.providers import get_locale_config, get_locale_provider, set_locale_context
class LocaleContextMiddleware(BaseHTTPMiddleware):
"""
Inject translator and locale config into context var.
This allows any part of the app to call get_locale_context, as long as it's within an HTTP request context.
"""
async def dispatch(self, request: Request, call_next):
accept_language = request.headers.get("accept-language")
translator = get_locale_provider(accept_language)
locale_config = get_locale_config(accept_language)
# Set context for this request
set_locale_context(translator, locale_config)
response = await call_next(request)
return response

View File

@@ -17,7 +17,8 @@ from mealie.core.root_logger import get_logger
from mealie.core.settings.directories import AppDirectories
from mealie.core.settings.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

View File

@@ -15,7 +15,7 @@ from mealie.core.exceptions import MissingClaimException, UserLockedOut
from mealie.core.security.providers.openid_provider import OpenIDProvider
from mealie.core.security.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")}

View File

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

View File

@@ -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__()