<?php namespace EesyPHP; use Locale; class I18n { // Gettext text domain public const TEXT_DOMAIN = 'DEFAULT'; /** * The root directory path of translation files * @var string */ protected static string $root_path = 'locales'; /** * The default locale * @var string */ protected static string $default_locale = 'en_US.UTF8'; /** * Initialize translation system * * Detect best translation language and configure the translation * system. * * @param string|null $root_path The root directory path of translation files * (optional, default: ./locales) * @param string|null $default_locale The default locale * (optional, default: en_US.UTF8) * @return void */ public static function init($root_path=null, $default_locale=null) { global $root_dir_path; if (!is_null($root_path)) self :: $root_path = $root_path; if (!is_null($default_locale)) self :: $default_locale = $default_locale; if (!class_exists('Locale')) { Log :: error('Locale PHP class does not exist. May be php-intl is not installed?'); return; } $available_langs = self :: get_available_langs(); if (php_sapi_name() != "cli") { if (isset($_REQUEST['lang']) && in_array($_REQUEST['lang'], $available_langs)) { $lang = $_REQUEST['lang']; Log :: trace("Select lang from request parameter: '$lang'"); } elseif (isset($_SESSION['lang']) && in_array($_SESSION['lang'], $available_langs) && !isset($_REQUEST['reset_lang'])) { $lang = $_SESSION['lang']; Log :: trace("Restore lang from session: '$lang'"); } else { $lang = Locale::lookup( self :: get_available_langs(), Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']), true, Locale::getPrimaryLanguage(self :: $default_locale) ); Log :: trace("Best lang found is '$lang'"); } } else { $lang = null; $sys_current = getenv('LC_ALL'); if (!$sys_current) $sys_current = getenv('LANG'); if ($sys_current) $lang = Locale::getPrimaryLanguage($sys_current); if (is_null($lang)) { Log :: trace('No configured lang detected from CLI env, use default.'); $lang = Locale::getPrimaryLanguage(self :: $default_locale); } else Log :: trace("Lang detected from CLI env : '$lang'"); } // Keep selected lang in session $_SESSION['lang'] = $lang; $locale = self :: lang2locale($lang); Log :: trace("Matching locale found with language '$lang' is '$locale'"); // Gettext firstly look the LANGUAGE env variable, so set it if (!putenv("LANGUAGE=$locale")) Log :: error("Fail to set LANGUAGE variable in environnement to '$locale'"); // Set the locale if (setlocale(LC_ALL, $locale) === false) Log :: error("An error occured setting locale to '$locale'"); // Configure and set the text domain $fullpath = bindtextdomain(self :: TEXT_DOMAIN, self :: $root_path); Log :: trace("Text domain fullpath is '$fullpath'."); Log :: trace("Text domain is '".textdomain(self :: TEXT_DOMAIN)."'."); Log :: trace("Test: "._('Hello world !')); // JS translation file $js_translation_file = "translations/$lang.js"; if ( php_sapi_name() != "cli" && isset($root_dir_path) && $root_dir_path && is_file("$root_dir_path/public_html/$js_translation_file") && Tpl :: initialized() ) { Tpl :: add_js_file(array("lib/babel.js", "js/translation.js", $js_translation_file)); } if (php_sapi_name() == 'cli') { Cli :: add_command( 'extract_messages', array('\\EesyPHP\\I18n', 'cli_extract_messages'), self :: ___("Extract messages that need to be translated"), null, self :: ___("This command could be used to generate/update lang/messages.pot file.") ); Cli :: add_command( 'update_messages', array('\\EesyPHP\\I18n', 'cli_update_messages'), self :: ___("Update messages in translation PO lang files"), null, self :: ___("This command could be used to init/update PO files in lang/*/LC_MESSAGES directories.") ); Cli :: add_command( 'compile_messages', array('\\EesyPHP\\I18n', 'cli_compile_messages'), self :: ___( "Compile messages from existing translation PO lang files to ". "corresponding MO files and JSON catalogs" ), null, self :: ___( "This command could be used to compile PO files in lang/*/LC_MESSAGES ". "directories to MO files and as JSON catalogs in public_html/translations." ) ); } } /** * List available translation languages * * @param $as_locales boolean If true, locale names will be return instead * of primary languages (optional, default: false) * * @return array Array of available translation languages (or locales) */ public static function get_available_langs($as_locales=false) { if (!is_dir(self :: $root_path)) Log :: fatal("Root land directory not found (%s)", self :: $root_path); $langs = array(($as_locales?'en_US.UTF8':'en')); if ($dh = opendir(self :: $root_path)) { while (($file = readdir($dh)) !== false) { if (!is_dir(self :: $root_path . '/' . $file) || in_array($file, array('.', '..'))) continue; if ($as_locales) { $langs[] = $file; continue; } $lang = Locale::getPrimaryLanguage($file); if (!in_array($lang, $langs)) $langs[] = $lang; } closedir($dh); } $langs = array_unique($langs); Log :: trace('Available '.($as_locales?'locales':'languages').': '.implode(', ', $langs)); return $langs; } /** * Get locale name corresponding to specified translation language * * @param $lang string The translation language * @param $default string|null Default locale name to return if any available translation * locales matched with the specified language * (optional, default: self :: $default_locale) * @return string Corresponding locale */ public static function lang2locale($lang, $default=null) { if (is_null($default)) $default = self :: $default_locale; foreach (self :: get_available_langs(true) as $locale) { if (strpos($locale, $lang) === false) continue; return $locale; } return $default; } /** * Helper function: just mark message for translation * * @param string $msg The message to translate * * @return string The message without transformation */ public static function ___($msg) { return $msg; } /* ******************************************************************** * Translations CLI commands * ******************************************************************** */ /** * Convert PO file to JSON file * * @param string $locale The locale of the input PO file * @param string $path The path of the input PO file * * @return string JSON encoded file content */ public static function po2json($locale, $path) { $fileHandler = new \Sepia\PoParser\SourceHandler\FileSystem($path); $poparser = new \Sepia\PoParser\Parser($fileHandler); $catalog = $poparser->parse(); $headers = $catalog->getHeader(); $messages = array(); foreach ($catalog->getEntries() as $entry) { // msg id json format $msg = $entry->getMsgStr(); if ($entry->isPlural()) $msg = array($msg, $entry->getMsgIdPlural()); $messages[$entry->getMsgId()] = $msg; } return json_encode(array( 'messages' => $messages, 'locale' => $locale, 'domain' => self :: TEXT_DOMAIN, 'plural_expr' => '(n > 1)', )); } /** * Command to extract messages from PHP/JS & template files and * generate the lang/messages.pot file. * * @param array $command_args The command arguments * @return void */ public static function cli_extract_messages($command_args) { global $root_dir_path; // Store list of generated POT files $pot_files = array(); // List PHP files to parse $php_files = run_external_command( array('find', escapeshellarg($root_dir_path), '-name', "'*.php'"), null, // no STDIN data false // do not escape command args (already done) ); if (!is_array($php_files) || $php_files[0] != 0) { Log :: fatal(_("Fail to list PHP files.")); } // Extract messages from PHP files using xgettext $result = run_external_command( array( "xgettext", "--from-code utf-8", "--language=PHP", "-o", self :: $root_path."/php-messages.pot", // Output "--omit-header", // No POT header "--keyword=___", // Handle custom ___() translation function "--files=-" // Read files to parse from STDIN ), $php_files[1] // Pass PHP files list via STDIN ); if (!is_array($result) || $result[0] != 0) Log :: fatal(_("Fail to extract messages from PHP files using xgettext.")); $pot_files[] = self :: $root_path."/php-messages.pot"; // List JS files to parse $js_files = run_external_command( array('find', escapeshellarg("$root_dir_path/public_html/js"), '-name', "'*.js'"), null, // no STDIN data false // do not escape command args (already done) ); if (!is_array($js_files) || $js_files[0] != 0) { Log :: fatal(_("Fail to list JS files.")); } // Extract messages from JS files using xgettext $result = run_external_command( array( "xgettext", "--from-code utf-8", "--language=JavaScript", "-o", self :: $root_path."/js-messages.pot", // Output "--omit-header", // No POT header "--keyword=___", // Handle custom ___() translation function "--files=-" // Read files to parse from STDIN ), $js_files[1] // Pass JS files list via STDIN ); if (!is_array($result) || $result[0] != 0) Log :: fatal(_("Fail to extract messages from JS files using xgettext.")); $pot_files[] = self :: $root_path."/js-messages.pot"; if (Tpl :: initialized()) { // Extract messages from templates files using tsmarty2c.php $result = run_external_command( array ( "$root_dir_path/vendor/bin/tsmarty2c.php", "-o", self :: $root_path."/templates-messages.pot", Tpl :: templates_directory(), ) ); if (!is_array($result) || $result[0] != 0) Log :: fatal( _("Fail to extract messages from template files using tsmarty2c.php script.")); $pot_files[] = self :: $root_path."/templates-messages.pot"; } $fd = fopen(self :: $root_path."/headers.pot", 'w'); $headers = array( 'msgid ""', 'msgstr ""', '"POT-Creation-Date: '.date('Y-m-d H:iO').'\n"', '"PO-Revision-Date: '.date('Y-m-d H:iO').'\n"', '"MIME-Version: 1.0\n"', '"Content-Type: text/plain; charset=utf-8\n"', '"Content-Transfer-Encoding: 8bit\n"', ); fwrite($fd, implode("\n", $headers)); fclose($fd); // Merge previous results in messages.pot file using msgcat $result = run_external_command(array_merge( array( 'msgcat', "-t", "utf-8", "--use-first", "-o", self :: $root_path."/messages.pot", self :: $root_path."/headers.pot", ), $pot_files )); if (!is_array($result) || $result[0] != 0) Log :: fatal(_("Fail to merge messages using msgcat.")); } /** * Command to update messages from lang/messages.pot file to * all PO file in lang/[lang]/LC_MESSAGES. * * @param array $command_args The command arguments * @return bool */ public static function cli_update_messages($command_args) { global $root_dir_path; $compendium_args = array(); foreach ($command_args as $path) { if (!file_exists($path)) Log :: fatal(_("Compendium file %s not found."), $path); $compendium_args[] = '-C'; $compendium_args[] = $path; } $pot_file = self :: $root_path."/messages.pot"; if (!is_file($pot_file)) Log :: fatal(_("POT file not found (%s). Please run extract_messages first."), $pot_file); if ($dh = opendir(self :: $root_path)) { $error = False; while (($file = readdir($dh)) !== false) { if ( !is_dir(self :: $root_path . '/' . $file) || in_array($file, array('.', '..')) || is_link(self :: $root_path . '/' . $file) ) continue; Log :: debug(_("Lang directory '%s' found"), $file); // Check LC_MESSAGES directory exists $lang = $file; $lang_dir = self :: $root_path . '/' . $file . '/LC_MESSAGES' ; if (!is_dir($lang_dir)) { Log :: debug(_("LC_MESSAGES directory not found in lang '%s' directory, ignore it."), $lang); continue; } $po_file = $lang_dir . '/' . self :: TEXT_DOMAIN . '.po'; $created = false; if (!is_file($po_file)) { // Init PO file from POT file using msginit $result = run_external_command( array("msginit", "-i", "$pot_file", "-l", "$lang", "-o", $po_file) ); if (is_array($result) && $result[0] == 0) { $created = true; } else { Log :: error(_("Fail to init messages in %s PO file using msginit (%s)."), $lang, $po_file); $error = True; } } // Update messages in PO file from POT file using msgmerge // Note: msginit does not accept compendium files, so we also run // msgmerge on creation with compendium file(s). if (is_file($po_file) && (!$created || $compendium_args)) { $result = run_external_command( array_merge( array("msgmerge", "-q", "-U"), $compendium_args, array($po_file, $pot_file) ) ); if (!is_array($result) || $result[0] != 0) { Log :: error(_("Fail to update messages in %s PO file using msgmerge (%s)."), $lang, $po_file); $error = True; } } elseif (!$created) { Log :: debug(_("PO file not found in lang '%s' directory, ignore it."), $lang); } } closedir($dh); return !$error; } Log :: fatal(_("Fail to open root lang directory (%s)."), $root_dir_path); return false; } /** * Command to compile messages from existing translation PO lang files * to corresponding MO files and as JSON catalog (for translation in JS). * * @param array $command_args The command arguments * @return bool */ public static function cli_compile_messages($command_args) { global $root_dir_path; if ($dh = opendir(self :: $root_path)) { $error = False; while (($file = readdir($dh)) !== false) { if ( !is_dir(self :: $root_path . '/' . $file) || in_array($file, array('.', '..')) ) continue; if (is_link(self :: $root_path . '/' . $file)) { $real_lang_dir = readlink(self :: $root_path . '/' . $file); if (dirname($real_lang_dir) != '.' || !is_dir(self :: $root_path . '/' . $real_lang_dir)) continue; $lang = $file; Log :: debug(_("Lang alias symlink found: %s -> %s"), $lang, $real_lang_dir); // Create JSON catalog symlink (if not exists) $js_link = "$root_dir_path/public_html/translations/$lang.js"; $link_target = "$real_lang_dir.js"; if (!file_exists($js_link)) { if (symlink($link_target, $js_link)) { Log :: info(_("JSON catalog symlink for %s -> %s created (%s)"), $lang, $real_lang_dir, $js_link); } else { Log :: error(_("Fail to create JSON catalog symlink for %s -> %s (%s)"), $lang, $real_lang_dir, $js_link); $error = True; } } elseif (readlink($js_link) == $link_target) { Log :: debug(_("JSON catalog symlink for %s -> %s already exist (%s)"), $lang, $real_lang_dir, $js_link); } else { Log :: warning( _("JSON catalog file for %s already exist, but it's not a symlink to %s (%s)"), $lang, $real_lang_dir, $js_link ); $error = True; } continue; } Log :: debug(_("Lang directory '%s' found"), $file); // Check LC_MESSAGES directory exists $lang = $file; $lang_dir = self :: $root_path . '/' . $file . '/LC_MESSAGES' ; if (!is_dir($lang_dir)) { Log :: debug(_("LC_MESSAGES directory not found in lang '%s' directory, ignore it."), $lang); continue; } // Test .PO file is present $po_file = $lang_dir . '/' . self :: TEXT_DOMAIN . '.po'; if (!is_file($po_file)) { Log :: debug(_("PO file not found in lang '%s' directory, ignore it."), $lang); continue; } $mo_file = preg_replace('/\.po$/', '.mo', $po_file); // Compile messages from PO file to MO file using msgfmt $result = run_external_command( array("msgfmt", "-o", $mo_file, $po_file) ); if (!is_array($result) || $result[0] != 0) { Log :: error( _("Fail to compile messages from %s PO file as MO file using msgfmt (%s)."), $lang, $po_file ); $error = True; } // Compile messages from PO file to JSON catalog file $json_catalog = self :: po2json($lang, $po_file); $js_file = "$root_dir_path/public_html/translations/$lang.js"; if(!$fd = fopen($js_file, 'w')) { Log :: error(_("Fail to open %s JSON catalog file in write mode (%s)."), $lang, $js_file); $error = True; } elseif (fwrite($fd, sprintf("translations_data = %s;", $json_catalog)) === false) { Log :: error(_("Fail to write %s JSON catalog in file (%s)."), $lang, $js_file); $error = True; } else { Log :: info(_("%s JSON catalog writed (%s)."), $lang, $js_file); } } closedir($dh); return !$error; } Log :: fatal(_("Fail to open root lang directory (%s)."), $root_dir_path); return false; } } # vim: tabstop=2 shiftwidth=2 softtabstop=2 expandtab