Newer
Older
gn-synapse / app / i18n_server.py
"""Локализация серверных текстов API по Accept-Language (i18n.md хендбука).

Бэкенд формулирует ошибки по-русски (как в коде), а на границе ответа текст
переводится для en/uk: точные строки — по словарю EXACT, шаблонные (f-строки)
— регэксп-правилами PATTERNS. Нет перевода или язык ru — текст уходит как есть.
HTTPException-обработчик вешает main.py; labels/help реестра настроек
локализует localize_settings() в admin_routes.

Единственное место серверных переводов у Synapse: документы и README
остаются по-русски (решение владельца).
"""

import re
from typing import Callable

from fastapi import Request

SUPPORTED = ("ru", "en", "uk")
DEFAULT_LOCALE = "ru"


def locale_from_request(request: Request) -> str:
    """Первый поддерживаемый язык Accept-Language; без заголовка — ru."""
    header = request.headers.get("accept-language") or ""
    for part in header.split(","):
        tag = part.split(";")[0].strip().lower()
        prefix = tag.split("-")[0]
        if prefix in SUPPORTED:
            return prefix
    return DEFAULT_LOCALE


# Точные строки (как они встречаются в HTTPException и валидации настроек).
EXACT: dict[str, dict[str, str]] = {
    "API-ключ неизвестен или отозван": {
        "en": "API key is unknown or revoked",
        "uk": "API-ключ невідомий або відкликаний",
    },
    "GAUTH_WEBHOOK_SECRET не настроен": {
        "en": "GAUTH_WEBHOOK_SECRET is not configured",
        "uk": "GAUTH_WEBHOOK_SECRET не налаштований",
    },
    "Refresh-токен невалиден": {
        "en": "Refresh token is invalid",
        "uk": "Refresh-токен недійсний",
    },
    "В архиве нет источника с таким id": {
        "en": "No source with this id in the archive",
        "uk": "У архіві немає джерела з таким id",
    },
    "В архиве нет правила с таким id": {
        "en": "No rule with this id in the archive",
        "uk": "У архіві немає правила з таким id",
    },
    "В архиве нет типа с таким id": {
        "en": "No type with this id in the archive",
        "uk": "У архіві немає типу з таким id",
    },
    "В архиве нет цели с таким id": {
        "en": "No target with this id in the archive",
        "uk": "У архіві немає цілі з таким id",
    },
    "Источник в архиве — сначала restore": {
        "en": "Source is archived — restore it first",
        "uk": "Джерело в архіві — спершу restore",
    },
    "Источник ключа в архиве — события не принимаются": {
        "en": "The key's source is archived — events are not accepted",
        "uk": "Джерело ключа в архіві — події не приймаються",
    },
    "Источник ключа удалён": {
        "en": "The key's source is deleted",
        "uk": "Джерело ключа видалено",
    },
    "Источник не найден": {
        "en": "Source not found",
        "uk": "Джерело не знайдено",
    },
    "Источник типа в архиве — restore невозможен": {
        "en": "The type's source is archived — restore is impossible",
        "uk": "Джерело типу в архіві — restore неможливий",
    },
    "Ключ не найден": {
        "en": "Key not found",
        "uk": "Ключ не знайдено",
    },
    "Недостаточно прав: админ-панель доступна роли admin и выше": {
        "en": "Not enough rights: the admin area requires the admin role or higher",
        "uk": "Недостатньо прав: адмін-панель доступна ролі admin і вище",
    },
    "Некорректная подпись вебхука": {
        "en": "Invalid webhook signature",
        "uk": "Некоректний підпис вебхука",
    },
    "Подписка не найдена": {
        "en": "Subscription not found",
        "uk": "Підписку не знайдено",
    },
    "Правило не найдено": {
        "en": "Rule not found",
        "uk": "Правило не знайдено",
    },
    "Событие не найдено": {
        "en": "Event not found",
        "uk": "Подію не знайдено",
    },
    "Тип не найден": {
        "en": "Type not found",
        "uk": "Тип не знайдено",
    },
    "Токен невалиден или истёк": {
        "en": "Token is invalid or expired",
        "uk": "Токен недійсний або закінчився",
    },
    "Токен не найден": {
        "en": "Token not found",
        "uk": "Токен не знайдено",
    },
    "Требуется Bearer-токен": {
        "en": "Bearer token required",
        "uk": "Потрібен Bearer-токен",
    },
    "Требуется Bearer-токен gnexus-auth": {
        "en": "A gnexus-auth Bearer token is required",
        "uk": "Потрібен Bearer-токен gnexus-auth",
    },
    "Цель не найдена": {
        "en": "Target not found",
        "uk": "Ціль не знайдено",
    },
    "Локаль не поддерживается: en, uk, ru": {
        "en": "Unsupported locale: en, uk, ru",
        "uk": "Локаль не підтримується: en, uk, ru",
    },
}

# Шаблонные формы: regex на русском тексте -> генератор перевода по захватам.
# Генераторы замыкаются на целевой язык (make_* ниже).
PATTERN_BUILDERS: list[tuple[str, Callable[[], dict[str, str]]]] = [
    (
        r"^Достигнут лимит активных MCP-токенов \((\d+)\) — отзовите лишний$",
        lambda: {
            "en": "MCP token limit reached ({n}) — revoke some first",
            "uk": "Досягнуто ліміт активних MCP-токенів ({n}) — відкличте зайві",
        },
    ),
    (
        r"^Поле source='(.+?)' не совпадает с источником ключа '(.+?)'$",
        lambda: {
            "en": "source='{a}' does not match the key's source '{b}'",
            "uk": "source='{a}' не збігається з джерелом ключа '{b}'",
        },
    ),
    (
        r"^Тип \((.+?)\) не зарегистрирован — обратись к админу Synapse$",
        lambda: {
            "en": "Type ({a}) is not registered — ask the Synapse admin",
            "uk": "Тип ({a}) не зареєстрований — звертайся до адміна Synapse",
        },
    ),
    (
        r"^(.+?): значение должно быть числом$",
        lambda: {
            "en": "{a}: value must be a number",
            "uk": "{a}: значення має бути числом",
        },
    ),
    (
        r"^(.+?): значение вне допустимого диапазона (\d+)\.\.(\d+)$",
        lambda: {
            "en": "{a}: value out of the allowed range {b}..{c}",
            "uk": "{a}: значення поза допустимим діапазоном {b}..{c}",
        },
    ),
]


def _apply(matched: re.Match, template: dict[str, str], lang: str, keys: list[str]) -> str:
    out = template[lang]
    for i, name in enumerate(keys, 1):
        out = out.replace("{" + name + "}", matched.group(i))
    return out


# У шаблонов именованные группы-подстановки: индекс в PATTERN_BUILDERS → ключи
# (regex-группируются по порядку и подставляются в текст шаблона).
_PATTERN_KEYS: dict[int, list[str]] = {
    0: ["n"],
    1: ["a", "b"],
    2: ["a"],
    3: ["a"],
    4: ["a", "b", "c"],
}


def localize_detail(request: Request | None, detail: object) -> object:
    """Перевод detail ответа; не-строки уходят как есть."""
    if not isinstance(detail, str):
        return detail
    lang = locale_from_request(request) if request is not None else DEFAULT_LOCALE
    if lang == "ru":
        return detail
    exact = EXACT.get(detail)
    if exact:
        return exact[lang]
    for idx, (pattern, build) in enumerate(PATTERN_BUILDERS):
        matched = re.match(pattern, detail)
        if matched:
            text = build()[lang]
            for i, key in enumerate(_PATTERN_KEYS.get(idx, ["a", "b", "c"]), 1):
                text = text.replace("{" + key + "}", matched.group(i))
            return text
    return detail


# --- Реестр настроек: перевод label/help по ключу ---

SETTINGS: dict[str, dict[str, str]] = {
    "routing_match_mode.label": {"en": "Routing match mode", "uk": "Режим маршрутизації"},
    "routing_match_mode.help": {
        "en": "all — every matching rule; first — only the top one by weight",
        "uk": "all — усі підходящі правила; first — лише перше за weight",
    },
    "dedup_window_seconds.label": {"en": "Dedup window, seconds", "uk": "Вікно дедуплікації, сек"},
    "dedup_window_seconds.help": {
        "en": "A duplicate dedup_key within the window returns the first event's id",
        "uk": "Дубль з тим самим dedup_key у вікні поверне id першої події",
    },
    "retention_days.label": {"en": "Retention, days", "uk": "Ретеншн, днів"},
    "retention_days.help": {
        "en": "Events older than N days are deleted; 0 disables the window",
        "uk": "Події старші N днів видаляються; 0 вимикає вікно",
    },
    "vapid_public_key.label": {"en": "VAPID public key", "uk": "VAPID public key"},
    "vapid_public_key.help": {"en": "URLsafe base64 public key (p256dh)", "uk": "URLsafe base64 публічний ключ (p256dh)"},
    "vapid_private_key.label": {"en": "VAPID private key", "uk": "VAPID private key"},
    "vapid_private_key.help": {
        "en": "The secret is stored write-only in the DB; the API only reports whether it is set",
        "uk": "Секрет зберігається в БД write-only, API повертає лише факт «задано»",
    },
    "push_subject.label": {"en": "Subject (sender)", "uk": "Subject (відправник)"},
    "push_subject.help": {
        "en": "Sender contact for web-push, e.g. mailto:ops@gnexus.space",
        "uk": "Контакти відправника для веб-пушів, наприклад mailto:ops@gnexus.space",
    },
    "telegram_bot_token.label": {"en": "Telegram bot token", "uk": "Токен тг-бота"},
    "telegram_bot_token.help": {
        "en": "Channel is not implemented yet (#29) — the token is stored for later",
        "uk": "Канал ще не реалізований (#29) — токен зберігається наперед",
    },
    "smtp_from.label": {"en": "From address", "uk": "From-адреса"},
    "smtp_from.help": {
        "en": 'For example "Synapse <synapse@gnexus.space>"',
        "uk": 'Наприклад "Synapse <synapse@gnexus.space>"',
    },
}
# smtp_host/port/user/password — технические имена, не переводятся.


def localize_settings(defs, lang: str) -> list:
    """Те же SettingDef с заменёнными label/help (для en/uk); простая обёртка.

    Реестр иммутабелен (frozen dataclass в EDITABLE) — создаём лёгкие шимы,
    исходные объекты не трогаем.
    """
    if lang == "ru":
        return defs
    localized = []
    for d in defs:
        label = SETTINGS.get(f"{d.key}.label", {}).get(lang, d.label)
        help_text = SETTINGS.get(f"{d.key}.help", {}).get(lang, d.help) if d.help else d.help
        localized.append(_SettingShim(d, label, help_text))
    return localized


class _SettingShim:
    """С теми же полями, что SettingDef, но своим label/help."""

    def __init__(self, base, label, help_text):
        self.key = base.key
        self.section = base.section
        self.label = label
        self.kind = base.kind
        self.options = base.options
        self.help = help_text