Webware and Rich Internet Applications
12 May
W internecie można znaleźć kilka fajnych stron na temat lokalizacji i umiędzynaradawiania aplikacji CakePHP. Sam również się z nich uczyłem, jednak podawane informacje były zwykle niepełne i wymagały poszukiwania informacji w innych źródłach. Ten artykuł jest podsumowaniem moich doświadczeń na temat L10n i I18n i mam szczerą nadzieję, że pozwoli znaleźć wam odpowiedź na wiele pytań związanych z tą tematyką.
L10n to skrót od l +10 liter + n, czyli localization. Lokalizacja oprogramowania, czyli tłumaczenie interface’u na inne języki. Służy do tłumaczenia stałych elementów takich jak: menu, nagłówki, komunikaty, paginacja itp. Jednym słowem, wszystko to, co na sztywno zaszywamy w aplikacji. Pozwala również na dostosowanie programu do konwencji obowiązujących w danym języku/dialekcie - między innymi sposobu sortowania liter w alfabecie, używanych jednostek miary, albo formatów dat.
I18n - I + 18 liter + n - umiędzynarodowienie oprogramowania, czyli wprowadzenie do programu możliwości obsługi wyświetlania komunikatów i dokumentacji w wielu różnych językach. CakePHP pozwala na istnienie wielu wersji językowych danego rekordu w bazie danych jednocześnie.
Wszystkie tłumaczenia trzymane są w plikach *.po. Można je stworzyć ręcznie, wygenerować za pomocą odpowiednich programów (np. POedit), lub przy pomocy skryptu powłoki cake i18n. Wygenerowany plik należy umieścić w katalogu: app/locale/kod_kraju/LC_MESSAGES/, gdzie kod_kraju to trzy literowy symbol państwa zgodny z normą ISO. Dla języka polskiego będzie to np. app/locale/pol/LC_MESSAGES/default.po . Kody znajdziecie po adresem http://translation.cakephp.org/languages.
Mając przygotowany plik .po, możemy zająć się tłumaczeniami. Umieszczamy w nim pary ciągów znaków takich jak np:
msgid “someText”
msgstr “Tłumaczenie someText”
gdzie msgid to ciąg znaków, po którym będziemy szukać tłumaczenia, a msgstr to właśnie jego tłumaczenie.
Dalsza obsługa w CakePHP jest bardzo prosta. Korzystamy z globalnie dostępnej funkcji __( parametr1, [parametr2]), gdzie Parametr1 to właśnie nasz msgid, dla którego będziemy szukać tłumaczenia, a parametr2 mówi nam o tym, czy dane powinny być zwrócone (np. do funkcji), czy od razu wyrzucone na ekran.
Np. w widoku możemy użyć
<?php __("Article") ?>
, aby wyświetlić tłumaczenie słowa Article, lub
<?php $html->link(__('Article',true),'/articles/index); ?>
, aby zwrócić je do funkcji link helpera html.
Jeżeli tłumaczenie nie zostanie znalezione, zwrócony zostanie msgid.
Powyższy przykład jest najprostszym możliwym, ale najczęściej wykorzystywanym.
Cakephp wspiera odmianę rzeczowników w liczbie mnogiej (1 jabłko, 2 jabłka, 5 jabłek).
W celu przetłumaczenia wyrazu na jego poprawną formę w liczbie mnogiej, używamy funkcji __n(). Funkcja ta przyjmuje 3 wymagane parametry: ’forma_liczby_pojedynczej’,’forma_liczb_mnogiej’, ‘liczba_elementow’,[return]. Czwarty parametr określa, czy funkcja ma wyświetlić ciąg znaków czy tylko go zwrócić.
Przykład:
__n('1 apple','%s apples', 4,true)
W pliku .po:
msgid “1 apple”
msgid_plural “%s apples”
msgstr[0] “1 jabłko”
msgstr[1] “%s jabłka”
msgstr[2] “%s jabłek”
W CakePHP dostępnych jest jeszcze kilka innych przydatnych funkcji. Po szczegóły zapraszam do API oraz do manuala gettext.
Korzystanie z I18n jest jeszcze prostsze. W tym celu korzystamy z wbudowanego behavior’a Translate. Pierwsze co należy zrobić to utworzyć w bazie danych tabele i18n - kod sql dostępnych w app/config/sql/i18n.sql.
Po drugie, w modelu w którym chcemy korzystać z dobrodziejstw i18n dodajemy behavior:
var $actsAs = array( 'Translate'=>array('pole1','pole2',...));
Pola podane w tablicy będą tłumaczone i zapisywane w tabeli i18n, dlatego w ogóle nie musimy ich tworzyć dla danego modelu. Osobiście zwykle zostawiam te “zbędne” pola do momentu skorzystania z cake bake, dzięki czemu nie muszę ręcznie dokładać inputów do widoków.
Jeżeli wszystkie pola dla danego modelu, poza id będą oznaczone jako do tłumaczenia - może się okazać, że nowe rekordy nie będą dodawane do bazy danych. Zwykle w takim momencie dodaje do bazy fałszywy atrybut, np pole typu SMALLINT, i przed samym zapisem ustawiam mu wartość na 1. Dzięki temu wymuszam zapis nowego wiersza danych.
Na dzień dzisiejszy jest również problem z pobieraniem tłumaczeń z tabel asocjacyjnych. Wyobraźmy sobie sytuację, w której pobieramy listę artykułów, wraz z nazwami kategorii, gdzie nazwy te również są wielojęzyczne. Niestety, póki co, translate behavior jeszcze sobie z tym nie radzi, ale podobno w stable realese dla 1.2 ma to już działać jak należy.
Problem pojawia się także przy pobieraniu listy elementów, które chcielibyśmy wykorzystać np w DropDownList. Normalnie wystarczy wpisać:
<?php $this->set('categories', $this->Article->Category->find("list"));?>
jednak ten kod zwróci nam tylko tablice z pustymi wartościami. Można to jednak obejść pobierając dane w ten sposób:
$dat = $this->Article->Category->find("all");
$this->set('categories', Set::combine($dat,'{n}.Category.id' ,'{n}.Category.name' ));
Wiemy już jak tłumaczyć tekst i jakie czyhają na nas pułapki. Przydałaby się nam jeszcze informacja na temat tego, jak wybrać język. Ja w tym celu korzystam z app_controller.
W callback’u beforeFilter dodaje:
uses('L10n');
if (!$this->Session->check('Config.language')){
$this->L10n = new L10n();
$this->L10n->get('eng');
$this->{$this->modelClass}->locale = 'eng';
Configure::write('Config.language', 'eng');
$this->Session->write('Config.language', 'eng');
}else{
$this->L10n = new L10n();
$lang = $this->Session->read('Config.language');
$this->L10n->get($lang);
$this->{$this->modelClass}->locale = $lang;
Configure::write('Config.language', $lang);
}
Należy utworzyć obiekt L10n, dla którego ustawiamy za pomocą funkcji get ustawimy nasz trzy literowy kod kraju. Korzystając z $this->{$this->modelClass}->locale = ‘eng’; ustawiamy natomiast język, który ma być wykorzystywany przy pobieraniu danych z tabeli i18n. To w jaki sposób będziecie wybierać język i jak zamierzacie go zapamiętywać zależy już od was. Ja zazwyczaj korzystam z mechanizmu sesji.
Aby ułatwić sobie pracę, trzymam listę języków w bazie danych. Skrypt tworzenia tabeli wygląda następująco:
CREATE TABLE languages ( id int(10) unsigned NOT NULL auto_increment, name varchar(45) , code varchar(45), active smallint(5), PRIMARY KEY (id) );
Wybór języka dokonuję za pomocą akcji kontrolera languages:
function change($lang = null) {
if (strlen($lang) == 3){
$language = $this->Language->find(array('code'=>$lang));
}
if ($language['Language']['code'] == $lang){
$this->Session->write(’Config.language’,$lang);
$this->redirect($this->referer(null, true));
exit();
}else{
$this->redirect($this->referer(null, true));
exit();
}
}
Dla każdego modelu, dla którego używam behaviora translate, w kontrolerze dodaje dla tablicy $uses model Language:
var $uses = array('Article', 'Language');
, a dla akcji ADD, tuż po zapisaniu danych, a jeszcze przed redirect, następujący kod:
if ($this->Article->save($this->data)) {
//******
$langs = $this->Language->findAllByActive(1);
$i=0;
$row_id = $this->{$this->modelClass}->getLastInsertID();
foreach($langs as $lang)
{
if($this->Session->read('Config.language')!=$lang['Language']['code'])
{
$this->{$this->modelClass}->locale = $lang['Language']['code'];
foreach($this->{$this->modelClass}->actsAs['Translate'] as $field)
{
$i++;
$this->{$this->modelClass}->query("INSERT INTO `i18n` (`locale`,`model`,`foreign_key`,`field`,`content`) VALUES (’".$lang['Language']['code']."’,'".$this->modelClass."’,'".$row_id."’,'".$field."’,'".$this->data[$this->modelClass][$field]."’)");
}
}
}
//******
$this->redirect(array(’action’ => ‘index’), null, true);
Dzięki temu, dla każdego języka znajdującego się w tabeli languages, zostaną wygenerowane wiersze tłumaczeń w tabeli i18n, dla danych które właśnie zostały zapisane.
Generalnie nie można użyć funkcji ‘__()’ wprost w tablicy $validate. Jest jednak sposób, żeby to obejść.
Należy zdefiniować ją w konstruktorze danego modelu. Np. w ten oto sposób:
function __construct(){
parent::__construct();
$this->validate = array(
'newpassword' => array( array('rule'=>array('minLength',5), 'message'=>__('Password must contain min. 5 characters',true))
);
}
Pisząc ten artykuł posiłkowałem się danymi z http://wiki.devayd.com/documentation/cakephp/i18n