Создав и поддерживая проект с открытыми исходными текстами хочется сразу решить все возможные проблемы по мультиязычной поддержке как проекта, так и сайта. С поддержкой мультиязычности в различных проектах я сталкиваюсь очень давно, начиная ещё с десктопных программ. Таким образом, имея представление о возможных потребностях, я начал знакомиться с предлагаемыми решениями. Да, практически все SaaS сервисы предлагают бесплатное использование для open-source проектов, но там в основном всё заточено на перевод строковых ресурсов. А как быть с сайтом и документацией? К сожалению, я так и не нашел ничего подходящего и приступил к самостоятельной реализации. Сразу скажу, что результатом доволен и использую систему практически полгода, хотя предупреждаю, что это не массовое законченное решения, а скорее конкретная реализация под мои нужды, но я надеюсь, что некоторые идеи могут быть полезны и другим разработчикам.
Для начала я перечислю требования, которые установил для будущего детища.
Отображение изменений в реальном времени мне было не актуально, и я решил сделать несколько промежуточных таблиц со всей внутренней кухней и затем по команде делать сборку JSON и генерацию страниц самого сайта. На самом деле, достаточно четырех таблиц.
Таблица языков 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 и прочие данные или просто забыть про эту регистрацию.
Я нарисовал схему, чтобы вы лучше представили все связи между таблицами.

Так как мне нужна возможность вставки ресурсов в другие ресурсы, то я добавил макросы вида #идентификатор#. Например, в простейшем случае, если мы имеем ресурс name = «Имя», то мы можем использовать его в ресурсе entername = «Укажите своё #name#», которое при генерации заменится на Укажите свое Имя.
Теперь, для генерации страниц сайта достаточно пройтись по всем языкам и ресурсам с соответствующим типом, обработать каждый текст специальной функцией замены и записать результат в отдельную таблицу с готовыми страницами. Причем обработка происходит таким образом, что если #идентификатор# не найден на текущем языке, то он ищется на других языках. Вот набросок рекурсивной функции (с защитой от зацикливания), которая производит эту обработку.
Кроме замены макросов вида #name#, я также сразу конвертирую MarkDown разметку в HTML и обрабатываю свои собственные директивы. Например, у меня есть таблица картинок, где на одну запись можно навесить скриншоты для разных языков, и если я в тексте указываю тэг [img "/file/#*indexes#"], то у меня подставляется изображение с именем indexes с нужным мне языком. Но самое главное — я могу генерировать выгрузки для различных целей в любом формате. В качестве примера приведу код генерации JSON файлов, там правда, за ненадобностью, не используется функция подстановки идентификаторов.
Таким образом, затратив не так много усилий я реализовал практически всё, что хотел. Нереализованными остались только вещи, которые неактуальны на данный момент из-за низкой активности на сайте. Зато были добавлены дополнительные возможности, которые понадобились в процессе использования. Например, получение текстового файла с ресурсами, которые нуждаются в переводе и обратная загрузка переведенного текста.
Желающие могут взглянуть на рабочую страницу, где пользователи могут переводить, редактировать и создавать новые ресурсы для моего проекта.

Для начала я перечислю требования, которые установил для будущего детища.
- Локализовать нужно как ресурсы для проекта хранящиеся в виде JSON в .js, так и все тексты и документацию на сайте.
- Ресурс может не иметь перевода на другие языки. То есть, я например могу накопить тексты на русском, а потом отдать переводчику, причем в русской версии сайта эти тексты уже будут доступны.
- Должна быть удобная система на сайте для того чтобы пользователь мог перевести не переведенные на его язык ресурсы, создать новый ресурс (текст) или проверить и отредактировать уже существующие тексты на своем родном языке. Выглядеть это должно примерно так — пользователь выбирает действие (перевод, проверка), родной язык (и в случае перевода еще язык оригинала), а также желаемый объем. По этим параметрам ищется ресурс и предлагается пользователю для перевода или редактирования. Естественно, должен вестись лог действий пользователя и накапливаться статистика по выполненным работам.
- На сайте должен быть выбор языков, но на каждой странице должны показываться только те языки, для которых уже есть перевод данной страницы.
- Одна и та же строка может использоваться в нескольких местах. Например, строка используется в .js и в документации. То есть, ресурс должен быть в одном экземпляре и при его изменении, он должен меняться и в JSON и в документации.
- В идеале должна быть какая-то авто-модерируемая система, но пока можно остановится на личном принятии решений по публикации.
Отображение изменений в реальном времени мне было не актуально, и я решил сделать несколько промежуточных таблиц со всей внутренней кухней и затем по команде делать сборку 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 и прочие данные или просто забыть про эту регистрацию.
Я нарисовал схему, чтобы вы лучше представили все связи между таблицами.

Так как мне нужна возможность вставки ресурсов в другие ресурсы, то я добавил макросы вида #идентификатор#. Например, в простейшем случае, если мы имеем ресурс 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 файлов для RU и 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>";
Таким образом, затратив не так много усилий я реализовал практически всё, что хотел. Нереализованными остались только вещи, которые неактуальны на данный момент из-за низкой активности на сайте. Зато были добавлены дополнительные возможности, которые понадобились в процессе использования. Например, получение текстового файла с ресурсами, которые нуждаются в переводе и обратная загрузка переведенного текста.
Желающие могут взглянуть на рабочую страницу, где пользователи могут переводить, редактировать и создавать новые ресурсы для моего проекта.
