Як я реалізував багатомовність на сайті і в проекті

Створивши і підтримуючи проект з відкритими вихідними текстами хочеться відразу вирішити всі можливі проблеми з мультимовної підтримки як проекту, так і сайту. З підтримкою багатомовності в різних проектах я стикаюся дуже давно, починаючи ще з десктопних програм. Таким чином, маючи уявлення про можливі потреби, я почав знайомитися з пропонованими рішеннями. Так, практично всі SaaS сервіси пропонують безкоштовне використання для open-source проектів, але там в основному все заточене на переклад строкових ресурсів. А як бути з сайтом і документацією? На жаль, я так і не знайшов нічого підходящого і приступив до самостійної реалізації. Відразу скажу, що результатом задоволений і використовую систему практично півроку, хоча попереджаю, що це не масове закінчене рішення, а скоріше конкретна реалізація під мої потреби, але я сподіваюся, що деякі ідеї можуть бути корисні та іншим розробникам.

Для початку я перерахую вимоги, які встановив для майбутнього дітища.

  1. Локалізувати потрібно як ресурси для проекту зберігаються у вигляді JSON в .js, так і всі тексти і документацію на сайті.
  2. Ресурс може не мати перекладу на інші мови. Тобто, я наприклад можу накопичити тексти російською, а потім віддати перекладачеві, причому в російській версії сайту ці тексти вже будуть доступні.
  3. Повинна бути зручна система на сайті для того, щоб користувач міг перевести не перекладені на його мову ресурси, створити новий ресурс (текст) або перевірити і відредагувати вже існуючі тексти рідною мовою. Виглядати це має приблизно так — користувач вибирає дію (переклад, перевірка), рідна мова (і в разі переведення ще мова оригіналу), а також бажаний обсяг. За цими параметрами шукається ресурс і користувачеві пропонується для перекладу й редагування. Природно, повинен вестися лог дій користувача і накопичуватися статистика по виконаним роботам.
  4. На сайті повинен бути вибір мов, але на кожній сторінці повинні показуватися тільки ті мови, для яких вже є переклад даної сторінки.
  5. Одна і та ж рядок може використовуватися в декількох місцях. Наприклад, рядок використовується .js і в документації. Тобто, ресурс повинен бути в одному екземплярі і при його зміні, він повинен мінятися і в JSON і в документації.
  6. В ідеалі повинна бути якась авто-модерируемая система, але поки можна зупиниться на особистому прийнятті рішень щодо публікації.
Відображення змін в реальному часі мені було не актуально, і я вирішив зробити кілька проміжних таблиць з усією внутрішньою кухнею і потім по команді робити збірку JSON і генерацію сторінок самого сайту. Насправді, достатньо чотирьох таблиць.
Структура таблиць
CREATE TABLE IF NOT EXISTS `languages` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`_uptime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`_owner` smallint(5) unsigned NOT NULL,
`name` varchar(32) NOT NULL,
`native` varchar(32) NOT NULL,
`iso639` varchar(2) NOT NULL,
PRIMARY KEY (`id`),
KEY `_uptime` (`_uptime`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 ;

CREATE TABLE IF NOT EXISTS `langid` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`_uptime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`_owner` smallint(5) unsigned NOT NULL,
`name` varchar(96) NOT NULL,
`comment` text NOT NULL,
`restype` tinyint(3) unsigned NOT NULL,
`attrib` tinyint(3) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `_uptime` (`_uptime`),
KEY `name` (`name`),
KEY `restype` (`restype`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 ;

CREATE TABLE IF NOT EXISTS `langlog` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`_uptime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`_owner` smallint(5) unsigned NOT NULL,
`iduser` int(10) unsigned NOT NULL,
`idlangres` int(10) unsigned NOT NULL,
`action` tinyint(3) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `_uptime` (`_uptime`),
KEY `iduser` (`iduser`,`idlangres`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 ;

CREATE TABLE IF NOT EXISTS `langres` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`_uptime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`_owner` smallint(5) unsigned NOT NULL,
`langid` smallint(5) unsigned NOT NULL,
`lang` tinyint(3) unsigned NOT NULL,
`text` text NOT NULL,
`prev` mediumint(9) unsigned NOT NULL,
`verified` tinyint(3) NOT NULL,
`size` mediumint(9) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `_uptime` (`_uptime`),
KEY `langid` (`langid`,`lang`),
KEY `size` (`size`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 ;

Таблиця мов languages з трьома полями name, native, iso639. Приклад запису: Russian, Російська, ru

Таблиця текстових ідентифікаторів ресурсів langid, де можна вказати ще коментар і тип. Я розділив для себе всі ресурси на кілька типів: JSON рядок, сторінка сайту, простий текст, текст у форматі MarkDown. Ви можете звичайно використовувати свої власні типи.
Приклад: сancelbtn, Text for Cancel button, JSON

Таблиця текстових ресурсів langres ( langid, language, text, prev). Зберігаємо посилання на ідентифікатор, мову і сам текст.
Останнє поле prev забезпечує версії тексту при поправках і вказує на попередню версію ресурсу.

Всі зміни фіксуються в лог-таблиці langlog ( iduser, idlangres, action ). Поле action буде вказувати на досконале дію — створення, редагування, перевірка.

Я не буду зупинятися на роботі з користувачами, скажу лише, що користувач реєструється автоматично при відправленні переказу або виправлення. Так як email не обов'язковий, то користувачеві відразу повідомляється логін і пароль. Всі зроблені зміни будуть прив'язані до аккаунту. Надалі він може вказати свій email та інші дані або просто забути про цю реєстрацію.

Я намалював схему, щоб ви краще уявили всі зв'язки між таблицями.
image

Так як мені потрібна можливість вставки ресурсів в інші ресурси, то я додав макроси виду #ідентифікатор#. Наприклад, у найпростішому випадку, якщо ми маємо ресурс name = «Ім'я», то ми можемо використовувати його в ресурсі entername = «Вкажіть своє #name#», яке при генерації заміниться на Укажіть своє Ім'я.
Тепер, для генерації сторінок сайту досить пройтися по всім мовам і ресурсів з відповідним типом, обробити кожен текст спеціальною функцією заміни і записати результат в окрему таблицю з готовими сторінками. Причому обробка відбувається таким чином, що якщо #ідентифікатор# не знайдено на поточному мовою, то він шукається на інших мовах. Ось начерк рекурсивної функції (з захистом від зациклення), яка виробляє цю обробку.
Приклад PHP функції підстановки
public function proceed( $input, $recurse = false )
{
global $db, $syslang;

if ( !$recurse )
$this->chain = array();
$result = ";
$off = 0;
$start = 0;
$len = strlen( $input );
while ( ($off = strpos( $input, '#', $off )) !== false && $off < $len - 2 )
{
$end = strpos( $input, '#', $off + 2 );
if ( $end === false )
break;
if ( $end - $off > $this->lenlimit )
{
$off = $end - 1;
continue;
}
$name = substr( $input, $off + 1, $end - $off - 1 );
$langid = $db->getone("select id from langid where name=?s", $name );
if ( $langid && !in_array( $langid, $this->chain ))
{
$langres = $db->getrow("select _uptime, id text from langres where langid=?s && verified>0
order by if( lang=?s, 0, 1 ),lang", $langid, $this->lang );
if ( $langres )
{
if ( $langres['_uptime'] > $this->time )
$this->time = $langres['_uptime'];
$result .= substr( $input, $start, $off - $start );
$off = $end + 1;
$start = $off;
array_push( $this->chain, $langid );
$result .= $this->proceed( $langres['text'], true );
array_pop( $this->chain );
if ( $off >= $len - 2 )
break;
continue;
}
}
$off = $end - 1;
}
if ( $start < $len )
$result .= substr( $input, $start );

return $result;
}


Крім заміни макросів виду #name#, я також відразу конвертую MarkDown розмітки HTML і обробляю свої власні директиви. Наприклад, у мене є таблиця картинок, де на одну запис можна навісити скріншоти для різних мов, і якщо я в тексті вказую тег [img "/file/#*indexes#"], то у мене підставляється зображення з ім'ям indexes з потрібним мені мовою. Але найголовніше — я можу генерувати вивантаження для різних цілей у будь-якому форматі. В якості прикладу наведу код генерації JSON файлів, там правда, за непотрібністю, не використовується функція підстановки ідентифікаторів.
Генерація JSON файлів для UA EN
function jsonerror( $message )
{
print $message;
exit();
}

function save_json( $filename )
{
global $db, $original;

preg_match("/^\w*_(?<lang>\w*)\.js$/", $filename, $matches );
if ( empty( $matches['lang'] ))
jsonerror( 'No locale' );
$lang = $db->getrow("select * from languages where iso639=?s", $matches['lang'] );
if ( !$lang )
jsonerror( 'Unknown locale '.$matches['lang'] );

$list = $db->getall("select lng.name, r.text from langid as lng
left join langres as r on r.langid = lng.id
where lng.restype=5 && verified>0 && r.lang=?s
order by lng.name", $lang['id'] );
$out = array();
foreach ( $list as $il )
$out[ $il['name']] = $il['text'];
if ( $lang['id'] == 1 )
$original = $out;
else
foreach ( $original as $ik => $io )
if ( !isset( $out[ $ik ] ))
$out[ $ik ] = $io;
$output = "/* This file is automatically generated on eonza.org.
Use http://www.eonza.org/translate.html to edit or translate these text resources.
*/

var lng = {
\tcode: '$lang[iso639]',
\tnative: '$lang[native]',
";
foreach ( $out as $ok => $ov )
{
if ( strpos( $ov, "'" ) === false )
$text = "'$ov'";
elseif (strpos( $ov, '"' ) === false )
$text = "\"$ov\"";
else
jsonerror( 'Wrong text:'.$text );
$output .= "\t$ok: $text,\r\n";
}
$output .= "\r\n};\r\n";
$jsfile = dirname(__FILE__)."/i18n/$lang[iso639].js";
if ( file_exists( $jsfile ))
$output .= file_get_contents( $jsfile );
if (file_put_contents( HOME."tmp/$filename", $output ))
print "Save: ".HOME."tmp/$filename<br>";
else
jsonerror( 'Save error:'.HOME."tmp/$filename" );
}

$original = array();
$files = array( 'en', 'ru');

foreach ( $files as $if )
save_json( "locale_$if.js" );

$zip = new ZipArchive();
print $zip->open( HOME."tmp/locale.zip", ZipArchive::CREATE );
foreach ( $files as $f )
print $zip->addFile( HOME."tmp/locale_$f.js", "locale_$f.js" );
print $zip->close();
print "Finish<br><a href='/tmp/locale.zip'>ZIP file</a>";


Таким чином, витративши не так багато зусиль я реалізував практично все, що хотів. Нереалізованими залишилися тільки речі, які не актуальні на даний момент з-за низької активності на сайті. Зате були додані додаткові можливості, які знадобилися в процесі використання. Наприклад, отримання текстового файлу з ресурсами, які потребують переведення і зворотний завантаження перекладеного тексту.
Бажаючі можуть поглянути на робочу сторінку, де користувачі можуть перекладати, редагувати і створювати нові ресурси для мого проекту.

image

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.