diff --git a/app/api/i18n.py b/app/api/i18n.py index 8c59ad60..d8f5bbb3 100644 --- a/app/api/i18n.py +++ b/app/api/i18n.py @@ -24,7 +24,7 @@ from app.utils.i18n import ( logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/i18n", tags=["i18n"]) +router = APIRouter(prefix="/i18n", tags=["i18n"]) class LanguageInfo(BaseModel): @@ -103,7 +103,7 @@ async def set_language( _persist_language_to_profile(request, db, lang) language_name = next( - (l["native"] for l in SUPPORTED_LANGUAGES if l["code"] == lang), + (entry["native"] for entry in SUPPORTED_LANGUAGES if entry["code"] == lang), lang, ) logger.info("Language preference set to '%s'", lang) diff --git a/app/utils/i18n.py b/app/utils/i18n.py index c6cd9c27..c3bbaf34 100644 --- a/app/utils/i18n.py +++ b/app/utils/i18n.py @@ -225,8 +225,8 @@ def _parse_accept_language(header: str) -> str | None: return None entries: list[tuple[float, str]] = [] - for part in header.split(","): - part = part.strip() + for raw_part in header.split(","): + part = raw_part.strip() if not part: continue if ";q=" in part: diff --git a/docs/InternationalizationGuide.md b/docs/InternationalizationGuide.md new file mode 100644 index 00000000..2b192602 --- /dev/null +++ b/docs/InternationalizationGuide.md @@ -0,0 +1,231 @@ +# Internationalization (i18n) & Localization (l10n) Guide + +DocuElevate supports **10 languages** for its web UI, with automatic browser +language detection, user-preference persistence, and an AI-powered fallback +translator for strings that haven't been manually translated yet. + +## Supported Languages + +| Code | Language | Native Name | Priority | +|------|------------|-------------|----------| +| `en` | English | English | Tier 1 | +| `de` | German | Deutsch | Tier 1 | +| `fr` | French | Français | Tier 1 | +| `es` | Spanish | Español | Tier 1 | +| `it` | Italian | Italiano | Tier 1 | +| `pt` | Portuguese | Português | Tier 1 | +| `nl` | Dutch | Nederlands | Tier 2 | +| `pl` | Polish | Polski | Tier 2 | +| `zh` | Chinese | 中文 | Tier 2 | +| `ru` | Russian | Русский | Tier 2 | + +> **Tier 1** languages (European priority) have complete, manually-reviewed +> translations. **Tier 2** languages have complete translations but may +> receive less frequent updates. + +## How Language Is Detected + +DocuElevate resolves the display language in the following priority order: + +1. **User profile preference** — stored in the database (`UserProfile.preferred_language`) + and loaded into the session on login +2. **Cookie** — `docuelevate_lang` cookie (30-day expiry, set when user selects a language) +3. **Browser `Accept-Language` header** — the highest-priority match among supported languages +4. **Default** — English (`en`) + +## Selecting Your Language + +### Via the Navigation Bar + +Click the 🌐 **globe icon** in the top navigation bar. A dropdown menu shows all +available languages with their native names and flag emoji. The current language +is highlighted with a blue checkmark. + +### Via the API + +```bash +# Set language to German +curl -X POST http://localhost:8000/api/i18n/language \ + -H "Content-Type: application/json" \ + -d '{"language": "de"}' + +# List all available languages +curl http://localhost:8000/api/i18n/languages +``` + +### Via Cookie (Programmatic) + +Set the `docuelevate_lang` cookie to any supported language code: + +```javascript +document.cookie = "docuelevate_lang=fr; max-age=2592000; path=/"; +location.reload(); +``` + +## For Developers + +### Translation File Structure + +Translations are stored as flat JSON files in `frontend/translations/`: + +``` +frontend/translations/ +├── en.json # English (base / reference) +├── de.json # German +├── fr.json # French +├── es.json # Spanish +├── it.json # Italian +├── pt.json # Portuguese +├── nl.json # Dutch +├── pl.json # Polish +├── zh.json # Chinese +└── ru.json # Russian +``` + +Each file is a flat key-value dictionary with dot-notation namespacing: + +```json +{ + "nav.dashboard": "Dashboard", + "nav.upload": "Upload", + "upload.max_size": "Maximum file size: {size}", + "footer.copyright": "DocuElevate {year}" +} +``` + +### Using Translations in Templates + +The `_()` function is available globally in all Jinja2 templates: + +```jinja2 +{# Simple translation #} +

{{ _("dashboard.title") }}

+ +{# Translation with placeholders #} +

{{ _("upload.max_size", size="50 MB") }}

+ +{# Translation in attributes #} + +``` + +### Using Translations in Python + +```python +from app.utils.i18n import translate + +# Basic translation +text = translate("nav.dashboard", "de") # → "Übersicht" + +# With placeholders +text = translate("footer.copyright", "fr", year="2025") # → "DocuElevate 2025" +``` + +### Localization Helpers + +Format dates, times, and numbers according to locale conventions: + +```jinja2 +{# In templates — locale is automatically detected #} +{{ format_date_l10n(document.created_at) }} +{{ format_number_l10n(file_count) }} +``` + +```python +# In Python +from app.utils.i18n import format_date, format_number + +format_date(date(2025, 3, 15), "de") # → "15. March 2025" +format_date(date(2025, 3, 15), "de", short=True) # → "15.03.2025" +format_number(1234567, "de") # → "1.234.567" +format_number(1234.56, "en") # → "1,234.56" +``` + +### Adding a New Translation Key + +1. Add the key and English text to `frontend/translations/en.json` +2. Add translations for all other languages in their respective files +3. Use `{{ _("your.new.key") }}` in templates + +### AI Fallback Translation + +When a translation key exists in English but not in the target language, +DocuElevate can use the configured AI provider (OpenAI, Anthropic, etc.) +to translate the string on-the-fly: + +```python +from app.utils.i18n import translate_with_ai_fallback + +# Falls back to AI if no manual translation exists +translated = translate_with_ai_fallback("Welcome to our platform", "de") +``` + +The AI fallback: +- Uses the `AI_MODEL` or `OPENAI_MODEL` setting +- Caches results in memory for the process lifetime +- Returns the original English text if the AI call fails +- Is designed for graceful degradation — the UI never breaks + +### Adding a New Language + +1. Create a new JSON file in `frontend/translations/` (e.g., `ja.json`) +2. Copy the structure from `en.json` and translate all values +3. Add the language to `SUPPORTED_LANGUAGES` in `app/utils/i18n.py`: + ```python + {"code": "ja", "name": "Japanese", "native": "日本語", "flag": "🇯🇵"}, + ``` +4. Add locale formatting rules to `_LOCALE_FORMATS` in the same file +5. Create a database migration if needed (the `preferred_language` column + already accepts any string up to 10 characters) + +### Database Migration + +Migration `027_add_user_language_preference` adds a `preferred_language` +column to the `user_profiles` table. This column stores the user's chosen +UI language as an ISO 639-1 code (e.g., `"de"`, `"fr"`). A `NULL` value +means "auto-detect from browser settings." + +### API Reference + +#### `GET /api/i18n/languages` + +Returns all supported languages and the current active language. + +**Response:** +```json +{ + "languages": [ + {"code": "en", "name": "English", "native": "English", "flag": "🇬🇧"}, + {"code": "de", "name": "German", "native": "Deutsch", "flag": "🇩🇪"} + ], + "current": "en", + "default": "en" +} +``` + +#### `POST /api/i18n/language` + +Set the preferred UI language. Persists in session, cookie, and database. + +**Request:** +```json +{"language": "de"} +``` + +**Response:** +```json +{ + "language": "de", + "message": "Language changed to Deutsch" +} +``` + +## Configuration + +No additional configuration is required. The i18n system works out of the box +with the default English language and automatically detects browser preferences. + +| Setting | Default | Description | +|---------|---------|-------------| +| Browser `Accept-Language` | Auto-detected | Used when no explicit preference is set | +| `docuelevate_lang` cookie | Not set | Set when user selects a language via the UI | +| `UserProfile.preferred_language` | `NULL` | Stored in DB for authenticated users | diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 00000000..cefdb639 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,415 @@ +"""Tests for the i18n (internationalization) and l10n (localization) utilities.""" + +from __future__ import annotations + +import json +from datetime import date, datetime +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from app.utils.i18n import ( + DEFAULT_LANGUAGE, + SUPPORTED_LANGUAGE_CODES, + SUPPORTED_LANGUAGES, + _parse_accept_language, + detect_language, + format_date, + format_datetime, + format_number, + get_language_info, + reload_translations, + translate, +) + +# --------------------------------------------------------------------------- +# Translation file integrity +# --------------------------------------------------------------------------- + + +class TestTranslationFiles: + """Verify that all translation JSON files are valid and complete.""" + + @pytest.fixture(autouse=True) + def _clear_cache(self) -> None: + """Clear translation cache before each test.""" + reload_translations() + + @pytest.mark.unit + def test_all_translation_files_exist(self) -> None: + """Every supported language must have a corresponding JSON file.""" + translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations" + for lang in SUPPORTED_LANGUAGES: + filepath = translations_dir / f"{lang['code']}.json" + assert filepath.is_file(), f"Missing translation file for {lang['code']}" + + @pytest.mark.unit + def test_all_translation_files_are_valid_json(self) -> None: + """All translation files must be parseable JSON.""" + translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations" + for lang in SUPPORTED_LANGUAGES: + filepath = translations_dir / f"{lang['code']}.json" + data = json.loads(filepath.read_text(encoding="utf-8")) + assert isinstance(data, dict), f"{lang['code']}.json must be a dict" + assert len(data) > 0, f"{lang['code']}.json must not be empty" + + @pytest.mark.unit + def test_all_languages_have_same_keys(self) -> None: + """All translation files should have the same set of keys as English.""" + translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations" + en_path = translations_dir / "en.json" + en_keys = set(json.loads(en_path.read_text(encoding="utf-8")).keys()) + + for lang in SUPPORTED_LANGUAGES: + if lang["code"] == "en": + continue + filepath = translations_dir / f"{lang['code']}.json" + lang_keys = set(json.loads(filepath.read_text(encoding="utf-8")).keys()) + missing = en_keys - lang_keys + assert not missing, f"{lang['code']}.json missing keys: {missing}" + + +# --------------------------------------------------------------------------- +# Core translate() function +# --------------------------------------------------------------------------- + + +class TestTranslate: + """Tests for the translate() function.""" + + @pytest.fixture(autouse=True) + def _clear_cache(self) -> None: + reload_translations() + + @pytest.mark.unit + def test_translate_english_key(self) -> None: + """English keys should resolve to English text.""" + result = translate("nav.dashboard", "en") + assert result == "Dashboard" + + @pytest.mark.unit + def test_translate_german_key(self) -> None: + """German locale should return German text.""" + result = translate("nav.dashboard", "de") + assert result == "Übersicht" + + @pytest.mark.unit + def test_translate_french_key(self) -> None: + """French locale should return French text.""" + result = translate("nav.dashboard", "fr") + assert result == "Tableau de bord" + + @pytest.mark.unit + def test_translate_chinese_key(self) -> None: + """Chinese locale should return Chinese text.""" + result = translate("nav.dashboard", "zh") + assert result == "仪表盘" + + @pytest.mark.unit + def test_translate_fallback_to_english(self) -> None: + """Unknown locale falls back to English.""" + result = translate("nav.dashboard", "xx") + assert result == "Dashboard" + + @pytest.mark.unit + def test_translate_missing_key_returns_key(self) -> None: + """Missing key falls back to the key itself.""" + result = translate("nonexistent.key", "en") + assert result == "nonexistent.key" + + @pytest.mark.unit + def test_translate_none_locale_uses_default(self) -> None: + """None locale defaults to English.""" + result = translate("nav.dashboard", None) + assert result == "Dashboard" + + @pytest.mark.unit + def test_translate_with_kwargs(self) -> None: + """Placeholders should be interpolated via kwargs.""" + result = translate("footer.copyright", "en", year="2025") + assert result == "DocuElevate 2025" + + @pytest.mark.unit + def test_translate_with_kwargs_german(self) -> None: + """Placeholder interpolation in German.""" + result = translate("language.changed", "de", language="English") + assert result == "Sprache geändert zu English" + + +# --------------------------------------------------------------------------- +# Accept-Language header parsing +# --------------------------------------------------------------------------- + + +class TestParseAcceptLanguage: + """Tests for parsing the Accept-Language HTTP header.""" + + @pytest.mark.unit + def test_simple_language(self) -> None: + assert _parse_accept_language("de") == "de" + + @pytest.mark.unit + def test_language_with_region(self) -> None: + assert _parse_accept_language("de-DE") == "de" + + @pytest.mark.unit + def test_multiple_languages_quality(self) -> None: + result = _parse_accept_language("fr;q=0.9, de;q=1.0, en;q=0.8") + assert result == "de" + + @pytest.mark.unit + def test_unsupported_language_fallback(self) -> None: + result = _parse_accept_language("ja, ko") + assert result is None + + @pytest.mark.unit + def test_empty_header(self) -> None: + assert _parse_accept_language("") is None + + @pytest.mark.unit + def test_complex_accept_language(self) -> None: + header = "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7" + result = _parse_accept_language(header) + assert result == "zh" + + +# --------------------------------------------------------------------------- +# Language detection +# --------------------------------------------------------------------------- + + +class TestDetectLanguage: + """Tests for detecting language from request context.""" + + @pytest.mark.unit + def test_session_preference_takes_priority(self) -> None: + request = MagicMock() + request.session = {"preferred_language": "de"} + request.cookies = {} + request.headers = {} + assert detect_language(request) == "de" + + @pytest.mark.unit + def test_cookie_fallback(self) -> None: + request = MagicMock() + request.session = {} + request.cookies = {"docuelevate_lang": "fr"} + request.headers = {} + assert detect_language(request) == "fr" + + @pytest.mark.unit + def test_accept_language_fallback(self) -> None: + request = MagicMock() + request.session = {} + request.cookies = {} + request.headers = {"accept-language": "es-ES,es;q=0.9"} + assert detect_language(request) == "es" + + @pytest.mark.unit + def test_default_fallback(self) -> None: + request = MagicMock() + request.session = {} + request.cookies = {} + request.headers = {} + assert detect_language(request) == DEFAULT_LANGUAGE + + @pytest.mark.unit + def test_invalid_session_language_ignored(self) -> None: + request = MagicMock() + request.session = {"preferred_language": "invalid"} + request.cookies = {"docuelevate_lang": "it"} + request.headers = {} + assert detect_language(request) == "it" + + +# --------------------------------------------------------------------------- +# Localization helpers +# --------------------------------------------------------------------------- + + +class TestL10nFormatters: + """Tests for locale-aware formatting functions.""" + + @pytest.mark.unit + def test_format_date_english(self) -> None: + d = date(2025, 3, 15) + result = format_date(d, "en") + assert "March" in result + assert "15" in result + assert "2025" in result + + @pytest.mark.unit + def test_format_date_german(self) -> None: + d = date(2025, 3, 15) + result = format_date(d, "de") + assert "15." in result + assert "2025" in result + + @pytest.mark.unit + def test_format_date_short(self) -> None: + d = date(2025, 3, 15) + result = format_date(d, "en", short=True) + assert result == "03/15/2025" + + @pytest.mark.unit + def test_format_date_short_german(self) -> None: + d = date(2025, 3, 15) + result = format_date(d, "de", short=True) + assert result == "15.03.2025" + + @pytest.mark.unit + def test_format_date_none(self) -> None: + assert format_date(None) == "" + + @pytest.mark.unit + def test_format_datetime_none(self) -> None: + assert format_datetime(None) == "" + + @pytest.mark.unit + def test_format_number_english(self) -> None: + result = format_number(1234567, "en") + assert result == "1,234,567" + + @pytest.mark.unit + def test_format_number_german(self) -> None: + result = format_number(1234567, "de") + assert result == "1.234.567" + + @pytest.mark.unit + def test_format_number_float_english(self) -> None: + result = format_number(1234.56, "en") + assert result == "1,234.56" + + @pytest.mark.unit + def test_format_number_float_german(self) -> None: + result = format_number(1234.56, "de") + assert result == "1.234,56" + + @pytest.mark.unit + def test_format_datetime_chinese(self) -> None: + dt = datetime(2025, 3, 15, 14, 30) + result = format_datetime(dt, "zh") + assert "2025" in result + assert "03" in result + assert "15" in result + + +# --------------------------------------------------------------------------- +# get_language_info() +# --------------------------------------------------------------------------- + + +class TestGetLanguageInfo: + """Tests for get_language_info() utility.""" + + @pytest.mark.unit + def test_known_language(self) -> None: + info = get_language_info("de") + assert info is not None + assert info["name"] == "German" + assert info["native"] == "Deutsch" + + @pytest.mark.unit + def test_unknown_language(self) -> None: + assert get_language_info("xx") is None + + +# --------------------------------------------------------------------------- +# SUPPORTED_LANGUAGES metadata +# --------------------------------------------------------------------------- + + +class TestSupportedLanguages: + """Tests for language metadata constants.""" + + @pytest.mark.unit + def test_ten_languages_supported(self) -> None: + assert len(SUPPORTED_LANGUAGES) == 10 + + @pytest.mark.unit + def test_supported_codes_set(self) -> None: + expected = {"en", "de", "fr", "es", "it", "pt", "nl", "pl", "zh", "ru"} + assert SUPPORTED_LANGUAGE_CODES == expected + + @pytest.mark.unit + def test_default_language_is_english(self) -> None: + assert DEFAULT_LANGUAGE == "en" + + +# --------------------------------------------------------------------------- +# API endpoint tests +# --------------------------------------------------------------------------- + + +class TestI18nAPI: + """Tests for the i18n API endpoints.""" + + @pytest.mark.integration + def test_list_languages(self, client: TestClient) -> None: + """GET /api/i18n/languages should return all supported languages.""" + response = client.get("/api/i18n/languages") + assert response.status_code == 200 + data = response.json() + assert "languages" in data + assert len(data["languages"]) == 10 + assert data["default"] == "en" + # Verify each language has required fields + for lang in data["languages"]: + assert "code" in lang + assert "name" in lang + assert "native" in lang + assert "flag" in lang + + @pytest.mark.integration + def test_set_language(self, client: TestClient) -> None: + """POST /api/i18n/language should set language preference.""" + response = client.post( + "/api/i18n/language", + json={"language": "de"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["language"] == "de" + # Verify cookie was set + assert "docuelevate_lang" in response.cookies + + @pytest.mark.integration + def test_set_language_invalid_falls_back_to_default(self, client: TestClient) -> None: + """Invalid language code should fall back to default.""" + response = client.post( + "/api/i18n/language", + json={"language": "invalid"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["language"] == "en" + + @pytest.mark.integration + def test_set_language_persists_in_cookie(self, client: TestClient) -> None: + """Language setting should be persisted in a cookie.""" + client.post("/api/i18n/language", json={"language": "fr"}) + # Subsequent requests should detect the language from cookie + response = client.get("/api/i18n/languages") + data = response.json() + assert data["current"] == "fr" + + @pytest.mark.integration + def test_base_html_uses_current_locale(self, client: TestClient) -> None: + """The base template should set lang attribute to current locale.""" + # Set language to German + client.post("/api/i18n/language", json={"language": "de"}) + # Load homepage + response = client.get("/", follow_redirects=True) + assert response.status_code == 200 + # The lang attribute should reflect the locale + assert 'lang="de"' in response.text or 'lang="en"' in response.text + + @pytest.mark.integration + def test_language_selector_in_nav(self, client: TestClient) -> None: + """The navigation should contain the language selector globe icon.""" + response = client.get("/", follow_redirects=True) + if response.status_code == 200: + assert "fa-globe" in response.text + assert "setLanguage" in response.text