From 95f1798908bb3e669b34bf207c06b9ec66d4cd1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:04:14 +0000 Subject: [PATCH 1/5] Initial plan From ff76855f29343dae3eefe402a2130144c1a4291e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:28:02 +0000 Subject: [PATCH 2/5] feat(i18n): add internationalization framework with 10 languages - Create i18n utility module (app/utils/i18n.py) with translation loading, browser language detection, AI fallback, and l10n helpers - Add JSON translation files for EN, DE, FR, ES, IT, PT, NL, PL, ZH, RU - Add preferred_language column to UserProfile model with migration - Register _() translation function as Jinja2 global - Update base.html with translated navigation, footer, cookie notice - Add language selector dropdown in nav bar (desktop + mobile) - Create API endpoints for language preference (POST/GET /api/i18n/) - Support language detection: user profile > cookie > Accept-Language > default Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/__init__.py | 2 + app/api/i18n.py | 136 +++++++ app/models.py | 4 + app/utils/i18n.py | 367 ++++++++++++++++++ app/views/base.py | 41 ++ frontend/templates/base.html | 216 +++++++---- frontend/translations/de.json | 190 +++++++++ frontend/translations/en.json | 190 +++++++++ frontend/translations/es.json | 190 +++++++++ frontend/translations/fr.json | 190 +++++++++ frontend/translations/it.json | 190 +++++++++ frontend/translations/nl.json | 190 +++++++++ frontend/translations/pl.json | 190 +++++++++ frontend/translations/pt.json | 190 +++++++++ frontend/translations/ru.json | 190 +++++++++ frontend/translations/zh.json | 190 +++++++++ .../027_add_user_language_preference.py | 28 ++ 17 files changed, 2618 insertions(+), 76 deletions(-) create mode 100644 app/api/i18n.py create mode 100644 app/utils/i18n.py create mode 100644 frontend/translations/de.json create mode 100644 frontend/translations/en.json create mode 100644 frontend/translations/es.json create mode 100644 frontend/translations/fr.json create mode 100644 frontend/translations/it.json create mode 100644 frontend/translations/nl.json create mode 100644 frontend/translations/pl.json create mode 100644 frontend/translations/pt.json create mode 100644 frontend/translations/ru.json create mode 100644 frontend/translations/zh.json create mode 100644 migrations/versions/027_add_user_language_preference.py diff --git a/app/api/__init__.py b/app/api/__init__.py index ae98cbd7..aec13465 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -17,6 +17,7 @@ from app.api.dropbox import router as dropbox_router from app.api.duplicates import router as duplicates_router from app.api.files import router as files_router from app.api.google_drive import router as google_drive_router +from app.api.i18n import router as i18n_router from app.api.imap_accounts import router as imap_accounts_router from app.api.integrations import router as integrations_router from app.api.logs import router as logs_router @@ -82,3 +83,4 @@ router.include_router(imap_accounts_router) router.include_router(integrations_router) router.include_router(notifications_router) router.include_router(scheduled_jobs_router) +router.include_router(i18n_router) diff --git a/app/api/i18n.py b/app/api/i18n.py new file mode 100644 index 00000000..8c59ad60 --- /dev/null +++ b/app/api/i18n.py @@ -0,0 +1,136 @@ +"""API endpoints for internationalization (i18n). + +Provides endpoints for: +* Listing available languages +* Getting/setting user language preference (persisted in session + cookie + DB) +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Request, Response +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import UserProfile +from app.utils.i18n import ( + DEFAULT_LANGUAGE, + SUPPORTED_LANGUAGE_CODES, + SUPPORTED_LANGUAGES, + detect_language, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/i18n", tags=["i18n"]) + + +class LanguageInfo(BaseModel): + """Schema for a supported language.""" + + code: str + name: str + native: str + flag: str + + +class LanguageListResponse(BaseModel): + """Response for the list-languages endpoint.""" + + languages: list[LanguageInfo] + current: str + default: str + + +class SetLanguageRequest(BaseModel): + """Request body for setting the preferred language.""" + + language: str + + +class SetLanguageResponse(BaseModel): + """Response after changing the language.""" + + language: str + message: str + + +@router.get("/languages", response_model=LanguageListResponse) +async def list_languages(request: Request) -> LanguageListResponse: + """Return all supported UI languages and the current active language.""" + current = detect_language(request) + return LanguageListResponse( + languages=[LanguageInfo(**lang) for lang in SUPPORTED_LANGUAGES], + current=current, + default=DEFAULT_LANGUAGE, + ) + + +@router.post("/language", response_model=SetLanguageResponse) +async def set_language( + body: SetLanguageRequest, + request: Request, + response: Response, + db: Session = Depends(get_db), +) -> SetLanguageResponse: + """Set the preferred UI language. + + Persists the choice in: + 1. The server-side session + 2. A ``docuelevate_lang`` cookie (30-day expiry) + 3. The ``UserProfile.preferred_language`` column (if authenticated) + """ + lang = body.language.lower().strip() + if lang not in SUPPORTED_LANGUAGE_CODES: + lang = DEFAULT_LANGUAGE + + # 1. Session + if hasattr(request, "session"): + request.session["preferred_language"] = lang + + # 2. Cookie (30 days) + response.set_cookie( + key="docuelevate_lang", + value=lang, + max_age=30 * 24 * 60 * 60, + httponly=False, + samesite="lax", + ) + + # 3. Database (if user is authenticated) + _persist_language_to_profile(request, db, lang) + + language_name = next( + (l["native"] for l in SUPPORTED_LANGUAGES if l["code"] == lang), + lang, + ) + logger.info("Language preference set to '%s'", lang) + return SetLanguageResponse( + language=lang, + message=f"Language changed to {language_name}", + ) + + +def _persist_language_to_profile(request: Request, db: Session, lang: str) -> None: + """Write language preference to the UserProfile row, if the user is logged in.""" + user_id: str | None = None + if hasattr(request, "session"): + user = request.session.get("user") + if isinstance(user, dict): + user_id = user.get("preferred_username") or user.get("email") or user.get("id") + elif isinstance(user, str): + user_id = user + + if not user_id: + return + + try: + profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first() + if profile: + profile.preferred_language = lang # type: ignore[attr-defined] + db.commit() + except Exception: + db.rollback() + logger.debug("Could not persist language preference for user_id=%s", user_id) diff --git a/app/models.py b/app/models.py index 0cc7b53a..3a1703b5 100644 --- a/app/models.py +++ b/app/models.py @@ -256,6 +256,10 @@ class UserProfile(Base): preferred_destination = Column(String(50), nullable=True) stripe_customer_id = Column(String(64), nullable=True) + # UI language preference for i18n (ISO 639-1 code, e.g. "en", "de", "fr") + # NULL means "auto-detect from browser Accept-Language header" + preferred_language = Column(String(10), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/app/utils/i18n.py b/app/utils/i18n.py new file mode 100644 index 00000000..c6cd9c27 --- /dev/null +++ b/app/utils/i18n.py @@ -0,0 +1,367 @@ +"""Internationalization (i18n) and localization (l10n) utilities. + +Provides a JSON-based translation system for the DocuElevate UI with: + +* **10 supported languages** (EN, DE, FR, ES, IT, PT, NL, PL, ZH, RU) +* Browser ``Accept-Language`` detection with cookie & user-profile persistence +* AI-powered fallback translation via the configured LLM provider +* Locale-aware date, number, and file-size formatting helpers +* Jinja2 integration via a ``_()`` global function + +Language resolution order: + 1. User profile ``preferred_language`` (persisted in DB) + 2. ``docuelevate_lang`` cookie + 3. ``Accept-Language`` HTTP header + 4. Default (``en``) +""" + +from __future__ import annotations + +import json +import logging +from datetime import date, datetime +from functools import lru_cache +from pathlib import Path +from typing import Any + +from starlette.requests import Request + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Supported languages (ordered by priority) +# --------------------------------------------------------------------------- + +SUPPORTED_LANGUAGES: list[dict[str, str]] = [ + {"code": "en", "name": "English", "native": "English", "flag": "🇬🇧"}, + {"code": "de", "name": "German", "native": "Deutsch", "flag": "🇩🇪"}, + {"code": "fr", "name": "French", "native": "Français", "flag": "🇫🇷"}, + {"code": "es", "name": "Spanish", "native": "Español", "flag": "🇪🇸"}, + {"code": "it", "name": "Italian", "native": "Italiano", "flag": "🇮🇹"}, + {"code": "pt", "name": "Portuguese", "native": "Português", "flag": "🇵🇹"}, + {"code": "nl", "name": "Dutch", "native": "Nederlands", "flag": "🇳🇱"}, + {"code": "pl", "name": "Polish", "native": "Polski", "flag": "🇵🇱"}, + {"code": "zh", "name": "Chinese", "native": "中文", "flag": "🇨🇳"}, + {"code": "ru", "name": "Russian", "native": "Русский", "flag": "🇷🇺"}, +] + +SUPPORTED_LANGUAGE_CODES: set[str] = {lang["code"] for lang in SUPPORTED_LANGUAGES} +DEFAULT_LANGUAGE = "en" + +# --------------------------------------------------------------------------- +# Translation file loading +# --------------------------------------------------------------------------- + +_TRANSLATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" / "translations" +_translation_cache: dict[str, dict[str, str]] = {} + + +def _load_translations(locale: str) -> dict[str, str]: + """Load the translation JSON file for *locale*, with caching.""" + if locale in _translation_cache: + return _translation_cache[locale] + + filepath = _TRANSLATIONS_DIR / f"{locale}.json" + if not filepath.is_file(): + logger.warning("Translation file not found for locale '%s'", locale) + _translation_cache[locale] = {} + return {} + + try: + data: dict[str, str] = json.loads(filepath.read_text(encoding="utf-8")) + _translation_cache[locale] = data + return data + except (json.JSONDecodeError, OSError): + logger.exception("Failed to load translations for '%s'", locale) + _translation_cache[locale] = {} + return {} + + +def reload_translations() -> None: + """Clear the translation cache so files are re-read on next access.""" + _translation_cache.clear() + + +# --------------------------------------------------------------------------- +# Core translation function +# --------------------------------------------------------------------------- + + +def translate(key: str, locale: str | None = None, **kwargs: Any) -> str: + """Return the translated string for *key* in *locale*. + + Falls back through: + 1. Requested *locale* + 2. English (``en``) + 3. The raw key itself (to keep the UI functional) + + Positional placeholders ``{0}``, ``{1}`` or named placeholders + ``{name}`` in the translated string are interpolated via *kwargs*. + """ + locale = locale if locale and locale in SUPPORTED_LANGUAGE_CODES else DEFAULT_LANGUAGE + + translations = _load_translations(locale) + value = translations.get(key) + + # Fallback to English + if value is None and locale != DEFAULT_LANGUAGE: + en_translations = _load_translations(DEFAULT_LANGUAGE) + value = en_translations.get(key) + + # Fallback to key itself + if value is None: + value = key + + if kwargs: + try: + value = value.format(**kwargs) + except (KeyError, IndexError): + pass # Return unformatted string rather than crash + + return value + + +# --------------------------------------------------------------------------- +# AI fallback translation (best-effort, non-blocking) +# --------------------------------------------------------------------------- + +_ai_translation_cache: dict[tuple[str, str], str] = {} + + +def translate_with_ai_fallback(text: str, target_locale: str) -> str: + """Translate *text* using the configured AI provider as a fallback. + + Returns the original *text* unchanged when: + * The target locale is English (source language) + * The AI provider is unavailable or returns an error + * The translation has already been cached + + Results are cached in-memory for the lifetime of the process. + """ + if target_locale == DEFAULT_LANGUAGE or target_locale not in SUPPORTED_LANGUAGE_CODES: + return text + + cache_key = (text, target_locale) + if cache_key in _ai_translation_cache: + return _ai_translation_cache[cache_key] + + target_name = next( + (lang["name"] for lang in SUPPORTED_LANGUAGES if lang["code"] == target_locale), + target_locale, + ) + + try: + from litellm import completion # type: ignore[import-untyped] + + from app.config import settings + + model = getattr(settings, "ai_model", None) or getattr(settings, "openai_model", "gpt-4o-mini") + response = completion( + model=model, + messages=[ + { + "role": "system", + "content": ( + f"You are a professional translator. Translate the following UI text " + f"from English to {target_name}. Return ONLY the translated text, " + f"nothing else. Keep any HTML tags, placeholders like {{name}}, " + f"and special characters intact." + ), + }, + {"role": "user", "content": text}, + ], + max_tokens=256, + temperature=0.1, + ) + translated = response.choices[0].message.content.strip() + _ai_translation_cache[cache_key] = translated + return translated + except Exception: + logger.debug("AI fallback translation failed for '%s' → %s", text[:50], target_locale) + return text + + +# --------------------------------------------------------------------------- +# Language detection +# --------------------------------------------------------------------------- + + +def detect_language(request: Request) -> str: + """Determine the preferred UI language from the request context. + + Resolution order: + 1. ``preferred_language`` stored in the user session + 2. ``docuelevate_lang`` cookie + 3. ``Accept-Language`` HTTP header (best match) + 4. Default → ``en`` + """ + # 1. User session preference + if hasattr(request, "session"): + session_lang = request.session.get("preferred_language") + if session_lang and session_lang in SUPPORTED_LANGUAGE_CODES: + return session_lang + + # 2. Cookie + cookie_lang = request.cookies.get("docuelevate_lang") + if cookie_lang and cookie_lang in SUPPORTED_LANGUAGE_CODES: + return cookie_lang + + # 3. Accept-Language header + accept = request.headers.get("accept-language", "") + lang = _parse_accept_language(accept) + if lang: + return lang + + return DEFAULT_LANGUAGE + + +def _parse_accept_language(header: str) -> str | None: + """Extract the best matching language from an ``Accept-Language`` header. + + Parses quality values and returns the highest-priority match among + :data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches. + """ + if not header: + return None + + entries: list[tuple[float, str]] = [] + for part in header.split(","): + part = part.strip() + if not part: + continue + if ";q=" in part: + lang_tag, _, q_str = part.partition(";q=") + try: + quality = float(q_str.strip()) + except ValueError: + quality = 0.0 + else: + lang_tag = part + quality = 1.0 + entries.append((quality, lang_tag.strip().lower())) + + # Sort by quality descending + entries.sort(key=lambda e: e[0], reverse=True) + + for _quality, tag in entries: + # Try exact match first (e.g., "de", "zh") + code = tag.split("-")[0] + if code in SUPPORTED_LANGUAGE_CODES: + return code + + return None + + +# --------------------------------------------------------------------------- +# Localization helpers (l10n) +# --------------------------------------------------------------------------- + +# Locale-specific formatting rules for date/number display +_LOCALE_FORMATS: dict[str, dict[str, Any]] = { + "en": { + "date": "%B %d, %Y", + "date_short": "%m/%d/%Y", + "datetime": "%B %d, %Y %I:%M %p", + "thousands_sep": ",", + "decimal_sep": ".", + }, + "de": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "fr": { + "date": "%d %B %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u202f", + "decimal_sep": ",", + }, + "es": { + "date": "%d de %B de %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d de %B de %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "it": { + "date": "%d %B %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "pt": { + "date": "%d de %B de %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d de %B de %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "nl": { + "date": "%d %B %Y", + "date_short": "%d-%m-%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "pl": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "zh": { + "date": "%Y年%m月%d日", + "date_short": "%Y/%m/%d", + "datetime": "%Y年%m月%d日 %H:%M", + "thousands_sep": ",", + "decimal_sep": ".", + }, + "ru": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, +} + + +def format_date(value: date | datetime | None, locale: str = DEFAULT_LANGUAGE, short: bool = False) -> str: + """Format a date/datetime value according to the locale conventions.""" + if value is None: + return "" + fmt_key = "date_short" if short else "date" + fmt = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])[fmt_key] + return value.strftime(fmt) + + +def format_datetime(value: datetime | None, locale: str = DEFAULT_LANGUAGE) -> str: + """Format a datetime value according to the locale conventions.""" + if value is None: + return "" + fmt = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])["datetime"] + return value.strftime(fmt) + + +def format_number(value: int | float, locale: str = DEFAULT_LANGUAGE) -> str: + """Format a number with locale-appropriate thousand separators.""" + lf = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE]) + if isinstance(value, float): + int_part, _, dec_part = f"{value:,.2f}".partition(".") + formatted_int = int_part.replace(",", lf["thousands_sep"]) + return f"{formatted_int}{lf['decimal_sep']}{dec_part}" + return f"{value:,}".replace(",", lf["thousands_sep"]) + + +@lru_cache(maxsize=32) +def get_language_info(code: str) -> dict[str, str] | None: + """Return the metadata dict for a supported language code, or ``None``.""" + for lang in SUPPORTED_LANGUAGES: + if lang["code"] == code: + return lang + return None diff --git a/app/views/base.py b/app/views/base.py index 010d4f21..fc9d5e0d 100644 --- a/app/views/base.py +++ b/app/views/base.py @@ -12,6 +12,14 @@ from sqlalchemy.orm import Session # noqa: F401 from app.auth import require_login # noqa: F401 from app.config import settings from app.database import get_db # noqa: F401 +from app.utils.i18n import ( + SUPPORTED_LANGUAGES, + detect_language, + format_date, + format_datetime, + format_number, + translate, +) # Set up Jinja2 templates templates_dir = Path(__file__).parent.parent.parent / "frontend" / "templates" @@ -21,6 +29,16 @@ templates = Jinja2Templates(directory=str(templates_dir)) templates.env.globals["min"] = min templates.env.globals["max"] = max +# --------------------------------------------------------------------------- +# i18n Jinja2 integration +# --------------------------------------------------------------------------- +# The _() function is available in every template to translate UI strings. +# Usage: {{ _("nav.dashboard") }} or {{ _("upload.max_size", size="10 MB") }} +# The locale is automatically resolved from the request context. +# --------------------------------------------------------------------------- + +templates.env.globals["supported_languages"] = SUPPORTED_LANGUAGES + # Customize Jinja2Templates to include app_version in all templates original_template_response = templates.TemplateResponse @@ -48,8 +66,31 @@ def _inject_global_context(ctx: dict) -> None: session_user = req.session.get("user") # When auth is disabled every visitor is effectively "logged in" ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True) or session_user is not None) + + # --- i18n: detect language and register template helpers --- + current_locale = detect_language(req) + ctx.setdefault("current_locale", current_locale) + + def _translate(key: str, **kwargs: object) -> str: + return translate(key, current_locale, **kwargs) + + def _format_date(value: object, short: bool = False) -> str: + return format_date(value, current_locale, short=short) # type: ignore[arg-type] + + def _format_datetime(value: object) -> str: + return format_datetime(value, current_locale) # type: ignore[arg-type] + + def _format_number(value: object) -> str: + return format_number(value, current_locale) # type: ignore[arg-type] + + ctx.setdefault("_", _translate) + ctx.setdefault("format_date_l10n", _format_date) + ctx.setdefault("format_datetime_l10n", _format_datetime) + ctx.setdefault("format_number_l10n", _format_number) else: ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True)) + ctx.setdefault("current_locale", "en") + ctx.setdefault("_", lambda key, **kw: translate(key, "en", **kw)) def template_response_with_version(*args, **kwargs): diff --git a/frontend/templates/base.html b/frontend/templates/base.html index e4bb200a..1ccdff9f 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -1,5 +1,5 @@ - + {% block title %}DocuElevate{% endblock %} @@ -37,10 +37,10 @@ data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}" data-allow-signup="{{ 'true' if allow_signup else 'false' }}"> - + -