feat: add runtime locale overrides
This commit is contained in:
@@ -16,6 +16,7 @@ frontend/node_modules/
|
||||
deploy/compose/docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
|
||||
# CI
|
||||
|
||||
@@ -63,3 +63,4 @@ locales/en_backup.json
|
||||
db/models_old.py
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {}}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,13 +247,17 @@ 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()
|
||||
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"🇬🇧 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}",
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"ru": {
|
||||
"menu_personal_account_button": "Личный кабинет"
|
||||
},
|
||||
"en": {
|
||||
"menu_personal_account_button": "Account"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. Оба переключателя включены по умолчанию.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+38
-4
@@ -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) => ({
|
||||
$: 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: LANGUAGE_LABELS[code] || code.toUpperCase(),
|
||||
flag: LANGUAGE_FLAGS[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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
Home,
|
||||
Info,
|
||||
Key,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
LifeBuoy,
|
||||
Lock,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "Админка, логи, статусы синхронизации и сервисные уведомления."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") == "Только БД"
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user