Files
redl-gamepanel/panel/engine/main/lang.php
T
redl e0b96d3501 Двуязычный интерфейс (RU/EN) с автоопределением + документация на двух языках
Панель:
- engine/main/lang.php: класс Lang, автоопределение языка (?lang= -> cookie -> Accept-Language -> конфиг)
- перевод применяется к готовому ответу через ob_start(): покрывает всю панель, админку и письма,
  не требуя правки 200 файлов шаблонов; отсутствующая фраза остаётся русской
- замена только на границах слов, иначе короткий ключ портил длинные слова (Модуль -> Modуль)
- AJAX-ответы переводятся отдельно (json_encode экранирует кириллицу в \uXXXX)
- application/lang/en.php: 657 переводов; ru.php как точка расширения
- переключатель RU/EN в шапке кабинета, админки и в подвале страницы входа
- 'lang' в config.php — язык по умолчанию
- проверено обходом 20 разделов: 0 непереведённых фраз, 0 мешанины языков, 0 фаталов

Документация — теперь на русском и английском:
- README.en.md, CHANGES.en.md, SECURITY.en.md, GUIDE.en.md
- переключатели языка в начале каждого документа
- раздел «Язык интерфейса» в инструкции: как работает, как добавить свой язык
2026-07-30 02:25:34 +00:00

260 lines
9.4 KiB
PHP

<?php
/*
Многоязычность панели. Добавлено в сборке REDL.
Поддерживается и развивается с помощью REDL.IO — Хостинг с искусственным интеллектом.
Как работает
------------
Панель изначально написана с русским текстом прямо в шаблонах (около 1500 строк
в 200 файлах). Переписывать их все на вызовы t() — большой риск что-нибудь сломать,
поэтому перевод делается на уровне ГОТОВОГО ОТВЕТА: index.php оборачивает вывод в
ob_start(), и перед отправкой браузеру строки заменяются по словарю.
Плюсы такого подхода:
* работает сразу во всей панели, включая админку и письма;
* ничего не может «исчезнуть» — если фразы нет в словаре, остаётся русский текст;
* добавить язык = положить один файл в application/lang/.
Порядок определения языка:
1. ?lang=en — явный выбор пользователя, запоминается в cookie на год
2. cookie lang — прошлый выбор
3. Accept-Language браузера — автоопределение
4. 'lang' из application/config.php, иначе русский
*/
class Lang {
/** Языки, для которых есть словарь. */
const AVAILABLE = array('ru', 'en');
/** Языки, для которых уместнее оставить русский интерфейс. */
private static $ruFamily = array('ru','uk','be','kk','ky','uz','az','hy','ka','tg','tk','mo','md');
private $code = 'ru';
private $map = array();
private $patterns = null;
private static $instance = null;
public function __construct($registry = null) {
$this->code = $this->detect($registry);
// Русский — исходный язык шаблонов, словарь ему не нужен.
if ($this->code !== 'ru') {
$file = APPLICATION_DIR . 'lang/' . $this->code . '.php';
if (is_readable($file)) {
$map = include($file);
if (is_array($map)) {
// пустые переводы отбрасываем, иначе текст пропал бы со страницы
foreach ($map as $from => $to) {
if (is_string($from) && is_string($to) && $from !== '' && $to !== '') {
$this->map[$from] = $to;
}
}
}
}
}
self::$instance = $this;
}
public static function instance() {
return self::$instance;
}
public function code() {
return $this->code;
}
public function available() {
return self::AVAILABLE;
}
/** Человеческое имя языка для переключателя. */
public function name($code) {
$names = array('ru' => 'Русский', 'en' => 'English');
return isset($names[$code]) ? $names[$code] : strtoupper($code);
}
/** Адрес текущей страницы с другим языком — для ссылок переключателя. */
public function switchUrl($code) {
$uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
$parts = explode('#', $uri, 2);
$uri = $parts[0];
$parts = explode('?', $uri, 2);
$path = $parts[0];
$query = array();
if (isset($parts[1]) && $parts[1] !== '') {
parse_str($parts[1], $query);
}
$query['lang'] = $code;
return $path . '?' . http_build_query($query);
}
private function detect($registry) {
// 1. явный выбор
if (isset($_GET['lang']) && in_array($_GET['lang'], self::AVAILABLE, true)) {
$code = $_GET['lang'];
if (!headers_sent()) {
setcookie('lang', $code, time() + 31536000, '/');
}
$_COOKIE['lang'] = $code;
return $code;
}
// 2. прошлый выбор
if (isset($_COOKIE['lang']) && in_array($_COOKIE['lang'], self::AVAILABLE, true)) {
return $_COOKIE['lang'];
}
// 3. автоопределение по браузеру
if (!empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
foreach (self::parseAccept($_SERVER['HTTP_ACCEPT_LANGUAGE']) as $tag) {
$short = strtolower(substr($tag, 0, 2));
if (in_array($short, self::$ruFamily, true)) {
return 'ru';
}
if (in_array($short, self::AVAILABLE, true)) {
return $short;
}
}
// браузер просит язык, которого у нас нет и который не русскоязычный
return 'en';
}
// 4. значение по умолчанию из конфигурации
if ($registry && isset($registry->config)) {
$default = $registry->config->lang;
if ($default && in_array($default, self::AVAILABLE, true)) {
return $default;
}
}
return 'ru';
}
/** Разбор Accept-Language в список тегов по убыванию веса q. */
private static function parseAccept($header) {
$tags = array();
foreach (explode(',', $header) as $chunk) {
$chunk = trim($chunk);
if ($chunk === '') { continue; }
$q = 1.0;
if (strpos($chunk, ';') !== false) {
list($chunk, $params) = explode(';', $chunk, 2);
if (preg_match('~q\s*=\s*([0-9.]+)~i', $params, $m)) {
$q = (float)$m[1];
}
$chunk = trim($chunk);
}
if ($chunk !== '' && $chunk !== '*') {
$tags[] = array($chunk, $q);
}
}
usort($tags, function($a, $b) {
if ($a[1] == $b[1]) { return 0; }
return ($a[1] < $b[1]) ? 1 : -1;
});
$out = array();
foreach ($tags as $t) { $out[] = $t[0]; }
return $out;
}
/** Перевод одной строки. Нет в словаре — возвращаем как есть. */
public function get($text) {
return isset($this->map[$text]) ? $this->map[$text] : $text;
}
/**
* Коллбэк для ob_start: переводит весь ответ перед отправкой.
* Ответы AJAX обрабатываются отдельно: json_encode экранирует кириллицу
* в \uXXXX, и словарь по сырому UTF-8 туда бы не попал.
*/
public function apply($buffer) {
if ($this->code === 'ru' || !$this->map || !is_string($buffer) || $buffer === '') {
return $buffer;
}
$head = ltrim($buffer);
if ($head !== '' && ($head[0] === '{' || $head[0] === '[')) {
$data = json_decode($head, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
$json = json_encode($this->walk($data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json !== false) {
return $json;
}
}
}
return $this->replace($buffer);
}
private function walk($value) {
if (is_array($value)) {
$out = array();
foreach ($value as $k => $v) { $out[$k] = $this->walk($v); }
return $out;
}
return is_string($value) ? $this->replace($value) : $value;
}
/**
* Замена по словарю ТОЛЬКО на границах слов.
*
* Простой strtr() здесь не годится: короткий ключ подменяется внутри более
* длинного слова и получается каша из двух языков — 'Мод' превращал
* «Модуль» в «Modуль», а 'день' делал из «деньги» «dayги». Поэтому вокруг
* каждого ключа требуем отсутствие буквы. Цифра слева разрешена намеренно:
* иначе «0руб.» осталось бы без перевода.
*
* Ключи отсортированы от длинных к коротким, чтобы «Сменить пароль»
* срабатывало раньше, чем «Пароль».
*/
private function replace($text) {
if ($this->patterns === null) {
$this->buildPatterns();
}
foreach ($this->patterns as $pattern) {
$out = preg_replace_callback($pattern, array($this, 'hit'), $text);
if ($out !== null) { $text = $out; }
}
return $text;
}
private function hit($m) {
return isset($this->map[$m[1]]) ? $this->map[$m[1]] : $m[1];
}
private function buildPatterns() {
$keys = array_keys($this->map);
// длинные первыми: иначе короткий ключ перехватит часть длинной фразы
usort($keys, function($a, $b) {
$la = mb_strlen($a, 'UTF-8'); $lb = mb_strlen($b, 'UTF-8');
if ($la === $lb) { return strcmp($a, $b); }
return ($la < $lb) ? 1 : -1;
});
$this->patterns = array();
// режем на группы, чтобы не упереться в предел размера шаблона PCRE
foreach (array_chunk($keys, 150) as $chunk) {
$quoted = array();
foreach ($chunk as $k) { $quoted[] = preg_quote($k, '~'); }
$this->patterns[] = '~(?<!\p{L})(' . implode('|', $quoted) . ')(?!\p{L})~u';
}
}
}
/**
* Перевод строки в шаблонах и контроллерах: t('Сохранить').
* Поддерживает подстановку как sprintf: t('Осталось %d дней', $n).
*/
function t($text) {
$lang = Lang::instance();
$out = $lang ? $lang->get($text) : $text;
if (func_num_args() > 1) {
$args = func_get_args();
$args[0] = $out;
$formatted = @call_user_func_array('sprintf', $args);
if (is_string($formatted) && $formatted !== '') {
return $formatted;
}
}
return $out;
}