mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-15 04:13:11 -05:00
feat: Customize Ingredient Plural Handling (#7057)
This commit is contained in:
@@ -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__()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user