diff --git a/libraries/Language.php b/libraries/Language.php new file mode 100644 index 0000000000..c40b5b9bd1 --- /dev/null +++ b/libraries/Language.php @@ -0,0 +1,175 @@ +code = $code; + $this->name = $name; + $this->native = $native; + if (strpos($regex, '[-_]') === false) { + $regex = str_replace('|', '([-_][[:alpha:]]{2,3})?|', $regex); + } + $this->regex = $regex; + } + + /** + * Returns native name for language + * + * @return string + */ + public function getNativeName() + { + return $this->native; + } + + /** + * Returns English name for language + * + * @return string + */ + public function getEnglishName() + { + return $this->name; + } + + /** + * Returns verbose name for language + * + * @return string + */ + public function getName() + { + if (! empty($this->native)) { + return $this->native . ' - ' . $this->name; + } else { + return $this->name; + } + } + + /** + * Returns language code + * + * @return string + */ + public function getCode() + { + return $this->code; + } + + /** + * Checks whether language is currently active. + * + * @return bool + */ + public function isActive() + { + return $GLOBALS['lang'] == $this->code; + } + + /** + * Checks whether language matches HTTP header Accept-Language. + * + * @param string $header Header content + * + * @return bool + */ + public function matchesAcceptLanguage($header) + { + $pattern = '/^(' + . addcslashes($this->regex, '/') + . ')(;q=[0-9]\\.[0-9])?$/i'; + return preg_match($pattern, $header); + } + + /** + * Checks whether language matches HTTP header User-Agent + * + * @param string $header Header content + * + * @return bool + */ + public function matchesUserAgent($header) + { + $pattern = '/(\(|\[|;[[:space:]])(' + . addcslashes($this->regex, '/') + . ')(;|\]|\))/i'; + return preg_match($pattern, $header); + } + + /** + * Checks whether langauge is RTL + * + * @return bool + */ + public function isRTL() + { + return in_array($this->code, array('ar', 'fa', 'he', 'ur')); + } + + /** + * Activates given translation + * + * @return bool + */ + public function activate() + { + $GLOBALS['lang'] = $this->code; + + // Set locale + _setlocale(LC_MESSAGES, $this->code); + _bindtextdomain('phpmyadmin', LOCALE_PATH); + _bind_textdomain_codeset('phpmyadmin', 'UTF-8'); + _textdomain('phpmyadmin'); + + /* Text direction for language */ + if ($this->isRTL()) { + $GLOBALS['text_dir'] = 'rtl'; + } else { + $GLOBALS['text_dir'] = 'ltr'; + } + + /* TCPDF */ + $GLOBALS['l'] = array(); + + /* TCPDF settings */ + $GLOBALS['l']['a_meta_charset'] = 'UTF-8'; + $GLOBALS['l']['a_meta_dir'] = $GLOBALS['text_dir']; + $GLOBALS['l']['a_meta_language'] = $this->code; + + /* TCPDF translations */ + $GLOBALS['l']['w_page'] = __('Page number:'); + + /* Show possible warnings from langauge selection */ + LanguageManager::getInstance()->showWarnings(); + } +} diff --git a/libraries/LanguageManager.php b/libraries/LanguageManager.php new file mode 100644 index 0000000000..7c4b31c5a9 --- /dev/null +++ b/libraries/LanguageManager.php @@ -0,0 +1,687 @@ + array( + 'Afrikaans', + '', + 'af|afrikaans', + ), + 'ar' => array( + 'Arabic', + 'العربية', + 'ar|arabic', + ), + 'az' => array( + 'Azerbaijani', + 'Azərbaycanca', + 'az|azerbaijani', + ), + 'bn' => array( + 'Bangla', + 'বাংলা', + 'bn|bangla', + ), + 'be' => array( + 'Belarusian', + 'Беларуская', + 'be|belarusian', + ), + 'be@latin' => array( + 'Belarusian (latin)', + 'Biełaruskaja', + 'be[-_]lat|be@latin|belarusian latin', + ), + 'bg' => array( + 'Bulgarian', + 'Български', + 'bg|bulgarian', + ), + 'bs' => array( + 'Bosnian', + 'Bosanski', + 'bs|bosnian', + ), + 'br' => array( + 'Breton', + 'Brezhoneg', + 'br|breton', + ), + 'brx' => array( + 'Bodo', + 'बड़ो', + 'brx|bodo', + ), + 'ca' => array( + 'Catalan', + 'Català', + 'ca|catalan', + ), + 'ckb' => array( + 'Sorani', + 'سۆرانی', + 'ckb|sorani', + ), + 'cs' => array( + 'Czech', + 'Čeština', + 'cs|czech', + ), + 'cy' => array( + 'Welsh', + 'Cymraeg', + 'cy|welsh', + ), + 'da' => array( + 'Danish', + 'Dansk', + 'da|danish', + ), + 'de' => array( + 'German', + 'Deutsch', + 'de|german', + ), + 'el' => array( + 'Greek', + 'Ελληνικά', + 'el|greek', + ), + 'en' => array( + 'English', + '', + 'en|english', + ), + 'en_gb' => array( + 'English (United Kingdom)', + '', + 'en[_-]gb|english (United Kingdom)', + ), + 'eo' => array( + 'Esperanto', + 'Esperanto', + 'eo|esperanto', + ), + 'es' => array( + 'Spanish', + 'Español', + 'es|spanish', + ), + 'et' => array( + 'Estonian', + 'Eesti', + 'et|estonian', + ), + 'eu' => array( + 'Basque', + 'Euskara', + 'eu|basque', + ), + 'fa' => array( + 'Persian', + 'فارسی', + 'fa|persian', + ), + 'fi' => array( + 'Finnish', + 'Suomi', + 'fi|finnish', + ), + 'fr' => array( + 'French', + 'Français', + 'fr|french', + ), + 'fy' => array( + 'Frisian', + 'Frysk', + 'fy|frisian', + ), + 'gl' => array( + 'Galician', + 'Galego', + 'gl|galician', + ), + 'gu' => array( + 'Gujarati', + 'ગુજરાતી', + 'gu|gujarati', + ), + 'he' => array( + 'Hebrew', + 'עברית', + 'he|hebrew', + ), + 'hi' => array( + 'Hindi', + 'हिन्दी', + 'hi|hindi', + ), + 'hr' => array( + 'Croatian', + 'Hrvatski', + 'hr|croatian', + ), + 'hu' => array( + 'Hungarian', + 'Magyar', + 'hu|hungarian', + ), + 'hy' => array( + 'Armenian', + 'Հայերէն', + 'hy|armenian', + ), + 'ia' => array( + 'Interlingua', + '', + 'ia|interlingua', + ), + 'id' => array( + 'Indonesian', + 'Bahasa Indonesia', + 'id|indonesian', + ), + 'it' => array( + 'Italian', + 'Italiano', + 'it|italian', + ), + 'ja' => array( + 'Japanese', + '日本語', + 'ja|japanese', + ), + 'ko' => array( + 'Korean', + '한국어', + 'ko|korean', + ), + 'ka' => array( + 'Georgian', + 'ქართული', + 'ka|georgian', + ), + 'kk' => array( + 'Kazakh', + 'Қазақ', + 'kk|kazakh', + ), + 'km' => array( + 'Khmer', + 'ខ្មែរ', + 'km|khmer', + ), + 'kn' => array( + 'Kannada', + 'ಕನ್ನಡ', + 'kn|kannada', + ), + 'ksh' => array( + 'Colognian', + 'Kölsch', + 'ksh|colognian', + ), + 'ky' => array( + 'Kyrgyz', + 'Кыргызча', + 'ky|kyrgyz', + ), + 'li' => array( + 'Limburgish', + 'Lèmbörgs', + 'li|limburgish', + ), + 'lt' => array( + 'Lithuanian', + 'Lietuvių', + 'lt|lithuanian', + ), + 'lv' => array( + 'Latvian', + 'Latviešu', + 'lv|latvian', + ), + 'mk' => array( + 'Macedonian', + 'Macedonian', + 'mk|macedonian', + ), + 'ml' => array( + 'Malayalam', + 'Malayalam', + 'ml|malayalam', + ), + 'mn' => array( + 'Mongolian', + 'Монгол', + 'mn|mongolian', + ), + 'ms' => array( + 'Malay', + 'Bahasa Melayu', + 'ms|malay', + ), + 'ne' => array( + 'Nepali', + 'नेपाली', + 'ne|nepali', + ), + 'nl' => array( + 'Dutch', + 'Nederlands', + 'nl|dutch', + ), + 'nb' => array( + 'Norwegian', + 'Norsk', + 'nb|norwegian', + ), + 'pa' => array( + 'Punjabi', + 'ਪੰਜਾਬੀ', + 'pa|punjabi', + ), + 'pl' => array( + 'Polish', + 'Polski', + 'pl|polish', + ), + 'pt_br' => array( + 'Brazilian Portuguese', + 'Português', + 'pt[-_]br|brazilian portuguese', + ), + 'pt' => array( + 'Portuguese', + 'Português', + 'pt|portuguese', + ), + 'ro' => array( + 'Romanian', + 'Română', + 'ro|romanian', + ), + 'ru' => array( + 'Russian', + 'Русский', + 'ru|russian', + ), + 'si' => array( + 'Sinhala', + 'සිංහල', + 'si|sinhala', + ), + 'sk' => array( + 'Slovak', + 'Slovenčina', + 'sk|slovak', + ), + 'sl' => array( + 'Slovenian', + 'Slovenščina', + 'sl|slovenian', + ), + 'sq' => array( + 'Slbanian', + 'Shqip', + 'sq|albanian', + ), + 'sr@latin' => array( + 'Serbian (latin)', + 'Srpski', + 'sr[-_]lat|sr@latin|serbian latin', + ), + 'sr' => array( + 'Serbian', + 'Српски', + 'sr|serbian', + ), + 'sv' => array( + 'Swedish', + 'Svenska', + 'sv|swedish', + ), + 'ta' => array( + 'Tamil', + 'தமிழ்', + 'ta|tamil', + ), + 'te' => array( + 'Telugu', + 'తెలుగు', + 'te|telugu', + ), + 'th' => array( + 'Thai', + 'ภาษาไทย', + 'th|thai', + ), + 'tk' => array( + 'Turkmen', + 'Türkmençe', + 'tk|turkmen', + ), + 'tr' => array( + 'Turkish', + 'Türkçe', + 'tr|turkish', + ), + 'tt' => array( + 'Tatarish', + 'Tatarça', + 'tt|tatarish', + ), + 'ug' => array( + 'Uyghur', + 'ئۇيغۇرچە', + 'ug|uyghur', + ), + 'uk' => array( + 'Ukrainian', + 'Українська', + 'uk|ukrainian', + ), + 'ur' => array( + 'Urdu', + 'اُردوُ', + 'ur|urdu', + ), + 'uz@latin' => array( + 'Uzbek (latin)', + 'O‘zbekcha', + 'uz[-_]lat|uz@latin|uzbek-latin', + ), + 'uz' => array( + 'Uzbek (cyrillic)', + 'Ўзбекча', + 'uz[-_]cyr|uz@cyrillic|uzbek-cyrillic', + ), + 'vi' => array( + 'Vietnamese', + 'Tiếng Việt', + 'vi|vietnamese', + ), + 'vls' => array( + 'Flemish', + 'West-Vlams', + 'vls|flemish', + ), + 'zh_tw' => array( + 'Chinese traditional', + '中文', + 'zh[-_](tw|hk)|chinese traditional', + ), + // only TW and HK use traditional Chinese while others (CN, SG, MY) + // use simplified Chinese + 'zh_cn' => array( + 'Chinese simplified', + '中文', + 'zh(?![-_](tw|hk))([-_][[:alpha:]]{2,3})?|chinese simplified', + ), + ); + + private $_available_locales; + private $_available_languages; + private $_lang_failed_cfg; + private $_lang_failed_cookie; + private $_lang_failed_request; + private static $instance; + + /** + * Returns LanguageManager singleton + * + * @return LanguageManager + */ + public static function getInstance() + { + if (self::$instance === NULL) { + self::$instance = new LanguageManager; + } + return self::$instance; + } + + /** + * Returns list of available locales + * + * @return array + */ + public function listLocaleDir() + { + $result = array('en'); + + /* Check for existing directory */ + if (!is_dir(LOCALE_PATH)) { + return $result; + } + + /* Open the directory */ + $handle = @opendir(LOCALE_PATH); + /* This can happen if the kit is English-only */ + if ($handle === false) { + return $result; + } + + /* Process all files */ + while (false !== ($file = readdir($handle))) { + $path = LOCALE_PATH + . '/' . $file + . '/LC_MESSAGES/phpmyadmin.mo'; + if ($file != "." + && $file != ".." + && file_exists($path) + ) { + $result[] = $file; + } + } + /* Close the handle */ + closedir($handle); + + return $result; + } + + /** + * Returns (cached) list of all available locales + * + * @return array of strings + */ + public function availableLocales() + { + if (! $this->_available_locales) { + + if (empty($GLOBALS['cfg']['FilterLanguages'])) { + $this->_available_locales = $this->listLocaleDir(); + } else { + $this->_available_locales = preg_grep( + '@' . $GLOBALS['cfg']['FilterLanguages'] . '@', + $this->listLocaleDir() + ); + } + } + return $this->_available_locales; + } + + /** + * Returns (cached) list of all available languages + * + * @return array of Language objects + */ + public function availableLanguages() + { + if (! $this->_available_languages) { + $this->_available_languages = array(); + + foreach($this->availableLocales() as $lang) { + $lang = strtolower($lang); + if (isset($this::$_language_data[$lang])) { + $data = $this::$_language_data[$lang]; + $this->_available_languages[$lang] = new Language( + $lang, + $data[0], + $data[1], + $data[2] + ); + } else { + $this->_available_languages[$lang] = new Language( + $lang, + ucfirst($lang), + ucfirst($lang), + $lang + ); + } + } + } + return $this->_available_languages; + } + + /** + * Returns (cached) list of all available languages sorted + * by name + * + * @return array of Language objects + */ + public function sortedLanguages() + { + $this->availableLanguages(); + uasort($this->_available_languages, function($a, $b) + { + return strcmp($a->name, $b->name); + } + ); + return $this->_available_languages; + } + + /** + * Return Language object for given code + * + * @param string $code Language code + * + * @return object|false Language object or false on failure + */ + public function getLanguage($code) + { + $langs = $this->availableLanguages(); + if (isset($langs[$code])) { + return $langs[$code]; + } + return false; + } + + /** + * Activates language based on configuration, user preferences or + * browser + * + * @return Language + */ + public function selectLanguage() + { + $langs = $this->availableLanguages(); + + // check forced language + if (! empty($GLOBALS['cfg']['Lang'])) { + if (isset($langs[$GLOBALS['cfg']['Lang']])) { + return $langs[$GLOBALS['cfg']['Lang']]; + } + $this->_lang_failed_cfg = true; + } + + // Don't use REQUEST in following code as it might be confused by cookies + // with same name. Check user requested language (POST) + if (! empty($_POST['lang'])) { + if (isset($langs[$_POST['lang']])) { + return $langs[$_POST['lang']]; + } + $this->_lang_failed_request = true; + } + + // check user requested language (GET) + if (! empty($_GET['lang'])) { + if (isset($langs[$_GET['lang']])) { + return $langs[$_GET['lang']]; + } + $this->_lang_failed_request = true; + } + + // check previous set language + if (! empty($_COOKIE['pma_lang'])) { + if (isset($langs[$_COOKIE['pma_lang']])) { + return $langs[$_COOKIE['pma_lang']]; + } + $this->_lang_failed_cookie = true; + } + + // try to find out user's language by checking its HTTP_ACCEPT_LANGUAGE variable; + // prevent XSS + $accepted_languages = PMA_getenv('HTTP_ACCEPT_LANGUAGE'); + if ($accepted_languages && false === mb_strpos($accepted_languages, '<')) { + foreach (explode(',', $accepted_languages) as $header) { + foreach ($langs as $language) { + if ($language->matchesAcceptLanguage($header)) { + return $language; + } + } + } + } + + // try to find out user's language by checking its HTTP_USER_AGENT variable + $user_agent = PMA_getenv('HTTP_USER_AGENT'); + if (! empty($user_agent)) { + foreach ($langs as $language) { + if ($language->matchesUserAgent($user_agent)) { + return $language; + } + } + } + + // Didn't catch any valid lang : we use the default settings + if (isset($langs[$GLOBALS['cfg']['DefaultLang']])) { + return $langs[$GLOBALS['cfg']['DefaultLang']]; + } + + // Fallback to English + return $langs['en']; + } + + /** + * Displays warnings about invalid languages. This needs to be postponed + * to show messages at time when language is initialized. + * + * @return void + */ + public function showWarnings() + { + // now, that we have loaded the language strings we can send the errors + if ($this->_lang_failed_cfg + || $this->_lang_failed_cookie + || $this->_lang_failed_request + ) { + trigger_error( + __('Ignoring unsupported language code.'), + E_USER_ERROR + ); + } + } +} diff --git a/libraries/vendor_config.php b/libraries/vendor_config.php index 70871509b6..54bd4bb205 100644 --- a/libraries/vendor_config.php +++ b/libraries/vendor_config.php @@ -89,6 +89,11 @@ define('PHPSECLIB_INC_DIR', './libraries/phpseclib/'); */ define('SQL_PARSER_AUTOLOAD', './libraries/sql-parser/autoload.php'); +/** + * Path to files with compiled locales (*.mo) + */ +define('LOCALE_PATH', './locale/'); + /** * Avoid referring to nonexistent files (causes warnings when open_basedir * is used) diff --git a/test/classes/LanguageTest.php b/test/classes/LanguageTest.php new file mode 100644 index 0000000000..f86c736c4f --- /dev/null +++ b/test/classes/LanguageTest.php @@ -0,0 +1,217 @@ +markTestSkipped('Missing compiled locales.'); + } + $this->manager = new LanguageManager(); + } + + /** + * Test language filtering + * + * @return void + */ + public function testAvailable() + { + $GLOBALS['cfg']['FilterLanguages'] = 'cs'; + + $langs = $this->manager->availableLocales(); + + $this->assertEquals(1, count($langs)); + $this->assertContains('cs', $langs); + $GLOBALS['cfg']['FilterLanguages'] = ''; + } + + /** + * Test no language filtering + * + * @return void + */ + public function testAllAvailable() + { + $GLOBALS['cfg']['FilterLanguages'] = ''; + + $langs = $this->manager->availableLocales(); + + $this->assertContains('cs', $langs); + $this->assertContains('en', $langs); + } + + /** + * Test whether listing locales works + * + * @return void + */ + public function testList() + { + $langs = $this->manager->listLocaleDir(); + $this->assertContains('cs', $langs); + $this->assertContains('en', $langs); + } + + /** + * Test for getting available languages + * + * @return void + */ + public function testLanguages() + { + $langs = $this->manager->availableLanguages(); + $this->assertGreaterThan(1, count($langs)); + + /* Ensure we have name for every language */ + foreach($langs as $lang) { + $this->assertNotEquals($lang->getCode(), strtolower($lang->getEnglishName())); + } + } + + /** + * Test for getting available sorted languages + * + * @return void + */ + public function testSortedLanguages() + { + $langs = $this->manager->sortedLanguages(); + $this->assertGreaterThan(1, count($langs)); + } + + /** + * Test getting language by code + * + * @return void + */ + public function testGet() + { + $lang = $this->manager->getLanguage('cs'); + $this->assertNotEquals(false, $lang); + $this->assertEquals('Czech', $lang->getEnglishName()); + $this->assertEquals('Čeština', $lang->getNativeName()); + } + + /** + * Test language selection + * + * @param string $lang Value for forced language + * @param string $post Value for language in POST + * @param string $get Value for language in GET + * @param string $cookie Value for language in COOKIE + * @param string $accept Value for HTTP Accept-Language header + * @param string $agent Value for HTTP User-Agent header + * @param string $default Value for default language + * @param string $expect Expected language name + * + * @return void + * + * @dataProvider selectDataProvider + */ + public function testSelect($lang, $post, $get, $cookie, $accept, $agent, $default, $expect) + { + $GLOBALS['cfg']['Lang'] = $lang; + $_POST['lang'] = $post; + $_GET['lang'] = $get; + $_COOKIE['pma_lang'] = $cookie; + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = $accept; + $_SERVER['HTTP_USER_AGENT'] = $agent; + $GLOBALS['cfg']['DefaultLang'] = $default; + + $lang = $this->manager->selectLanguage(); + + $this->assertEquals($expect, $lang->getEnglishName()); + + $GLOBALS['cfg']['Lang'] = ''; + $_POST['lang'] = ''; + $_GET['lang'] = ''; + $_COOKIE['pma_lang'] = ''; + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = ''; + $_SERVER['HTTP_USER_AGENT'] = ''; + $GLOBALS['cfg']['DefaultLang'] = 'en'; + } + + /** + * Data provider for language selection test. + * + * @return array Test parameters. + */ + public function selectDataProvider() + { + return array( + array('cs', 'en', '', '' ,'' ,'', '', 'Czech'), + array('', 'cs', '', '' ,'' ,'', '', 'Czech'), + array('', 'cs', 'en', '' ,'' ,'', '', 'Czech'), + array('', '', 'cs', '' ,'' ,'', '', 'Czech'), + array('', '', '', '' ,'cs,en-US;q=0.7,en;q=0.3' ,'', '', 'Czech'), + array( + '', '', '', '', '', + 'Mozilla/5.0 (Linux; U; Android 2.2.2; tr-tr; GM FOX)', + '', 'Turkish' + ), + array('', '', '', '' ,'' ,'', 'cs', 'Czech'), + array('', '', '', '' ,'' ,'', '', 'English'), + ); + } + + /** + * Test for setting and parsing locales + * + * @param string $locale locale name + * + * @return void + * + * @group large + * @dataProvider listLocales + */ + public function testGettext($locale) + { + /* We should be able to set the language */ + $this->manager->getLanguage($locale)->activate(); + + /* Grab some texts */ + $this->assertContains('%s', _ngettext('%s table', '%s tables', 10)); + $this->assertContains('%s', _ngettext('%s table', '%s tables', 1)); + } + + /** + * Data provider to generate list of available locales. + * + * @return array with arrays of available locales + */ + public function listLocales() + { + $ret = array(); + foreach (LanguageManager::getInstance()->availableLanguages() as $language) { + $ret[] = array($language->getCode()); + } + return $ret; + } +}