diff --git a/application/clicommands/TranslationCommand.php b/application/clicommands/TranslationCommand.php deleted file mode 100644 index be56df543..000000000 --- a/application/clicommands/TranslationCommand.php +++ /dev/null @@ -1,43 +0,0 @@ -translator = new TranslationHelper( - $this->application, - $this->params->get('locale', 'C'), - $this->params->get('module', 'monitoring') // bullshit. NULL? - ); - } - - /** - * Refresh translation catalogs - * - * Extracts all translatable strings for a given module (or core) from the - * Icingaweb source code, adds those to the existing catalog for the given - * locale and marks obsolete translations. - * - * Usage: icingaweb translation refresh --module --locale - */ - public function refreshAction() - { - $this->translator->createTemporaryFileList()->extractTexts(); - } -} diff --git a/application/forms/Config/GeneralForm.php b/application/forms/Config/GeneralForm.php index 376e0dbc0..d6eb5db91 100644 --- a/application/forms/Config/GeneralForm.php +++ b/application/forms/Config/GeneralForm.php @@ -37,6 +37,7 @@ use \Zend_View_Helper_DateFormat; use \Icinga\Application\Config as IcingaConfig; use \Icinga\Data\ResourceFactory; use \Icinga\Web\Form; +use \Icinga\Util\Translator; use \Icinga\Web\Form\Validator\WritablePathValidator; use \Icinga\Web\Form\Validator\TimeFormatValidator; use \Icinga\Web\Form\Validator\DateFormatValidator; @@ -161,6 +162,35 @@ class GeneralForm extends Form } + /** + * Add a select field for setting the default language + * + * Possible values are determined by Translator::getAvailableLocaleCodes. + * + * @param Zend_Config $cfg The "global" section of the config.ini + */ + private function addLanguageSelection(Zend_Config $cfg) + { + $languages = array(); + foreach (Translator::getAvailableLocaleCodes() as $language) { + $languages[$language] = $language; + } + $languages[Translator::DEFAULT_LOCALE] = Translator::DEFAULT_LOCALE; + + $this->addElement( + 'select', + 'language', + array( + 'label' => t('Default Language'), + 'required' => true, + 'multiOptions' => $languages, + 'helptext' => t('Select the language to use by default. Can be' + . ' overwritten by a user in his preferences.'), + 'value' => $cfg->get('language', Translator::DEFAULT_LOCALE) + ) + ); + } + /** * Add a select field for setting the default timezone. * @@ -353,6 +383,7 @@ class GeneralForm extends Form } $this->setName('form_config_general'); $this->addDevelopmentCheckbox($global); + $this->addLanguageSelection($global); $this->addTimezoneSelection($global); $this->addModuleSettings($global); $this->addDateFormatSettings($global); @@ -389,6 +420,7 @@ class GeneralForm extends Form $values = $this->getValues(); $cfg = clone $config; $cfg->global->environment = ($values['environment'] == 1) ? 'development' : 'production'; + $cfg->global->language = $values['language']; $cfg->global->timezone = $values['timezone']; $cfg->global->moduleFolder = $values['module_folder']; $cfg->global->modulePath = $values['module_path']; diff --git a/application/forms/Preference/GeneralForm.php b/application/forms/Preference/GeneralForm.php index f91a5f504..5b671e3b2 100644 --- a/application/forms/Preference/GeneralForm.php +++ b/application/forms/Preference/GeneralForm.php @@ -37,6 +37,7 @@ use \Zend_View_Helper_DateFormat; use \Icinga\Web\Form; use \Icinga\Web\Form\Validator\TimeFormatValidator; use \Icinga\Web\Form\Validator\DateFormatValidator; +use \Icinga\Util\Translator; /** * General user preferences @@ -73,13 +74,54 @@ class GeneralForm extends Form $this->dateHelper = $dateHelper; } + /** + * Add a select field for setting the user's language + * + * Possible values are determined by Translator::getAvailableLocaleCodes. + * Also, a 'use default format' checkbox is added in order to allow a user to discard his overwritten setting + * + * @param Zend_Config $cfg The "global" section of the config.ini to be used as default value + */ + private function addLanguageSelection(Zend_Config $cfg) + { + $languages = array(); + foreach (Translator::getAvailableLocaleCodes() as $language) { + $languages[$language] = $language; + } + $languages[Translator::DEFAULT_LOCALE] = Translator::DEFAULT_LOCALE; + $prefs = $this->getUserPreferences(); + $useDefaultLanguage = $this->getRequest()->getParam('default_language', !$prefs->has('app.language')); + + $this->addElement( + 'checkbox', + 'default_language', + array( + 'label' => t('Use Default Language'), + 'value' => !$prefs->has('app.language'), + 'required' => true + ) + ); + $selectOptions = array( + 'label' => t('Your Current Language'), + 'required' => !$useDefaultLanguage, + 'multiOptions' => $languages, + 'helptext' => t('Use the following language to display texts and messages'), + 'value' => $prefs->get('app.language', $cfg->get('language', Translator::DEFAULT_LOCALE)) + ); + if ($useDefaultLanguage) { + $selectOptions['disabled'] = 'disabled'; + } + $this->addElement('select', 'language', $selectOptions); + $this->enableAutoSubmit(array('default_language')); + } + /** * Add a select field for setting the user's timezone. * * Possible values are determined by DateTimeZone::listIdentifiers * Also, a 'use default format' checkbox is added in order to allow a user to discard his overwritten setting * - * @param Zend_Config $cfg The "global" section of the config.ini to be used as default valuse + * @param Zend_Config $cfg The "global" section of the config.ini to be used as default value */ private function addTimezoneSelection(Zend_Config $cfg) { @@ -210,6 +252,7 @@ class GeneralForm extends Form $global = new Zend_Config(array()); } + $this->addLanguageSelection($global); $this->addTimezoneSelection($global); $this->addDateFormatSettings($global); @@ -234,6 +277,7 @@ class GeneralForm extends Form { $values = $this->getValues(); return array( + 'app.language' => $values['language'], 'app.timezone' => $values['timezone'], 'app.dateFormat' => $values['date_format'], 'app.timeFormat' => $values['time_format'], diff --git a/bin/refresh-translations.php b/bin/refresh-translations.php deleted file mode 100755 index 5519dc573..000000000 --- a/bin/refresh-translations.php +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/php - - * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2 - * @author Icinga Development Team - * - */ -// {{{ICINGA_LICENSE_HEADER}}} - -require_once dirname(__FILE__) . '/../library/Icinga/Application/Cli.php'; - -use Icinga\Application\Cli; -use Icinga\Application\TranslationHelper; - -$bootstrap = Cli::start(); - -if (count($argv) < 2) { - die(sprintf( - "Usage: ./%s lc_LC [module]\n", - basename($argv[0]) - )); -} - -$locale = $argv[1]; -if (array_key_exists(2, $argv)) { - $module = $argv[2]; -} else { - $module = null; -} - -$translation = new TranslationHelper($bootstrap, $locale, $module); -$translation->createTemporaryFileList() - ->extractTexts(); - - diff --git a/library/Icinga/Application/ApplicationBootstrap.php b/library/Icinga/Application/ApplicationBootstrap.php index c404cd222..67eb61630 100755 --- a/library/Icinga/Application/ApplicationBootstrap.php +++ b/library/Icinga/Application/ApplicationBootstrap.php @@ -31,13 +31,11 @@ namespace Icinga\Application; use \DateTimeZone; use \Exception; -use \Zend_Loader_Autoloader; use \Icinga\Application\Modules\Manager as ModuleManager; -use \Icinga\Application\Platform; use \Icinga\Application\Config; -use \Icinga\Exception\ProgrammingError; use \Icinga\Exception\ConfigurationError; use \Icinga\Util\DateTimeFactory; +use \Icinga\Util\Translator; use Icinga\Data\ResourceFactory; @@ -427,4 +425,23 @@ abstract class ApplicationBootstrap DateTimeFactory::setConfig(array('timezone' => $tz)); return $this; } + + /** + * Setup internationalization using gettext + * + * Uses the language defined in the global config or the default one + * + * @return self + */ + protected function setupInternationalization() + { + Translator::setupLocale($this->config->global->get('language', Translator::DEFAULT_LOCALE)); + + $localeDir = $this->getApplicationDir('locale'); + if (file_exists($localeDir) && is_dir($localeDir)) { + Translator::registerDomain('icinga', $localeDir); + } + + return $this; + } } diff --git a/library/Icinga/Application/Cli.php b/library/Icinga/Application/Cli.php index d73980675..6c8f64afa 100644 --- a/library/Icinga/Application/Cli.php +++ b/library/Icinga/Application/Cli.php @@ -31,7 +31,6 @@ namespace Icinga\Application; use Icinga\Application\Platform; use Icinga\Application\ApplicationBootstrap; -use Icinga\Application\Modules\Manager as ModuleManager; use Icinga\Cli\Params; use Icinga\Cli\Loader; use Icinga\Cli\Screen; @@ -63,12 +62,12 @@ class Cli extends ApplicationBootstrap { $this->assertRunningOnCli(); $this->setupConfig() - ->parseBasicParams() - ->fixLoggingConfig() - ->setupErrorHandling() - ->setupResourceFactory() - ->setupModuleManager() - ; + ->setupInternationalization() + ->parseBasicParams() + ->fixLoggingConfig() + ->setupErrorHandling() + ->setupResourceFactory() + ->setupModuleManager(); } protected function fixLoggingConfig() diff --git a/library/Icinga/Application/Modules/Module.php b/library/Icinga/Application/Modules/Module.php index b7a2ccd33..d1c435d52 100644 --- a/library/Icinga/Application/Modules/Module.php +++ b/library/Icinga/Application/Modules/Module.php @@ -35,6 +35,7 @@ use Icinga\Application\ApplicationBootstrap; use Icinga\Application\Config; use Icinga\Application\Icinga; use Icinga\Application\Logger; +use Icinga\Util\Translator; use Icinga\Web\Hook; /** @@ -422,7 +423,7 @@ class Module protected function registerLocales() { if (file_exists($this->localedir) && is_dir($this->localedir)) { - bindtextdomain($this->name, $this->localedir); + Translator::registerDomain($this->name, $this->localedir); } return $this; } diff --git a/library/Icinga/Application/TranslationHelper.php b/library/Icinga/Application/TranslationHelper.php deleted file mode 100644 index c7fe82dde..000000000 --- a/library/Icinga/Application/TranslationHelper.php +++ /dev/null @@ -1,174 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2 - * @author Icinga Development Team - * - */ -// {{{ICINGA_LICENSE_HEADER}}} - -namespace Icinga\Application; - -class TranslationHelper -{ - protected $basedir; - protected $moduledir; - protected $tmpfile; - protected $potfile; - protected $locale; - protected $module; - - public function __construct(ApplicationBootstrap $bootstrap, $locale, $module = null) - { - $this->moduledir = $bootstrap->getModuleDir(); - if ($module) { - $this->basedir = $bootstrap->getModuleDir($module) . '/application'; - } else { - $this->basedir = $bootstrap->getApplicationDir(); - } - $this->locale = $locale; - $this->module = $module; - $this->targetfile = $this->basedir - . '/locale/' - . $this->locale - . '/LC_MESSAGES/' - . ($module ? $module : 'icinga') - . '.po'; - $target_dir = dirname($this->targetfile); - if (! is_dir($target_dir)) { - mkdir($target_dir, 0755, true); - } - } - - public function __destruct() - { - if ($this->tmpfile !== null) { - unlink($this->tmpfile); - } - if ($this->potfile !== null) { - unlink($this->potfile); - } - } - - public function extractTexts() - { - $tmpdir = sys_get_temp_dir(); - $this->potfile = tempnam($tmpdir, 'IcingaPot_'); - $cmd = '/usr/bin/xgettext' - . ' --language=PHP' - . ' --from-code=iso-8859-15' - . ' --keyword=' - . ($this->module ? '_mt:2' : '_t') - . ' --sort-output' - . ' --force-po' - . ' --package-name=Icinga' - . ' --package-version=0.1' - . ' --copyright-holder="Icinga Team"' - . ' --msgid-bugs-address="dev@icinga.org"' - . ' --files-from=' . $this->tmpfile - . ' --output=' . $this->potfile - ; - `$cmd`; - $this->fixPotfile(); - $this->mergeOldTranslations(); - return $this; - } - - protected function fixPotfile() - { - $content = file_get_contents($this->potfile); - $fh = fopen($this->potfile, 'w'); - foreach (preg_split('~\n~', $content) as $line) { - // if (preg_match('~^"Language:~', $line)) continue; - if (preg_match('~^"Content-Type:~', $line)) { - $line = '"Content-Type: text/plain; charset=utf-8\n"'; - } - fwrite($fh, $line . "\n"); - } - fclose($fh); - } - - protected function mergeOldTranslations() - { - if (is_file($this->targetfile)) { - $cmd = sprintf( - '/usr/bin/msgmerge %s %s -o %s 2>&1', - $this->targetfile, - $this->potfile, - $this->targetfile . '.new' - ); - `$cmd`; - rename($this->targetfile . '.new', $this->targetfile); - } else { - file_put_contents($this->targetfile, file_get_contents($this->potfile)); - } - } - - public function createTemporaryFileList() - { - $tmpdir = sys_get_temp_dir(); - $this->tmpfile = tempnam($tmpdir, 'IcingaTranslation_'); - $tmp_fh = fopen($this->tmpfile, 'w'); - if (! $tmp_fh) { - throw new \Exception('Unable to create ' . $this->tmpfile); - } - if ($this->module) { - $blacklist = array(); - } else { - $blacklist = array( - $this->moduledir - ); - } - $this->getSourceFileNames($this->basedir, $tmp_fh, $blacklist); - $this->getSourceFileNames(ICINGA_LIBDIR, $tmp_fh, $blacklist); - fclose($tmp_fh); - return $this; - } - - protected function getSourceFileNames($dir, & $fh, $blacklist = array()) - { - $dh = opendir($dir); - if (! $dh) { - throw new \Exception("Unable to read files from $dir"); - } - $subdirs = array(); - while ($filename = readdir($dh)) { - if ($filename[0] === '.') { - continue; - } - $fullname = $dir . '/' . $filename; - if (preg_match('~\.(?:php|phtml)$~', $filename)) { - fwrite($fh, "$fullname\n"); - } elseif (is_dir($fullname)) { - if (in_array($fullname, $blacklist)) { - continue; - } - $subdirs[] = $fullname; - } - } - closedir($dh); - foreach ($subdirs as $dir) { - $this->getSourceFileNames($dir, $fh, $blacklist); - } - } -} diff --git a/library/Icinga/Application/Web.php b/library/Icinga/Application/Web.php index ad4c58d3c..0714fee64 100644 --- a/library/Icinga/Application/Web.php +++ b/library/Icinga/Application/Web.php @@ -50,6 +50,7 @@ use Icinga\User\Preferences\SessionStore; use Icinga\Util\DateTimeFactory; use Icinga\Session\Session as BaseSession; use Icinga\Web\Session; +use Icinga\Util\Translator; /** * Use this if you want to make use of Icinga functionality in other web projects @@ -116,10 +117,10 @@ class Web extends ApplicationBootstrap ->setupResourceFactory() ->setupSession() ->setupUser() + ->setupInternationalization() ->setupTimezone() ->setupRequest() ->setupZendMvc() - ->setupTranslation() ->setupModuleManager() ->loadEnabledModules() ->setupRoute() @@ -168,25 +169,6 @@ class Web extends ApplicationBootstrap return $this->viewRenderer; } - /** - * Load translations - * - * @return self - */ - private function setupTranslation() - { - // AuthManager::getInstance()->getSession()->language; - $locale = null; - if (!$locale) { - $locale = 'en_US'; - } - putenv('LC_ALL=' . $locale . '.UTF-8'); - setlocale(LC_ALL, $locale . '.UTF-8'); - bindtextdomain('icinga', $this->getApplicationDir() . '/locale'); - textdomain('icinga'); - return $this; - } - /** * Dispatch public interface */ @@ -449,4 +431,30 @@ class Web extends ApplicationBootstrap DateTimeFactory::setConfig(array('timezone' => $tz)); return $this; } + + /** + * Setup internationalization using gettext + * + * Uses the preferred user language or the configured default and system default, respectively. + * + * @return self + */ + protected function setupInternationalization() + { + parent::setupInternationalization(); + $userLocale = $this->user === null ? null : $this->user->getPreferences()->get('app.language'); + + if ($userLocale) { + try { + Translator::setupLocale($userLocale); + } catch (Exception $error) { + Logger::error( + 'Cannot set locale "' . $userLocale . '" configured in ' . + 'preferences of user "' . $this->user->getUsername() . '"' + ); + } + } + + return $this; + } } diff --git a/library/Icinga/Application/functions.php b/library/Icinga/Application/functions.php index 77b401746..b600028d6 100755 --- a/library/Icinga/Application/functions.php +++ b/library/Icinga/Application/functions.php @@ -27,31 +27,25 @@ */ // {{{ICINGA_LICENSE_HEADER}}} -if (function_exists('_')) { - function t($messageId = null) +use \Icinga\Util\Translator; + +if (extension_loaded('gettext')) { + function t($messageId) { - $msg = _($messageId); - if (! $msg) { - return $messageId; - } - return $msg; + return Translator::translate($messageId, 'icinga'); } - function mt($domain, $messageId = null) + function mt($domain, $messageId) { - $msg = dgettext($domain, $messageId); - if (! $msg) { - return $messageId; - } - return $msg; + return Translator::translate($messageId, $domain); } } else { - function t($messageId = null) + function t($messageId) { return $messageId; } - function mt($domain, $messageId = null) + function mt($domain, $messageId) { return $messageId; } diff --git a/library/Icinga/Cli/Command.php b/library/Icinga/Cli/Command.php index 6e68e6aad..6a977760e 100644 --- a/library/Icinga/Cli/Command.php +++ b/library/Icinga/Cli/Command.php @@ -2,8 +2,8 @@ namespace Icinga\Cli; -use Icinga\Cli\Loader; use Icinga\Cli\Screen; +use Icinga\Util\Translator; use Icinga\Application\ApplicationBootstrap as App; use Exception; @@ -48,6 +48,21 @@ abstract class Command return $this->trace; } + /** + * Translate a string + * + * Autoselects the module domain, if any, and falls back to the global one if no translation could be found. + * + * @param string $text The string to translate + * + * @return string The translated string + */ + public function translate($text) + { + $domain = $this->moduleName === null ? 'icinga' : $this->moduleName; + return Translator::translate($text, $domain); + } + public function fail($msg) { throw new Exception($msg); diff --git a/library/Icinga/Util/Translator.php b/library/Icinga/Util/Translator.php new file mode 100644 index 000000000..7c2264241 --- /dev/null +++ b/library/Icinga/Util/Translator.php @@ -0,0 +1,134 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2 + * @author Icinga Development Team + * + */ +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Util; + +use \Exception; + +/** + * Helper class to ease internationalization when using gettext + */ +class Translator +{ + /** + * The default gettext domain used as fallback + */ + const DEFAULT_DOMAIN = 'icinga'; + + /** + * The locale code that is used in the project + */ + const DEFAULT_LOCALE = 'en_US'; + + /** + * Known gettext domains and directories + * + * @var array + */ + private static $knownDomains = array(); + + /** + * Translate a string + * + * Falls back to the default domain in case the string cannot be translated using the given domain + * + * @param string $text The string to translate + * @param string $domain The primary domain to use + * + * @return string The translated string + * + * @throws Exception In case the given domain is unknown + */ + public static function translate($text, $domain) + { + if ($domain !== self::DEFAULT_DOMAIN && !array_key_exists($domain, self::$knownDomains)) { + throw new Exception("Cannot translate string '$text' with unknown domain '$domain'"); + } + + $res = dgettext($domain, $text); + if ($res === $text && $domain !== self::DEFAULT_DOMAIN) { + return dgettext(self::DEFAULT_DOMAIN, $text); + } + return $res; + } + + /** + * Register a new gettext domain + * + * @param string $name The name of the domain to register + * @param string $directory The directory where message catalogs can be found + * + * @throws Exception In case the domain was not successfully registered + */ + public static function registerDomain($name, $directory) + { + if (bindtextdomain($name, $directory) === false) { + throw new Exception("Cannot register domain '$name' with path '$directory'"); + } + bind_textdomain_codeset($name, 'UTF-8'); + self::$knownDomains[$name] = $directory; + } + + /** + * Set the locale to use + * + * @param string $localeName The name of the locale to use + * + * @throws Exception In case the locale's name is invalid + */ + public static function setupLocale($localeName) + { + if (setlocale(LC_ALL, $localeName . '.UTF-8') === false) { + throw new Exception("Cannot set locale '$localeName.UTF-8' for category 'LC_ALL'"); + } + putenv('LC_ALL=' . $localeName . '.UTF-8'); // Failsafe, Win and Unix + putenv('LANG=' . $localeName . '.UTF-8'); // Windows fix, untested + } + + /** + * Return a list of all locale codes currently available in the known domains + * + * @return array + */ + public static function getAvailableLocaleCodes() + { + $codes = array(); + + foreach (array_values(self::$knownDomains) as $directory) { + $dh = opendir($directory); + while (false !== ($name = readdir($dh))) { + if (!preg_match('@\.|\.\.@', $name) && is_dir($directory . DIRECTORY_SEPARATOR . $name)) { + $codes[] = $name; + } + } + } + + return $codes; + } +} diff --git a/library/Icinga/Web/Controller/ActionController.php b/library/Icinga/Web/Controller/ActionController.php index 1e95e36b1..2cd581604 100755 --- a/library/Icinga/Web/Controller/ActionController.php +++ b/library/Icinga/Web/Controller/ActionController.php @@ -32,17 +32,13 @@ namespace Icinga\Web\Controller; use \Exception; use \Zend_Controller_Action; use \Zend_Controller_Request_Abstract; -use \Zend_Controller_Front; use \Zend_Controller_Response_Abstract; use \Zend_Controller_Action_HelperBroker; -use \Zend_Layout; use Icinga\Authentication\Manager as AuthManager; use Icinga\Application\Benchmark; -use Icinga\Application\Config; -use Icinga\Web\Notification; +use Icinga\Util\Translator; use Icinga\Web\Widget\Tabs; use Icinga\Web\Url; -use Icinga\Web\Request; /** * Base class for all core action controllers @@ -152,15 +148,19 @@ class ActionController extends Zend_Controller_Action } /** - * Translate the given string with the global translation catalog + * Translate a string * - * @param string $string The string that should be translated + * Autoselects the module domain, if any, and falls back to the global one if no translation could be found. * - * @return string + * @param string $text The string to translate + * + * @return string The translated string */ - public function translate($string) + public function translate($text) { - return t($string); + $module = $this->getRequest()->getModuleName(); + $domain = $module === 'default' ? 'icinga' : $module; + return Translator::translate($text, $domain); } /** @@ -212,7 +212,6 @@ class ActionController extends Zend_Controller_Action $this->_helper->Redirector->gotoUrlAndExit($url); } - /** * Detect whether the current request requires changes in the layout and apply them before rendering * diff --git a/modules/translation/application/clicommands/CompileCommand.php b/modules/translation/application/clicommands/CompileCommand.php new file mode 100644 index 000000000..59b2580ea --- /dev/null +++ b/modules/translation/application/clicommands/CompileCommand.php @@ -0,0 +1,93 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2 + * @author Icinga Development Team + * + */ +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Translation\Clicommands; + +use Icinga\Module\Translation\Cli\TranslationCommand; +use Icinga\Module\Translation\Util\GettextTranslationHelper; + +/** + * Translation compiler + * + * This command will compile the PO-file of a domain. The actions below allow + * you to select a particular domain for which the PO-file should be compiled. + * + * Domains are the global one 'icinga' and all available and enabled modules + * identified by their name. + * + * Once a PO-file is compiled it's content is used by Icinga Web 2 to display + * messages in the configured language. + */ +class CompileCommand extends TranslationCommand +{ + /** + * Compile the global domain + * + * This will compile the PO-file of the global 'icinga' domain. + * + * USAGE: + * + * icingaweb translation compile icinga + * + * EXAMPLES: + * + * icingaweb translation compile icinga de_DE + * icingaweb translation compile icinga fr_FR + */ + public function icingaAction() + { + $locale = $this->validateLocaleCode($this->params->shift()); + + $helper = new GettextTranslationHelper($this->app, $locale); + $helper->compileIcingaTranslation(); + } + + /** + * Compile a module domain + * + * This will compile the PO-file of the given module domain. + * + * USAGE: + * + * icingaweb translation compile + * + * EXAMPLES: + * + * icingaweb translation compile monitoring de_DE + * icingaweb trnslations compile monitoring de_DE + */ + public function moduleAction() + { + $module = $this->validateModuleName($this->params->shift()); + $locale = $this->validateLocaleCode($this->params->shift()); + + $helper = new GettextTranslationHelper($this->app, $locale); + $helper->compileModuleTranslation($module); + } +} diff --git a/modules/translation/application/clicommands/RefreshCommand.php b/modules/translation/application/clicommands/RefreshCommand.php new file mode 100644 index 000000000..54a8bacce --- /dev/null +++ b/modules/translation/application/clicommands/RefreshCommand.php @@ -0,0 +1,93 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2 + * @author Icinga Development Team + * + */ +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Translation\Clicommands; + +use Icinga\Module\Translation\Cli\TranslationCommand; +use Icinga\Module\Translation\Util\GettextTranslationHelper; + +/** + * Translation updater + * + * This command will create a new or update any existing PO-file of a domain. The + * actions below allow to select a particular domain for whom to touch the PO-file. + * + * Domains are the global one 'icinga' and all available and enabled modules + * identified by their name. + * + * Once a PO-file has been created/updated one can open it with a editor for + * PO-files and start with the actual translation. + */ +class RefreshCommand extends TranslationCommand +{ + /** + * Touch the global domain + * + * This will create/update the PO-file of the global 'icinga' domain. + * + * USAGE: + * + * icingaweb translation refresh icinga + * + * EXAMPLES: + * + * icingaweb translation refresh icinga de_DE + * icingaweb translation refresh icinga fr_FR + */ + public function icingaAction() + { + $locale = $this->validateLocaleCode($this->params->shift()); + + $helper = new GettextTranslationHelper($this->app, $locale); + $helper->updateIcingaTranslations(); + } + + /** + * Touch a module domain + * + * This will create/update the PO-file of the given module domain. + * + * USAGE: + * + * icingaweb translation refresh module + * + * EXAMPLES: + * + * icingaweb translation refresh module monitoring de_DE + * icingaweb translation refresh module monitoring fr_FR + */ + public function moduleAction() + { + $module = $this->validateModuleName($this->params->shift()); + $locale = $this->validateLocaleCode($this->params->shift()); + + $helper = new GettextTranslationHelper($this->app, $locale); + $helper->updateModuleTranslations($module); + } +} diff --git a/modules/translation/library/Translation/Cli/TranslationCommand.php b/modules/translation/library/Translation/Cli/TranslationCommand.php new file mode 100644 index 000000000..08223cbbb --- /dev/null +++ b/modules/translation/library/Translation/Cli/TranslationCommand.php @@ -0,0 +1,81 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2 + * @author Icinga Development Team + * + */ +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Translation\Cli; + +use \Exception; +use Icinga\Cli\Command; + +/** + * Base class for translation commands + */ +class TranslationCommand extends Command +{ + /** + * Check whether the given locale code is valid + * + * @param string $code The locale code to validate + * + * @return string The validated locale code + * + * @throws Exception In case the locale code is invalid + */ + public function validateLocaleCode($code) + { + $current = setlocale(LC_ALL, '0'); + $result = setlocale(LC_ALL, $code); + setlocale(LC_ALL, $current); + + if ($result === false) { + throw new Exception("Locale code '$code' is not valid"); + } + + return $code; + } + + /** + * Check whether the given module is available and enabled + * + * @param string $name The module name to validate + * + * @return string The validated module name + * + * @throws Exception In case the given module is not available or not enabled + */ + public function validateModuleName($name) + { + $enabledModules = $this->app->getModuleManager()->listEnabledModules(); + + if (!in_array($name, $enabledModules)) { + throw new Exception("Module with name '$name' not found or is not enabled"); + } + + return $name; + } +} diff --git a/modules/translation/library/Translation/Util/GettextTranslationHelper.php b/modules/translation/library/Translation/Util/GettextTranslationHelper.php new file mode 100644 index 000000000..3c9ca5b15 --- /dev/null +++ b/modules/translation/library/Translation/Util/GettextTranslationHelper.php @@ -0,0 +1,422 @@ + + * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2 + * @author Icinga Development Team + * + */ +// {{{ICINGA_LICENSE_HEADER}}} + +namespace Icinga\Module\Translation\Util; + +use \Exception; +use Icinga\Application\Modules\Manager; +use Icinga\Application\ApplicationBootstrap; + +/** + * This class provides some useful utility functions to handle gettext translations + */ +class GettextTranslationHelper +{ + /** + * All project files are supposed to have the same/this encoding + */ + const FILE_ENCODING = 'UTF-8'; + + /** + * The source files to parse + * + * @var array + */ + private $sourceExtensions = array( + 'php', + 'phtml' + ); + + /** + * The module manager of the application's bootstrap + * + * @var Manager + */ + private $moduleMgr; + + /** + * The current version of IcingaWeb2 + * + * @var string + */ + private $version; + + /** + * The locale used by this helper + * + * @var string + */ + private $locale; + + /** + * The path to the Zend application root + * + * @var string + */ + private $appDir; + + /** + * The path to the module, if any + * + * @var string + */ + private $moduleDir; + + /** + * The path to the file catalog + * + * @var string + */ + private $catalogPath; + + /** + * The path to the *.pot file + * + * @var string + */ + private $templatePath; + + /** + * The path to the *.po file + * + * @var string + */ + private $tablePath; + + /** + * Create a new TranslationHelper object + * + * @param ApplicationBootstrap $bootstrap The application's bootstrap object + * @param string $locale The locale to be used by this helper + */ + public function __construct(ApplicationBootstrap $bootstrap, $locale) + { + $this->version = $bootstrap->getConfig()->app()->global->get('version', '0.1'); + $this->moduleMgr = $bootstrap->getModuleManager(); + $this->appDir = $bootstrap->getApplicationDir(); + $this->locale = $locale; + } + + /** + * Update the translation table for the main application + */ + public function updateIcingaTranslations() + { + $this->catalogPath = tempnam(sys_get_temp_dir(), 'IcingaTranslation_'); + $this->templatePath = tempnam(sys_get_temp_dir(), 'IcingaPot_'); + + $this->moduleDir = null; + $this->tablePath = implode( + DIRECTORY_SEPARATOR, + array( + $this->appDir, + 'locale', + $this->locale, + 'LC_MESSAGES', + 'icinga.po' + ) + ); + + $this->createFileCatalog(); + $this->createTemplateFile(); + $this->updateTranslationTable(); + } + + /** + * Update the translation table for a particular module + * + * @param string $module The name of the module for which to update the translation table + */ + public function updateModuleTranslations($module) + { + $this->catalogPath = tempnam(sys_get_temp_dir(), 'IcingaTranslation_'); + $this->templatePath = tempnam(sys_get_temp_dir(), 'IcingaPot_'); + + $this->moduleDir = $this->moduleMgr->getModuleDir($module); + $this->tablePath = implode( + DIRECTORY_SEPARATOR, + array( + $this->moduleDir, + 'application', + 'locale', + $this->locale, + 'LC_MESSAGES', + $module . '.po' + ) + ); + + $this->createFileCatalog(); + $this->createTemplateFile(); + $this->updateTranslationTable(); + } + + /** + * Compile the translation table for the main application + */ + public function compileIcingaTranslation() + { + $this->tablePath = implode( + DIRECTORY_SEPARATOR, + array( + $this->appDir, + 'locale', + $this->locale, + 'LC_MESSAGES', + 'icinga.po' + ) + ); + + $this->compileTranslationTable(); + } + + /** + * Compile the translation table for a particular module + * + * @param string $module The name of the module for which to compile the translation table + */ + public function compileModuleTranslation($module) + { + $this->moduleDir = $this->moduleMgr->getModuleDir($module); + $this->tablePath = implode( + DIRECTORY_SEPARATOR, + array( + $this->moduleDir, + 'application', + 'locale', + $this->locale, + 'LC_MESSAGES', + $module . '.po' + ) + ); + + $this->compileTranslationTable(); + } + + /** + * Update any existing or create a new translation table using the gettext tools + * + * @throws Exception In case the translation table does not yet exist and cannot be created + */ + private function updateTranslationTable() + { + if (is_file($this->tablePath)) { + shell_exec(sprintf('/usr/bin/msgmerge --update %s %s 2>&1', $this->tablePath, $this->templatePath)); + } else { + if ((!is_dir(dirname($this->tablePath)) && !@mkdir(dirname($this->tablePath), 0755, true)) || + !rename($this->templatePath, $this->tablePath)) { + throw new Exception('Unable to create ' . $this->tablePath); + } + } + $this->updateHeader($this->tablePath); + } + + /** + * Create the template file using the gettext tools + */ + private function createTemplateFile() + { + shell_exec( + implode( + ' ', + array( + '/usr/bin/xgettext', + '--language=PHP', + '--keyword=translate', + '--keyword=mt:2', + '--keyword=t', + '--sort-output', + '--force-po', + '--omit-header', + '--from-code=' . self::FILE_ENCODING, + '--files-from="' . $this->catalogPath . '"', + '--output="' . $this->templatePath . '"' + ) + ) + ); + } + + /** + * Create or update a gettext conformant header in the given file + * + * @param string $path The path to the file + */ + private function updateHeader($path) + { + $headerInfo = array( + 'title' => 'Icinga Web 2 - Head for multiple monitoring backends', + 'copyright_holder' => 'Icinga Development Team', + 'copyright_year' => date('Y'), + 'author_name' => 'FIRST AUTHOR', + 'author_mail' => 'EMAIL@ADDRESS', + 'author_year' => 'YEAR', + 'project_name' => 'Icinga Web 2', + 'project_version' => $this->version, + 'project_bug_mail' => 'dev@icinga.org', + 'pot_creation_date' => date('Y-m-d H:iO'), + 'po_revision_date' => 'YEAR-MO-DA HO:MI+ZONE', + 'translator_name' => 'FULL NAME', + 'translator_mail' => 'EMAIL@ADDRESS', + 'language_team_name' => 'LANGUAGE', + 'language_team_url' => 'LL@li.org', + 'charset' => self::FILE_ENCODING + ); + + $content = file_get_contents($path); + if (strpos($content, '# ') === 0) { + $authorInfo = array(); + if (preg_match('@# (.+) <(.+)>, (\d+|YEAR)\.@', $content, $authorInfo)) { + $headerInfo['author_name'] = $authorInfo[1]; + $headerInfo['author_mail'] = $authorInfo[2]; + $headerInfo['author_year'] = $authorInfo[3]; + } + $revisionInfo = array(); + if (preg_match('@Revision-Date: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}\+\d{4})@', $content, $revisionInfo)) { + $headerInfo['po_revision_date'] = $revisionInfo[1]; + } + $translatorInfo = array(); + if (preg_match('@Last-Translator: (.+) <(.+)>@', $content, $translatorInfo)) { + $headerInfo['translator_name'] = $translatorInfo[1]; + $headerInfo['translator_mail'] = $translatorInfo[2]; + } + $languageInfo = array(); + if (preg_match('@Language-Team: (.+) <(.+)>@', $content, $languageInfo)) { + $headerInfo['language_team_name'] = $languageInfo[1]; + $headerInfo['language_team_url'] = $languageInfo[2]; + } + } + + file_put_contents( + $path, + implode( + PHP_EOL, + array( + '# ' . $headerInfo['title'] . '.', + '# Copyright (C) ' . $headerInfo['copyright_year'] . ' ' . $headerInfo['copyright_holder'], + '# This file is distributed under the same license as ' . $headerInfo['project_name'] . '.', + '# ' . $headerInfo['author_name'] . ' <' . $headerInfo['author_mail'] + . '>, ' . $headerInfo['author_year'] . '.', + '# ', + '#, fuzzy', + 'msgid ""', + 'msgstr ""', + '"Project-Id-Version: ' . $headerInfo['project_name'] . ' (' + . $headerInfo['project_version'] . ')\n"', + '"Report-Msgid-Bugs-To: ' . $headerInfo['project_bug_mail'] . '\n"', + '"POT-Creation-Date: ' . $headerInfo['pot_creation_date'] . '\n"', + '"PO-Revision-Date: ' . $headerInfo['po_revision_date'] . '\n"', + '"Last-Translator: ' . $headerInfo['translator_name'] . ' <' + . $headerInfo['translator_mail'] . '>\n"', + '"Language-Team: ' . $headerInfo['language_team_name'] . ' <' + . $headerInfo['language_team_url'] . '>\n"', + '"MIME-Version: 1.0\n"', + '"Content-Type: text/plain; charset=' . $headerInfo['charset'] . '\n"', + '"Content-Transfer-Encoding: 8bit\n"', + '' + ) + ) . PHP_EOL . substr($content, strpos($content, '#: ')) + ); + } + + /** + * Create the file catalog + * + * @throws Exception In case the catalog-file cannot be created + */ + private function createFileCatalog() + { + $catalogHandle = fopen($this->catalogPath, 'w'); + if (!$catalogHandle) { + throw new Exception('Unable to create ' . $this->catalogPath); + } + + try { + if ($this->moduleDir) { + $this->getSourceFileNames($this->moduleDir, $catalogHandle); + } else { + $this->getSourceFileNames($this->appDir, $catalogHandle); + $this->getSourceFileNames(realpath($this->appDir . '/../library/Icinga'), $catalogHandle); + } + } catch (Exception $error) { + fclose($catalogHandle); + throw $error; + } + + fclose($catalogHandle); + } + + /** + * Recursively scan the given directory for translatable source files + * + * @param string $directory The directory where to search for sources + * @param resource $fileHandle The file where to write the results + * @param array $blacklist A list of directories to omit + * + * @throws Exception In case the given directory is not readable + */ + private function getSourceFileNames($directory, &$fileHandle) + { + $directoryHandle = opendir($directory); + if (!$directoryHandle) { + throw new Exception('Unable to read files from ' . $directory); + } + + $subdirs = array(); + while (($filename = readdir($directoryHandle)) !== false) { + $filepath = $directory . DIRECTORY_SEPARATOR . $filename; + if (preg_match('@^[^\.].+\.(' . implode('|', $this->sourceExtensions) . ')$@', $filename)) { + fwrite($fileHandle, $filepath . PHP_EOL); + } elseif (is_dir($filepath) && !preg_match('@^(\.|\.\.)$@', $filename)) { + $subdirs[] = $filepath; + } + } + closedir($directoryHandle); + + foreach ($subdirs as $subdir) { + $this->getSourceFileNames($subdir, $fileHandle); + } + } + + /** + * Compile the translation table + */ + private function compileTranslationTable() + { + $targetPath = substr($this->tablePath, 0, strrpos($this->tablePath, '.')) . '.mo'; + shell_exec( + implode( + ' ', + array( + '/usr/bin/msgfmt', + '-o ' . $targetPath, + $this->tablePath + ) + ) + ); + } +}