feat: add runtime locale overrides

This commit is contained in:
3252a8
2026-05-25 17:47:45 +03:00
parent 23784d00fe
commit 59fa301344
51 changed files with 4026 additions and 214 deletions
+1
View File
@@ -16,6 +16,7 @@ frontend/node_modules/
deploy/compose/docker-compose-dev.yml
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
# CI
+1
View File
@@ -63,3 +63,4 @@ locales/en_backup.json
db/models_old.py
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
+1
View File
@@ -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
```
+1 -1
View File
@@ -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,
+2
View File
@@ -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,
)
@@ -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,
@@ -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)
@@ -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),
}
)
+5
View File
@@ -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)
+1 -1
View File
@@ -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": {}}
+10
View File
@@ -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(
+5 -9
View File
@@ -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,
+6 -2
View File
@@ -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:
+1 -1
View File
@@ -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,
)
+14 -2
View File
@@ -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:
+13 -8
View File
@@ -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()
+2
View File
@@ -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"
+439 -9
View File
@@ -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(
+4 -1
View File
@@ -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()
+5 -2
View File
@@ -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:
@@ -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
@@ -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)
@@ -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)
+1 -1
View File
@@ -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,
+2
View File
@@ -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",
)
+86
View File
@@ -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)
+22
View File
@@ -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,
),
]
+15
View File
@@ -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)
+2
View File
@@ -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()
+8
View File
@@ -0,0 +1,8 @@
{
"ru": {
"menu_personal_account_button": "Личный кабинет"
},
"en": {
"menu_personal_account_button": "Account"
}
}
+3 -1
View File
@@ -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
+21
View File
@@ -51,6 +51,27 @@
Для каждого платежного метода в разделе провайдера доступны presentation-настройки `PAYMENT_<METHOD>_WEBAPP_LABEL_RU`, `PAYMENT_<METHOD>_WEBAPP_LABEL_EN`, `PAYMENT_<METHOD>_WEBAPP_ICON`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` и `PAYMENT_<METHOD>_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. Оба переключателя включены по умолчанию.
+1
View File
@@ -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
+1
View File
@@ -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
+39 -5
View File
@@ -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);
}
+42
View File
@@ -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", {}, "Сохранить")}
</AdminButton>
{/if}
{#if active === "translations"}
{#if translationsDirtyCount}
<AdminBadge variant="warning"
>{at(
"settings_dirty_count",
{ count: translationsDirtyCount },
"Изменений: " + translationsDirtyCount
)}</AdminBadge
>
{/if}
<AdminButton
variant="primary"
onclick={() => translationsStore.saveTranslations(onTranslationsSaved)}
disabled={!translationsDirtyCount || translationsSaving}
>
<Save size={14} />
{translationsSaving
? at("btn_saving", {}, "Сохранение...")
: at("btn_save", {}, "Сохранить")}
</AdminButton>
{/if}
</div>
</header>
@@ -751,6 +789,10 @@
{#if active === "settings"}
<SettingsSection {at} {onSettingsSaved} {currentLang} />
{/if}
{#if active === "translations"}
<TranslationsSection {at} {onTranslationsSaved} />
{/if}
</div>
{/key}
</main>
@@ -0,0 +1,636 @@
<script>
import { ChevronRight, Languages, Plus, Search, X } from "$components/ui/icons.js";
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
import { getContext, onDestroy, onMount } from "svelte";
import { slide } from "svelte/transition";
export let at;
export let onTranslationsSaved;
const translationsStore = getContext("translationsStore");
const AUDIENCE_ORDER = ["user", "internal"];
const AUDIENCE_FILTERS = ["all", ...AUDIENCE_ORDER];
$: ({
translationGroups,
translationLanguages,
translationsLoading,
translationsDirty,
translationsSaving,
translationsPath,
} = $translationsStore);
let openGroups = [];
let readyGroups = [];
let openLocaleEditors = [];
let closedLocaleEditors = [];
let search = "";
let audienceFilter = "all";
let newLanguageCode = "";
const readyTimers = new Map();
$: openGroupSet = new Set(openGroups);
$: readyGroupSet = new Set(readyGroups);
$: openLocaleEditorSet = new Set(openLocaleEditors);
$: closedLocaleEditorSet = new Set(closedLocaleEditors);
$: filteredTranslationGroups = filteredGroups(translationGroups, search, translationLanguages);
$: audienceSections = buildAudienceSections(filteredTranslationGroups, audienceFilter);
$: visibleGroupKeys = audienceSections.flatMap((section) =>
section.groups.map((group) => groupPanelId(section.id, group.id))
);
$: allOpen =
visibleGroupKeys.length > 0 && visibleGroupKeys.every((key) => openGroups.includes(key));
$: scheduleReadyGroups(openGroups);
onMount(() => {
translationsStore.loadTranslations();
});
onDestroy(() => {
for (const timer of readyTimers.values()) clearTimeout(timer);
readyTimers.clear();
});
function dirtyKey(lang, key) {
return `${lang}:${key}`;
}
function dirtyFor(lang, key) {
return translationsDirty[dirtyKey(lang, key)] || null;
}
function defaultBaseValue(item) {
for (const values of Object.values(item.values || {})) {
if (values?.fallback) return values.fallback;
}
const baseLanguage = (translationLanguages || []).find((language) => language.base);
const baseCode = baseLanguage?.code || translationLanguages?.[0]?.code || "";
const values = item.values?.[baseCode] || {};
return values.base || values.fallback || values.effective || "";
}
function valueRecord(item, lang) {
return (
item.values?.[lang] || {
base: "",
fallback: defaultBaseValue(item),
effective: defaultBaseValue(item),
override: "",
overridden: false,
}
);
}
function localeValue(item, lang, dirty = dirtyFor(lang, item.key)) {
if (dirty?.deleted) return "";
if (dirty) return dirty.value;
return valueRecord(item, lang).override || "";
}
function isOverridden(item, lang, dirty = dirtyFor(lang, item.key)) {
return Boolean(valueRecord(item, lang).overridden) && !dirty?.deleted;
}
function isDirty(item, lang, dirty = dirtyFor(lang, item.key)) {
return Boolean(dirty);
}
function baseValue(item, lang) {
const values = valueRecord(item, lang);
return values.base || values.fallback || "";
}
function baseKind(item, lang) {
return valueRecord(item, lang).base
? at("translations_base_value", {}, "Base")
: at("translations_fallback_value", {}, "Fallback");
}
function effectiveValue(item, lang) {
return valueRecord(item, lang).effective || baseValue(item, lang);
}
function localePreview(item, lang, dirty = dirtyFor(lang, item.key)) {
return (
localeValue(item, lang, dirty) || effectiveValue(item, lang) || baseValue(item, lang) || "-"
);
}
function itemAudience(item, group = null) {
return item.audience || group?.audience || "user";
}
function audienceLabel(id) {
if (id === "internal") {
return at("translations_audience_internal", {}, "Admin/internal");
}
if (id === "user") {
return at("translations_audience_user", {}, "User-visible");
}
return at("translations_audience_all", {}, "All");
}
function audienceHint(id) {
if (id === "internal") {
return at("translations_audience_internal_hint", {}, "Admin panel, logs, and sync copy");
}
return at("translations_audience_user_hint", {}, "Mini App, bot, payment, and support copy");
}
function groupPanelId(sectionId, groupId) {
return `${sectionId}:${groupId}`;
}
function localePanelId(key, lang) {
return `${key}:${lang}`;
}
function toggleLocaleEditor(item, lang) {
const id = localePanelId(item.key, lang);
const defaultOpen = isOverridden(item, lang) || isDirty(item, lang);
const openByUser = openLocaleEditors.includes(id);
const closedByUser = closedLocaleEditors.includes(id);
const currentlyOpen = openByUser || (defaultOpen && !closedByUser);
if (currentlyOpen) {
openLocaleEditors = openLocaleEditors.filter((itemId) => itemId !== id);
if (!closedByUser) closedLocaleEditors = [...closedLocaleEditors, id];
return;
}
closedLocaleEditors = closedLocaleEditors.filter((itemId) => itemId !== id);
if (!openByUser) openLocaleEditors = [...openLocaleEditors, id];
}
function groupDirtyCount(
group,
dirtyState = translationsDirty,
languages = translationLanguages
) {
return (group.items || []).reduce(
(count, item) =>
count +
languages.filter((lang) => Boolean(dirtyState[dirtyKey(lang.code, item.key)])).length,
0
);
}
function groupOverrideCount(
group,
dirtyState = translationsDirty,
languages = translationLanguages
) {
return (group.items || []).reduce(
(count, item) =>
count +
languages.filter((lang) =>
isOverridden(item, lang.code, dirtyState[dirtyKey(lang.code, item.key)])
).length,
0
);
}
function itemHasOverride(item, dirtyState = translationsDirty, languages = translationLanguages) {
return languages.some((lang) =>
isOverridden(item, lang.code, dirtyState[dirtyKey(lang.code, item.key)])
);
}
function itemHasDirty(item, dirtyState = translationsDirty, languages = translationLanguages) {
return languages.some((lang) => Boolean(dirtyState[dirtyKey(lang.code, item.key)]));
}
function filteredGroups(groups, query, languages = translationLanguages) {
const needle = String(query || "")
.trim()
.toLowerCase();
if (!needle) return groups || [];
return (groups || [])
.map((group) => ({
...group,
items: (group.items || []).filter((item) => itemMatches(item, group, needle, languages)),
}))
.filter((group) => group.items.length);
}
function itemMatches(item, group, needle, languages = translationLanguages) {
if (
String(item.key || "")
.toLowerCase()
.includes(needle)
)
return true;
if (audienceLabel(itemAudience(item, group)).toLowerCase().includes(needle)) return true;
return languages.some((lang) => {
const values = valueRecord(item, lang.code);
return [values.base, values.fallback, values.override, values.effective]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(needle));
});
}
function buildAudienceSections(groups, filter) {
return AUDIENCE_ORDER.map((audience) => ({
id: audience,
title: audienceLabel(audience),
hint: audienceHint(audience),
groups: (groups || [])
.map((group) => ({
...group,
audience,
items: (group.items || []).filter((item) => itemAudience(item, group) === audience),
}))
.filter((group) => group.items.length),
})).filter((section) => (filter === "all" || section.id === filter) && section.groups.length);
}
function toggleAllGroups() {
openGroups = allOpen ? [] : visibleGroupKeys;
}
function isGroupOpen(id) {
return openGroups.includes(id);
}
function toggleGroup(id) {
if (isGroupOpen(id)) {
openGroups = openGroups.filter((groupId) => groupId !== id);
return;
}
openGroups = [...openGroups, id];
queueGroupReady(id);
}
function scheduleReadyGroups(groups) {
const openSet = new Set(groups);
const nextReady = readyGroups.filter((id) => openSet.has(id));
if (nextReady.length !== readyGroups.length) readyGroups = nextReady;
for (const [id, timer] of readyTimers.entries()) {
if (!openSet.has(id)) {
clearTimeout(timer);
readyTimers.delete(id);
}
}
for (const id of groups) {
queueGroupReady(id);
}
}
function queueGroupReady(id) {
if (readyGroups.includes(id) || readyTimers.has(id)) return;
readyTimers.set(
id,
setTimeout(() => {
readyTimers.delete(id);
if (!readyGroups.includes(id)) {
readyGroups = [...readyGroups, id];
}
}, 70)
);
}
function groupTitle(group) {
return group.title_key ? at(group.title_key, {}, group.title) : group.title;
}
function groupDescription(group) {
return group.description_key
? at(group.description_key, {}, group.description)
: group.description;
}
function canAddLanguage(code) {
const normalized = String(code || "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
return (
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(normalized) &&
normalized.length >= 2 &&
normalized.length <= 16 &&
!translationLanguages.some((lang) => lang.code === normalized)
);
}
function addLanguage() {
if (translationsStore.addTranslationLanguage(newLanguageCode)) {
newLanguageCode = "";
}
}
</script>
{#snippet renderTranslationsSkeleton()}
<div class="admin-translations-skeleton">
<div class="admin-translations-toolbar">
<span class="admin-skeleton admin-skeleton-line"></span>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
</div>
{#each Array(4) as _, index (index)}
<div class="admin-card admin-translation-skeleton-card">
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"></span>
<span class="admin-skeleton admin-skeleton-line"></span>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-soft"></span>
</div>
{/each}
</div>
{/snippet}
{#snippet renderGroupSkeleton(group)}
<div class="admin-translation-group-skeleton" aria-label={at("loading", {}, "Loading")}>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
{#each Array(Math.min(3, Math.max(1, group.items.length))) as _, index (index)}
<div class="admin-translation-row admin-translation-row-skeleton">
<span>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"></span>
<span class="admin-skeleton admin-skeleton-line"></span>
</span>
<span>
<span class="admin-skeleton admin-skeleton-line"></span>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-soft"></span>
</span>
</div>
{/each}
</div>
{/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))}
<div
class="admin-translation-locale"
class:is-overridden={overridden}
class:is-dirty={dirty}
class:is-expanded={expanded}
>
<button
type="button"
class="admin-translation-locale-toggle"
aria-expanded={expanded}
onclick={() => toggleLocaleEditor(item, lang)}
>
<span class="admin-translation-locale-main">
<strong>{language.label}</strong>
<code>{lang}</code>
</span>
<span class="admin-translation-locale-badges">
{#if !language.base}
<AdminBadge variant="muted">{at("translations_language_custom", {}, "Custom")}</AdminBadge
>
{/if}
{#if overridden}
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
{/if}
{#if dirty}
<AdminBadge variant="warning">{at("settings_badge_dirty", {}, "Dirty")}</AdminBadge>
{/if}
<ChevronRight size={14} class="admin-accordion-chev" />
</span>
<small>{localePreview(item, lang, dirtyEntry)}</small>
</button>
{#if expanded}
<div class="admin-translation-locale-body" transition:slide={{ duration: 130 }}>
<textarea
class="admin-setting-textarea admin-translation-textarea"
rows="3"
spellcheck="false"
placeholder={baseValue(item, lang)}
value={localeValue(item, lang, dirtyEntry)}
oninput={(event) =>
translationsStore.markDirty(lang, item.key, event.currentTarget.value)}
></textarea>
<div class="admin-translation-base">
<small>{baseKind(item, lang)}</small>
<span title={baseValue(item, lang)}>{baseValue(item, lang) || "-"}</span>
</div>
{#if overridden || dirty}
<AdminButton
size="sm"
variant="ghost"
onclick={() => translationsStore.resetField(lang, item.key, overridden)}
>
<X size={12} />
{at("reset", {}, "Reset")}
</AdminButton>
{/if}
</div>
{/if}
</div>
{/snippet}
{#snippet renderTranslationItem(item, group)}
{@const audience = itemAudience(item, group)}
<div class="admin-translation-row">
<div class="admin-setting-meta">
<strong>
{item.key}
<AdminBadge variant={audience === "internal" ? "warning" : "success"}>
{audienceLabel(audience)}
</AdminBadge>
{#if itemHasOverride(item, translationsDirty, translationLanguages)}
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
{/if}
{#if itemHasDirty(item, translationsDirty, translationLanguages)}
<AdminBadge variant="warning">{at("settings_badge_dirty", {}, "Dirty")}</AdminBadge>
{/if}
</strong>
<code>{item.key}</code>
<small>{effectiveValue(item, translationLanguages[0]?.code)}</small>
</div>
<div class="admin-translation-locales">
{#each translationLanguages as language (language.code)}
{@render renderLocaleEditor(item, language)}
{/each}
</div>
</div>
{/snippet}
{#if translationsLoading || !translationGroups.length}
{@render renderTranslationsSkeleton()}
{:else}
<div class="admin-translations-toolbar">
<label class="admin-translations-search">
<Search size={15} />
<input
bind:value={search}
class="input"
type="text"
placeholder={at("translations_search_placeholder", {}, "Search keys and text")}
/>
</label>
<div class="admin-translations-actions">
<AdminButton size="sm" variant="ghost" onclick={toggleAllGroups}>
{allOpen ? at("collapse_all", {}, "Collapse all") : at("expand_all", {}, "Expand all")}
</AdminButton>
{#if Object.keys(translationsDirty).length > 0}
<AdminButton
size="sm"
variant="primary"
onclick={() => translationsStore.saveTranslations(onTranslationsSaved)}
disabled={translationsSaving}
>
{translationsSaving ? at("saving", {}, "Saving...") : at("save", {}, "Save")}
</AdminButton>
{/if}
</div>
</div>
<div class="admin-translations-language-panel">
<div class="admin-translations-language-head">
<Languages size={17} />
<strong>{at("translations_languages_title", {}, "Languages")}</strong>
<small>{at("translations_languages_hint", {}, "Override any locale code")}</small>
</div>
<div class="admin-translations-language-list">
{#each translationLanguages as language (language.code)}
<span class="admin-translations-language-chip" class:is-custom={!language.base}>
<strong>{language.label}</strong>
<code>{language.code}</code>
</span>
{/each}
</div>
<form
class="admin-translations-language-add"
onsubmit={(event) => {
event.preventDefault();
addLanguage();
}}
>
<input
bind:value={newLanguageCode}
class="input"
type="text"
inputmode="latin"
placeholder={at("translations_language_placeholder", {}, "de, uk, pt-BR")}
/>
<AdminButton type="submit" size="sm" disabled={!canAddLanguage(newLanguageCode)}>
<Plus size={14} />
{at("add", {}, "Add")}
</AdminButton>
</form>
</div>
<div class="admin-translations-audience-tabs" role="tablist">
{#each AUDIENCE_FILTERS as option (option)}
<button
type="button"
class:is-active={audienceFilter === option}
onclick={() => {
audienceFilter = option;
openGroups = [];
}}
>
{audienceLabel(option)}
</button>
{/each}
</div>
<p class="admin-muted admin-translations-path">
{at(
"translations_hint",
{ path: translationsPath },
`Overrides are stored in DB and mirrored to ${translationsPath}.`
)}
</p>
{#if audienceSections.length}
<div class="admin-translations-accordion-root">
{#each audienceSections as section (section.id)}
<section class="admin-translations-audience-section">
<div class="admin-translations-audience-head">
<span>
<strong>{section.title}</strong>
<small>{section.hint}</small>
</span>
<AdminBadge variant={section.id === "internal" ? "warning" : "success"}>
{section.groups.reduce((count, group) => count + group.items.length, 0)}
</AdminBadge>
</div>
<div class="admin-accordion">
{#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)}
<div
class="admin-accordion-item admin-card"
data-state={groupOpen ? "open" : "closed"}
>
<div class="admin-accordion-header">
<button
type="button"
class="admin-accordion-trigger"
data-state={groupOpen ? "open" : "closed"}
aria-expanded={groupOpen}
onclick={() => toggleGroup(panelId)}
>
<span class="admin-accordion-title admin-translation-title-line">
{groupTitle(group)}
<AdminBadge variant={section.id === "internal" ? "warning" : "success"}>
{section.title}
</AdminBadge>
</span>
<span class="admin-accordion-meta">
{at(
"translations_keys_count",
{ count: group.items.length },
`${group.items.length} keys`
)}{#if overrideCount}
/ {at(
"settings_overridden_count",
{ count: overrideCount },
`${overrideCount} override`
)}{/if}{#if dirtyCount}
/ {at(
"settings_dirty_count",
{ count: dirtyCount },
`${dirtyCount} changed`
)}
{/if}
</span>
<ChevronRight size={16} class="admin-accordion-chev" />
</button>
</div>
{#if groupOpen}
<div
class="admin-accordion-content"
data-state="open"
transition:slide={{ duration: 140 }}
>
{#if groupReady}
{#if groupDescription(group)}
<p class="admin-muted admin-translation-group-description">
{groupDescription(group)}
</p>
{/if}
<div class="admin-translation-list">
{#each group.items as item (item.key)}
{@render renderTranslationItem(item, group)}
{/each}
</div>
{:else}
{@render renderGroupSkeleton(group)}
{/if}
</div>
{/if}
</div>
{/each}
</div>
</section>
{/each}
</div>
{:else}
<AdminEmptyState>{at("translations_no_matches", {}, "No matching strings")}</AdminEmptyState>
{/if}
{/if}
@@ -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,
};
}
+1
View File
@@ -28,6 +28,7 @@ export {
Home,
Info,
Key,
Languages,
LayoutDashboard,
LifeBuoy,
Lock,
+114 -2
View File
@@ -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";
+10 -12
View File
@@ -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();
}
+72
View File
@@ -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,
+5
View File
@@ -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,
+6
View File
@@ -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,
+387
View File
@@ -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);
+78 -76
View File
@@ -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<code>{install_share_link}</code>",
@@ -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": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\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": "🆔 <b>ID:</b>",
"admin_user_name_label": "👤 <b>Name:</b>",
@@ -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: <b>{name}</b>\nUser ID: <code>{user_id}</code>\nTelegram ID: <code>{telegram_id}</code>\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."
}
+78 -76
View File
@@ -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<code>{install_share_link}</code>",
@@ -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": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
"admin_promo_unlimited_validity": "♾️ Неограниченно",
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
"admin_user_id_label": "🆔 <b>ID:</b>",
"admin_user_name_label": "👤 <b>Имя:</b>",
@@ -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": "Профиль пользователя: <b>{name}</b>\nUser ID: <code>{user_id}</code>\nTelegram ID: <code>{telegram_id}</code>\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": "Админка, логи, статусы синхронизации и сервисные уведомления."
}
+10 -2
View File
@@ -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)
+813
View File
@@ -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<fn>\bat\b|\bt\b|\bgettext\b|\bget_text\b|\btranslator\b|(?<![\w.])_)"""
r"""\(\s*(?:key\s*=\s*)?["'](?P<key>[^"']+)["']"""
)
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 <strong>{minutes}</strong>",
"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") == "Только БД"
+2 -1
View File
@@ -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)
+11
View File
@@ -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)