From 59fa301344768e8673cd9e4d9117a6ad023250a3 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 25 May 2026 17:47:45 +0300 Subject: [PATCH] feat: add runtime locale overrides --- .dockerignore | 1 + .gitignore | 1 + README.md | 1 + backend/bot/app/factories/build_services.py | 2 +- backend/bot/app/web/admin_api.py | 2 + .../bot/app/web/admin_api_impl/_runtime.py | 1 + backend/bot/app/web/admin_api_impl/routes.py | 2 + .../app/web/admin_api_impl/translations.py | 145 ++++ backend/bot/app/web/webapp/account.py | 5 + backend/bot/app/web/webapp/application.py | 2 +- backend/bot/app/web/webapp/assets.py | 10 + backend/bot/app/web/webapp/auth.py | 14 +- backend/bot/app/web/webapp/common.py | 8 +- backend/bot/app/web/webapp/routes.py | 2 +- backend/bot/handlers/user/start.py | 16 +- .../bot/keyboards/inline/user_keyboards.py | 21 +- backend/bot/main_bot.py | 2 + backend/bot/middlewares/i18n.py | 448 +++++++++- backend/bot/services/email_auth_service.py | 5 +- backend/bot/services/email_templates.py | 7 +- .../bot/services/locale_override_service.py | 721 ++++++++++++++++ backend/bot/services/panel_webhook_service.py | 3 +- .../subscription_service_impl/payments.py | 4 +- backend/bot/services/support_service.py | 2 +- backend/db/dal/__init__.py | 2 + backend/db/dal/locale_overrides_dal.py | 86 ++ backend/db/migrator.py | 22 + backend/db/models.py | 15 + backend/main_worker.py | 2 + data/locales-overrides.example.json | 8 + deploy/docker/Dockerfile | 4 +- docs/admin.md | 21 + docs/configuration.md | 1 + docs/deployment.md | 1 + frontend/src/App.svelte | 44 +- frontend/src/admin/AdminPanel.svelte | 42 + .../admin/sections/TranslationsSection.svelte | 636 ++++++++++++++ .../src/lib/admin/stores/translationsStore.js | 175 ++++ frontend/src/lib/components/ui/icons.js | 1 + frontend/src/lib/webapp/constants.js | 116 ++- frontend/src/lib/webapp/i18n.js | 22 +- frontend/src/lib/webapp/mockApi.js | 72 ++ frontend/src/lib/webapp/previewMock.js | 5 + frontend/src/styles/admin-controls.css | 6 + frontend/src/styles/admin.css | 387 +++++++++ locales/en.json | 154 ++-- locales/ru.json | 154 ++-- tests/test_admin_settings_manifest_i18n.py | 12 +- tests/test_locale_overrides.py | 813 ++++++++++++++++++ tests/test_provider_labels.py | 3 +- tests/test_webapp_route_contract.py | 11 + 51 files changed, 4026 insertions(+), 214 deletions(-) create mode 100644 backend/bot/app/web/admin_api_impl/translations.py create mode 100644 backend/bot/services/locale_override_service.py create mode 100644 backend/db/dal/locale_overrides_dal.py create mode 100644 data/locales-overrides.example.json create mode 100644 frontend/src/admin/sections/TranslationsSection.svelte create mode 100644 frontend/src/lib/admin/stores/translationsStore.js create mode 100644 tests/test_locale_overrides.py diff --git a/.dockerignore b/.dockerignore index 303a129..81a8956 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,7 @@ frontend/node_modules/ deploy/compose/docker-compose-dev.yml data/* !data/tariffs.example.json +!data/locales-overrides.example.json # CI diff --git a/.gitignore b/.gitignore index 81bef46..579eba4 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ locales/en_backup.json db/models_old.py data/* !data/tariffs.example.json +!data/locales-overrides.example.json diff --git a/README.md b/README.md index 510278b..9fa59b8 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ docker compose logs -f backend worker frontend ```bash mkdir -p data/themes data/webapp-logo data/webapp-emoji +touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data ``` diff --git a/backend/bot/app/factories/build_services.py b/backend/bot/app/factories/build_services.py index 9ca23a1..3966d2c 100644 --- a/backend/bot/app/factories/build_services.py +++ b/backend/bot/app/factories/build_services.py @@ -30,7 +30,7 @@ def build_core_services( subscription_service = SubscriptionService(settings, panel_service, bot, i18n) referral_service = ReferralService(settings, subscription_service, bot, i18n) promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n) - email_auth_service = EmailAuthService(settings) + email_auth_service = EmailAuthService(settings, i18n) notification_service = NotificationService( bot, settings, diff --git a/backend/bot/app/web/admin_api.py b/backend/bot/app/web/admin_api.py index 6d277b5..ad06c70 100644 --- a/backend/bot/app/web/admin_api.py +++ b/backend/bot/app/web/admin_api.py @@ -19,6 +19,7 @@ from bot.app.web.admin_api_impl import ( sync as _sync, tariffs as _tariffs, themes as _themes, + translations as _translations, users as _users, ) @@ -38,6 +39,7 @@ _MODULES = ( _settings, _tariffs, _themes, + _translations, _panel, _routes, ) diff --git a/backend/bot/app/web/admin_api_impl/_runtime.py b/backend/bot/app/web/admin_api_impl/_runtime.py index 6cf286e..5506556 100644 --- a/backend/bot/app/web/admin_api_impl/_runtime.py +++ b/backend/bot/app/web/admin_api_impl/_runtime.py @@ -41,6 +41,7 @@ from config.tariffs_config import TariffsConfig from db.dal import ( ad_dal, app_settings_dal, + locale_overrides_dal, message_log_dal, panel_sync_dal, payment_dal, diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index fe57f77..b5d5682 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -66,6 +66,8 @@ def setup_admin_routes(app: web.Application) -> None: router.add_get("/api/admin/settings", admin_settings_get_route) router.add_patch("/api/admin/settings", admin_settings_patch_route) + router.add_get("/api/admin/translations", admin_translations_get_route) + router.add_patch("/api/admin/translations", admin_translations_patch_route) router.add_get("/api/admin/tariffs", admin_tariffs_get_route) router.add_put("/api/admin/tariffs", admin_tariffs_save_route) diff --git a/backend/bot/app/web/admin_api_impl/translations.py b/backend/bot/app/web/admin_api_impl/translations.py new file mode 100644 index 0000000..57fd34f --- /dev/null +++ b/backend/bot/app/web/admin_api_impl/translations.py @@ -0,0 +1,145 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + +from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key +from bot.services.locale_override_service import ( + LOCALE_OVERRIDES_PATH, + audience_for_locale_key, + group_id_for_locale_key, + locale_group_catalog, + load_locale_overrides, + update_locale_overrides, +) + +def _locale_languages( + i18n: JsonI18n, + overrides: Optional[List[Dict[str, Any]]] = None, +) -> List[Dict[str, Any]]: + base_languages = set((i18n.base_locales_data or {}).keys()) + override_languages = {str(entry.get("lang") or "") for entry in overrides or []} + override_languages.update((i18n.locale_overrides or {}).keys()) + return locale_language_options( + base_languages | override_languages, + base_languages=base_languages, + ) + + +def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]: + result: Dict[Tuple[str, str], Dict] = {} + for entry in overrides: + lang = str(entry.get("lang") or "") + raw_key = str(entry.get("key") or "") + key = resolve_locale_key(raw_key) + if lang and key: + if raw_key != key and (lang, key) in result: + continue + result[(lang, key)] = entry + return result + + +def _admin_translations_payload( + i18n: JsonI18n, + overrides: List[Dict[str, Any]], +) -> Dict[str, Any]: + base_data = i18n.base_locales_data or i18n.locales_data or {} + effective_data = i18n.locales_data or {} + override_meta = _locale_override_meta_map(overrides) + language_items = _locale_languages(i18n, overrides) + languages = [item["code"] for item in language_items] + all_keys = sorted( + {key for messages in base_data.values() for key in messages.keys()} + | {key for _, key in override_meta.keys()} + ) + + groups_by_id = { + group["id"]: { + **group, + "items": [], + } + for group in locale_group_catalog() + } + + for key in all_keys: + values: Dict[str, Dict[str, Any]] = {} + for lang in languages: + meta = override_meta.get((lang, key)) + fallback_base = base_data.get(i18n.default_lang, {}).get(key, "") + values[lang] = { + "base": base_data.get(lang, {}).get(key, ""), + "fallback": fallback_base, + "effective": effective_data.get(lang, {}).get(key, ""), + "override": meta.get("value") if meta else "", + "overridden": bool(meta), + "updated_at": meta.get("updated_at") if meta else None, + "updated_by": meta.get("updated_by") if meta else None, + } + group_id = group_id_for_locale_key(key) + groups_by_id.setdefault( + group_id, + {"id": group_id, "title": group_id, "description": "", "items": []}, + ) + groups_by_id[group_id]["items"].append( + { + "key": key, + "audience": audience_for_locale_key(key), + "values": values, + } + ) + + groups = [group for group in groups_by_id.values() if group["items"]] + return { + "languages": language_items, + "groups": groups, + "path": str(LOCALE_OVERRIDES_PATH), + "override_count": len(overrides), + } + + +async def admin_translations_get_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + i18n: Optional[JsonI18n] = request.app.get("i18n") + if i18n is None: + return _error(503, "i18n_unavailable") + async_session_factory: sessionmaker = request.app["async_session_factory"] + + await load_locale_overrides(i18n, async_session_factory) + async with async_session_factory() as session: + overrides = await locale_overrides_dal.get_overrides_with_meta(session) + + return _ok(_admin_translations_payload(i18n, overrides)) + + +async def admin_translations_patch_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + i18n: Optional[JsonI18n] = request.app.get("i18n") + if i18n is None: + return _error(503, "i18n_unavailable") + async_session_factory: sessionmaker = request.app["async_session_factory"] + payload = await _read_json(request) + updates = payload.get("updates") or {} + deletes = payload.get("deletes") or [] + if not isinstance(updates, dict): + return _error(400, "invalid_updates") + if not isinstance(deletes, list): + return _error(400, "invalid_deletes") + + result = await update_locale_overrides( + i18n, + async_session_factory, + updates=updates, + deletes=deletes, + actor_id=actor_id, + ) + if not result.get("ok"): + return web.json_response( + {"ok": False, "error": "validation_failed", "errors": result.get("errors", {})}, + status=400, + ) + + return _ok( + { + "applied": result.get("applied", 0), + "reverted": result.get("reverted", 0), + "file_written": result.get("file_written", False), + } + ) diff --git a/backend/bot/app/web/webapp/account.py b/backend/bot/app/web/webapp/account.py index fec749b..f090f4c 100644 --- a/backend/bot/app/web/webapp/account.py +++ b/backend/bot/app/web/webapp/account.py @@ -488,6 +488,11 @@ async def account_language_route(request: web.Request) -> web.Response: return validation_error language = _normalize_language(str(language_payload.language or "")) + i18n = request.app.get("i18n") + if i18n and hasattr(i18n, "reload_overrides_from_file"): + i18n.reload_overrides_from_file() + if i18n and language not in getattr(i18n, "locales_data", {}): + return _json_error(400, "unsupported_language", "Unsupported language") async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: db_user = await user_dal.get_user_by_id(session, user_id) diff --git a/backend/bot/app/web/webapp/application.py b/backend/bot/app/web/webapp/application.py index ca6a38b..3dce8e2 100644 --- a/backend/bot/app/web/webapp/application.py +++ b/backend/bot/app/web/webapp/application.py @@ -21,7 +21,7 @@ def create_subscription_webapp_application( app["settings"] = settings app["async_session_factory"] = async_session_factory app["i18n"] = dp.get("i18n_instance") - app["email_auth_service"] = EmailAuthService(settings) + app["email_auth_service"] = EmailAuthService(settings, app["i18n"]) app["webapp_logo_cache"] = None app["webapp_logo_cache_lock"] = asyncio.Lock() app["webapp_settings_cache"] = {"ts": 0.0, "data": {}} diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index 1184b84..6bec6b2 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -9,6 +9,7 @@ from config.webapp_themes_config import ( public_theme_payload, public_themes_catalog_payload, ) +from bot.middlewares.i18n import locale_language_options _TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {} _BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {} @@ -1157,7 +1158,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]: preview_key = "" i18n_instance: Optional[object] = request.app.get("i18n") i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp") + if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"): + i18n_instance.reload_overrides_from_file() locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} + base_locales_data = getattr(i18n_instance, "base_locales_data", {}) if i18n_instance else {} return { "config": { "title": settings.WEBAPP_TITLE, @@ -1188,6 +1192,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]: "userAgreementUrl": cached["user_agreement_url"], "currency": cached["currency"], "language": cached["language"], + "languages": locale_language_options( + locales_data.keys(), + base_languages=base_locales_data.keys(), + ), "emailAuthEnabled": cached["email_auth_enabled"], "appVersion": _resolve_app_version(), "appRepositoryUrl": APP_REPOSITORY_URL, @@ -1204,6 +1212,8 @@ async def bootstrap_route(request: web.Request) -> web.Response: async def i18n_route(request: web.Request) -> web.Response: i18n_instance: Optional[object] = request.app.get("i18n") + if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"): + i18n_instance.reload_overrides_from_file() scope = _normalize_i18n_scope(request.query.get("scope") or "webapp") locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} response = web.json_response( diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py index 6b966c5..89bbcd5 100644 --- a/backend/bot/app/web/webapp/auth.py +++ b/backend/bot/app/web/webapp/auth.py @@ -1202,11 +1202,9 @@ def _apply_telegram_profile_to_user( telegram_user: Dict[str, Any], settings: Settings, ) -> None: - language_code = ( + language_code = _normalize_language( telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE ) - if language_code not in {"ru", "en"}: - language_code = user.language_code or settings.DEFAULT_LANGUAGE user.telegram_id = int(telegram_user["id"]) user.username = sanitize_username(telegram_user.get("username")) @@ -1252,13 +1250,11 @@ async def _link_telegram_to_user( return merged_user if not existing_telegram_user and int(current_user.user_id) < 0: - language_code = ( + language_code = _normalize_language( telegram_user.get("language_code") or current_user.language_code or settings.DEFAULT_LANGUAGE ) - if language_code not in {"ru", "en"}: - language_code = current_user.language_code or settings.DEFAULT_LANGUAGE target_user, _ = await user_dal.create_user( session, { @@ -1404,9 +1400,9 @@ async def _ensure_user_from_telegram( referral_param: Optional[str] = None, ) -> User: user_id = int(telegram_user["id"]) - language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE - if language_code not in {"ru", "en"}: - language_code = settings.DEFAULT_LANGUAGE + language_code = _normalize_language( + telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE + ) update_data = { "telegram_id": user_id, diff --git a/backend/bot/app/web/webapp/common.py b/backend/bot/app/web/webapp/common.py index 53932c5..c39fa9f 100644 --- a/backend/bot/app/web/webapp/common.py +++ b/backend/bot/app/web/webapp/common.py @@ -4,6 +4,10 @@ from ._runtime import * # noqa: F403,F405 from bot.app.web.webapp.cache_helpers import ( invalidate_webapp_user_caches as _invalidate_user_payload_caches, ) +from bot.middlewares.i18n import ( + is_valid_locale_language_code, + normalize_locale_language_code, +) async def _read_json(request: web.Request) -> Dict[str, Any]: @@ -73,8 +77,8 @@ def _validate_model_payload( def _normalize_language(lang: Optional[str]) -> str: - value = (lang or "ru").split("-")[0].lower() - return value if value in {"ru", "en"} else "ru" + value = normalize_locale_language_code(lang, prefer_known_base=False) + return value if is_valid_locale_language_code(value) else "ru" def _format_remaining(seconds: int, lang: str) -> str: diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index 507f84e..978f40a 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -19,7 +19,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get( ( "/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|" - "appearance|settings|support}" + "appearance|settings|translations|support}" ), index_route, ) diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index 981fb73..0fd870d 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -17,7 +17,7 @@ from bot.keyboards.inline.user_keyboards import ( get_language_selection_keyboard, get_main_menu_inline_keyboard, ) -from bot.middlewares.i18n import JsonI18n +from bot.middlewares.i18n import JsonI18n, normalize_locale_language_code from bot.services.panel_api_service import PanelApiService from bot.services.promo_code_service import PromoCodeService from bot.services.referral_service import ReferralService @@ -913,7 +913,12 @@ async def select_language_callback_handler( try: lang_payload = callback.data.split("_", 2)[2] - lang_code, _, return_target = lang_payload.partition(":") + raw_lang_code, _, return_target = lang_payload.partition(":") + lang_code = normalize_locale_language_code( + raw_lang_code, + set(i18n.locales_data.keys()), + prefer_known_base=True, + ) except IndexError: await safe_answer_callback( callback, @@ -921,6 +926,13 @@ async def select_language_callback_handler( show_alert=True, ) return + if lang_code not in i18n.locales_data: + await safe_answer_callback( + callback, + "Unsupported language.", + show_alert=True, + ) + return user_id = callback.from_user.id try: diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index e9ed30d..255b27f 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple from aiogram.types import InlineKeyboardMarkup, WebAppInfo from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from bot.middlewares.i18n import locale_language_options from bot.utils.install_links import bot_install_guide_url from bot.utils.mini_app_url import subscription_mini_app_trial_url from config.settings import Settings @@ -246,14 +247,18 @@ def get_language_selection_keyboard( _ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs) callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else "" builder = InlineKeyboardBuilder() - builder.button( - text=f"🇬🇧 English {'✅' if current_lang == 'en' else ''}", - callback_data=f"set_lang_en{callback_suffix}", - ) - builder.button( - text=f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}", - callback_data=f"set_lang_ru{callback_suffix}", - ) + if hasattr(i18n_instance, "language_options"): + languages = i18n_instance.language_options() + else: + locales_data = getattr(i18n_instance, "locales_data", {}) or {"ru": {}, "en": {}} + languages = locale_language_options(locales_data.keys(), base_languages=locales_data.keys()) + for language in languages: + lang_code = language["code"] + checked = " ✅" if current_lang == lang_code else "" + builder.button( + text=f"{language['flag']} {language['label']}{checked}", + callback_data=f"set_lang_{lang_code}{callback_suffix}", + ) builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback) builder.adjust(1) return builder.as_markup() diff --git a/backend/bot/main_bot.py b/backend/bot/main_bot.py index a9a7fab..f0d153c 100644 --- a/backend/bot/main_bot.py +++ b/backend/bot/main_bot.py @@ -12,6 +12,7 @@ from bot.app.web.web_server import build_and_start_web_app from bot.infra.redis import close_redis from bot.middlewares.i18n import JsonI18n from bot.routers import build_root_router +from bot.services.locale_override_service import load_locale_overrides from bot.services.settings_override_service import load_overrides_from_db from bot.utils.message_queue import init_queue_manager from config.settings import Settings @@ -269,6 +270,7 @@ async def run_bot(settings_param: Settings): await load_overrides_from_db(settings_param, local_async_session_factory) dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory) i18n_instance = extra["i18n_instance"] + await load_locale_overrides(i18n_instance, local_async_session_factory) # Get bot username for YooKassa default return URL if needed actual_bot_username = "your_bot_username" diff --git a/backend/bot/middlewares/i18n.py b/backend/bot/middlewares/i18n.py index a73f73d..cbfa84d 100644 --- a/backend/bot/middlewares/i18n.py +++ b/backend/bot/middlewares/i18n.py @@ -1,7 +1,10 @@ import json import logging import os -from typing import Any, Awaitable, Callable, Dict, Optional +import re +import time +from pathlib import Path +from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple from aiogram import BaseMiddleware from aiogram.types import Update, User @@ -10,14 +13,309 @@ from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings from db.dal import user_dal +LocaleOverrides = Dict[str, Dict[str, str]] + +_LOCALE_LANGUAGE_CODE_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$") +LANGUAGE_LABELS: Dict[str, str] = { + "ru": "Русский", + "en": "English", + "de": "Deutsch", + "es": "Español", + "fr": "Français", + "pt-br": "Português (BR)", + "tr": "Türkçe", + "uk": "Українська", +} +LANGUAGE_FLAGS: Dict[str, str] = { + "ru": "🇷🇺", + "en": "🇬🇧", + "de": "🇩🇪", + "es": "🇪🇸", + "fr": "🇫🇷", + "pt-br": "🇧🇷", + "tr": "🇹🇷", + "uk": "🇺🇦", +} +DEFAULT_LANGUAGE_ORDER = ("ru", "en") +LOCALE_KEY_ALIASES: Dict[str, str] = { + "admin_apply": "wa_apply", + "admin_ads_col_status": "admin_status", + "admin_ad_label_source": "admin_ads_col_source", + "admin_back": "wa_back", + "admin_btn_refresh": "admin_refresh", + "admin_btn_save": "admin_save", + "admin_btn_saving": "admin_saving", + "admin_close": "wa_close", + "admin_copied": "wa_copied", + "admin_copy": "wa_copy", + "admin_csv_amount": "admin_amount", + "admin_csv_description": "admin_description", + "admin_csv_payment_id": "admin_id", + "admin_csv_status": "admin_status", + "admin_link_copied": "wa_link_copied", + "admin_next": "wa_next", + "admin_payment_detail_copied": "wa_copied", + "admin_payment_detail_provider": "admin_provider", + "admin_payment_detail_provider_section": "admin_provider", + "admin_payment_detail_user_section": "admin_user", + "admin_payments_col_user_id": "admin_id", + "admin_promo_col_code": "admin_promo_csv_code", + "admin_promo_col_status": "admin_status", + "admin_promo_csv_is_active": "admin_badge_active", + "admin_promo_csv_status": "admin_status", + "admin_promo_label_code": "admin_promo_csv_code", + "admin_promo_unlimited_validity": "admin_promo_unlimited", + "admin_stats_revenue_custom_range_apply": "wa_apply", + "admin_stats_revenue_tooltip_amount": "admin_amount", + "admin_stats_sync_status": "admin_status", + "admin_status_active": "admin_badge_active", + "admin_support_category": "wa_support_category", + "admin_support_category_account": "wa_support_category_account", + "admin_support_category_billing": "wa_support_category_billing", + "admin_support_category_other": "wa_support_category_other", + "admin_support_category_technical": "wa_support_category_technical", + "admin_support_close_ticket": "wa_close", + "admin_support_empty": "wa_support_empty", + "admin_support_filter_active": "wa_support_filter_active", + "admin_support_filter_all": "wa_support_filter_all", + "admin_support_internal_note": "wa_support_internal_note", + "admin_support_no_messages": "wa_support_no_messages", + "admin_support_priority": "wa_support_priority", + "admin_support_priority_high": "wa_support_priority_high", + "admin_support_priority_low": "wa_support_priority_low", + "admin_support_priority_normal": "wa_support_priority_normal", + "admin_support_priority_urgent": "wa_support_priority_urgent", + "admin_support_role_system": "wa_support_role_system", + "admin_support_role_user": "admin_user", + "admin_support_search": "admin_search", + "admin_support_status": "admin_status", + "admin_support_status_awaiting_admin": "wa_support_status_awaiting_admin", + "admin_support_status_awaiting_user": "wa_support_status_awaiting_user", + "admin_support_status_closed": "wa_support_status_closed", + "admin_support_status_open": "wa_support_status_open", + "admin_support_status_resolved": "wa_support_status_resolved", + "admin_support_ticket_number": "wa_support_ticket_number", + "admin_support_user_context": "admin_user", + "admin_tariffs_legacy_traffic_packages": "admin_tariff_traffic_packages", + "admin_tariffs_stat_enabled": "admin_enabled", + "admin_user_btn_cancel": "wa_cancel", + "admin_user_history_until": "wa_until_date", + "admin_user_label_provider": "admin_provider", + "admin_user_short": "admin_user", + "admin_user_stats_total_label": "admin_total", + "back_to_autopay_method_choice_button": "back_to_main_menu_button", + "back_to_payment_methods_button": "back_to_main_menu_button", + "cancel_broadcast_button": "cancel_button", + "csv_no": "no_button", + "csv_yes": "yes_button", + "user_premium_override_status_unlimited": "user_regular_override_status_unlimited", + "user_regular_override_save": "admin_save", + "wa_devices_disconnect_title": "wa_devices_disconnect", + "wa_install_link_copied": "wa_link_copied", + "wa_link_email_modal_title": "wa_settings_link_email_action", +} + + +def resolve_locale_key(key: object) -> str: + value = str(key or "").strip() + seen: Set[str] = set() + while value in LOCALE_KEY_ALIASES and value not in seen: + seen.add(value) + value = LOCALE_KEY_ALIASES[value] + return value + + +def is_valid_locale_language_code(value: str) -> bool: + return 2 <= len(value) <= 16 and bool(_LOCALE_LANGUAGE_CODE_RE.fullmatch(value)) + + +def normalize_locale_language_code( + raw: object, + valid_languages: Optional[Set[str]] = None, + *, + prefer_known_base: bool = True, +) -> str: + value = str(raw or "").strip().lower().replace("_", "-") + if not value: + return "" + if prefer_known_base and valid_languages and value not in valid_languages: + base = value.split("-", 1)[0] + if base in valid_languages: + return base + return value + + +def _normalize_language_code(raw: object, valid_languages: Optional[Set[str]] = None) -> str: + return normalize_locale_language_code(raw, valid_languages) + + +def locale_language_label(code: object) -> str: + value = normalize_locale_language_code(code, prefer_known_base=False) + return LANGUAGE_LABELS.get(value, value.upper()) + + +def locale_language_flag(code: object) -> str: + value = normalize_locale_language_code(code, prefer_known_base=False) + return LANGUAGE_FLAGS.get(value, "🏳️") + + +def sort_locale_language_codes(codes: Iterable[object]) -> List[str]: + normalized = { + normalize_locale_language_code(code, prefer_known_base=False) + for code in codes + } + normalized = {code for code in normalized if code and is_valid_locale_language_code(code)} + preferred = [code for code in DEFAULT_LANGUAGE_ORDER if code in normalized] + rest = sorted(code for code in normalized if code not in DEFAULT_LANGUAGE_ORDER) + return [*preferred, *rest] + + +def locale_language_options( + codes: Iterable[object], + *, + base_languages: Iterable[object] = (), +) -> List[Dict[str, Any]]: + base_set = set(sort_locale_language_codes(base_languages)) + return [ + { + "code": code, + "label": locale_language_label(code), + "flag": locale_language_flag(code), + "base": code in base_set, + } + for code in sort_locale_language_codes(codes) + ] + + +def _valid_locale_keys_by_language( + locales_data: Dict[str, Dict[str, str]], +) -> Dict[str, Set[str]]: + return { + lang: {str(key) for key in messages.keys()} + for lang, messages in locales_data.items() + if isinstance(messages, dict) + } + + +def normalize_locale_overrides_payload( + payload: object, + *, + valid_languages: Optional[Iterable[str]] = None, + valid_keys_by_language: Optional[Dict[str, Set[str]]] = None, + allow_extra_languages: bool = False, + key_aliases: Optional[Dict[str, str]] = None, +) -> Tuple[LocaleOverrides, Dict[str, str]]: + """Normalize a user/admin supplied locale override JSON payload. + + The canonical shape is ``{"ru": {"welcome": "..."}, "en": {...}}``. + For convenience, files may also wrap it as ``{"overrides": {...}}`` or + ``{"locales": {...}}``. + """ + + if not isinstance(payload, dict): + return {}, {"_payload": "invalid_payload"} + + raw_payload = payload + for wrapper_key in ("overrides", "locales"): + wrapped = raw_payload.get(wrapper_key) + if isinstance(wrapped, dict): + raw_payload = wrapped + break + + valid_lang_set = {str(lang).lower() for lang in valid_languages or []} + aliases = key_aliases or LOCALE_KEY_ALIASES + + def resolve_payload_key(raw_key: str) -> str: + value = raw_key + seen: Set[str] = set() + while value in aliases and value not in seen: + seen.add(value) + value = aliases[value] + return value + + all_valid_keys: Set[str] = set() + if valid_keys_by_language: + for keys in valid_keys_by_language.values(): + all_valid_keys.update(str(key) for key in keys) + + overrides: LocaleOverrides = {} + errors: Dict[str, str] = {} + + for raw_lang, raw_messages in raw_payload.items(): + lang = normalize_locale_language_code( + raw_lang, + valid_lang_set or None, + prefer_known_base=not allow_extra_languages, + ) + error_key = str(raw_lang or "_language") + if not lang: + errors[error_key] = "invalid_language" + continue + if valid_lang_set and lang not in valid_lang_set: + if not allow_extra_languages: + errors[error_key] = "unknown_language" + continue + if not is_valid_locale_language_code(lang): + errors[error_key] = "invalid_language" + continue + elif allow_extra_languages and not is_valid_locale_language_code(lang): + errors[error_key] = "invalid_language" + continue + if not isinstance(raw_messages, dict): + errors[lang] = "invalid_language_bucket" + continue + + lang_keys = valid_keys_by_language.get(lang, set()) if valid_keys_by_language else set() + bucket: Dict[str, str] = {} + for raw_key, raw_value in raw_messages.items(): + raw_key_text = str(raw_key or "").strip() + key = resolve_payload_key(raw_key_text) + item_error_key = f"{lang}.{raw_key_text or '_key'}" + if not raw_key_text or not key: + errors[item_error_key] = "invalid_key" + continue + if all_valid_keys and key not in all_valid_keys and key not in lang_keys: + errors[item_error_key] = "unknown_key" + continue + if raw_value is None: + continue + if not isinstance(raw_value, str): + errors[item_error_key] = "invalid_value" + continue + if len(raw_value) > 20000: + errors[item_error_key] = "value_too_long" + continue + if raw_key_text in aliases and key in bucket: + continue + bucket[key] = raw_value + if bucket: + overrides[lang] = dict(sorted(bucket.items())) + + return dict(sorted(overrides.items())), errors + class JsonI18n: - def __init__(self, path: str, default: str = "en", domain: str = "bot"): + def __init__( + self, + path: str, + default: str = "en", + domain: str = "bot", + overrides_path: Optional[str] = None, + ): self.domain = domain self.path = path self.default_lang = default + self.base_locales_data: Dict[str, Dict[str, str]] = {} + self.locale_overrides: LocaleOverrides = {} self.locales_data: Dict[str, Dict[str, str]] = {} + self._overrides_path: Optional[Path] = None + self._overrides_file_mtime_ns: Optional[int] = None + self._overrides_file_next_check = 0.0 + self._overrides_file_check_interval_seconds = 1.0 self._load_locales() + if overrides_path: + self.configure_overrides_file(overrides_path) + self.reload_overrides_from_file(force=True) logging.info( f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501 ) @@ -26,13 +324,26 @@ class JsonI18n: if not os.path.isdir(self.path): logging.error(f"Locales path not found or not a directory: {self.path}") return + loaded: Dict[str, Dict[str, str]] = {} for item in os.listdir(self.path): if item.endswith(".json"): lang_code = item.split(".")[0] file_path = os.path.join(self.path, item) try: with open(file_path, "r", encoding="utf-8") as f: - self.locales_data[lang_code] = json.load(f) + data = json.load(f) + if isinstance(data, dict): + loaded[lang_code] = { + str(key): str(value) + for key, value in data.items() + if isinstance(value, str) + } + else: + logging.error( + "Locale %s from %s is not a JSON object", + lang_code, + file_path, + ) except json.JSONDecodeError as e_json_load: logging.error( f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501 @@ -42,24 +353,143 @@ class JsonI18n: f"Error loading locale {lang_code} from {file_path}: {e_load}", exc_info=True, ) + self.base_locales_data = loaded + self._rebuild_effective_locales() + + def _rebuild_effective_locales(self) -> None: + effective: Dict[str, Dict[str, str]] = {} + for lang, messages in self.base_locales_data.items(): + merged = dict(messages) + merged.update(self.locale_overrides.get(lang, {})) + effective[lang] = merged + fallback_base = ( + self.base_locales_data.get(self.default_lang) + or self.base_locales_data.get("en") + or next(iter(self.base_locales_data.values()), {}) + ) + for lang, messages in self.locale_overrides.items(): + if lang in effective: + continue + merged = dict(fallback_base) + merged.update(messages) + effective[lang] = merged + self.locales_data = effective + + def _valid_keys_by_language(self) -> Dict[str, Set[str]]: + return _valid_locale_keys_by_language(self.base_locales_data) + + def language_options(self) -> List[Dict[str, Any]]: + self.reload_overrides_from_file() + return locale_language_options( + self.locales_data.keys(), + base_languages=self.base_locales_data.keys(), + ) + + def set_locale_overrides(self, overrides: object) -> Dict[str, str]: + normalized, errors = normalize_locale_overrides_payload( + overrides, + valid_languages=set(self.base_locales_data.keys()), + valid_keys_by_language=self._valid_keys_by_language(), + allow_extra_languages=True, + ) + if errors: + logging.warning("Some locale overrides were skipped: %s", errors) + self.locale_overrides = normalized + self._rebuild_effective_locales() + return errors + + def configure_overrides_file(self, path: str | Path) -> None: + self._overrides_path = Path(path) + try: + self._overrides_file_mtime_ns = self._overrides_path.stat().st_mtime_ns + except FileNotFoundError: + self._overrides_file_mtime_ns = None + except OSError as exc: + logging.warning("Failed to stat locale overrides file %s: %s", path, exc) + self._overrides_file_mtime_ns = None + + def reload_overrides_from_file(self, *, force: bool = False) -> bool: + if self._overrides_path is None: + return False + now = time.monotonic() + if not force and now < self._overrides_file_next_check: + return False + self._overrides_file_next_check = now + self._overrides_file_check_interval_seconds + + try: + stat = self._overrides_path.stat() + except FileNotFoundError: + if self._overrides_file_mtime_ns is None: + return False + self._overrides_file_mtime_ns = None + logging.info( + "Locale overrides file removed; keeping current in-memory overrides until " + "the DB fallback is reloaded" + ) + return False + except OSError as exc: + logging.warning( + "Failed to stat locale overrides file %s: %s", + self._overrides_path, + exc, + ) + return False + + if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns: + return False + + try: + payload = json.loads(self._overrides_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + logging.warning( + "Failed to parse locale overrides file %s: %s", + self._overrides_path, + exc, + ) + self._overrides_file_mtime_ns = stat.st_mtime_ns + return False + except OSError as exc: + logging.warning( + "Failed to read locale overrides file %s: %s", + self._overrides_path, + exc, + ) + return False + + self._overrides_file_mtime_ns = stat.st_mtime_ns + self.set_locale_overrides(payload) + logging.info("Locale overrides reloaded from %s", self._overrides_path) + return True def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str: + self.reload_overrides_from_file() + lookup_key = resolve_locale_key(key) + + requested_lang_code = normalize_locale_language_code( + lang_code, + set(self.locales_data.keys()), + prefer_known_base=False, + ) + requested_base_lang_code = requested_lang_code.split("-", 1)[0] + # Determine effective language with robust fallback - if lang_code and lang_code in self.locales_data: - effective_lang_code = lang_code + if requested_lang_code and requested_lang_code in self.locales_data: + effective_lang_code = requested_lang_code + elif requested_base_lang_code and requested_base_lang_code in self.locales_data: + effective_lang_code = requested_base_lang_code elif self.default_lang in self.locales_data: effective_lang_code = self.default_lang elif "en" in self.locales_data: effective_lang_code = "en" else: - effective_lang_code = lang_code or self.default_lang + effective_lang_code = requested_lang_code or self.default_lang lang_data = self.locales_data.get(effective_lang_code) if lang_data is None: # Try explicit fallback to English if available fallback_data = self.locales_data.get("en") if fallback_data is not None: - text = fallback_data.get(key) + text = fallback_data.get(lookup_key) if text is not None: try: return text.format(**kwargs) if kwargs else text @@ -70,11 +500,11 @@ class JsonI18n: ) return key.format(**kwargs) if kwargs else key - text = lang_data.get(key) + text = lang_data.get(lookup_key) if text is None: if effective_lang_code != self.default_lang: default_lang_data = self.locales_data.get(self.default_lang, {}) - text = default_lang_data.get(key) + text = default_lang_data.get(lookup_key) if text is None: logging.warning( diff --git a/backend/bot/services/email_auth_service.py b/backend/bot/services/email_auth_service.py index c9afd1d..8d9b90b 100644 --- a/backend/bot/services/email_auth_service.py +++ b/backend/bot/services/email_auth_service.py @@ -15,6 +15,7 @@ from typing import Optional from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession +from bot.middlewares.i18n import JsonI18n from bot.services.email_templates import EmailContent, render_login_code from config.settings import Settings from db.dal import security_dal @@ -70,8 +71,9 @@ def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optiona class EmailAuthService: - def __init__(self, settings: Settings): + def __init__(self, settings: Settings, i18n: Optional[JsonI18n] = None): self.settings = settings + self.i18n = i18n def _smtp_attempts(self) -> list[SmtpAttempt]: attempts: list[SmtpAttempt] = [] @@ -465,6 +467,7 @@ class EmailAuthService: language_code=language_code, magic_link=magic_link, purpose=purpose, + i18n=self.i18n, ) message = EmailMessage() diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py index 1e1569e..187fa78 100644 --- a/backend/bot/services/email_templates.py +++ b/backend/bot/services/email_templates.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from typing import Optional, Sequence, Tuple from urllib.parse import urlsplit -from bot.middlewares.i18n import JsonI18n, get_i18n_instance +from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code from config.settings import Settings _BG = "#05070a" @@ -64,7 +64,10 @@ def _brand_title(settings: Settings) -> str: def _normalize_lang(language_code: Optional[str], settings: Settings) -> str: - return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0] + return normalize_locale_language_code( + language_code or settings.DEFAULT_LANGUAGE or "ru", + prefer_known_base=False, + ) def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n: diff --git a/backend/bot/services/locale_override_service.py b/backend/bot/services/locale_override_service.py new file mode 100644 index 0000000..86a0455 --- /dev/null +++ b/backend/bot/services/locale_override_service.py @@ -0,0 +1,721 @@ +"""Load, persist and apply runtime overrides for localization strings.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.middlewares.i18n import ( + JsonI18n, + LocaleOverrides, + is_valid_locale_language_code, + normalize_locale_language_code, + normalize_locale_overrides_payload, + resolve_locale_key, +) +from db.dal import locale_overrides_dal + +logger = logging.getLogger(__name__) + +APP_ROOT = Path(__file__).resolve().parents[3] +LOCALE_OVERRIDES_PATH = APP_ROOT / "data" / "locales-overrides.json" + +LOCALE_GROUPS = [ + { + "id": "admin_navigation", + "title": "Admin navigation and shared UI", + "description": "Sidebar, section headers, toolbar actions, filters, and shared controls.", + "audience": "internal", + "prefixes": ( + "admin_nav_", + "admin_section_", + "admin_panel_title", + "admin_back_to_panel", + "admin_sidebar_", + "admin_exit", + "admin_menu", + "admin_language", + "admin_page_", + "admin_close", + "admin_collapse", + "admin_expand", + "admin_show", + "admin_hide", + "admin_loading", + "admin_btn_", + "admin_filter_", + "admin_sort_", + "admin_status_", + "admin_badge_", + "admin_aria_", + "admin_search", + "admin_clear", + "admin_save", + "admin_saving", + "admin_add", + "admin_apply", + "admin_reset", + "admin_copy", + "admin_copied", + "admin_error", + "admin_unknown_action", + "back_to_admin_panel_button", + "back_to_ads_list_button", + "back_to_stats_monitoring_button", + "back_to_user_management_button", + "prev_page_button", + "next_page_button", + ), + }, + { + "id": "admin_dashboard", + "title": "Admin dashboard and stats", + "description": "Dashboard cards, revenue charts, panel sync status, and monitoring copy.", + "audience": "internal", + "prefixes": ( + "admin_stats_", + "admin_financial_", + "admin_enhanced_", + "admin_panel_stats_", + "admin_panel_traffic_", + "admin_queue_", + "admin_sync_status_", + "admin_stats_button", + "admin_sync_panel_button", + "admin_sync_initiated_from_panel", + "admin_total", + "error_displaying_statistics", + "inline_admin_", + "inline_user_stats_", + "inline_financial_", + "inline_system_", + ), + }, + { + "id": "admin_users", + "title": "Admin users", + "description": ( + "User lists, user cards, bans, grants, premium overrides, and direct messages." + ), + "audience": "internal", + "prefixes": ( + "admin_user_", + "admin_users_", + "admin_ban_", + "admin_unban_", + "admin_banned_", + "admin_premium_override_", + "admin_traffic_grant_", + "admin_view_banned_", + "user_card_", + "user_premium_", + "user_regular_", + "user_traffic_", + "user_override_", + "premium_override_", + "regular_override_", + "traffic_grant_", + ), + }, + { + "id": "admin_payments", + "title": "Admin payments", + "description": ( + "Payment tables, payment details, exports, provider labels, and payment stats." + ), + "audience": "internal", + "prefixes": ( + "admin_payment_", + "admin_payments_", + "admin_no_payments", + "admin_view_payments", + "admin_refresh_payments", + "admin_export_payments", + "admin_export_sent", + "admin_amount", + "admin_provider", + "admin_description", + "admin_date", + "admin_csv_payment_", + "admin_csv_amount", + "admin_csv_currency", + "admin_csv_provider", + "admin_csv_status", + "admin_csv_description", + "admin_csv_units", + "admin_csv_months", + "admin_csv_created_at", + ), + }, + { + "id": "admin_promos_marketing", + "title": "Admin promos, ads, and broadcasts", + "description": "Promo management, ad campaigns, marketing tools, and broadcast workflows.", + "audience": "internal", + "prefixes": ( + "admin_promo_", + "admin_promos_", + "admin_bulk_promo_", + "admin_ads_", + "admin_ad_", + "admin_broadcast_", + "admin_create_promo_", + "admin_create_bulk_promo_", + "admin_active_promos_", + "broadcast_", + "confirm_broadcast_", + "cancel_broadcast_", + ), + }, + { + "id": "admin_tariffs", + "title": "Admin tariffs", + "description": ( + "Tariff catalog, tariff dialogs, legacy tariff rows, and trial tariff widgets." + ), + "audience": "internal", + "prefixes": ("admin_tariff_", "admin_tariffs_", "admin_trial"), + }, + { + "id": "admin_support", + "title": "Admin support inbox", + "description": "Support ticket inbox, ticket filters, admin replies, and support statuses.", + "audience": "internal", + "prefixes": ("admin_support_",), + }, + { + "id": "admin_appearance", + "title": "Admin appearance", + "description": "Theme catalog, branding, logo, favicon, and public page links.", + "audience": "internal", + "prefixes": ( + "admin_themes_", + "admin_appearance", + "admin_settings_icon_", + "admin_settings_field_webapp_", + "admin_settings_field_subscription_mini_app_url", + "admin_settings_field_support_link", + "admin_settings_field_server_status_url", + "admin_settings_field_terms_", + "admin_settings_field_privacy_", + "admin_settings_field_user_agreement_", + "appearance_", + ), + }, + { + "id": "admin_settings_payments", + "title": "Admin payment settings", + "description": ( + "Payment method toggles, prices, provider credentials, and webhook settings." + ), + "audience": "internal", + "prefixes": ( + "admin_settings_field_default_currency_", + "admin_settings_field_month_", + "admin_settings_field_rub_", + "admin_settings_field_stars_", + "admin_settings_field_traffic_packages_", + "admin_settings_field_payment_methods_", + "admin_settings_field_subscription_purchase_", + "admin_settings_field_yookassa_", + "admin_settings_field_freekassa_", + "admin_settings_field_platega_", + "admin_settings_field_severpay_", + "admin_settings_field_cryptopay_", + "admin_settings_field_wata_", + "admin_settings_field_heleket_", + ), + }, + { + "id": "admin_settings_subscriptions", + "title": "Admin subscription settings", + "description": ( + "Panel connection, default squads, trials, referrals, device limits, and guides." + ), + "audience": "internal", + "prefixes": ( + "admin_settings_field_panel_", + "admin_settings_field_user_", + "admin_settings_field_trial_", + "admin_settings_field_referral_", + "admin_settings_field_legacy_refs", + "admin_settings_field_my_devices_", + "admin_settings_field_subscription_guides_", + "admin_settings_field_subscription_page_", + ), + }, + { + "id": "admin_settings_notifications", + "title": "Admin notifications and logs", + "description": "Logging, required channel, subscription notifications, and support limits.", + "audience": "internal", + "prefixes": ( + "admin_settings_field_log_", + "admin_settings_field_support_", + "admin_settings_field_subscription_notifications_", + "admin_settings_field_subscription_notify_", + "admin_settings_field_required_", + "admin_settings_field_disable_welcome_", + "admin_settings_field_start_command_", + "admin_settings_field_default_language_", + ), + }, + { + "id": "admin_settings", + "title": "Admin settings", + "description": ( + "Settings screen groups, subsections, helper text, and uncategorized settings." + ), + "audience": "internal", + "prefixes": ("admin_settings_",), + }, + { + "id": "admin_translations", + "title": "Admin translations", + "description": "Translation override screen, language controls, and locale group labels.", + "audience": "internal", + "prefixes": ("admin_translations_",), + }, + { + "id": "admin_logs", + "title": "Admin logs and exports", + "description": "Activity logs, log exports, CSV headers, and event detail labels.", + "audience": "internal", + "prefixes": ( + "admin_logs_", + "admin_log_", + "admin_all_logs_", + "admin_view_logs_", + "admin_export_logs_", + "admin_no_logs", + "admin_csv_header_", + "admin_event", + "admin_content", + "csv_yes", + "csv_no", + "error_displaying_logs_", + ), + }, + { + "id": "admin_misc", + "title": "Admin miscellaneous", + "description": ( + "Older bot-admin labels and admin-only strings that do not fit another section." + ), + "audience": "internal", + "prefixes": ("admin_",), + }, + { + "id": "webapp", + "title": "Mini App", + "description": "User-facing Mini App screens, navigation, settings, and toasts.", + "audience": "user", + "prefixes": ("wa_",), + }, + { + "id": "bot_menu", + "title": "Telegram bot menu", + "description": "Start menu, inline buttons, language selector, and bot-only flows.", + "audience": "user", + "prefixes": ( + "main_menu_", + "menu_", + "bot_interface_", + "choose_language", + "language_", + "back_", + "cancel_", + "connect_", + ), + }, + { + "id": "subscriptions", + "title": "Subscriptions and devices", + "description": ( + "Subscription status, install guides, traffic packages, trials, and devices." + ), + "audience": "user", + "prefixes": ( + "subscription_", + "trial_", + "tariff_", + "traffic_", + "device_", + "devices_", + "my_devices_", + "install_", + "config_", + ), + }, + { + "id": "payments", + "title": "Payments", + "description": "Payment provider flows, invoices, payment methods, and checkout messages.", + "audience": "user", + "prefixes": ( + "payment_", + "pay_", + "yookassa_", + "free_kassa_", + "freekassa_", + "wata_", + "heleket_", + "cryptopay_", + "platega_", + "stars_", + "autorenew_", + ), + }, + { + "id": "support", + "title": "Support", + "description": "Support links, ticket inbox copy, ticket statuses, and notifications.", + "audience": "user", + "prefixes": ("support_", "ticket_"), + }, + { + "id": "referrals_promos", + "title": "Referrals and promos", + "description": "Referral program, invite copy, promo codes, and bonuses.", + "audience": "user", + "prefixes": ("referral_", "promo_", "invite_", "inline_referral_"), + }, + { + "id": "auth_security", + "title": "Auth and security", + "description": "Login, email verification, account linking, and security messages.", + "audience": "user", + "prefixes": ( + "auth_", + "login_", + "password_", + "security_", + "webapp_auth_", + "channel_subscription_", + ), + }, + { + "id": "emails", + "title": "Emails", + "description": "Transactional emails sent to users: login codes, payments, and reminders.", + "audience": "user", + "prefixes": ("email_",), + }, + { + "id": "notifications_sync", + "title": "Notifications and sync", + "description": "Admin notifications, panel sync, logs, and background status messages.", + "audience": "internal", + "prefixes": ("notification_", "notifications_", "sync_", "log_", "panel_"), + }, +] + +DEFAULT_LOCALE_GROUP = { + "id": "common", + "title": "Common", + "description": "Shared buttons, statuses, validation errors, and uncategorized strings.", + "audience": "user", + "prefixes": (), +} + +INTERNAL_LOCALE_KEY_PREFIXES = ( + "admin_", + "log_", + "notification_", + "notifications_", + "panel_", + "sync_", +) + + +@dataclass(frozen=True) +class LocaleOverridesFileState: + exists: bool + readable: bool + overrides: LocaleOverrides + + +def _valid_languages(i18n: JsonI18n) -> set[str]: + return set((i18n.base_locales_data or i18n.locales_data or {}).keys()) + + +def _valid_keys_by_language(i18n: JsonI18n) -> Dict[str, set[str]]: + source = i18n.base_locales_data or i18n.locales_data or {} + return { + lang: {str(key) for key in messages} + for lang, messages in source.items() + if isinstance(messages, dict) + } + + +def _normalize_for_i18n(i18n: JsonI18n, payload: object) -> tuple[LocaleOverrides, Dict[str, str]]: + return normalize_locale_overrides_payload( + payload, + valid_languages=_valid_languages(i18n), + valid_keys_by_language=_valid_keys_by_language(i18n), + allow_extra_languages=True, + ) + + +def _flatten(overrides: LocaleOverrides) -> Iterable[Tuple[str, str, str]]: + for lang, messages in overrides.items(): + for key, value in messages.items(): + yield lang, key, value + + +def _flat_map(overrides: LocaleOverrides) -> Dict[Tuple[str, str], str]: + return {(lang, key): value for lang, key, value in _flatten(overrides)} + + +def _count_overrides(overrides: LocaleOverrides) -> int: + return sum(len(messages) for messages in overrides.values()) + + +def _read_locale_overrides_file_state( + i18n: JsonI18n, + *, + path: Path = LOCALE_OVERRIDES_PATH, +) -> LocaleOverridesFileState: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return LocaleOverridesFileState(exists=False, readable=False, overrides={}) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to read locale overrides from %s: %s", path, exc) + return LocaleOverridesFileState(exists=True, readable=False, overrides={}) + + overrides, errors = _normalize_for_i18n(i18n, payload) + if errors: + logger.warning("Skipping invalid locale override entries from %s: %s", path, errors) + return LocaleOverridesFileState(exists=True, readable=True, overrides=overrides) + + +def read_locale_overrides_file( + i18n: JsonI18n, + *, + path: Path = LOCALE_OVERRIDES_PATH, +) -> LocaleOverrides: + return _read_locale_overrides_file_state(i18n, path=path).overrides + + +def write_locale_overrides_file( + overrides: LocaleOverrides, + *, + path: Path = LOCALE_OVERRIDES_PATH, +) -> bool: + payload = { + lang: dict(sorted(messages.items())) + for lang, messages in sorted(overrides.items()) + if messages + } + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return True + except OSError as exc: + logger.warning("Failed to write locale overrides to %s: %s", path, exc) + return False + + +async def _replace_db_overrides( + session: AsyncSession, + desired_overrides: LocaleOverrides, + *, + updated_by: Optional[int] = None, +) -> int: + current = _flat_map(await locale_overrides_dal.get_all_overrides(session)) + desired = _flat_map(desired_overrides) + changes: Dict[Tuple[str, str], Tuple[bool, str]] = {} + + for identity, value in desired.items(): + if current.get(identity) != value: + changes[identity] = (True, value) + for identity in current: + if identity not in desired: + changes[identity] = (False, "") + + if changes: + await locale_overrides_dal.bulk_apply(session, updates=changes, updated_by=updated_by) + return len(changes) + + +async def load_locale_overrides( + i18n: JsonI18n, + async_session_factory: sessionmaker, + *, + overrides_path: Path = LOCALE_OVERRIDES_PATH, +) -> int: + """Load locale overrides and keep the DB mirror in sync. + + A valid JSON file is the source of truth. The DB is used as a fallback only + when the file is missing or cannot be read/parsed. + """ + + i18n.configure_overrides_file(overrides_path) + file_state = _read_locale_overrides_file_state(i18n, path=overrides_path) + try: + async with async_session_factory() as session: + if file_state.readable: + async with session.begin(): + changed = await _replace_db_overrides( + session, + file_state.overrides, + updated_by=None, + ) + i18n.set_locale_overrides(file_state.overrides) + i18n.configure_overrides_file(overrides_path) + logger.info( + "Applied %s locale overrides from %s and synced %s DB rows", + _count_overrides(file_state.overrides), + overrides_path, + changed, + ) + return _count_overrides(file_state.overrides) + + db_overrides = await locale_overrides_dal.get_all_overrides(session) + except Exception as exc: + logger.warning("Could not load locale overrides from DB: %s", exc) + if file_state.readable: + i18n.set_locale_overrides(file_state.overrides) + return _count_overrides(file_state.overrides) + i18n.set_locale_overrides({}) + return 0 + + normalized, errors = _normalize_for_i18n(i18n, db_overrides) + if errors: + logger.warning("Skipping invalid DB locale override entries: %s", errors) + try: + async with async_session_factory() as session: + async with session.begin(): + changed = await _replace_db_overrides(session, normalized, updated_by=None) + if changed: + logger.info("Canonicalized %s DB locale override rows", changed) + except Exception as exc: + logger.warning("Could not canonicalize DB locale overrides: %s", exc) + i18n.set_locale_overrides(normalized) + if not file_state.exists: + logger.info( + "Locale overrides file %s is missing; trying to bootstrap it from DB state", + overrides_path, + ) + file_written = write_locale_overrides_file(normalized, path=overrides_path) + if file_written: + i18n.configure_overrides_file(overrides_path) + logger.info("Created locale overrides file %s from DB state", overrides_path) + logger.info("Applied %s locale overrides from DB fallback", _count_overrides(normalized)) + return _count_overrides(normalized) + + +async def update_locale_overrides( + i18n: JsonI18n, + async_session_factory: sessionmaker, + *, + updates: Dict[str, Dict[str, Any]], + deletes: Optional[List[Dict[str, str]]] = None, + actor_id: Optional[int] = None, + overrides_path: Path = LOCALE_OVERRIDES_PATH, +) -> Dict[str, Any]: + deletes = list(deletes or []) + normalized_updates, errors = _normalize_for_i18n(i18n, updates) + + normalized_deletes: List[Tuple[str, str]] = [] + valid_languages = _valid_languages(i18n) + valid_keys = {key for keys in _valid_keys_by_language(i18n).values() for key in keys} + for item in deletes: + if not isinstance(item, dict): + errors.setdefault("_deletes", "invalid_delete") + continue + lang = normalize_locale_language_code( + item.get("lang"), + valid_languages=None, + prefer_known_base=False, + ) + key = resolve_locale_key(item.get("key")) + error_key = f"{lang or '_language'}.{key or '_key'}" + if lang not in valid_languages and not is_valid_locale_language_code(lang): + errors.setdefault(error_key, "invalid_language") + continue + if key not in valid_keys: + errors.setdefault(error_key, "unknown_key") + continue + normalized_deletes.append((lang, key)) + + if errors: + return {"ok": False, "errors": errors} + + async with async_session_factory() as session: + db_overrides = await locale_overrides_dal.get_all_overrides(session) + + file_state = _read_locale_overrides_file_state(i18n, path=overrides_path) + source_overrides = file_state.overrides if file_state.readable else db_overrides + desired, source_errors = _normalize_for_i18n(i18n, source_overrides) + if source_errors: + logger.warning("Skipping invalid locale override entries before update: %s", source_errors) + + for lang, messages in normalized_updates.items(): + desired.setdefault(lang, {}).update(messages) + for lang, key in normalized_deletes: + if lang in desired: + desired[lang].pop(key, None) + if not desired[lang]: + desired.pop(lang, None) + desired = { + lang: dict(sorted(messages.items())) + for lang, messages in sorted(desired.items()) + if messages + } + + file_written = write_locale_overrides_file(desired, path=overrides_path) + if not file_written and file_state.exists and file_state.readable: + return {"ok": False, "errors": {"_file": "write_failed"}} + + async with async_session_factory() as session: + async with session.begin(): + await _replace_db_overrides(session, desired, updated_by=actor_id) + + i18n.set_locale_overrides(desired) + if file_written: + i18n.configure_overrides_file(overrides_path) + + return { + "ok": True, + "applied": sum(len(messages) for messages in normalized_updates.values()), + "reverted": len(normalized_deletes), + "file_written": file_written, + } + + +def group_id_for_locale_key(key: str) -> str: + for group in LOCALE_GROUPS: + if any(key.startswith(prefix) or key == prefix for prefix in group["prefixes"]): + return str(group["id"]) + return str(DEFAULT_LOCALE_GROUP["id"]) + + +def audience_for_locale_key(key: str) -> str: + if key.startswith(INTERNAL_LOCALE_KEY_PREFIXES): + return "internal" + group_id = group_id_for_locale_key(key) + for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]: + if group["id"] == group_id: + return str(group.get("audience") or "user") + return "user" + + +def locale_group_catalog() -> List[Dict[str, Any]]: + catalog: List[Dict[str, Any]] = [] + for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]: + item = {key: value for key, value in group.items() if key != "prefixes"} + item["title_key"] = f"translations_group_{item['id']}" + item["description_key"] = f"translations_group_{item['id']}_hint" + catalog.append(item) + return catalog diff --git a/backend/bot/services/panel_webhook_service.py b/backend/bot/services/panel_webhook_service.py index a2f3640..ab01ce7 100644 --- a/backend/bot/services/panel_webhook_service.py +++ b/backend/bot/services/panel_webhook_service.py @@ -206,8 +206,9 @@ class PanelWebhookService: days_left=days_left, end_date_text=end_date_text, dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None, + i18n=self.i18n, ) - email_service = EmailAuthService(self.settings) + email_service = EmailAuthService(self.settings, self.i18n) await email_service.send_rendered_email(email=recipient, content=content) except Exception: logging.exception("Failed to send subscription-expiring email to %s", recipient) diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index e5e05c7..b82bcb0 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -95,6 +95,7 @@ class PaymentContextMixin: except Exception: provider_label = self._PROVIDER_LABELS.get((provider or "").lower()) dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None + i18n = getattr(self, "i18n", None) try: content = render_payment_success( @@ -108,8 +109,9 @@ class PaymentContextMixin: end_date_text=end_date_text, dashboard_url=dashboard_url, provider_label=provider_label, + i18n=i18n, ) - email_service = EmailAuthService(self.settings) + email_service = EmailAuthService(self.settings, i18n) await email_service.send_rendered_email(email=recipient, content=content) except Exception: logging.exception("Failed to send payment success email to user %s", db_user.user_id) diff --git a/backend/bot/services/support_service.py b/backend/bot/services/support_service.py index 189d7eb..b173926 100644 --- a/backend/bot/services/support_service.py +++ b/backend/bot/services/support_service.py @@ -125,7 +125,7 @@ class SupportService: self.settings = settings self.bot = bot self.i18n = i18n - self.email_auth_service = email_auth_service or EmailAuthService(settings) + self.email_auth_service = email_auth_service or EmailAuthService(settings, i18n) self.notification_service = notification_service or NotificationService( bot, settings, diff --git a/backend/db/dal/__init__.py b/backend/db/dal/__init__.py index e0f74b5..8d062c4 100644 --- a/backend/db/dal/__init__.py +++ b/backend/db/dal/__init__.py @@ -1,6 +1,7 @@ from . import ( ad_dal, app_settings_dal, + locale_overrides_dal, message_log_dal, panel_sync_dal, payment_dal, @@ -23,5 +24,6 @@ __all__ = ( "ad_dal", "security_dal", "app_settings_dal", + "locale_overrides_dal", "support_dal", ) diff --git a/backend/db/dal/locale_overrides_dal.py b/backend/db/dal/locale_overrides_dal.py new file mode 100644 index 0000000..b8bbbec --- /dev/null +++ b/backend/db/dal/locale_overrides_dal.py @@ -0,0 +1,86 @@ +"""Persistent overrides for localization strings.""" + +from datetime import datetime, timezone +from typing import Dict, List, Optional, Tuple + +from sqlalchemy import delete, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models import LocaleOverride + + +async def get_all_overrides(session: AsyncSession) -> Dict[str, Dict[str, str]]: + rows = (await session.execute(select(LocaleOverride))).scalars().all() + result: Dict[str, Dict[str, str]] = {} + for row in rows: + result.setdefault(row.lang, {})[row.key] = row.value + return result + + +async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, object]]: + rows = (await session.execute(select(LocaleOverride))).scalars().all() + items: List[Dict[str, object]] = [] + for row in rows: + items.append( + { + "lang": row.lang, + "key": row.key, + "value": row.value, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "updated_by": row.updated_by, + } + ) + return items + + +async def upsert_override( + session: AsyncSession, + *, + lang: str, + key: str, + value: str, + updated_by: Optional[int], +) -> None: + now = datetime.now(timezone.utc) + stmt = ( + pg_insert(LocaleOverride) + .values(lang=lang, key=key, value=value, updated_at=now, updated_by=updated_by) + .on_conflict_do_update( + index_elements=[LocaleOverride.lang, LocaleOverride.key], + set_={ + "value": value, + "updated_at": now, + "updated_by": updated_by, + }, + ) + ) + await session.execute(stmt) + + +async def delete_override(session: AsyncSession, *, lang: str, key: str) -> bool: + stmt = delete(LocaleOverride).where( + LocaleOverride.lang == lang, + LocaleOverride.key == key, + ) + result = await session.execute(stmt) + return bool(result.rowcount or 0) + + +async def bulk_apply( + session: AsyncSession, + *, + updates: Dict[Tuple[str, str], Tuple[bool, str]], + updated_by: Optional[int], +) -> None: + for (lang, key), (set_flag, value) in updates.items(): + if set_flag: + await upsert_override( + session, + lang=lang, + key=key, + value=value, + updated_by=updated_by, + ) + else: + await delete_override(session, lang=lang, key=key) diff --git a/backend/db/migrator.py b/backend/db/migrator.py index 7ec884b..56fc9cb 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -902,6 +902,23 @@ def _migration_0027_add_subscription_install_share_token(connection: Connection) ) +def _migration_0028_add_locale_overrides(connection: Connection) -> None: + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS locale_overrides ( + lang VARCHAR(16) NOT NULL, + key VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by BIGINT, + PRIMARY KEY (lang, key) + ) + """ + ) + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1049,6 +1066,11 @@ MIGRATIONS: List[Migration] = [ description="Add stable public share tokens for install instructions", upgrade=_migration_0027_add_subscription_install_share_token, ), + Migration( + id="0028_add_locale_overrides", + description="Persist runtime overrides for localization strings", + upgrade=_migration_0028_add_locale_overrides, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 629ceee..afd4a79 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -489,3 +489,18 @@ class AppSettingOverride(Base): nullable=False, ) updated_by = Column(BigInteger, nullable=True) + + +class LocaleOverride(Base): + __tablename__ = "locale_overrides" + + lang = Column(String(16), primary_key=True) + key = Column(String(255), primary_key=True) + value = Column(Text, nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + updated_by = Column(BigInteger, nullable=True) diff --git a/backend/main_worker.py b/backend/main_worker.py index af303ee..951ee91 100644 --- a/backend/main_worker.py +++ b/backend/main_worker.py @@ -23,6 +23,7 @@ from bot.payment_providers.yookassa import ( process_cancelled_payment, process_successful_payment, ) +from bot.services.locale_override_service import load_locale_overrides from bot.services.settings_override_service import load_overrides_from_db from bot.services.tariff_worker import TariffTrafficWorker from bot.utils.message_queue import init_queue_manager @@ -37,6 +38,7 @@ async def _build_worker_context(settings): default=DefaultBotProperties(parse_mode=ParseMode.HTML), ) i18n = get_i18n_instance(path="locales", default=settings.DEFAULT_LANGUAGE) + await load_locale_overrides(i18n, session_factory) bot_username = "your_bot_username" try: bot_info = await bot.get_me() diff --git a/data/locales-overrides.example.json b/data/locales-overrides.example.json new file mode 100644 index 0000000..9e79498 --- /dev/null +++ b/data/locales-overrides.example.json @@ -0,0 +1,8 @@ +{ + "ru": { + "menu_personal_account_button": "Личный кабинет" + }, + "en": { + "menu_personal_account_button": "Account" + } +} diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index e3151ac..678def4 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -65,7 +65,9 @@ COPY data ./data COPY --from=version-builder /build-version /app/.build-version COPY --from=version-builder /build-tag /app/.build-tag COPY --from=version-builder /build-commit /app/.build-commit -RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data +RUN mkdir -p /app/logs /app/data \ + && if [ ! -f /app/data/locales-overrides.json ]; then printf '{}\n' > /app/data/locales-overrides.json; fi \ + && chown -R appuser:appuser /app/logs /app/data USER appuser diff --git a/docs/admin.md b/docs/admin.md index 507b667..6679879 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -51,6 +51,27 @@ Для каждого платежного метода в разделе провайдера доступны presentation-настройки `PAYMENT__WEBAPP_LABEL_RU`, `PAYMENT__WEBAPP_LABEL_EN`, `PAYMENT__WEBAPP_ICON`, `PAYMENT__TELEGRAM_LABEL_RU`, `PAYMENT__TELEGRAM_LABEL_EN` и `PAYMENT__TELEGRAM_EMOJI`. Пустое значение возвращает мультиязычный дефолт из модуля платежного провайдера. Иконка Web App выбирается из уже подключённых lucide-иконок (`frontend/src/lib/components/ui/icons.js`) через модалку в админке. +## Переводы + +Раздел **Система -> Переводы** позволяет переопределять отдельные строки из `locales/ru.json` и `locales/en.json` без монтирования полного файла локализации. Строки сгруппированы по месту применения: админка, Mini App, Telegram-бот, платежи, подписки, поддержка и другие группы. + +В разделе строки разделены по аудитории: пользовательские тексты Mini App/бота/платежей и внутренние тексты админки, логов и синхронизации. Дополнительные языки можно добавлять прямо в интерфейсе по коду локали, например `uk`, `de` или `pt-BR`; для таких языков оверрайды хранятся без отдельного базового файла локали, а отсутствующие строки берутся из языка по умолчанию. + +Файл `data/locales-overrides.json` считается источником правды, а таблица `locale_overrides` хранит его DB-зеркало. Если валидный JSON-файл есть, при старте backend и worker полностью синхронизируют БД с файлом, включая удаления строк. Если файл отсутствует, но каталог доступен для записи, он автоматически создается из текущих DB-оверрайдов или как пустой `{}`. Если файл не примонтирован, недоступен или временно сломан по JSON, используется fallback из БД. Сохранение из админки записывает полный итоговый снапшот и в JSON-файл, и в БД; если активный JSON-файл есть, но его нельзя перезаписать, сохранение отклоняется, чтобы БД не разъехалась с главным файлом. + +Формат файла: + +```json +{ + "ru": { + "menu_personal_account_button": "Личный кабинет" + }, + "en": { + "menu_personal_account_button": "Account" + } +} +``` + ### Инструкции подключения Секция **Система -> Настройки -> Инструкции подключения** управляет встроенным экраном установки. `SUBSCRIPTION_GUIDES_ENABLED` включает `/install` в личном кабинете, а `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED` заставляет кнопки подключения в Telegram-боте открывать Mini App вместо финальной Remnawave Subscription Page. Оба переключателя включены по умолчанию. diff --git a/docs/configuration.md b/docs/configuration.md index 4808805..72bc26f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -88,6 +88,7 @@ openssl rand -hex 32 ```bash mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs +touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data docker compose up -d --force-recreate backend worker diff --git a/docs/deployment.md b/docs/deployment.md index ffa009a..350d014 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -187,6 +187,7 @@ distributed lock; код подготовлен к нескольким репл ```bash mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs +touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data docker compose up -d --force-recreate backend worker diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index b1d5fa8..c19b3c5 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -35,6 +35,7 @@ TELEGRAM_SDK_ACTION_TIMEOUT_MS, TELEGRAM_SDK_BOOT_TIMEOUT_MS, TELEGRAM_WEBAPP_SCRIPT_URL, + uniqueLanguageCodes, WEBAPP_LANGUAGE_ORDER, } from "./lib/webapp/constants.js"; @@ -423,11 +424,20 @@ } $: referral = data?.referral || DEV_MOCK.data.referral; $: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru"); - $: languageOptions = WEBAPP_LANGUAGE_ORDER.map((code) => ({ - value: code, - label: LANGUAGE_LABELS[code] || code.toUpperCase(), - flag: LANGUAGE_FLAGS[code] || "🏳️", - })); + $: languageCodes = uniqueLanguageCodes( + WEBAPP_LANGUAGE_ORDER, + CFG.languages, + Object.keys(I18N || {}), + [currentLang] + ); + $: languageOptions = languageCodes.map((code) => { + const serverLanguage = (CFG.languages || []).find((language) => language.code === code); + return { + value: code, + label: serverLanguage?.label || LANGUAGE_LABELS[code] || code.toUpperCase(), + flag: serverLanguage?.flag || LANGUAGE_FLAGS[code] || "🏳️", + }; + }); $: currentLanguageOption = languageOptions.find((option) => option.value === currentLang) || languageOptions[0]; $: userLanguage = languageName(currentLang); @@ -1000,6 +1010,7 @@ onSettingsSaved: handleAdminPersistedSaved, onTariffsSaved: handleAdminPersistedSaved, onThemesSaved: handleAdminPersistedSaved, + onTranslationsSaved: handleAdminTranslationsSaved, brandTitle, brand, appFaviconUrl: CFG.faviconUrl, @@ -1670,6 +1681,29 @@ } } + async function refreshI18nScope(scope) { + if (MOCK) return; + const apiBase = String(CFG.apiBase || "/api").replace(/\/+$/, ""); + try { + const response = await fetch(`${apiBase}/i18n?scope=${encodeURIComponent(scope)}`, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + if (!response.ok) return; + const payload = await response.json(); + if (payload?.ok && payload.i18n) i18n.mergeMessages(payload.i18n); + if (scope === "admin") adminI18nLoaded = true; + } catch (_error) { + void _error; + } + } + + async function handleAdminTranslationsSaved(options = {}) { + adminI18nLoaded = false; + await Promise.all([refreshI18nScope("webapp"), refreshI18nScope("admin")]); + await handleAdminPersistedSaved({ ...options, deferFrontendReload: true }); + } + function selectTariff(tariff) { billingStore.selectTariff(tariff, plans); } diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index aaaab41..64da25d 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -8,6 +8,7 @@ Download, FileText, Globe2, + Languages, LayoutDashboard, LifeBuoy, Megaphone, @@ -38,6 +39,7 @@ import SupportSection from "./sections/SupportSection.svelte"; import TariffEditorModal from "./sections/TariffEditorModal.svelte"; import TariffsSection from "./sections/TariffsSection.svelte"; + import TranslationsSection from "./sections/TranslationsSection.svelte"; import AppearanceSection from "./sections/AppearanceSection.svelte"; import UserDetailModal from "./sections/UserDetailModal.svelte"; import UsersSection from "./sections/UsersSection.svelte"; @@ -51,6 +53,7 @@ import { createAdminSupportStore } from "../lib/admin/stores/supportStore.js"; import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js"; import { createThemesStore } from "../lib/admin/stores/themesStore.js"; + import { createTranslationsStore } from "../lib/admin/stores/translationsStore.js"; import { createUsersStore } from "../lib/admin/stores/usersStore.js"; import { fmtDate, @@ -83,6 +86,7 @@ export let onSettingsSaved = () => {}; export let onTariffsSaved = () => {}; export let onThemesSaved = () => {}; + export let onTranslationsSaved = () => {}; export let brand = {}; export let brandTitle = "/minishop"; export let appFaviconUrl = ""; @@ -128,6 +132,7 @@ items: [ { id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins }, { id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush }, + { id: "translations", label: at("nav_translations", {}, "Переводы"), icon: Languages }, { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders }, ], }, @@ -178,6 +183,14 @@ title: at("section_appearance_title", {}, "Внешний вид"), subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"), }, + translations: { + title: at("section_translations_title", {}, "Переводы"), + subtitle: at( + "section_translations_subtitle", + {}, + "Оверрайды строк локализации из базы данных и data/locales-overrides.json" + ), + }, settings: { title: at("section_settings_title", {}, "Настройки приложения"), subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"), @@ -223,6 +236,7 @@ const supportStore = createAdminSupportStore({ api, onToast: flash, at }); const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at }); const themesStore = createThemesStore({ api, onThemesSaved, flash, at }); + const translationsStore = createTranslationsStore({ api, onToast: flash, at }); const usersStore = createUsersStore({ api, onToast: flash, at }); setContext("promosStore", promosStore); @@ -236,13 +250,16 @@ setContext("usersStore", usersStore); setContext("tariffsStore", tariffsStore); setContext("themesStore", themesStore); + setContext("translationsStore", translationsStore); $: usersStore.setActive(active); $: paymentsStore.setActive(active); $: supportStore.setActive(active); $: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length; + $: translationsDirtyCount = Object.keys($translationsStore.translationsDirty || {}).length; $: syncBusy = $statsStore.syncBusy; $: settingsSaving = $settingsStore.settingsSaving; + $: translationsSaving = $translationsStore.translationsSaving; $: meta = SECTION_META[active] || { title: active, subtitle: "" }; $: currentLanguageOption = languageOptions.find((option) => option.value === currentLang) || languageOptions[0]; @@ -676,6 +693,27 @@ : at("btn_save", {}, "Сохранить")} {/if} + {#if active === "translations"} + {#if translationsDirtyCount} + {at( + "settings_dirty_count", + { count: translationsDirtyCount }, + "Изменений: " + translationsDirtyCount + )} + {/if} + translationsStore.saveTranslations(onTranslationsSaved)} + disabled={!translationsDirtyCount || translationsSaving} + > + + {translationsSaving + ? at("btn_saving", {}, "Сохранение...") + : at("btn_save", {}, "Сохранить")} + + {/if} @@ -751,6 +789,10 @@ {#if active === "settings"} {/if} + + {#if active === "translations"} + + {/if} {/key} diff --git a/frontend/src/admin/sections/TranslationsSection.svelte b/frontend/src/admin/sections/TranslationsSection.svelte new file mode 100644 index 0000000..902bb17 --- /dev/null +++ b/frontend/src/admin/sections/TranslationsSection.svelte @@ -0,0 +1,636 @@ + + +{#snippet renderTranslationsSkeleton()} +
+
+ + +
+ {#each Array(4) as _, index (index)} +
+ + + +
+ {/each} +
+{/snippet} + +{#snippet renderGroupSkeleton(group)} +
+ + {#each Array(Math.min(3, Math.max(1, group.items.length))) as _, index (index)} +
+ + + + + + + + +
+ {/each} +
+{/snippet} + +{#snippet renderLocaleEditor(item, language)} + {@const lang = language.code} + {@const dirtyEntry = translationsDirty[dirtyKey(lang, item.key)] || null} + {@const overridden = isOverridden(item, lang, dirtyEntry)} + {@const dirty = isDirty(item, lang, dirtyEntry)} + {@const localeId = localePanelId(item.key, lang)} + {@const expanded = + openLocaleEditorSet.has(localeId) || + ((overridden || dirty) && !closedLocaleEditorSet.has(localeId))} +
+ + + {#if expanded} +
+ +
+ {baseKind(item, lang)} + {baseValue(item, lang) || "-"} +
+ {#if overridden || dirty} + translationsStore.resetField(lang, item.key, overridden)} + > + + {at("reset", {}, "Reset")} + + {/if} +
+ {/if} +
+{/snippet} + +{#snippet renderTranslationItem(item, group)} + {@const audience = itemAudience(item, group)} +
+
+ + {item.key} + + {audienceLabel(audience)} + + {#if itemHasOverride(item, translationsDirty, translationLanguages)} + {at("settings_badge_override", {}, "Override")} + {/if} + {#if itemHasDirty(item, translationsDirty, translationLanguages)} + {at("settings_badge_dirty", {}, "Dirty")} + {/if} + + {item.key} + {effectiveValue(item, translationLanguages[0]?.code)} +
+
+ {#each translationLanguages as language (language.code)} + {@render renderLocaleEditor(item, language)} + {/each} +
+
+{/snippet} + +{#if translationsLoading || !translationGroups.length} + {@render renderTranslationsSkeleton()} +{:else} +
+ +
+ + {allOpen ? at("collapse_all", {}, "Collapse all") : at("expand_all", {}, "Expand all")} + + {#if Object.keys(translationsDirty).length > 0} + translationsStore.saveTranslations(onTranslationsSaved)} + disabled={translationsSaving} + > + {translationsSaving ? at("saving", {}, "Saving...") : at("save", {}, "Save")} + + {/if} +
+
+ +
+
+ + {at("translations_languages_title", {}, "Languages")} + {at("translations_languages_hint", {}, "Override any locale code")} +
+
+ {#each translationLanguages as language (language.code)} + + {language.label} + {language.code} + + {/each} +
+
{ + event.preventDefault(); + addLanguage(); + }} + > + + + + {at("add", {}, "Add")} + +
+
+ +
+ {#each AUDIENCE_FILTERS as option (option)} + + {/each} +
+ +

+ {at( + "translations_hint", + { path: translationsPath }, + `Overrides are stored in DB and mirrored to ${translationsPath}.` + )} +

+ + {#if audienceSections.length} +
+ {#each audienceSections as section (section.id)} +
+
+ + {section.title} + {section.hint} + + + {section.groups.reduce((count, group) => count + group.items.length, 0)} + +
+
+ {#each section.groups as group (groupPanelId(section.id, group.id))} + {@const dirtyCount = groupDirtyCount(group, translationsDirty, translationLanguages)} + {@const overrideCount = groupOverrideCount( + group, + translationsDirty, + translationLanguages + )} + {@const panelId = groupPanelId(section.id, group.id)} + {@const groupOpen = openGroupSet.has(panelId)} + {@const groupReady = readyGroupSet.has(panelId)} +
+
+ +
+ {#if groupOpen} +
+ {#if groupReady} + {#if groupDescription(group)} +

+ {groupDescription(group)} +

+ {/if} +
+ {#each group.items as item (item.key)} + {@render renderTranslationItem(item, group)} + {/each} +
+ {:else} + {@render renderGroupSkeleton(group)} + {/if} +
+ {/if} +
+ {/each} +
+
+ {/each} +
+ {:else} + {at("translations_no_matches", {}, "No matching strings")} + {/if} +{/if} diff --git a/frontend/src/lib/admin/stores/translationsStore.js b/frontend/src/lib/admin/stores/translationsStore.js new file mode 100644 index 0000000..6c47f07 --- /dev/null +++ b/frontend/src/lib/admin/stores/translationsStore.js @@ -0,0 +1,175 @@ +import { writable } from "svelte/store"; + +function dirtyId(lang, key) { + return `${lang}:${key}`; +} + +function normalizeLanguageCode(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/_/g, "-"); +} + +function isValidLanguageCode(value) { + return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value) && value.length >= 2 && value.length <= 16; +} + +function languageLabel(code) { + const labels = { + ru: "Русский", + en: "English", + de: "Deutsch", + es: "Español", + fr: "Français", + "pt-br": "Português (BR)", + uk: "Українська", + }; + return labels[code] || code.toUpperCase(); +} + +export function createTranslationsStore({ api, onToast, at }) { + const state = writable({ + translationGroups: [], + translationLanguages: [], + translationsLoading: false, + translationsDirty: {}, + translationsSaving: false, + translationsPath: "", + translationsOverrideCount: 0, + }); + + async function loadTranslations() { + state.update((s) => ({ ...s, translationsLoading: true, translationsDirty: {} })); + try { + const data = await api("/admin/translations"); + if (data?.ok) { + state.update((s) => ({ + ...s, + translationGroups: data.groups || [], + translationLanguages: data.languages || [], + translationsPath: data.path || "", + translationsOverrideCount: data.override_count || 0, + })); + } + } finally { + state.update((s) => ({ ...s, translationsLoading: false })); + } + } + + function markDirty(lang, key, value, deleted = false) { + state.update((s) => ({ + ...s, + translationsDirty: { + ...s.translationsDirty, + [dirtyId(lang, key)]: { lang, key, value, deleted }, + }, + })); + } + + function clearDirty(lang, key) { + state.update((s) => { + const next = { ...s.translationsDirty }; + delete next[dirtyId(lang, key)]; + return { ...s, translationsDirty: next }; + }); + } + + function resetField(lang, key, overridden) { + if (overridden) { + markDirty(lang, key, "", true); + } else { + clearDirty(lang, key); + } + } + + function addTranslationLanguage(rawCode) { + const code = normalizeLanguageCode(rawCode); + if (!isValidLanguageCode(code)) { + onToast(at("translations_language_invalid", {}, "Invalid language code")); + return false; + } + let exists = false; + state.update((s) => { + exists = (s.translationLanguages || []).some((lang) => lang.code === code); + if (exists) return s; + return { + ...s, + translationLanguages: [ + ...(s.translationLanguages || []), + { code, label: languageLabel(code), base: false }, + ].sort((a, b) => a.code.localeCompare(b.code)), + }; + }); + if (exists) { + onToast(at("translations_language_exists", { code }, `${code} already exists`)); + return false; + } + return true; + } + + async function saveTranslations(onTranslationsSaved) { + let dirty = {}; + state.update((s) => { + dirty = s.translationsDirty; + return s; + }); + if (!Object.keys(dirty).length) return true; + + state.update((s) => ({ ...s, translationsSaving: true })); + try { + const updates = {}; + const deletes = []; + for (const change of Object.values(dirty)) { + if (change.deleted || String(change.value ?? "") === "") { + deletes.push({ lang: change.lang, key: change.key }); + continue; + } + if (!updates[change.lang]) updates[change.lang] = {}; + updates[change.lang][change.key] = change.value; + } + const res = await api("/admin/translations", { + method: "PATCH", + body: JSON.stringify({ updates, deletes }), + }); + if (res?.ok) { + onToast( + res.file_written === false + ? at( + "translations_file_write_warning", + {}, + "Translations saved in DB, but JSON file was not updated" + ) + : at("translations_saved", {}, "Translations saved") + ); + state.update((s) => ({ ...s, translationsDirty: {} })); + if (onTranslationsSaved) await onTranslationsSaved({ updates, deletes }); + await loadTranslations(); + return true; + } + if (res?.errors) { + const summary = Object.entries(res.errors) + .map(([key, value]) => `${key}: ${value}`) + .join("; "); + onToast(at("translations_validation_errors", { errors: summary }, `Errors: ${summary}`)); + } else { + onToast(at("translations_save_error", { error: res?.error || "" }, res?.error || "Error")); + } + return false; + } finally { + state.update((s) => ({ ...s, translationsSaving: false })); + } + } + + return { + subscribe: state.subscribe, + set: state.set, + update: state.update, + loadTranslations, + markDirty, + clearDirty, + resetField, + addTranslationLanguage, + saveTranslations, + }; +} diff --git a/frontend/src/lib/components/ui/icons.js b/frontend/src/lib/components/ui/icons.js index bfeb765..20df700 100644 --- a/frontend/src/lib/components/ui/icons.js +++ b/frontend/src/lib/components/ui/icons.js @@ -28,6 +28,7 @@ export { Home, Info, Key, + Languages, LayoutDashboard, LifeBuoy, Lock, diff --git a/frontend/src/lib/webapp/constants.js b/frontend/src/lib/webapp/constants.js index 01581cf..ee7ebb2 100644 --- a/frontend/src/lib/webapp/constants.js +++ b/frontend/src/lib/webapp/constants.js @@ -1,23 +1,134 @@ export const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout"; export const LANGUAGE_LABELS = { - ru: "Русский", en: "English", de: "Deutsch", es: "Español", fr: "Français", + "pt-br": "Português (BR)", + ru: "Русский", tr: "Türkçe", uk: "Українська", }; export const LANGUAGE_FLAGS = { - ru: "🇷🇺", en: "🇬🇧", de: "🇩🇪", es: "🇪🇸", fr: "🇫🇷", + "pt-br": "🇧🇷", + ru: "🇷🇺", tr: "🇹🇷", uk: "🇺🇦", }; export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"]; +export const LOCALE_KEY_ALIASES = { + admin_apply: "wa_apply", + admin_ads_col_status: "admin_status", + admin_ad_label_source: "admin_ads_col_source", + admin_back: "wa_back", + admin_btn_refresh: "admin_refresh", + admin_btn_save: "admin_save", + admin_btn_saving: "admin_saving", + admin_close: "wa_close", + admin_copied: "wa_copied", + admin_copy: "wa_copy", + admin_csv_amount: "admin_amount", + admin_csv_description: "admin_description", + admin_csv_payment_id: "admin_id", + admin_csv_status: "admin_status", + admin_link_copied: "wa_link_copied", + admin_next: "wa_next", + admin_payment_detail_copied: "wa_copied", + admin_payment_detail_provider: "admin_provider", + admin_payment_detail_provider_section: "admin_provider", + admin_payment_detail_user_section: "admin_user", + admin_payments_col_user_id: "admin_id", + admin_promo_col_code: "admin_promo_csv_code", + admin_promo_col_status: "admin_status", + admin_promo_csv_is_active: "admin_badge_active", + admin_promo_csv_status: "admin_status", + admin_promo_label_code: "admin_promo_csv_code", + admin_promo_unlimited_validity: "admin_promo_unlimited", + admin_stats_revenue_custom_range_apply: "wa_apply", + admin_stats_revenue_tooltip_amount: "admin_amount", + admin_stats_sync_status: "admin_status", + admin_status_active: "admin_badge_active", + admin_support_category: "wa_support_category", + admin_support_category_account: "wa_support_category_account", + admin_support_category_billing: "wa_support_category_billing", + admin_support_category_other: "wa_support_category_other", + admin_support_category_technical: "wa_support_category_technical", + admin_support_close_ticket: "wa_close", + admin_support_empty: "wa_support_empty", + admin_support_filter_active: "wa_support_filter_active", + admin_support_filter_all: "wa_support_filter_all", + admin_support_internal_note: "wa_support_internal_note", + admin_support_no_messages: "wa_support_no_messages", + admin_support_priority: "wa_support_priority", + admin_support_priority_high: "wa_support_priority_high", + admin_support_priority_low: "wa_support_priority_low", + admin_support_priority_normal: "wa_support_priority_normal", + admin_support_priority_urgent: "wa_support_priority_urgent", + admin_support_role_system: "wa_support_role_system", + admin_support_role_user: "admin_user", + admin_support_search: "admin_search", + admin_support_status: "admin_status", + admin_support_status_awaiting_admin: "wa_support_status_awaiting_admin", + admin_support_status_awaiting_user: "wa_support_status_awaiting_user", + admin_support_status_closed: "wa_support_status_closed", + admin_support_status_open: "wa_support_status_open", + admin_support_status_resolved: "wa_support_status_resolved", + admin_support_ticket_number: "wa_support_ticket_number", + admin_support_user_context: "admin_user", + admin_tariffs_legacy_traffic_packages: "admin_tariff_traffic_packages", + admin_tariffs_stat_enabled: "admin_enabled", + admin_user_btn_cancel: "wa_cancel", + admin_user_history_until: "wa_until_date", + admin_user_label_provider: "admin_provider", + admin_user_short: "admin_user", + admin_user_stats_total_label: "admin_total", + back_to_autopay_method_choice_button: "back_to_main_menu_button", + back_to_payment_methods_button: "back_to_main_menu_button", + cancel_broadcast_button: "cancel_button", + csv_no: "no_button", + csv_yes: "yes_button", + user_premium_override_status_unlimited: "user_regular_override_status_unlimited", + user_regular_override_save: "admin_save", + wa_devices_disconnect_title: "wa_devices_disconnect", + wa_install_link_copied: "wa_link_copied", + wa_link_email_modal_title: "wa_settings_link_email_action", +}; + +export function resolveLocaleKey(key) { + let value = String(key || "").trim(); + const seen = new Set(); + while (LOCALE_KEY_ALIASES[value] && !seen.has(value)) { + seen.add(value); + value = LOCALE_KEY_ALIASES[value]; + } + return value; +} + +export function normalizeLanguageCode(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/_/g, "-"); +} + +export function uniqueLanguageCodes(...sources) { + const seen = new Set(); + const result = []; + for (const source of sources) { + for (const item of source || []) { + const code = normalizeLanguageCode(typeof item === "string" ? item : item?.code); + if (!code || seen.has(code)) continue; + seen.add(code); + result.push(code); + } + } + return result; +} + export const APP_SECTION_PATHS = { home: "/home", install: "/install", @@ -39,6 +150,7 @@ export const ADMIN_SECTIONS = new Set([ "support", "tariffs", "appearance", + "translations", "settings", ]); export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; diff --git a/frontend/src/lib/webapp/i18n.js b/frontend/src/lib/webapp/i18n.js index 192b74d..47fcd20 100644 --- a/frontend/src/lib/webapp/i18n.js +++ b/frontend/src/lib/webapp/i18n.js @@ -1,4 +1,4 @@ -import { LANGUAGE_LABELS } from "./constants.js"; +import { LANGUAGE_LABELS, normalizeLanguageCode, resolveLocaleKey } from "./constants.js"; import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js"; import { unitPluralBucket } from "./plurals.js"; @@ -21,14 +21,13 @@ export function createI18n({ mergeMessages(initialMessages); function normalizeLangCode(lang) { - const key = String(lang || "") - .trim() - .toLowerCase(); + const key = normalizeLanguageCode(lang); if (!key) return defaultLang; const base = key.split("-")[0]; - if (LANGUAGE_LABELS[base]) return base; - if (messages[base]) return base; if (messages[key]) return key; + if (messages[base]) return base; + if (LANGUAGE_LABELS[key]) return key; + if (LANGUAGE_LABELS[base]) return base; return defaultLang; } @@ -38,10 +37,11 @@ export function createI18n({ function t(key, params = {}, fallback = "") { const lang = currentLang(); + const lookupKey = resolveLocaleKey(key); const variants = [ - messages?.[lang]?.[key], - messages?.en?.[key], - messages?.ru?.[key], + messages?.[lang]?.[lookupKey], + messages?.en?.[lookupKey], + messages?.ru?.[lookupKey], fallback, key, ]; @@ -50,9 +50,7 @@ export function createI18n({ } function languageName(code) { - const key = String(code || "") - .trim() - .toLowerCase(); + const key = normalizeLanguageCode(code); if (!key) return t("wa_language_default"); return LANGUAGE_LABELS[key] || key.toUpperCase(); } diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 185ed67..4088e14 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -472,6 +472,78 @@ export async function mockApi(path, options = {}, context = {}) { } return { ok: true, applied: 1, reverted: 0 }; } + if (path === "/admin/translations" && String(options.method || "GET").toUpperCase() === "PATCH") { + return { ok: true, applied: 1, reverted: 0, file_written: true }; + } + if (path === "/admin/translations") { + return { + ok: true, + path: "data/locales-overrides.json", + override_count: 1, + languages: [ + { code: "en", label: "English", base: true }, + { code: "ru", label: "Русский", base: true }, + ], + groups: [ + { + id: "webapp", + title: "Mini App", + description: "User-facing Mini App strings.", + audience: "user", + items: [ + { + key: "wa_nav_home", + audience: "user", + values: { + ru: { + base: "Главная", + fallback: "Главная", + effective: "Главная", + override: "", + overridden: false, + }, + en: { + base: "Home", + fallback: "Главная", + effective: "Dashboard", + override: "Dashboard", + overridden: true, + }, + }, + }, + ], + }, + { + id: "admin", + title: "Admin panel", + description: "Admin navigation and labels.", + audience: "internal", + items: [ + { + key: "admin_nav_settings", + audience: "internal", + values: { + ru: { + base: "Настройки", + fallback: "Настройки", + effective: "Настройки", + override: "", + overridden: false, + }, + en: { + base: "Settings", + fallback: "Настройки", + effective: "Settings", + override: "", + overridden: false, + }, + }, + }, + ], + }, + ], + }; + } if (path === "/admin/settings") return { ok: true, diff --git a/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js index d758af7..2a014c8 100644 --- a/frontend/src/lib/webapp/previewMock.js +++ b/frontend/src/lib/webapp/previewMock.js @@ -216,6 +216,11 @@ export const DEV_MOCK = { userAgreementUrl: "https://example.com/agreement", currency: "RUB", language: "ru", + languages: [ + { code: "ru", label: "Русский", flag: "🇷🇺", base: true }, + { code: "en", label: "English", flag: "🇬🇧", base: true }, + { code: "uk", label: "Українська", flag: "🇺🇦", base: false }, + ], emailAuthEnabled: true, telegramLoginBotUsername: "preview_bot", telegramLoginBotId: 1234567890, diff --git a/frontend/src/styles/admin-controls.css b/frontend/src/styles/admin-controls.css index f3c9dda..ae0934d 100644 --- a/frontend/src/styles/admin-controls.css +++ b/frontend/src/styles/admin-controls.css @@ -60,6 +60,12 @@ transition: border-color 0.12s ease, box-shadow 0.12s ease; } +.admin-screen-wrap .admin-translations-search .input, +.admin-screen-wrap .admin-icon-picker-search .input, +.admin-screen-wrap .support-admin-search .input { + padding-left: 38px; +} + .admin-screen-wrap .input::placeholder, .admin-screen-wrap input::placeholder, .admin-screen-wrap textarea::placeholder, diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 577bea0..5a5784f 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -2307,6 +2307,393 @@ font-weight: 500; } +.admin-translations-toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + gap: 12px; + align-items: center; + margin-bottom: 10px; +} + +.admin-translations-search { + position: relative; + display: block; + min-width: 0; +} + +.admin-translations-search > svg { + position: absolute; + left: 12px; + top: 50%; + color: var(--admin-muted); + pointer-events: none; + transform: translateY(-50%); +} + +.admin-translations-search .input { + width: 100%; + padding-left: 36px; +} + +.admin-translations-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.admin-translations-path { + margin: 0 0 12px; + overflow-wrap: anywhere; +} + +.admin-translations-skeleton { + display: grid; + gap: 10px; +} + +.admin-translation-skeleton-card { + display: grid; + gap: 10px; + padding: 16px 18px; +} + +.admin-translations-language-panel { + display: grid; + grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.2fr) auto; + gap: 12px; + align-items: center; + margin-bottom: 12px; + padding: 12px; + border: 1px solid var(--admin-border); + border-radius: 10px; + background: var(--surface-muted); +} + +.admin-translations-language-head { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 4px 8px; + align-items: center; + min-width: 0; +} + +.admin-translations-language-head > svg { + grid-row: span 2; + color: var(--admin-muted); +} + +.admin-translations-language-head strong { + color: var(--admin-text); + font-size: 12px; + font-weight: 700; +} + +.admin-translations-language-head small { + color: var(--admin-muted); + font-size: 11px; +} + +.admin-translations-language-list { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 7px; +} + +.admin-translations-language-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 180px; + min-height: 30px; + padding: 5px 8px; + border: 1px solid var(--admin-border); + border-radius: 999px; + background: var(--admin-bg); + color: var(--admin-text); + font-size: 11px; +} + +.admin-translations-language-chip.is-custom { + border-color: color-mix(in srgb, var(--accent) 34%, var(--admin-border)); +} + +.admin-translations-language-chip strong, +.admin-translations-language-chip code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-translations-language-chip code { + color: var(--admin-muted); + font-size: 10px; +} + +.admin-translations-language-add { + display: grid; + grid-template-columns: minmax(100px, 150px) auto; + gap: 8px; + align-items: center; +} + +.admin-translations-audience-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0 0 12px; +} + +.admin-translations-audience-tabs button { + min-height: 32px; + padding: 7px 10px; + border: 1px solid var(--admin-border); + border-radius: 999px; + background: transparent; + color: var(--admin-muted); + font-size: 12px; + font-weight: 650; + cursor: pointer; + transition: + background 0.12s ease, + border-color 0.12s ease, + color 0.12s ease; +} + +.admin-translations-audience-tabs button:hover, +.admin-translations-audience-tabs button.is-active { + border-color: color-mix(in srgb, var(--accent) 34%, var(--admin-border)); + background: color-mix(in srgb, var(--accent) 11%, transparent); + color: var(--admin-text); +} + +.admin-translations-audience-section { + display: grid; + gap: 10px; + margin-top: 12px; +} + +.admin-translations-audience-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + min-width: 0; + padding: 0 2px; +} + +.admin-translations-audience-head span { + display: grid; + min-width: 0; + gap: 2px; +} + +.admin-translations-audience-head strong { + color: var(--admin-text); + font-size: 13px; + font-weight: 750; +} + +.admin-translations-audience-head small { + color: var(--admin-muted); + font-size: 11px; +} + +.admin-translation-title-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.admin-translation-group-description { + margin: 12px 18px 0; + font-size: 12px; + line-height: 1.45; +} + +.admin-translation-group-skeleton { + display: grid; + gap: 10px; + padding: 14px 18px; +} + +.admin-translation-list { + display: grid; + min-width: 0; +} + +.admin-translation-row { + display: grid; + grid-template-columns: minmax(220px, 0.75fr) minmax(0, 1.35fr); + gap: 20px; + align-items: start; + padding: 16px 18px; + border-bottom: 1px solid var(--admin-border); +} + +.admin-translation-row:last-child { + border-bottom: 0; +} + +.admin-translation-row-skeleton { + grid-template-columns: minmax(220px, 0.75fr) minmax(0, 1.35fr); + padding: 10px 0; +} + +.admin-translation-row-skeleton > span { + display: grid; + gap: 8px; +} + +.admin-translation-locales { + display: grid; + gap: 8px; + min-width: 0; +} + +.admin-translation-locale { + display: grid; + min-width: 0; + border: 1px solid var(--admin-border); + border-radius: 10px; + background: color-mix(in srgb, var(--admin-bg) 70%, transparent); + overflow: hidden; +} + +.admin-translation-locale.is-overridden { + border-color: color-mix(in srgb, var(--accent) 38%, var(--admin-border)); + background: color-mix(in srgb, var(--accent) 6%, var(--admin-bg)); +} + +.admin-translation-locale.is-dirty { + border-color: color-mix(in srgb, var(--warning-border) 72%, var(--admin-border)); + background: color-mix(in srgb, var(--warning) 7%, var(--admin-bg)); +} + +.admin-translation-locale-toggle { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + padding: 10px 11px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.admin-translation-locale-toggle:hover, +.admin-translation-locale-toggle:focus-visible { + background: var(--surface-hover); + outline: none; +} + +.admin-translation-locale-main { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.admin-translation-locale-main strong { + min-width: 0; + overflow: hidden; + color: var(--admin-text); + font-size: 12px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-translation-locale-main code { + color: var(--admin-muted); + font-size: 10px; +} + +.admin-translation-locale-badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px; +} + +.admin-translation-locale.is-expanded .admin-accordion-chev { + transform: rotate(90deg); +} + +.admin-translation-locale-toggle small { + grid-column: 1 / -1; + display: block; + min-width: 0; + overflow: hidden; + color: var(--admin-muted); + font-size: 11px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-translation-locale-body { + display: grid; + gap: 8px; + min-width: 0; + padding: 0 11px 11px; +} + +.admin-translation-textarea { + width: 100%; + min-height: 82px; + resize: vertical; +} + +.admin-translation-base { + display: grid; + gap: 3px; + min-width: 0; +} + +.admin-translation-base small { + color: var(--admin-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.admin-translation-base span { + display: -webkit-box; + max-height: 4.2em; + overflow: hidden; + color: var(--admin-muted); + font-size: 11px; + line-height: 1.4; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +@media (max-width: 860px) { + .admin-translation-row, + .admin-translations-toolbar, + .admin-translations-language-panel { + grid-template-columns: minmax(0, 1fr); + } + + .admin-translations-actions, + .admin-translations-language-add { + justify-content: flex-start; + } + + .admin-translations-language-add { + grid-template-columns: minmax(0, 1fr) auto; + } +} + .admin-tabs { display: inline-flex; border-bottom: 1px solid var(--admin-border); diff --git a/locales/en.json b/locales/en.json index 1df2a56..312fc0b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -46,9 +46,7 @@ "yookassa_autopay_pay_new_card_button": "➕ Pay with new card", "yookassa_autopay_choose_saved_card": "Choose a saved card to charge:", "yookassa_autopay_no_saved_cards": "No saved cards found. Pay with a new card or link one in Payment Methods.", - "back_to_autopay_method_choice_button": "⬅️ Back", "yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.", - "back_to_payment_methods_button": "⬅️ Back", "connect_button": "🔗 Connect", "install_guide_share_button": "🔗 Share install guide", "install_guide_share_link_line": "\n\nInstall guide for sharing:\n{install_share_link}", @@ -156,22 +154,17 @@ "admin_no_payments_to_export": "No payments to export.", "admin_payments_export_success": "📊 Payments export completed!\nTotal records: {count}", "admin_export_sent": "File sent!", - "admin_csv_payment_id": "ID", "admin_csv_user_id": "User ID", "admin_csv_username": "Username", "admin_csv_first_name": "First Name", - "admin_csv_amount": "Amount", "admin_csv_currency": "Currency", "admin_csv_provider": "Provider", - "admin_csv_status": "Status", - "admin_csv_description": "Description", "admin_csv_units": "Months/GB", "admin_csv_months": "Months", "admin_csv_created_at": "Created At", "admin_csv_provider_payment_id": "Provider Payment ID", "admin_stats_last_sync_header": "Last Panel Sync:", "admin_stats_sync_time": "Time", - "admin_stats_sync_status": "Status", "admin_stats_sync_users_processed": "Users Processed", "admin_stats_sync_subs_synced": "Subscriptions Synced", "admin_stats_sync_details_label": "Details", @@ -182,7 +175,6 @@ "broadcast_target_active_button": "✅ Active", "broadcast_target_inactive_button": "⌛ Inactive", "confirm_broadcast_send_button": "✅ Send", - "cancel_broadcast_button": "❌ Cancel", "admin_broadcast_sending_started": "Starting broadcast...", "admin_broadcast_error_no_message": "Error: no message to broadcast.", "admin_broadcast_error_no_message_alert": "Broadcast message is empty!", @@ -220,13 +212,9 @@ "admin_promo_csv_bonus_days": "Bonus Days", "admin_promo_csv_max_activations": "Max Activations", "admin_promo_csv_current_activations": "Current Activations", - "admin_promo_csv_status": "Status", - "admin_promo_csv_is_active": "Active", "admin_promo_csv_valid_until": "Valid Until", "admin_promo_csv_created_at": "Created", "admin_promo_csv_created_by_admin_id": "Created By (Admin ID)", - "csv_yes": "Yes", - "csv_no": "No", "admin_promo_edit_select_field": "Select a field to edit:", "admin_promo_prompt_bonus_days": "Enter the new number of bonus days:", "admin_promo_prompt_max_activations": "Enter the new maximum number of activations:", @@ -417,7 +405,6 @@ "admin_promo_step3_max_activations": "🎟 Create Promo Code\n\nStep 3 of 4: Max Activations\n\nCode: {code}\nBonus days: {bonus_days}\n\nEnter the maximum number of activations (1-10000):", "admin_promo_step4_validity": "🎟 Create Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nBonus days: {bonus_days}\nMax activations: {max_activations}\n\nChoose the validity period for the promo code:", "admin_promo_code_already_exists": "❌ A promo code with this code already exists", - "admin_promo_unlimited_validity": "♾️ Unlimited", "admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):", "admin_user_id_label": "🆔 ID:", "admin_user_name_label": "👤 Name:", @@ -494,7 +481,6 @@ "inline_referral_description": "Share referral link to get bonuses", "inline_financial_description": "Today: {today} RUB", "inline_system_description": "🟢 Online: {online}, 📊 Active: {active}", - "admin_user_stats_total_label": "Total", "admin_user_stats_paid_subs_label": "With paid subscription", "admin_user_stats_trial_label": "On trial period", "admin_user_stats_inactive_label": "Inactive", @@ -819,7 +805,6 @@ "wa_devices_platform_unknown": "Platform unknown", "wa_devices_connected_at": "Connected", "wa_devices_disconnect": "Disconnect device", - "wa_devices_disconnect_title": "Disconnect device", "wa_devices_disconnect_desc": "{device} will be removed from your device list. It may connect again next time you use the subscription on that device.", "wa_devices_disconnect_confirm": "Disconnect", "wa_device_disconnected": "Device disconnected", @@ -845,7 +830,6 @@ "wa_settings_link_email_action": "Link email", "wa_settings_email_linked_title": "Email linked", "wa_activate_promo_title": "Activate promo code", - "wa_link_email_modal_title": "Link email", "wa_traffic_reset_none": "No reset", "wa_traffic_reset_monthly": "Monthly reset", "wa_traffic_reset_weekly": "Weekly reset", @@ -940,8 +924,6 @@ "admin_users_col_registration": "Registered", "admin_page": "Page", "admin_page_short": "Page", - "admin_back": "Back", - "admin_next": "Next", "admin_user": "User", "admin_amount": "Amount", "admin_provider": "Provider", @@ -949,7 +931,6 @@ "admin_status": "Status", "admin_date": "Date", "admin_payments_empty": "No payments", - "admin_payments_col_user_id": "ID", "admin_payments_col_traffic_regular": "Main traffic", "admin_payments_col_traffic_premium": "Premium traffic", "admin_payments_col_actions": "", @@ -958,7 +939,6 @@ "admin_payments_desc_traffic_package_premium": "Traffic package {gb} GB (premium)", "admin_payment_detail_open": "Open payment", "admin_payment_detail_title": "Payment #{id}", - "admin_payment_detail_copied": "Copied", "admin_payment_load_failed": "Failed to load payment", "admin_payment_detail_updated_at": "Updated", "admin_payment_detail_provider_payment_id": "Provider ID", @@ -973,13 +953,9 @@ "admin_payment_detail_purchased_gb": "Purchased GB", "admin_payment_detail_hwid_devices": "HWID devices", "admin_payment_detail_promo_code": "Promo code", - "admin_payment_detail_provider": "Provider", - "admin_payment_detail_user_section": "User", "admin_payment_detail_payment_section": "Payment", - "admin_payment_detail_provider_section": "Provider", "admin_payment_detail_purchase_section": "Purchase", "admin_logs_user_filter_placeholder": "Filter by user ID", - "admin_apply": "Apply", "admin_reset": "Reset", "admin_event": "Event", "admin_content": "Content", @@ -1009,14 +985,11 @@ "admin_settings_provider_webhook_base_missing": "Set WEBHOOK_BASE_URL in .env to show the full URL for {path}.", "admin_settings_provider_admin_only_label": "Only for admins", "admin_settings_provider_admin_only_description": "Shows this provider only to admins. Webhooks and payment status handling remain active for test payments.", - "admin_copy": "Copy", - "admin_copied": "Copied", "admin_settings_validation_errors": "Errors: {errors}", "admin_settings_save_error": "Error: {error}", "admin_sync_started": "Synchronization started", "admin_sync_error": "Synchronization error", "admin_error": "Error", - "admin_link_copied": "Link copied", "admin_user_banned": "User banned", "admin_user_unbanned": "User unbanned", "admin_message_sent": "Message sent", @@ -1067,7 +1040,6 @@ "admin_stats_revenue_period_365": "1 yr", "admin_stats_revenue_period_custom": "Custom", "admin_stats_revenue_custom_range_title": "Date range (UTC)", - "admin_stats_revenue_custom_range_apply": "Apply", "admin_stats_revenue_granularity_aria": "Revenue chart step", "admin_stats_revenue_granularity_day": "By day", "admin_stats_revenue_granularity_week": "By week", @@ -1077,7 +1049,6 @@ "admin_stats_revenue_chart_bucket_count": "Points: {count}", "admin_stats_revenue_chart_custom_span": "Range: {days} d.", "admin_stats_revenue_tooltip_day": "Day", - "admin_stats_revenue_tooltip_amount": "Amount", "admin_stats_revenue_avg_check": "Average ticket today: {value}", "admin_stats_revenue_avg_none": "No successful payments today", "admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)", @@ -1141,7 +1112,6 @@ "admin_user_tg_profile_link_sent": "Link sent to Telegram", "admin_user_tg_profile_link_failed": "Failed to send link", "admin_user_profile_link_message": "User profile: {name}\nUser ID: {user_id}\nTelegram ID: {telegram_id}\n\nTap the button below to open the profile in Telegram.", - "admin_close": "Close", "admin_loading": "Loading…", "admin_badge_banned": "Banned", "admin_badge_active": "Active", @@ -1166,7 +1136,6 @@ "admin_user_label_active_until": "Active until", "admin_user_label_tariff": "Tariff", "admin_user_label_auto_renew": "Auto-renew", - "admin_user_label_provider": "Provider", "admin_user_label_main_traffic": "Main Traffic", "admin_user_traffic_left": "Left: {left}", "admin_user_label_premium_squads": "Premium Squads", @@ -1175,7 +1144,6 @@ "user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.", "user_regular_override_card_title": "Main traffic", "user_regular_override_card_hint": "Unlimited-style ceiling and a persistent bonus on the main traffic limit.", - "user_regular_override_save": "Save", "user_regular_override_status_unlimited": "Current: unlimited", "regular_override_saved": "Main traffic override saved", "user_traffic_override_title": "Traffic overrides", @@ -1191,7 +1159,6 @@ "user_premium_override_unlimited": "Unlimited premium", "user_premium_override_bonus": "Extra premium traffic, GB", "user_premium_override_save": "Save override", - "user_premium_override_status_unlimited": "Current: unlimited", "user_premium_override_status_bonus": "Current: +{gb} GB", "user_premium_override_status_none": "No premium override", "user_premium_unlimited_value": "∞ (used {used})", @@ -1211,7 +1178,6 @@ "admin_user_no_active_subscription": "No active subscription", "admin_user_history_title": "Subscription History · {count}", "admin_user_history_no_tariff": "No tariff", - "admin_user_history_until": "until {date}", "admin_user_history_active": "Active", "admin_user_history_status_panel": "History", "admin_user_recent_payments_title": "Recent Payments · {count}", @@ -1232,7 +1198,6 @@ "admin_user_btn_delete_account": "Delete account", "admin_user_msg_confirm_title": "Send message to user?", "admin_user_msg_confirm_recipient": "Recipient: {name}", - "admin_user_btn_cancel": "Cancel", "admin_user_btn_confirm_send": "Confirm Send", "admin_user_ban_confirm_title": "Ban user?", "admin_user_ban_confirm_subtitle": "{name} will no longer be able to interact with the bot. This can be undone later.", @@ -1249,7 +1214,6 @@ "admin_tariff_label_hwid_count_full": "How many devices does this package add", "admin_tariff_label_price_rub": "Price in Rubles", "admin_tariff_label_price_stars": "Price in Telegram Stars", - "admin_status_active": "Active", "admin_aria_label_main_traffic": "Main traffic usage", "admin_aria_label_premium_traffic": "Premium traffic usage", "admin_btn_delete": "Delete", @@ -1259,24 +1223,18 @@ "admin_btn_tariff": "Tariff", "admin_btn_sync": "Synchronize", "admin_btn_syncing": "Synchronizing...", - "admin_btn_save": "Save", - "admin_btn_saving": "Saving...", "admin_btn_enable": "On", "admin_btn_disable": "Off", "admin_btn_show_more": "Show more", "admin_settings_dirty_count": "Changes: {count}", - "admin_promo_col_code": "Code", "admin_promo_col_bonus": "Bonus", "admin_promo_col_activations": "Activations", "admin_promo_col_valid_until": "Valid until", - "admin_promo_col_status": "Status", "admin_promo_create_title": "Create promo code", - "admin_promo_label_code": "Code", "admin_promo_label_bonus_days": "Bonus (days)", "admin_promo_label_max_activations": "Max activations", "admin_promo_label_valid_days": "Validity (days)", "admin_ad_create_title": "New campaign", - "admin_ad_label_source": "Source", "admin_ad_label_param": "start parameter", "admin_ad_hint_param": "Unique identifier for the referral link", "admin_ad_label_cost": "Cost, RUB", @@ -1285,7 +1243,6 @@ "admin_ads_col_cost": "Cost", "admin_ads_col_registrations": "Registrations", "admin_ads_col_conversions": "Conversions", - "admin_ads_col_status": "Status", "admin_no_data": "No data", "admin_settings_hint": "Changes in the admin panel take precedence over .env. The 'Reset' button returns the value from environment variables.", "admin_settings_legacy_tariffs_warning_title": "remnawave-tg-shop legacy compatibility", @@ -1313,7 +1270,6 @@ "admin_show": "Show", "admin_hide": "Hide", "admin_tariffs_stat_total": "Total tariffs", - "admin_tariffs_stat_enabled": "Enabled", "admin_tariffs_stat_default": "Default", "admin_tariffs_stat_default_hint": "Used for new subscriptions", "admin_tariffs_stat_disabled": "Disabled", @@ -1340,7 +1296,6 @@ "admin_tariffs_legacy_subtitle": "Old remnawave-tg-shop periods and traffic packages used only when the JSON tariff catalog is not configured.", "admin_tariffs_legacy_period": "Period", "admin_tariffs_legacy_enabled": "Enabled", - "admin_tariffs_legacy_traffic_packages": "Traffic packages", "admin_tariffs_legacy_stars_traffic_packages": "Traffic packages, Stars", "admin_tariffs_legacy_traffic_hint": "Format: 10:199,50:799", "admin_tariff_tab_general": "General", @@ -1383,7 +1338,6 @@ "admin_months_short": "mo.", "admin_at": "for", "admin_tariff_traffic_packages": "Traffic packages", - "admin_btn_refresh": "Refresh", "admin_btn_create_tariff": "Create Tariff", "admin_enabled": "Enabled", "admin_disabled": "Disabled", @@ -1398,7 +1352,6 @@ "admin_broadcast_stat_failed": "Failed", "admin_broadcast_started": "Broadcast started", "admin_broadcast_failed": "Broadcast failed", - "admin_user_short": "User", "admin_target_short": "Target", "admin_settings_field_default_language_label": "Default Language", "admin_settings_field_default_language_description": "Controls the 'Default Language' setting in admin overrides.", @@ -1667,45 +1620,18 @@ "admin_nav_support": "Support", "admin_section_support_title": "Support", "admin_section_support_subtitle": "Ticket inbox and user replies", - "admin_support_search": "Search", - "admin_support_empty": "No tickets yet", "admin_support_select_ticket": "Select a ticket", - "admin_support_close_ticket": "Close", - "admin_support_internal_note": "Internal note", "admin_support_reply_placeholder": "Reply", - "admin_support_no_messages": "No messages yet", - "admin_support_filter_all": "All", - "admin_support_filter_active": "Active", "admin_support_filter_closed": "Closed", "admin_support_filter_all_priorities": "Any priority", "admin_support_filter_all_categories": "All categories", - "admin_support_ticket_number": "Ticket #{id}", - "admin_support_priority": "Priority", - "admin_support_category": "Category", - "admin_support_role_user": "User", "admin_support_role_admin": "Admin", - "admin_support_role_system": "System", - "admin_support_user_context": "User", "admin_support_open_user": "User card", "admin_support_tariff": "Tariff", - "admin_support_status": "Status", "admin_support_remaining": "Remaining", "admin_support_unread": "Unread", "admin_support_summary": "Support summary", "admin_support_ticket_dialog": "Support conversation", - "admin_support_status_open": "Open", - "admin_support_status_awaiting_user": "Awaiting user", - "admin_support_status_awaiting_admin": "Awaiting admin", - "admin_support_status_resolved": "Resolved", - "admin_support_status_closed": "Closed", - "admin_support_priority_low": "Low", - "admin_support_priority_normal": "Normal", - "admin_support_priority_high": "High", - "admin_support_priority_urgent": "Urgent", - "admin_support_category_billing": "Billing", - "admin_support_category_technical": "Technical", - "admin_support_category_account": "Account", - "admin_support_category_other": "Other", "admin_support_sort_importance_desc": "Most important", "admin_sort_updated_desc": "Newest activity", "admin_sort_updated_asc": "Oldest activity", @@ -1722,7 +1648,6 @@ "wa_install_subscription_link_hint": "Scan the QR code or copy the link.", "wa_install_qr_alt": "Subscription QR code", "wa_install_copy_subscription_link": "Copy link", - "wa_install_link_copied": "Link copied", "wa_install_share": "Share", "wa_install_share_copied": "Install guide link copied", "wa_app_launch_title": "Opening app", @@ -1750,5 +1675,82 @@ "admin_settings_field_subscription_page_config_json_label": "Subscription Page config JSON", "admin_settings_field_subscription_page_config_json_description": "Optional admin JSON override. It is applied only when the JSON override switch is enabled.", "admin_settings_field_subscription_page_config_json_placeholder": "{\n \"version\": \"1\"\n}", - "admin_settings_json_upload": "Load .json" + "admin_settings_json_upload": "Load .json", + "admin_nav_translations": "Translations", + "admin_section_translations_title": "Translations", + "admin_section_translations_subtitle": "Runtime localization string overrides from DB and data/locales-overrides.json", + "admin_translations_saved": "Translations saved", + "admin_translations_validation_errors": "Errors: {errors}", + "admin_translations_save_error": "Could not save translations: {error}", + "admin_translations_base_value": "Base text", + "admin_translations_search_placeholder": "Search keys and text", + "admin_translations_hint": "Overrides are stored in DB and mirrored to {path}.", + "admin_translations_keys_count": "Keys: {count}", + "admin_translations_no_matches": "No matching strings", + "admin_translations_group_admin": "Admin panel", + "admin_translations_group_admin_hint": "Navigation, tables, forms, dialogs, and admin-only labels.", + "admin_translations_group_admin_navigation": "Admin navigation and shared UI", + "admin_translations_group_admin_navigation_hint": "Sidebar, section headers, toolbar actions, filters, and shared controls.", + "admin_translations_group_admin_dashboard": "Admin dashboard and stats", + "admin_translations_group_admin_dashboard_hint": "Dashboard cards, revenue charts, panel sync status, and monitoring copy.", + "admin_translations_group_admin_users": "Admin users", + "admin_translations_group_admin_users_hint": "User lists, user cards, bans, grants, premium overrides, and direct messages.", + "admin_translations_group_admin_payments": "Admin payments", + "admin_translations_group_admin_payments_hint": "Payment tables, payment details, exports, provider labels, and payment stats.", + "admin_translations_group_admin_promos_marketing": "Admin promos, ads, and broadcasts", + "admin_translations_group_admin_promos_marketing_hint": "Promo management, ad campaigns, marketing tools, and broadcast workflows.", + "admin_translations_group_admin_tariffs": "Admin tariffs", + "admin_translations_group_admin_tariffs_hint": "Tariff catalog, tariff dialogs, legacy tariff rows, and trial tariff widgets.", + "admin_translations_group_admin_support": "Admin support inbox", + "admin_translations_group_admin_support_hint": "Support ticket inbox, ticket filters, admin replies, and support statuses.", + "admin_translations_group_admin_appearance": "Admin appearance", + "admin_translations_group_admin_appearance_hint": "Theme catalog, branding, logo, favicon, and public page links.", + "admin_translations_group_admin_settings_payments": "Admin payment settings", + "admin_translations_group_admin_settings_payments_hint": "Payment method toggles, prices, provider credentials, and webhook settings.", + "admin_translations_group_admin_settings_subscriptions": "Admin subscription settings", + "admin_translations_group_admin_settings_subscriptions_hint": "Panel connection, default squads, trials, referrals, device limits, and guides.", + "admin_translations_group_admin_settings_notifications": "Admin notifications and logs", + "admin_translations_group_admin_settings_notifications_hint": "Logging, required channel, subscription notifications, and support limits.", + "admin_translations_group_admin_settings": "Admin settings", + "admin_translations_group_admin_settings_hint": "Settings screen groups, subsections, helper text, and uncategorized settings.", + "admin_translations_group_admin_translations": "Admin translations", + "admin_translations_group_admin_translations_hint": "Translation override screen, language controls, and locale group labels.", + "admin_translations_group_admin_logs": "Admin logs and exports", + "admin_translations_group_admin_logs_hint": "Activity logs, log exports, CSV headers, and event detail labels.", + "admin_translations_group_admin_misc": "Admin miscellaneous", + "admin_translations_group_admin_misc_hint": "Older bot-admin labels and admin-only strings that do not fit another section.", + "admin_translations_group_webapp": "Mini App", + "admin_translations_group_webapp_hint": "User-facing Mini App screens, navigation, settings, and toasts.", + "admin_translations_group_bot_menu": "Telegram bot", + "admin_translations_group_bot_menu_hint": "Start menu, inline buttons, language selector, and bot-only flows.", + "admin_translations_group_subscriptions": "Subscriptions and devices", + "admin_translations_group_subscriptions_hint": "Subscription status, install guides, traffic packages, trials, and devices.", + "admin_translations_group_payments": "Payments", + "admin_translations_group_payments_hint": "Payment provider flows, invoices, payment methods, and checkout messages.", + "admin_translations_group_support": "Support", + "admin_translations_group_support_hint": "Support links, ticket inbox copy, ticket statuses, and notifications.", + "admin_translations_group_referrals_promos": "Referrals and promos", + "admin_translations_group_referrals_promos_hint": "Referral program, invite copy, promo codes, and bonuses.", + "admin_translations_group_auth_security": "Auth and security", + "admin_translations_group_auth_security_hint": "Login, email verification, account linking, and security messages.", + "admin_translations_group_emails": "Emails", + "admin_translations_group_emails_hint": "Transactional emails sent to users: login codes, payments, account merges, and reminders.", + "admin_translations_group_notifications_sync": "Notifications and sync", + "admin_translations_group_notifications_sync_hint": "Admin notifications, panel sync, logs, and background status messages.", + "admin_translations_group_common": "Common", + "admin_translations_group_common_hint": "Shared buttons, statuses, validation errors, and uncategorized strings.", + "admin_translations_file_write_warning": "Translations saved in DB, but JSON file was not updated", + "admin_add": "Add", + "admin_translations_fallback_value": "Fallback", + "admin_translations_languages_title": "Languages", + "admin_translations_languages_hint": "Add override-only languages without adding base locale files.", + "admin_translations_language_placeholder": "de, uk, pt-BR", + "admin_translations_language_custom": "Custom", + "admin_translations_language_invalid": "Invalid language code", + "admin_translations_language_exists": "{code} already exists", + "admin_translations_audience_all": "All", + "admin_translations_audience_user": "User-visible", + "admin_translations_audience_internal": "Admin/internal", + "admin_translations_audience_user_hint": "Mini App, bot, payment, subscription, support, and auth copy.", + "admin_translations_audience_internal_hint": "Admin panel, logs, sync statuses, and service notifications." } diff --git a/locales/ru.json b/locales/ru.json index 5be8a79..750cfc6 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -46,9 +46,7 @@ "yookassa_autopay_pay_new_card_button": "➕ Оплата новой картой", "yookassa_autopay_choose_saved_card": "Выберите привязанную карту для списания:", "yookassa_autopay_no_saved_cards": "Сохранённых карт нет. Оплатите новой картой или привяжите карту в разделе «Способы оплаты».", - "back_to_autopay_method_choice_button": "⬅️ Назад", "yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.", - "back_to_payment_methods_button": "⬅️ Назад", "connect_button": "🔗 Подключиться", "install_guide_share_button": "🔗 Поделиться инструкцией", "install_guide_share_link_line": "\n\nИнструкция для передачи:\n{install_share_link}", @@ -156,22 +154,17 @@ "admin_no_payments_to_export": "Нет платежей для экспорта.", "admin_payments_export_success": "📊 Экспорт платежей завершен!\nВсего записей: {count}", "admin_export_sent": "Файл отправлен!", - "admin_csv_payment_id": "ID", "admin_csv_user_id": "User ID", "admin_csv_username": "Логин", "admin_csv_first_name": "Имя", - "admin_csv_amount": "Сумма", "admin_csv_currency": "Валюта", "admin_csv_provider": "Платежная система", - "admin_csv_status": "Статус", - "admin_csv_description": "Описание", "admin_csv_units": "Месяцы/ГБ", "admin_csv_months": "Месяцев", "admin_csv_created_at": "Дата создания", "admin_csv_provider_payment_id": "ID платежа в системе", "admin_stats_last_sync_header": "Последняя синхронизация с панелью:", "admin_stats_sync_time": "Время", - "admin_stats_sync_status": "Статус", "admin_stats_sync_users_processed": "Обработано юзеров с панели", "admin_stats_sync_subs_synced": "Синхронизировано подписок", "admin_stats_sync_details_label": "Детали", @@ -182,7 +175,6 @@ "broadcast_target_active_button": "✅ Активные", "broadcast_target_inactive_button": "⌛ Неактивные", "confirm_broadcast_send_button": "✅ Отправить", - "cancel_broadcast_button": "❌ Отмена", "admin_broadcast_sending_started": "Начинаю рассылку...", "admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.", "admin_broadcast_error_no_message_alert": "Сообщение для рассылки пустое!", @@ -229,13 +221,9 @@ "admin_promo_csv_bonus_days": "Бонусные дни", "admin_promo_csv_max_activations": "Максимальные активации", "admin_promo_csv_current_activations": "Текущие активации", - "admin_promo_csv_status": "Статус", - "admin_promo_csv_is_active": "Активен", "admin_promo_csv_valid_until": "Действителен до", "admin_promo_csv_created_at": "Создан", "admin_promo_csv_created_by_admin_id": "Создал (Admin ID)", - "csv_yes": "Да", - "csv_no": "Нет", "admin_promo_edit_select_field": "Выберите поле для редактирования:", "admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:", "admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:", @@ -417,7 +405,6 @@ "admin_promo_step3_max_activations": "🎟 Создание промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", "admin_promo_step4_validity": "🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", "admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует", - "admin_promo_unlimited_validity": "♾️ Неограниченно", "admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):", "admin_user_id_label": "🆔 ID:", "admin_user_name_label": "👤 Имя:", @@ -494,7 +481,6 @@ "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", "inline_financial_description": "Сегодня: {today} RUB", "inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}", - "admin_user_stats_total_label": "Всего", "admin_user_stats_paid_subs_label": "С платной подпиской", "admin_user_stats_trial_label": "На пробном периоде", "admin_user_stats_inactive_label": "Неактивных", @@ -819,7 +805,6 @@ "wa_devices_platform_unknown": "Платформа неизвестна", "wa_devices_connected_at": "Подключено", "wa_devices_disconnect": "Отключить устройство", - "wa_devices_disconnect_title": "Отключить устройство", "wa_devices_disconnect_desc": "{device} будет удалено из списка устройств. Оно может подключиться снова при следующем использовании подписки на этом устройстве.", "wa_devices_disconnect_confirm": "Отключить", "wa_device_disconnected": "Устройство отключено", @@ -845,7 +830,6 @@ "wa_settings_link_email_action": "Привязать почту", "wa_settings_email_linked_title": "Почта привязана", "wa_activate_promo_title": "Активировать промокод", - "wa_link_email_modal_title": "Привязать почту", "wa_traffic_reset_none": "Без сброса", "wa_traffic_reset_monthly": "Сброс ежемесячно", "wa_traffic_reset_weekly": "Сброс еженедельно", @@ -940,8 +924,6 @@ "admin_users_col_registration": "Регистрация", "admin_page": "Страница", "admin_page_short": "Стр.", - "admin_back": "Назад", - "admin_next": "Далее", "admin_user": "Пользователь", "admin_amount": "Сумма", "admin_provider": "Провайдер", @@ -949,7 +931,6 @@ "admin_status": "Статус", "admin_date": "Дата", "admin_payments_empty": "Нет платежей", - "admin_payments_col_user_id": "ID", "admin_payments_col_traffic_regular": "Основной трафик", "admin_payments_col_traffic_premium": "Премиум трафик", "admin_payments_col_actions": "", @@ -958,7 +939,6 @@ "admin_payments_desc_traffic_package_premium": "Пакет трафика {gb} ГБ (премиум)", "admin_payment_detail_open": "Открыть платёж", "admin_payment_detail_title": "Платёж #{id}", - "admin_payment_detail_copied": "Скопировано", "admin_payment_load_failed": "Не удалось загрузить платёж", "admin_payment_detail_updated_at": "Обновлён", "admin_payment_detail_provider_payment_id": "ID у провайдера", @@ -973,13 +953,9 @@ "admin_payment_detail_purchased_gb": "Куплено GB", "admin_payment_detail_hwid_devices": "HWID-устройства", "admin_payment_detail_promo_code": "Промокод", - "admin_payment_detail_provider": "Провайдер", - "admin_payment_detail_user_section": "Пользователь", "admin_payment_detail_payment_section": "Платёж", - "admin_payment_detail_provider_section": "Провайдер", "admin_payment_detail_purchase_section": "Покупка", "admin_logs_user_filter_placeholder": "Фильтр по ID пользователя", - "admin_apply": "Применить", "admin_reset": "Сбросить", "admin_event": "Событие", "admin_content": "Контент", @@ -1009,14 +985,11 @@ "admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.", "admin_settings_provider_admin_only_label": "Только для админов", "admin_settings_provider_admin_only_description": "Показывает провайдер только администраторам. Вебхуки и обработка статусов остаются активными для тестовых платежей.", - "admin_copy": "Копировать", - "admin_copied": "Скопировано", "admin_settings_validation_errors": "Ошибки: {errors}", "admin_settings_save_error": "Ошибка: {error}", "admin_sync_started": "Синхронизация запущена", "admin_sync_error": "Ошибка синхронизации", "admin_error": "Ошибка", - "admin_link_copied": "Ссылка скопирована", "admin_user_banned": "Пользователь забанен", "admin_user_unbanned": "Пользователь разбанен", "admin_message_sent": "Сообщение отправлено", @@ -1067,7 +1040,6 @@ "admin_stats_revenue_period_365": "1 г.", "admin_stats_revenue_period_custom": "Свой период", "admin_stats_revenue_custom_range_title": "Диапазон дат (UTC)", - "admin_stats_revenue_custom_range_apply": "Применить", "admin_stats_revenue_granularity_aria": "Шаг графика выручки", "admin_stats_revenue_granularity_day": "По дням", "admin_stats_revenue_granularity_week": "По неделям", @@ -1077,7 +1049,6 @@ "admin_stats_revenue_chart_bucket_count": "Точек: {count}", "admin_stats_revenue_chart_custom_span": "Диапазон: {days} дн.", "admin_stats_revenue_tooltip_day": "День", - "admin_stats_revenue_tooltip_amount": "Сумма", "admin_stats_revenue_avg_check": "Средний чек сегодня: {value}", "admin_stats_revenue_avg_none": "Сегодня без успешных платежей", "admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)", @@ -1141,7 +1112,6 @@ "admin_user_tg_profile_link_sent": "Ссылка отправлена в Telegram", "admin_user_tg_profile_link_failed": "Не удалось отправить ссылку", "admin_user_profile_link_message": "Профиль пользователя: {name}\nUser ID: {user_id}\nTelegram ID: {telegram_id}\n\nНажмите кнопку ниже, чтобы открыть профиль в Telegram.", - "admin_close": "Закрыть", "admin_loading": "Загрузка…", "admin_badge_banned": "Бан", "admin_badge_active": "Активен", @@ -1166,7 +1136,6 @@ "admin_user_label_active_until": "Активна до", "admin_user_label_tariff": "Тариф", "admin_user_label_auto_renew": "Авто-продление", - "admin_user_label_provider": "Провайдер", "admin_user_label_main_traffic": "Основной трафик", "admin_user_traffic_left": "Осталось: {left}", "admin_user_label_premium_squads": "Premium-сквады", @@ -1175,7 +1144,6 @@ "user_premium_override_card_hint": "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.", "user_regular_override_card_title": "Основной трафик", "user_regular_override_card_hint": "Режим безлимита и постоянный бонус к лимиту основного трафика.", - "user_regular_override_save": "Сохранить", "user_regular_override_status_unlimited": "Сейчас: безлимит", "regular_override_saved": "Оверрайд основного трафика сохранён", "user_traffic_override_title": "Оверрайд трафика", @@ -1191,7 +1159,6 @@ "user_premium_override_unlimited": "Безлимит на премиум", "user_premium_override_bonus": "Доп. премиум-трафик, GB", "user_premium_override_save": "Сохранить оверрайд", - "user_premium_override_status_unlimited": "Сейчас: безлимит", "user_premium_override_status_bonus": "Сейчас: +{gb} GB", "user_premium_override_status_none": "Премиум-оверрайд не задан", "user_premium_unlimited_value": "∞ (использовано {used})", @@ -1211,7 +1178,6 @@ "admin_user_no_active_subscription": "Активной подписки нет", "admin_user_history_title": "История подписок · {count}", "admin_user_history_no_tariff": "Без тарифа", - "admin_user_history_until": "до {date}", "admin_user_history_active": "Активна", "admin_user_history_status_panel": "История", "admin_user_recent_payments_title": "Последние платежи · {count}", @@ -1232,7 +1198,6 @@ "admin_user_btn_delete_account": "Удалить аккаунт", "admin_user_msg_confirm_title": "Отправить сообщение пользователю?", "admin_user_msg_confirm_recipient": "Получатель: {name}", - "admin_user_btn_cancel": "Отмена", "admin_user_btn_confirm_send": "Подтвердить отправку", "admin_user_ban_confirm_title": "Заблокировать пользователя?", "admin_user_ban_confirm_subtitle": "{name} больше не сможет взаимодействовать с ботом. Действие можно отменить позже.", @@ -1249,7 +1214,6 @@ "admin_tariff_label_hwid_count_full": "Сколько устройств добавляет пакет", "admin_tariff_label_price_rub": "Цена в рублях", "admin_tariff_label_price_stars": "Цена в Telegram Stars", - "admin_status_active": "Активен", "admin_aria_label_main_traffic": "Использование основного трафика", "admin_aria_label_premium_traffic": "Использование premium-трафика", "admin_btn_delete": "Удалить", @@ -1259,24 +1223,18 @@ "admin_btn_tariff": "Тариф", "admin_btn_sync": "Синхронизировать", "admin_btn_syncing": "Синхронизация...", - "admin_btn_save": "Сохранить", - "admin_btn_saving": "Сохранение...", "admin_btn_enable": "Вкл", "admin_btn_disable": "Выкл", "admin_btn_show_more": "Показать еще", "admin_settings_dirty_count": "Изменений: {count}", - "admin_promo_col_code": "Код", "admin_promo_col_bonus": "Бонус", "admin_promo_col_activations": "Активаций", "admin_promo_col_valid_until": "Действует до", - "admin_promo_col_status": "Статус", "admin_promo_create_title": "Создать промокод", - "admin_promo_label_code": "Код", "admin_promo_label_bonus_days": "Бонус (дней)", "admin_promo_label_max_activations": "Макс. активаций", "admin_promo_label_valid_days": "Срок действия (дней)", "admin_ad_create_title": "Новая кампания", - "admin_ad_label_source": "Источник", "admin_ad_label_param": "start-параметр", "admin_ad_hint_param": "Unique identifier for the referral link", "admin_ad_label_cost": "Стоимость, RUB", @@ -1285,7 +1243,6 @@ "admin_ads_col_cost": "Стоимость", "admin_ads_col_registrations": "Регистрации", "admin_ads_col_conversions": "Конверсии", - "admin_ads_col_status": "Статус", "admin_no_data": "Нет данных", "admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.", "admin_settings_legacy_tariffs_warning_title": "Совместимость с remnawave-tg-shop legacy", @@ -1313,7 +1270,6 @@ "admin_show": "Показать", "admin_hide": "Скрыть", "admin_tariffs_stat_total": "Всего тарифов", - "admin_tariffs_stat_enabled": "Включено", "admin_tariffs_stat_default": "По умолчанию", "admin_tariffs_stat_default_hint": "Используется для новых подписок", "admin_tariffs_stat_disabled": "Отключено", @@ -1340,7 +1296,6 @@ "admin_tariffs_legacy_subtitle": "Старые периоды и пакеты трафика remnawave-tg-shop, которые используются только без JSON-каталога.", "admin_tariffs_legacy_period": "Период", "admin_tariffs_legacy_enabled": "Включён", - "admin_tariffs_legacy_traffic_packages": "Пакеты трафика", "admin_tariffs_legacy_stars_traffic_packages": "Пакеты трафика, Stars", "admin_tariffs_legacy_traffic_hint": "Формат: 10:199,50:799", "admin_tariff_tab_general": "Основное", @@ -1383,7 +1338,6 @@ "admin_months_short": "мес.", "admin_at": "за", "admin_tariff_traffic_packages": "Пакеты трафика", - "admin_btn_refresh": "Обновить", "admin_btn_create_tariff": "Создать тариф", "admin_enabled": "Включено", "admin_disabled": "Выключено", @@ -1398,7 +1352,6 @@ "admin_broadcast_stat_failed": "Неудач", "admin_broadcast_started": "Рассылка запущена", "admin_broadcast_failed": "Ошибка рассылки", - "admin_user_short": "Пользователь", "admin_target_short": "Цель", "admin_settings_field_default_language_label": "Язык по умолчанию", "admin_settings_field_default_language_description": "Используется для приветственных сообщений и публичных страниц.", @@ -1667,45 +1620,18 @@ "admin_nav_support": "Поддержка", "admin_section_support_title": "Поддержка", "admin_section_support_subtitle": "Инбокс тикетов и ответы пользователям", - "admin_support_search": "Поиск", - "admin_support_empty": "Тикетов пока нет", "admin_support_select_ticket": "Выберите тикет", - "admin_support_close_ticket": "Закрыть", - "admin_support_internal_note": "Внутренняя заметка", "admin_support_reply_placeholder": "Ответ", - "admin_support_no_messages": "Сообщений пока нет", - "admin_support_filter_all": "Все", - "admin_support_filter_active": "Активные", "admin_support_filter_closed": "Закрытые", "admin_support_filter_all_priorities": "Любой приоритет", "admin_support_filter_all_categories": "Все категории", - "admin_support_ticket_number": "Тикет #{id}", - "admin_support_priority": "Приоритет", - "admin_support_category": "Категория", - "admin_support_role_user": "Пользователь", "admin_support_role_admin": "Админ", - "admin_support_role_system": "Система", - "admin_support_user_context": "Пользователь", "admin_support_open_user": "Карточка", "admin_support_tariff": "Тариф", - "admin_support_status": "Статус", "admin_support_remaining": "Осталось", "admin_support_unread": "Непрочитано", "admin_support_summary": "Сводка поддержки", "admin_support_ticket_dialog": "Диалог поддержки", - "admin_support_status_open": "Открыт", - "admin_support_status_awaiting_user": "Ждёт пользователя", - "admin_support_status_awaiting_admin": "Ждёт админа", - "admin_support_status_resolved": "Решён", - "admin_support_status_closed": "Закрыт", - "admin_support_priority_low": "Низкий", - "admin_support_priority_normal": "Обычный", - "admin_support_priority_high": "Высокий", - "admin_support_priority_urgent": "Срочный", - "admin_support_category_billing": "Оплата", - "admin_support_category_technical": "Техническое", - "admin_support_category_account": "Аккаунт", - "admin_support_category_other": "Другое", "admin_support_sort_importance_desc": "Важные сверху", "admin_sort_updated_desc": "Сначала новые", "admin_sort_updated_asc": "Сначала старые", @@ -1722,7 +1648,6 @@ "wa_install_subscription_link_hint": "Отсканируйте QR-код или скопируйте ссылку.", "wa_install_qr_alt": "QR-код подписки", "wa_install_copy_subscription_link": "Скопировать ссылку", - "wa_install_link_copied": "Ссылка скопирована", "wa_install_share": "Поделиться", "wa_install_share_copied": "Ссылка на инструкцию скопирована", "wa_app_launch_title": "Открываем приложение", @@ -1750,5 +1675,82 @@ "admin_settings_field_subscription_page_config_json_label": "JSON-конфиг Subscription Page", "admin_settings_field_subscription_page_config_json_description": "Необязательный JSON-override из админки. Применяется только когда включен соответствующий тумблер.", "admin_settings_field_subscription_page_config_json_placeholder": "{\n \"version\": \"1\"\n}", - "admin_settings_json_upload": "Загрузить .json" + "admin_settings_json_upload": "Загрузить .json", + "admin_nav_translations": "Переводы", + "admin_section_translations_title": "Переводы", + "admin_section_translations_subtitle": "Оверрайды строк локализации из базы данных и data/locales-overrides.json", + "admin_translations_saved": "Переводы сохранены", + "admin_translations_validation_errors": "Ошибки: {errors}", + "admin_translations_save_error": "Не удалось сохранить переводы: {error}", + "admin_translations_base_value": "Базовый текст", + "admin_translations_search_placeholder": "Поиск по ключам и тексту", + "admin_translations_hint": "Оверрайды хранятся в базе данных и зеркалируются в {path}.", + "admin_translations_keys_count": "Ключей: {count}", + "admin_translations_no_matches": "Подходящих строк нет", + "admin_translations_group_admin": "Админ-панель", + "admin_translations_group_admin_hint": "Навигация, таблицы, формы, диалоги и подписи только для админов.", + "admin_translations_group_admin_navigation": "Навигация и общий UI админки", + "admin_translations_group_admin_navigation_hint": "Сайдбар, заголовки разделов, действия тулбаров, фильтры и общие контролы.", + "admin_translations_group_admin_dashboard": "Дашборд и статистика", + "admin_translations_group_admin_dashboard_hint": "Карточки дашборда, графики выручки, статус синхронизации панели и мониторинг.", + "admin_translations_group_admin_users": "Пользователи в админке", + "admin_translations_group_admin_users_hint": "Списки и карточки пользователей, баны, начисления, premium-override и личные сообщения.", + "admin_translations_group_admin_payments": "Платежи в админке", + "admin_translations_group_admin_payments_hint": "Таблицы платежей, детали оплат, экспорт, названия провайдеров и платёжная статистика.", + "admin_translations_group_admin_promos_marketing": "Промо, реклама и рассылки", + "admin_translations_group_admin_promos_marketing_hint": "Управление промокодами, рекламные кампании, маркетинговые инструменты и сценарии рассылок.", + "admin_translations_group_admin_tariffs": "Тарифы в админке", + "admin_translations_group_admin_tariffs_hint": "Каталог тарифов, диалоги тарифов, legacy-строки и блоки триала.", + "admin_translations_group_admin_support": "Инбокс поддержки", + "admin_translations_group_admin_support_hint": "Инбокс тикетов, фильтры, ответы админа и статусы поддержки.", + "admin_translations_group_admin_appearance": "Внешний вид админки", + "admin_translations_group_admin_appearance_hint": "Каталог тем, брендинг, логотип, favicon и публичные ссылки.", + "admin_translations_group_admin_settings_payments": "Платёжные настройки", + "admin_translations_group_admin_settings_payments_hint": "Тумблеры способов оплаты, цены, реквизиты провайдеров и настройки вебхуков.", + "admin_translations_group_admin_settings_subscriptions": "Настройки подписок", + "admin_translations_group_admin_settings_subscriptions_hint": "Подключение панели, squad по умолчанию, триал, рефералы, лимиты устройств и инструкции.", + "admin_translations_group_admin_settings_notifications": "Уведомления и логи", + "admin_translations_group_admin_settings_notifications_hint": "Логирование, обязательный канал, уведомления о подписке и лимиты поддержки.", + "admin_translations_group_admin_settings": "Настройки админки", + "admin_translations_group_admin_settings_hint": "Группы настроек, подразделы, подсказки и настройки без отдельной категории.", + "admin_translations_group_admin_translations": "Переводы в админке", + "admin_translations_group_admin_translations_hint": "Экран оверрайдов переводов, управление языками и названия групп локалей.", + "admin_translations_group_admin_logs": "Логи и экспорт", + "admin_translations_group_admin_logs_hint": "Логи активности, экспорт логов, CSV-заголовки и подписи деталей событий.", + "admin_translations_group_admin_misc": "Прочее в админке", + "admin_translations_group_admin_misc_hint": "Старые подписи bot-admin и внутренние строки, которые не попали в другие разделы.", + "admin_translations_group_webapp": "Mini App", + "admin_translations_group_webapp_hint": "Пользовательские экраны Mini App, навигация, настройки и уведомления.", + "admin_translations_group_bot_menu": "Telegram-бот", + "admin_translations_group_bot_menu_hint": "Стартовое меню, inline-кнопки, выбор языка и сценарии внутри бота.", + "admin_translations_group_subscriptions": "Подписки и устройства", + "admin_translations_group_subscriptions_hint": "Статус подписки, инструкции подключения, пакеты трафика, триал и устройства.", + "admin_translations_group_payments": "Платежи", + "admin_translations_group_payments_hint": "Платежные сценарии, инвойсы, способы оплаты и сообщения checkout.", + "admin_translations_group_support": "Поддержка", + "admin_translations_group_support_hint": "Ссылки поддержки, тикеты, статусы тикетов и уведомления.", + "admin_translations_group_referrals_promos": "Рефералы и промокоды", + "admin_translations_group_referrals_promos_hint": "Реферальная программа, приглашения, промокоды и бонусы.", + "admin_translations_group_auth_security": "Вход и безопасность", + "admin_translations_group_auth_security_hint": "Логин, подтверждение email, привязка аккаунтов и сообщения безопасности.", + "admin_translations_group_emails": "Email-письма", + "admin_translations_group_emails_hint": "Транзакционные письма пользователям: коды входа, платежи, объединение аккаунтов и напоминания.", + "admin_translations_group_notifications_sync": "Уведомления и синхронизация", + "admin_translations_group_notifications_sync_hint": "Админские уведомления, синхронизация панели, логи и фоновые статусы.", + "admin_translations_group_common": "Общие строки", + "admin_translations_group_common_hint": "Общие кнопки, статусы, ошибки валидации и строки без отдельной группы.", + "admin_translations_file_write_warning": "Переводы сохранены в БД, но JSON-файл не обновился", + "admin_add": "Добавить", + "admin_translations_fallback_value": "Фолбэк", + "admin_translations_languages_title": "Языки", + "admin_translations_languages_hint": "Можно добавлять языки только для оверрайдов, без базовых locale-файлов.", + "admin_translations_language_placeholder": "de, uk, pt-BR", + "admin_translations_language_custom": "Доп.", + "admin_translations_language_invalid": "Неверный код языка", + "admin_translations_language_exists": "Язык {code} уже есть", + "admin_translations_audience_all": "Все", + "admin_translations_audience_user": "Видно пользователю", + "admin_translations_audience_internal": "Админка/внутреннее", + "admin_translations_audience_user_hint": "Строки Mini App, бота, платежей, подписок, поддержки и входа.", + "admin_translations_audience_internal_hint": "Админка, логи, статусы синхронизации и сервисные уведомления." } diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index 246412b..6910cbc 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -3,6 +3,7 @@ import re from pathlib import Path from bot.app.web.admin_settings_manifest import manifest_payload +from bot.middlewares.i18n import resolve_locale_key REPO_ROOT = Path(__file__).resolve().parents[1] @@ -99,6 +100,10 @@ def _locale(language: str) -> dict[str, str]: return json.loads((REPO_ROOT / "locales" / f"{language}.json").read_text(encoding="utf-8")) +def _has_locale_key(messages: dict[str, str], key: str) -> bool: + return resolve_locale_key(key) in messages + + def test_webapp_title_is_first_general_admin_setting(): items = _manifest_items() manifest = {item["key"]: item for item in items} @@ -219,7 +224,10 @@ def test_platega_settings_share_one_admin_subsection(): def test_tariff_settings_page_i18n_keys_exist(): for language in ("ru", "en"): messages = _locale(language) - assert ADMIN_TARIFF_SETTINGS_PAGE_KEYS <= messages.keys() + missing = { + key for key in ADMIN_TARIFF_SETTINGS_PAGE_KEYS if not _has_locale_key(messages, key) + } + assert missing == set() def test_visible_admin_russian_labels_do_not_fall_back_to_english(): @@ -236,7 +244,7 @@ def test_visible_admin_russian_labels_do_not_fall_back_to_english(): for match in ADMIN_AT_SIMPLE_FALLBACK_RE.finditer(text): locale_key = f"admin_{match.group('key')}" fallback = match.group("fallback") - if locale_key in messages: + if _has_locale_key(messages, locale_key): continue has_latin = any("A" <= char <= "Z" or "a" <= char <= "z" for char in fallback) has_cyrillic = any("А" <= char <= "я" or char in "Ёё" for char in fallback) diff --git a/tests/test_locale_overrides.py b/tests/test_locale_overrides.py new file mode 100644 index 0000000..906c3c5 --- /dev/null +++ b/tests/test_locale_overrides.py @@ -0,0 +1,813 @@ +import asyncio +import json +import re +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.app.web.webapp.common import _normalize_language +from bot.keyboards.inline.user_keyboards import get_language_selection_keyboard +from bot.middlewares.i18n import ( + LOCALE_KEY_ALIASES, + JsonI18n, + normalize_locale_overrides_payload, + resolve_locale_key, +) +from bot.services import locale_override_service +from bot.services.email_templates import render_login_code +from bot.services.locale_override_service import audience_for_locale_key, group_id_for_locale_key + +REPO_ROOT = Path(__file__).resolve().parents[1] +LOCALE_CALL_RE = re.compile( + r"""(?P\bat\b|\bt\b|\bgettext\b|\bget_text\b|\btranslator\b|(?[^"']+)["']""" +) + + +class _Begin: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _Session: + def begin(self): + return _Begin() + + async def commit(self): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _SessionFactory: + def __call__(self): + return _Session() + + +def _write_locale(path: Path, lang: str, messages: dict[str, str]) -> None: + (path / f"{lang}.json").write_text( + json.dumps(messages, ensure_ascii=False), + encoding="utf-8", + ) + + +def _source_files(*roots: str) -> list[Path]: + result: list[Path] = [] + for root in roots: + path = REPO_ROOT / root + if path.is_file(): + result.append(path) + else: + result.extend( + child + for child in path.rglob("*") + if child.suffix in {".py", ".js", ".svelte"} + ) + return result + + +def _collect_locale_usage( + paths: list[Path], + locale_keys: set[str], + *, + frontend_admin: bool = False, + exclude_admin_frontend: bool = False, +) -> dict[str, set[str]]: + usage: dict[str, set[str]] = {} + for path in paths: + relative = path.relative_to(REPO_ROOT).as_posix() + if exclude_admin_frontend and ( + relative.startswith("frontend/src/admin/") + or relative.startswith("frontend/src/lib/admin/") + or relative == "frontend/src/adminEntry.js" + ): + continue + text = path.read_text(encoding="utf-8", errors="ignore") + for match in LOCALE_CALL_RE.finditer(text): + key = match.group("key") + actual_key = f"admin_{key}" if frontend_admin and match.group("fn") == "at" else key + resolved_key = resolve_locale_key(actual_key) + if resolved_key in locale_keys: + usage.setdefault(resolved_key, set()).add(relative) + return usage + + +def test_json_i18n_applies_locale_overrides(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет, {name}!", "plain": "База"}) + _write_locale(locales, "en", {"welcome": "Hello, {name}!", "plain": "Base"}) + + i18n = JsonI18n(str(locales), default="ru") + i18n.set_locale_overrides({"ru": {"welcome": "Добрый день, {name}!"}}) + + assert i18n.gettext("ru", "welcome", name="Анна") == "Добрый день, Анна!" + assert i18n.gettext("ru", "plain") == "База" + assert i18n.base_locales_data["ru"]["welcome"] == "Привет, {name}!" + + +def test_json_i18n_resolves_locale_key_aliases(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"wa_back": "Назад"}) + _write_locale(locales, "en", {"wa_back": "Back"}) + + i18n = JsonI18n(str(locales), default="ru") + + assert resolve_locale_key("admin_back") == "wa_back" + assert i18n.gettext("ru", "admin_back") == "Назад" + assert i18n.gettext("en", "admin_back") == "Back" + + +def test_json_i18n_applies_override_only_language_with_default_fallback(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет", "plain": "База"}) + _write_locale(locales, "en", {"welcome": "Hello", "plain": "Base"}) + + i18n = JsonI18n(str(locales), default="ru") + i18n.set_locale_overrides({"de": {"welcome": "Hallo"}}) + + assert i18n.gettext("de", "welcome") == "Hallo" + assert i18n.gettext("de-DE", "welcome") == "Hallo" + assert i18n.gettext("de", "plain") == "База" + assert i18n.locales_data["de"]["welcome"] == "Hallo" + + +def test_language_options_include_override_only_languages(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет"}) + _write_locale(locales, "en", {"welcome": "Hello"}) + + i18n = JsonI18n(str(locales), default="ru") + i18n.set_locale_overrides({"uk": {"welcome": "Вітаю"}, "pt-br": {"welcome": "Olá"}}) + + options = i18n.language_options() + + assert [item["code"] for item in options] == ["ru", "en", "pt-br", "uk"] + assert options[-1]["label"] == "Українська" + assert options[-1]["base"] is False + + +def test_language_keyboard_includes_override_only_languages(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"back_to_main_menu_button": "Назад"}) + _write_locale(locales, "en", {"back_to_main_menu_button": "Back"}) + + i18n = JsonI18n(str(locales), default="ru") + i18n.set_locale_overrides({"uk": {"back_to_main_menu_button": "Назад"}}) + + keyboard = get_language_selection_keyboard(i18n, "uk") + buttons = [button for row in keyboard.inline_keyboard for button in row] + + assert any(button.callback_data == "set_lang_uk" for button in buttons) + assert any("Українська" in button.text and "✅" in button.text for button in buttons) + + +def test_webapp_language_normalizer_accepts_valid_extra_languages(): + assert _normalize_language("uk") == "uk" + assert _normalize_language("pt-BR") == "pt-br" + assert _normalize_language("bad code") == "ru" + + +def test_email_templates_use_override_only_language_without_base_locale_file(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + base_messages = { + "email_login_code_subject": "Code {code}", + "email_login_code_preheader": "Expires in {minutes}", + "email_login_code_heading": "Login", + "email_login_code_intro": "Intro", + "email_login_code_expiry_html": "Expires in {minutes}", + "email_login_code_security": "Ignore this email.", + "email_footer_auto": "{brand}", + "email_login_code_text": "Code {code}; {minutes}", + } + _write_locale(locales, "ru", base_messages) + _write_locale(locales, "en", base_messages) + + i18n = JsonI18n(str(locales), default="ru") + i18n.set_locale_overrides( + { + "pt-br": { + "email_login_code_subject": "Código {code}", + "email_login_code_text": "Seu código é {code}", + } + } + ) + settings = SimpleNamespace( + DEFAULT_LANGUAGE="ru", + EMAIL_CODE_TTL_SECONDS=600, + WEBAPP_LOGO_URL="", + WEBAPP_LOGO_USE_EMOJI=False, + WEBAPP_PRIMARY_COLOR="#00fe7a", + WEBAPP_TITLE="Remnawave", + ) + + content = render_login_code( + settings, + code="123456", + language_code="pt-BR", + purpose="login", + i18n=i18n, + ) + + assert content.subject == "Código 123456" + assert content.text == "Seu código é 123456" + + +def test_locale_override_file_reload_replaces_effective_messages(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет"}) + _write_locale(locales, "en", {"welcome": "Hello"}) + overrides_path = tmp_path / "locales-overrides.json" + overrides_path.write_text('{"ru":{"welcome":"Первый"}}', encoding="utf-8") + + i18n = JsonI18n(str(locales), default="ru", overrides_path=str(overrides_path)) + assert i18n.gettext("ru", "welcome") == "Первый" + + overrides_path.write_text('{"ru":{"welcome":"Второй"}}', encoding="utf-8") + i18n._overrides_file_next_check = 0 + + assert i18n.gettext("ru", "welcome") == "Второй" + + +def test_locale_override_file_removal_keeps_current_overrides(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет"}) + _write_locale(locales, "en", {"welcome": "Hello"}) + overrides_path = tmp_path / "locales-overrides.json" + overrides_path.write_text('{"ru":{"welcome":"Из файла"}}', encoding="utf-8") + + i18n = JsonI18n(str(locales), default="ru", overrides_path=str(overrides_path)) + assert i18n.gettext("ru", "welcome") == "Из файла" + + overrides_path.unlink() + i18n._overrides_file_next_check = 0 + + assert i18n.gettext("ru", "welcome") == "Из файла" + + +def test_normalize_locale_overrides_rejects_unknown_keys(): + overrides, errors = normalize_locale_overrides_payload( + {"ru": {"known": "ok", "missing": "bad"}}, + valid_languages={"ru"}, + valid_keys_by_language={"ru": {"known"}}, + ) + + assert overrides == {"ru": {"known": "ok"}} + assert errors == {"ru.missing": "unknown_key"} + + +def test_normalize_locale_overrides_allows_extra_languages_when_enabled(): + overrides, errors = normalize_locale_overrides_payload( + {"pt-BR": {"known": "ok"}, "bad code": {"known": "bad"}}, + valid_languages={"ru", "en"}, + valid_keys_by_language={"ru": {"known"}, "en": {"known"}}, + allow_extra_languages=True, + ) + + assert overrides == {"pt-br": {"known": "ok"}} + assert errors == {"bad code": "invalid_language"} + + +def test_normalize_locale_overrides_canonicalizes_alias_keys(): + overrides, errors = normalize_locale_overrides_payload( + {"ru": {"admin_back": "Назад", "wa_back": "Назад!"}}, + valid_languages={"ru", "en"}, + valid_keys_by_language={"ru": {"wa_back"}, "en": {"wa_back"}}, + ) + + assert errors == {} + assert overrides == {"ru": {"wa_back": "Назад!"}} + + +def test_base_locale_files_do_not_store_alias_keys(): + for lang in ("en", "ru"): + messages = json.loads((REPO_ROOT / "locales" / f"{lang}.json").read_text(encoding="utf-8")) + assert sorted(set(messages) & set(LOCALE_KEY_ALIASES)) == [] + + +def test_admin_locale_keys_are_split_into_smaller_internal_groups(): + expected_groups = { + "admin_nav_support": "admin_navigation", + "admin_stats_revenue_title": "admin_dashboard", + "error_displaying_statistics": "admin_dashboard", + "inline_admin_user_stats_title": "admin_dashboard", + "inline_user_stats_message": "admin_dashboard", + "inline_financial_description": "admin_dashboard", + "inline_system_stats_message": "admin_dashboard", + "admin_user_card_title": "admin_users", + "user_card_open_profile_button": "admin_users", + "user_premium_override_card_title": "admin_users", + "traffic_grant_regular_done": "admin_users", + "admin_payment_detail_title": "admin_payments", + "admin_promo_management_title": "admin_promos_marketing", + "broadcast_target_all_button": "admin_promos_marketing", + "confirm_broadcast_send_button": "admin_promos_marketing", + "admin_tariffs_trial_title": "admin_tariffs", + "admin_support_ticket_dialog": "admin_support", + "admin_themes_catalog_title": "admin_appearance", + "appearance_logo_uploaded_pending": "admin_appearance", + "admin_settings_field_yookassa_enabled_label": "admin_settings_payments", + "admin_settings_field_subscription_guides_enabled_label": ( + "admin_settings_subscriptions" + ), + "admin_settings_field_log_level_label": "admin_settings_notifications", + "back_to_admin_panel_button": "admin_navigation", + "admin_translations_languages_title": "admin_translations", + "admin_logs_menu_title": "admin_logs", + "csv_yes": "admin_logs", + "error_displaying_logs_too_long": "admin_logs", + } + + for key, group_id in expected_groups.items(): + assert group_id_for_locale_key(key) == group_id + assert audience_for_locale_key(key) == "internal" + + +def test_email_locale_keys_have_dedicated_user_visible_group(): + expected_email_keys = [ + "email_footer_auto", + "email_login_code_subject", + "email_payment_success_subject", + "email_subscription_expiring_subject_today", + ] + + for key in expected_email_keys: + assert group_id_for_locale_key(key) == "emails" + assert audience_for_locale_key(key) == "user" + + +def test_admin_only_locale_keys_are_internal_by_actual_source_usage(): + locale_keys = set(json.loads((REPO_ROOT / "locales" / "en.json").read_text(encoding="utf-8"))) + frontend_admin_usage = _collect_locale_usage( + _source_files("frontend/src/admin", "frontend/src/lib/admin"), + locale_keys, + frontend_admin=True, + ) + backend_admin_usage = _collect_locale_usage( + _source_files( + "backend/bot/handlers/admin", + "backend/bot/keyboards/inline/admin_keyboards.py", + ), + locale_keys, + ) + inline_admin_usage = _collect_locale_usage( + _source_files("backend/bot/handlers/inline_mode.py"), + locale_keys, + ) + admin_usage = dict(frontend_admin_usage) + for key, paths in [*backend_admin_usage.items(), *inline_admin_usage.items()]: + admin_usage.setdefault(key, set()).update(paths) + + user_usage = _collect_locale_usage( + _source_files( + "backend/bot/handlers/user", + "backend/bot/keyboards/inline/user_keyboards.py", + "backend/bot/payment_providers", + "backend/bot/app/web/webapp", + "frontend/src", + ), + locale_keys, + exclude_admin_frontend=True, + ) + for key in locale_keys: + if key.startswith("email_"): + user_usage.setdefault(key, set()).add("backend/bot/services/email_templates.py") + if key.startswith("inline_referral_"): + user_usage.setdefault(key, set()).add("backend/bot/handlers/inline_mode.py") + for canonical_key in set(LOCALE_KEY_ALIASES.values()): + if canonical_key in locale_keys and audience_for_locale_key(canonical_key) == "user": + user_usage.setdefault(canonical_key, set()).add("locale-key-aliases") + + misplaced = { + key: sorted(paths) + for key, paths in admin_usage.items() + if key not in user_usage and audience_for_locale_key(key) != "internal" + } + + assert misplaced == {} + + +def test_load_locale_overrides_treats_file_as_source_of_truth(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет", "plain": "База"}) + _write_locale(locales, "en", {"welcome": "Hello", "plain": "Base"}) + overrides_path = tmp_path / "locales-overrides.json" + overrides_path.write_text( + json.dumps({"ru": {"welcome": "Из файла"}}, ensure_ascii=False), + encoding="utf-8", + ) + i18n = JsonI18n(str(locales), default="ru") + db_state = {"ru": {"welcome": "Из БД", "plain": "Только БД"}} + + async def bulk_apply(_session, *, updates, updated_by): + assert updated_by is None + for (lang, key), (set_flag, value) in updates.items(): + if set_flag: + db_state.setdefault(lang, {})[key] = value + else: + db_state.get(lang, {}).pop(key, None) + for lang in list(db_state): + if not db_state[lang]: + db_state.pop(lang) + + async def run(): + with ( + patch.object( + locale_override_service.locale_overrides_dal, + "get_all_overrides", + AsyncMock(side_effect=lambda _session: db_state), + ), + patch.object( + locale_override_service.locale_overrides_dal, + "bulk_apply", + AsyncMock(side_effect=bulk_apply), + ) as bulk_mock, + ): + count = await locale_override_service.load_locale_overrides( + i18n, + _SessionFactory(), + overrides_path=overrides_path, + ) + bulk_mock.assert_awaited_once() + return count + + count = asyncio.run(run()) + + assert count == 1 + assert db_state == {"ru": {"welcome": "Из файла"}} + assert i18n.gettext("ru", "welcome") == "Из файла" + assert i18n.gettext("ru", "plain") == "База" + + +def test_load_locale_overrides_empty_file_clears_db_overrides(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет"}) + _write_locale(locales, "en", {"welcome": "Hello"}) + overrides_path = tmp_path / "locales-overrides.json" + overrides_path.write_text("{}", encoding="utf-8") + i18n = JsonI18n(str(locales), default="ru") + db_state = {"ru": {"welcome": "Из БД"}} + + async def bulk_apply(_session, *, updates, updated_by): + assert updated_by is None + for (lang, key), (set_flag, value) in updates.items(): + if set_flag: + db_state.setdefault(lang, {})[key] = value + else: + db_state.get(lang, {}).pop(key, None) + for lang in list(db_state): + if not db_state[lang]: + db_state.pop(lang) + + async def run(): + with ( + patch.object( + locale_override_service.locale_overrides_dal, + "get_all_overrides", + AsyncMock(side_effect=lambda _session: db_state), + ), + patch.object( + locale_override_service.locale_overrides_dal, + "bulk_apply", + AsyncMock(side_effect=bulk_apply), + ), + ): + return await locale_override_service.load_locale_overrides( + i18n, + _SessionFactory(), + overrides_path=overrides_path, + ) + + count = asyncio.run(run()) + + assert count == 0 + assert db_state == {} + assert i18n.gettext("ru", "welcome") == "Привет" + + +def test_load_locale_overrides_uses_db_when_file_missing(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет"}) + _write_locale(locales, "en", {"welcome": "Hello"}) + overrides_path = tmp_path / "locales-overrides.json" + i18n = JsonI18n(str(locales), default="ru") + + async def run(): + with ( + patch.object( + locale_override_service.locale_overrides_dal, + "get_all_overrides", + AsyncMock(return_value={"ru": {"welcome": "Из БД"}}), + ), + patch.object( + locale_override_service.locale_overrides_dal, + "bulk_apply", + AsyncMock(), + ) as bulk_mock, + ): + count = await locale_override_service.load_locale_overrides( + i18n, + _SessionFactory(), + overrides_path=overrides_path, + ) + bulk_mock.assert_not_awaited() + return count + + count = asyncio.run(run()) + + assert count == 1 + assert i18n.gettext("ru", "welcome") == "Из БД" + assert json.loads(overrides_path.read_text(encoding="utf-8")) == { + "ru": {"welcome": "Из БД"} + } + + +def test_load_locale_overrides_creates_empty_file_when_file_missing_and_db_empty(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет"}) + _write_locale(locales, "en", {"welcome": "Hello"}) + overrides_path = tmp_path / "locales-overrides.json" + i18n = JsonI18n(str(locales), default="ru") + + async def run(): + with patch.object( + locale_override_service.locale_overrides_dal, + "get_all_overrides", + AsyncMock(return_value={}), + ): + return await locale_override_service.load_locale_overrides( + i18n, + _SessionFactory(), + overrides_path=overrides_path, + ) + + count = asyncio.run(run()) + + assert count == 0 + assert i18n.gettext("ru", "welcome") == "Привет" + assert json.loads(overrides_path.read_text(encoding="utf-8")) == {} + + +def test_load_locale_overrides_uses_db_when_file_is_invalid(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет"}) + _write_locale(locales, "en", {"welcome": "Hello"}) + overrides_path = tmp_path / "locales-overrides.json" + overrides_path.write_text("{not-json", encoding="utf-8") + i18n = JsonI18n(str(locales), default="ru") + + async def run(): + with ( + patch.object( + locale_override_service.locale_overrides_dal, + "get_all_overrides", + AsyncMock(return_value={"ru": {"welcome": "Из БД"}}), + ), + patch.object( + locale_override_service.locale_overrides_dal, + "bulk_apply", + AsyncMock(), + ) as bulk_mock, + ): + count = await locale_override_service.load_locale_overrides( + i18n, + _SessionFactory(), + overrides_path=overrides_path, + ) + bulk_mock.assert_not_awaited() + return count + + count = asyncio.run(run()) + + assert count == 1 + assert i18n.gettext("ru", "welcome") == "Из БД" + assert overrides_path.read_text(encoding="utf-8") == "{not-json" + + +def test_update_locale_overrides_persists_applies_and_writes_file(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет"}) + _write_locale(locales, "en", {"welcome": "Hello"}) + i18n = JsonI18n(str(locales), default="ru") + overrides_path = tmp_path / "locales-overrides.json" + db_state = {} + + async def get_all(_session): + return db_state + + async def bulk_apply(_session, *, updates, updated_by): + assert updated_by == 7 + for (lang, key), (set_flag, value) in updates.items(): + if set_flag: + db_state.setdefault(lang, {})[key] = value + else: + db_state.get(lang, {}).pop(key, None) + + async def run(): + with ( + patch.object( + locale_override_service.locale_overrides_dal, + "get_all_overrides", + AsyncMock(side_effect=get_all), + ), + patch.object( + locale_override_service.locale_overrides_dal, + "bulk_apply", + AsyncMock(side_effect=bulk_apply), + ) as bulk_mock, + ): + result = await locale_override_service.update_locale_overrides( + i18n, + _SessionFactory(), + updates={"ru": {"welcome": "Здравствуйте"}}, + deletes=[], + actor_id=7, + overrides_path=overrides_path, + ) + bulk_mock.assert_awaited_once() + return result + + result = asyncio.run(run()) + + assert result["ok"] is True + assert result["file_written"] is True + assert db_state == {"ru": {"welcome": "Здравствуйте"}} + assert i18n.gettext("ru", "welcome") == "Здравствуйте" + assert json.loads(overrides_path.read_text(encoding="utf-8")) == { + "ru": {"welcome": "Здравствуйте"} + } + + +def test_update_locale_overrides_accepts_extra_language(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет", "plain": "База"}) + _write_locale(locales, "en", {"welcome": "Hello", "plain": "Base"}) + i18n = JsonI18n(str(locales), default="ru") + overrides_path = tmp_path / "locales-overrides.json" + db_state = {} + + async def get_all(_session): + return db_state + + async def bulk_apply(_session, *, updates, updated_by): + assert updated_by == 7 + for (lang, key), (set_flag, value) in updates.items(): + if set_flag: + db_state.setdefault(lang, {})[key] = value + else: + db_state.get(lang, {}).pop(key, None) + + async def run(): + with ( + patch.object( + locale_override_service.locale_overrides_dal, + "get_all_overrides", + AsyncMock(side_effect=get_all), + ), + patch.object( + locale_override_service.locale_overrides_dal, + "bulk_apply", + AsyncMock(side_effect=bulk_apply), + ), + ): + return await locale_override_service.update_locale_overrides( + i18n, + _SessionFactory(), + updates={"uk": {"welcome": "Вітаю"}}, + deletes=[], + actor_id=7, + overrides_path=overrides_path, + ) + + result = asyncio.run(run()) + + assert result["ok"] is True + assert db_state == {"uk": {"welcome": "Вітаю"}} + assert i18n.gettext("uk", "welcome") == "Вітаю" + assert i18n.gettext("uk", "plain") == "База" + assert json.loads(overrides_path.read_text(encoding="utf-8")) == { + "uk": {"welcome": "Вітаю"} + } + + +def test_update_locale_overrides_fails_when_active_file_cannot_be_written(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет"}) + _write_locale(locales, "en", {"welcome": "Hello"}) + i18n = JsonI18n(str(locales), default="ru") + overrides_path = tmp_path / "locales-overrides.json" + overrides_path.write_text( + json.dumps({"ru": {"welcome": "Из файла"}}, ensure_ascii=False), + encoding="utf-8", + ) + + async def run(): + with ( + patch.object( + locale_override_service.locale_overrides_dal, + "get_all_overrides", + AsyncMock(return_value={"ru": {"welcome": "Из БД"}}), + ), + patch.object( + locale_override_service.locale_overrides_dal, + "bulk_apply", + AsyncMock(), + ) as bulk_mock, + patch.object( + locale_override_service, + "write_locale_overrides_file", + return_value=False, + ), + ): + result = await locale_override_service.update_locale_overrides( + i18n, + _SessionFactory(), + updates={"ru": {"welcome": "Новое"}}, + deletes=[], + actor_id=7, + overrides_path=overrides_path, + ) + bulk_mock.assert_not_awaited() + return result + + result = asyncio.run(run()) + + assert result == {"ok": False, "errors": {"_file": "write_failed"}} + assert i18n.gettext("ru", "welcome") == "Привет" + + +def test_update_locale_overrides_allows_db_only_when_file_is_missing_and_unwritable(tmp_path): + locales = tmp_path / "locales" + locales.mkdir() + _write_locale(locales, "ru", {"welcome": "Привет"}) + _write_locale(locales, "en", {"welcome": "Hello"}) + i18n = JsonI18n(str(locales), default="ru") + overrides_path = tmp_path / "locales-overrides.json" + db_state = {} + + async def get_all(_session): + return db_state + + async def bulk_apply(_session, *, updates, updated_by): + assert updated_by == 7 + for (lang, key), (set_flag, value) in updates.items(): + if set_flag: + db_state.setdefault(lang, {})[key] = value + else: + db_state.get(lang, {}).pop(key, None) + + async def run(): + with ( + patch.object( + locale_override_service.locale_overrides_dal, + "get_all_overrides", + AsyncMock(side_effect=get_all), + ), + patch.object( + locale_override_service.locale_overrides_dal, + "bulk_apply", + AsyncMock(side_effect=bulk_apply), + ), + patch.object( + locale_override_service, + "write_locale_overrides_file", + return_value=False, + ), + ): + return await locale_override_service.update_locale_overrides( + i18n, + _SessionFactory(), + updates={"ru": {"welcome": "Только БД"}}, + deletes=[], + actor_id=7, + overrides_path=overrides_path, + ) + + result = asyncio.run(run()) + + assert result["ok"] is True + assert result["file_written"] is False + assert db_state == {"ru": {"welcome": "Только БД"}} + assert i18n.gettext("ru", "welcome") == "Только БД" diff --git a/tests/test_provider_labels.py b/tests/test_provider_labels.py index 91ea631..405a848 100644 --- a/tests/test_provider_labels.py +++ b/tests/test_provider_labels.py @@ -58,8 +58,9 @@ class _FakeUser(SimpleNamespace): class _FakeEmailService: instances: List["_FakeEmailService"] = [] - def __init__(self, settings): + def __init__(self, settings, i18n=None): self.settings = settings + self.i18n = i18n self.sent: List[dict] = [] _FakeEmailService.instances.append(self) diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 6ff0a97..a38d138 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -178,6 +178,8 @@ class WebAppRouteContractTests(unittest.TestCase): ("DELETE", "/api/admin/ads/{campaign_id}"): "admin_ad_delete_route", ("GET", "/api/admin/settings"): "admin_settings_get_route", ("PATCH", "/api/admin/settings"): "admin_settings_patch_route", + ("GET", "/api/admin/translations"): "admin_translations_get_route", + ("PATCH", "/api/admin/translations"): "admin_translations_patch_route", ("GET", "/api/admin/tariffs"): "admin_tariffs_get_route", ("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route", ("GET", "/api/admin/themes"): "admin_themes_get_route", @@ -208,6 +210,15 @@ class WebAppRouteContractTests(unittest.TestCase): self.assertEqual(match_info.handler.__name__, "index_route") + def test_admin_translations_page_route_is_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request("GET", "/admin/translations", app=app) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.handler.__name__, "index_route") + def test_webapp_favicon_asset_route_is_registered(self): app = web.Application() subscription_webapp.setup_subscription_webapp_routes(app)