Панель: - 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 - переключатели языка в начале каждого документа - раздел «Язык интерфейса» в инструкции: как работает, как добавить свой язык
260 lines
9.4 KiB
PHP
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;
|
|
}
|