diff --git a/bootstrap.php b/bootstrap.php index 41cd7cf4f5d700772919a697182d4eb50f882f1f..d6f75d0f7e8d30d4fe5ac78751a174ee7beaf3c4 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -180,6 +180,7 @@ class Bootstrap { function loadCode() { #include required files require_once INCLUDE_DIR.'class.util.php'; + require INCLUDE_DIR.'class.translation.php'; require(INCLUDE_DIR.'class.signal.php'); require(INCLUDE_DIR.'class.user.php'); require(INCLUDE_DIR.'class.auth.php'); diff --git a/include/ajax.config.php b/include/ajax.config.php index d2547d98c4abd967837d9a49073ff29b64bc5d32..ab1ebecc07ae23856d22c36bbb27624a053992ac 100644 --- a/include/ajax.config.php +++ b/include/ajax.config.php @@ -39,6 +39,8 @@ class ConfigAjaxAPI extends AjaxController { 'lang' => $lang, 'short_lang' => $sl, 'has_rtl' => $rtl, + 'primary_language' => $cfg->getPrimaryLanguage(), + 'secondary_languages' => $cfg->getSecondaryLanguages(), ); return $this->json_encode($config); } @@ -60,6 +62,8 @@ class ConfigAjaxAPI extends AjaxController { 'lang' => $lang, 'short_lang' => $sl, 'has_rtl' => $rtl, + 'primary_language' => $cfg->getPrimaryLanguage(), + 'secondary_languages' => $cfg->getSecondaryLanguages(), ); $config = $this->json_encode($config); diff --git a/include/ajax.i18n.php b/include/ajax.i18n.php index 3aeaefa135b8448db40e97fc8fb2b0ce1a13ac6e..2f5ef9ebe7cbc319b3099f5538e16c120fa3eb51 100644 --- a/include/ajax.i18n.php +++ b/include/ajax.i18n.php @@ -37,5 +37,80 @@ class i18nAjaxAPI extends AjaxController { Http::cacheable(md5($data), $cfg->lastModified()); echo $data; } + + function getTranslations($tag) { + $t = CustomDataTranslation::allTranslations($tag); + $phrases = array(); + $lm = 0; + foreach ($t as $translation) { + $phrases[$translation->lang] = $translation->text; + $lm = max($lm, strtotime($translation->updated)); + } + $json = JsonDataEncoder::encode($phrases) ?: '{}'; + //Http::cacheable(md5($json), $lm); + + return $json; + } + + function updateTranslations($tag) { + global $thisstaff, $cfg; + + if (!$thisstaff) + Http::response(403, "Agent login required"); + if (!$_POST) + Http::response(422, "No translations found to update"); + + $t = CustomDataTranslation::allTranslations($tag); + $phrases = array(); + foreach ($t as $translation) { + $phrases[$translation->lang] = $translation; + } + foreach ($_POST as $lang => $phrase) { + if (isset($phrases[$lang])) { + if (!$phrase) { + $p->delete(); + } + else { + $p = $phrases[$lang]; + // Avoid XSS injection + $p->text = trim(Format::striptags($phrase)); + $p->agent_id = $thisstaff->getId(); + } + } + elseif (in_array($lang, $cfg->getSecondaryLanguages())) { + if (!$phrase) + continue; + $phrases[$lang] = CustomDataTranslation::create(array( + 'lang' => $lang, + 'text' => $phrase, + 'object_hash' => $tag, + 'type' => 'phrase', + 'agent_id' => $thisstaff->getId(), + 'updated' => new SqlFunction('NOW'), + )); + } + else { + Http::response(400, + sprintf("%s: Must be a secondary language", $lang)); + } + } + // Commit. + foreach ($phrases as $p) + if (!$p->save()) + Http::response(500, sprintf("%s: Unable to commit language")); + } + + function getSecondaryLanguages() { + global $cfg; + + $langs = array(); + foreach (array('de', 'ja', 'zh_CN') as $l) { + $langs[$l] = Internationalization::getLanguageDescription($l); + } + $json = JsonDataEncoder::encode($langs); + Http::cacheable(md5($json), $cfg->lastModified()); + + return $json; + } } ?> diff --git a/include/class.config.php b/include/class.config.php index 04a7cea4546bdf65430034564fd3d2812a5fc50a..9685fb55d40ae45e8006920d2683841605469deb 100644 --- a/include/class.config.php +++ b/include/class.config.php @@ -797,10 +797,14 @@ class OsticketConfig extends Config { return ($this->get('allow_attachments')); } - function getSystemLanguage() { + function getPrimaryLanguage() { return $this->get('system_language'); } + function getSecondaryLanguages() { + return array('de', 'ja', 'zh_CN'); + } + /* Needed by upgrader on 1.6 and older releases upgrade - not not remove */ function getUploadDir() { return $this->get('upload_dir'); diff --git a/include/class.dept.php b/include/class.dept.php index 7abb372d1e9c16f75deb80b8ed60ee31a6a87c1b..d12216f5611f26a26ac2c22c814b1f6e82cfb029 100644 --- a/include/class.dept.php +++ b/include/class.dept.php @@ -14,7 +14,8 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ -class Dept { +class Dept implements Translatable { + var $id; var $email; @@ -77,6 +78,12 @@ class Dept { return $this->ht['name']; } + function getLocalName($locale=false) { + return CustomDataTranslation::translate($this->getTranslationTag(), $locale); + } + function getTranslationTag() { + return _H('dept.name.' . $this->getId()); + } function getEmailId() { return $this->ht['email_id']; @@ -391,6 +398,12 @@ class Dept { $depts[$id] = $name; } + // Fetch local names + foreach (CustomDataTranslation::getDepartmentNames(array_keys($depts)) as $id=>$name) { + // Translate the department + $depts[$id] = $name; + } + return $depts; } diff --git a/include/class.i18n.php b/include/class.i18n.php index 4a0d138f295bec3586369127a16d255d3d51e61a..8db307de912384cc1874f3487b71755c2aeccac8 100644 --- a/include/class.i18n.php +++ b/include/class.i18n.php @@ -26,7 +26,7 @@ class Internationalization { function Internationalization($language=false) { global $cfg; - if ($cfg && ($lang = $cfg->getSystemLanguage())) + if ($cfg && ($lang = $cfg->getPrimaryLanguage())) array_unshift($this->langs, $language); // Detect language filesystem path, case insensitively @@ -252,7 +252,7 @@ class Internationalization { global $cfg; if (empty($_SERVER["HTTP_ACCEPT_LANGUAGE"])) - return $cfg->getSystemLanguage(); + return $cfg->getPrimaryLanguage(); $languages = self::availableLanguages(); @@ -337,6 +337,7 @@ class Internationalization { $user = $user ?: $thisstaff ?: $thisclient; if ($user && method_exists($user, 'getLanguage')) return $user->getLanguage(); + // Support the flag buttons for guests if (isset($_SESSION['client:lang'])) return $_SESSION['client:lang']; return self::getDefaultLanguage(); diff --git a/include/class.translation.php b/include/class.translation.php index cc21848a31e9153f8940c939a48d624bed36d461..4900bae82dc1d98c8721024785a0d3fe3884e58f 100644 --- a/include/class.translation.php +++ b/include/class.translation.php @@ -856,6 +856,116 @@ class TextDomain { } } +require_once INCLUDE_DIR . 'class.orm.php'; +class CustomDataTranslation extends VerySimpleModel { + + static $meta = array( + 'table' => 'ost_translation', + 'pk' => array('id') + ); + + const FLAG_FUZZY = 0x01; // Source string has been changed + const FLAG_UNAPPROVED = 0x02; // String has been reviewed by an authority + const FLAG_CURRENT = 0x04; // If more than one version exist, this is current + + static function lookup($msgid, $flags=0) { + if (!is_string($msgid)) + return parent::lookup($msgid); + + // Hash is 16 char of md5 + $hash = substr(md5($msgid), -16); + + $criteria = array('object_hash'=>$hash); + + if ($flags) + $criteria += array('flags__hasbit'=>$flags); + + return parent::lookup($criteria); + } + + static function getTranslation($locale, $cache=true) { + static $_cache = array(); + + if ($cache && isset($_cache[$locale])) + return $_cache[$locale]; + + $criteria = array( + 'lang' => $locale, + 'type' => 'phrase', + ); + + $mo = array(); + foreach (static::objects()->filter($criteria) as $t) { + $mo[$t->object_hash] = $t; + } + + return $_cache[$locale] = $mo; + } + + static function translate($msgid, $locale=false, $cache=true) { + global $thisstaff, $thisclient; + + if (!$locale + && ($user = $thisstaff ?: $thisclient) + && method_exists($user, 'getLanguage')) + $locale = $user->getLanguage(); + + // Support sending a User as the locale + elseif (is_object($locale) && method_exists($locale, 'getLanguage')) + $locale = $locale->getLanguage(); + + if ($locale) { + if ($cache) { + $mo = static::getTranslation($locale); + if (isset($mo[$msgid])) + $msgid = $mo[$msgid]->text; + } + elseif ($p = static::lookup(array( + 'type' => 'phrase', + 'lang' => $locale, + 'object_hash' => $msgid + ))) { + $msgid = $p->text; + } + } + return $msgid; + } + + static function allTranslations($msgid) { + return static::objects()->filter(array( + 'type' => 'phrase', + 'object_hash' => $msgid + ))->all(); + } + + static function getDepartmentNames($ids) { + global $cfg; + + $tags = array(); + $names = array(); + foreach ($ids as $i) + $tags[_H('dept.name.'.$i)] = $i; + + if (($lang = Internationalization::getCurrentLanguage()) + && $lang != $cfg->getPrimaryLanguage() + ) { + foreach (CustomDataTranslation::objects()->filter(array( + 'object_hash__in'=>array_keys($tags), + 'lang'=>$lang + )) as $translation + ) { + $names[$tags[$translation->object_hash]] = $translation->text; + } + } + return $names; + } + +} + +class CustomTextDomain { + +} + // Functions for gettext library. Since the gettext extension for PHP is not // used as a fallback, there is no detection and compat funciton // installation for the gettext library function calls. @@ -911,6 +1021,18 @@ function _dcnpgettext($domain, $context, $singular, $plural, $category, $n) { ->npgettext($context, $singular, $plural, $n); } +// Custom data translations +function _H($tag) { + return substr(md5($tag), -16); +} +function _C(Translatable $object) { + return $objet->getLocalName(); +} + +interface Translatable { + function getTranslationTag(); + function getLocalName($user=false); +} do { if (PHP_SAPI != 'cli') break; diff --git a/include/staff/department.inc.php b/include/staff/department.inc.php index 53d30908c9b324be7e0a1ce3db0554cc15e0c30f..502c309208cd69490a824064585a34bb06ae20df 100644 --- a/include/staff/department.inc.php +++ b/include/staff/department.inc.php @@ -47,7 +47,8 @@ $info=Format::htmlchars(($errors && $_POST)?$_POST:$info); <?php echo __('Name');?>: </td> <td> - <input type="text" size="30" name="name" value="<?php echo $info['name']; ?>"> + <input data-translate-tag="<?php echo $dept ? $dept->getTranslationTag() : ''; + ?>" type="text" size="30" name="name" value="<?php echo $info['name']; ?>"> <span class="error">* <?php echo $errors['name']; ?></span> </td> </tr> @@ -294,3 +295,6 @@ $info=Format::htmlchars(($errors && $_POST)?$_POST:$info); <input type="button" name="cancel" value="<?php echo __('Cancel');?>" onclick='window.location.href="departments.php"'> </p> </form> +<script type="text/javascript"> + $('input[data-translate-tag]').translatable(); +</script> diff --git a/include/staff/header.inc.php b/include/staff/header.inc.php index 60079586dfc40c86fb3a3490a1aa8a6cdc0ce97e..3c29232e550e3c2de4490749c62d52b4605e69b7 100644 --- a/include/staff/header.inc.php +++ b/include/staff/header.inc.php @@ -29,10 +29,12 @@ if (($lang = Internationalization::getCurrentLanguage()) <script type="text/javascript" src="<?php echo ROOT_PATH; ?>js/redactor-osticket.js"></script> <script type="text/javascript" src="<?php echo ROOT_PATH; ?>js/redactor-fonts.js"></script> <script type="text/javascript" src="./js/bootstrap-typeahead.js"></script> + <script type="text/javascript" src="./js/jquery.translatable.js"></script> <link rel="stylesheet" href="<?php echo ROOT_PATH ?>css/thread.css" media="all"> <link rel="stylesheet" href="./css/scp.css" media="all"> <link rel="stylesheet" href="<?php echo ROOT_PATH; ?>css/redactor.css" media="screen"> <link rel="stylesheet" href="./css/typeahead.css" media="screen"> + <link rel="stylesheet" href="./css/jquery.translatable.css" media="screen"> <link type="text/css" href="<?php echo ROOT_PATH; ?>css/ui-lightness/jquery-ui-1.10.3.custom.min.css" rel="stylesheet" media="screen" /> <link type="text/css" rel="stylesheet" href="<?php echo ROOT_PATH; ?>css/font-awesome.min.css"> diff --git a/include/staff/template.inc.php b/include/staff/template.inc.php index 475ac80b5d250c6ab30d57e70527822318b07e23..325a7423f60eaf50edfb685a8007dfd5537d6141 100644 --- a/include/staff/template.inc.php +++ b/include/staff/template.inc.php @@ -15,7 +15,7 @@ if($template && $_REQUEST['a']!='add'){ $action='add'; $submit_text=__('Add Template'); $info['isactive']=isset($info['isactive'])?$info['isactive']:0; - $info['lang_id'] = $cfg->getSystemLanguage(); + $info['lang_id'] = $cfg->getPrimaryLanguage(); $qstr.='&a='.urlencode($_REQUEST['a']); } $info=Format::htmlchars(($errors && $_POST)?$_POST:$info); diff --git a/scp/ajax.php b/scp/ajax.php index 09dce15dd85103fadcc55bb0b2b25c0a7cc08aac..4ca0abf850e615d3f09479db47a904ecd511169c 100644 --- a/scp/ajax.php +++ b/scp/ajax.php @@ -176,8 +176,11 @@ $dispatcher = patterns('', url_get('^tips/(?P<namespace>[\w_.]+)$', 'getTipsJson'), url_get('^(?P<lang>[\w_]+)?/tips/(?P<namespace>[\w_.]+)$', 'getTipsJsonForLang') )), - url('^/i18n/(?P<lang>[\w_]+)/', patterns('ajax.i18n.php:i18nAjaxAPI', - url_get('(?P<tag>\w+)$', 'getLanguageFile') + url('^/i18n/', patterns('ajax.i18n.php:i18nAjaxAPI', + url_get('^langs$', 'getSecondaryLanguages'), + url_get('^translate/(?P<tag>\w+)$', 'getTranslations'), + url_post('^translate/(?P<tag>\w+)$', 'updateTranslations'), + url_get('^(?P<lang>[\w_]+)/(?P<tag>\w+)$', 'getLanguageFile') )) ); diff --git a/scp/css/jquery.translatable.css b/scp/css/jquery.translatable.css new file mode 100644 index 0000000000000000000000000000000000000000..4f7f3898256f9510f2b33b746b65ec2c13810a62 --- /dev/null +++ b/scp/css/jquery.translatable.css @@ -0,0 +1,43 @@ +button.translatable { + background-color: #ccc; + border: none; + border-radius: 0 4px 4px 0; +} + +div.add-translation { + margin-top: 5px; + padding: 5px; + border-top: 1px solid rgba(0, 0, 0, 0.3); +} + +div.translations { + min-height: 20px; +} + +ul.translations { + padding-left: 0; + min-width: 300px; + max-height: 150px; + overflow-y: auto; +} +ul.translations li { + list-style: none; + padding: 0 10px; + box-sizing: border-box; + display: block; +} +ul.translations li + li { + margin-top: 10px; +} +ul.translations li label.language { + color: #888; +} +ul.translations li input { + width: 100%; + box-sizing: border-box; +} +.language-commit { + text-align: right; + padding: 5px 10px; + background-color: cyan; +} diff --git a/scp/js/jquery.translatable.js b/scp/js/jquery.translatable.js new file mode 100644 index 0000000000000000000000000000000000000000..f75208ac640ace77ae9642d50ee7413fb08121de --- /dev/null +++ b/scp/js/jquery.translatable.js @@ -0,0 +1,186 @@ + +!function( $ ){ + + "use strict"; + + var Translatable = function( element, options ) { + this.$element = $(element); + this.options = $.extend({}, $.fn.translatable.defaults, options); + this.$translations = $('<ul class="translations"></ul>'); + this.$status = $('<li class="status"><i class="icon-spinner icon-spin"></i> Loading ...</li>') + .appendTo(this.$translations); + this.$footer = $('<div class="add-translation"></div>'); + this.$select = $('<select name="locale"></select>'); + this.$menu = $(this.options.menu).appendTo('body'); + this.$button = $(this.options.button).insertAfter(this.$element); + //this.$menu.append('<a class="close pull-right" href=""><i class="icon-remove-circle"></i></a>') + // .on('click', $.proxy(this.hide, this)); + this.$menu.append(this.$translations).append(this.$footer); + this.shown = false; + this.populated = false; + this.decorate(); + }, + // Class-static variables + urlcache = {}; + + Translatable.prototype = { + + constructor: Translatable, + + fetch: function( url, data, callback ) { + if ( !urlcache[ url ] ) { + urlcache[ url ] = $.Deferred(function( defer ) { + $.ajax( url, { data: data, dataType: 'json' } ) + .then( defer.resolve, defer.reject ); + }).promise(); + } + return urlcache[ url ].done( callback ); + }, + + decorate: function() { + this.$button.on('click', $.proxy(this.toggle, this)); + var self = this; + this.fetch('ajax.php/i18n/langs').then(function(json) { self.langs = json; }); + }, + + buildAdd: function() { + var self=this; + this.$footer + .append($('<form method="post"></form>') + .append(this.$select) + .append($('<button type="button"><i class="icon-plus-sign"></i> Add</button>') + .on('click', $.proxy(this.define, this)) + ) + ); + this.fetch('ajax.php/i18n/langs').then(function(langs) { + $.each(langs, function(k, v) { + self.$select.append($('<option>').val(k).text(v)); + }); + }); + }, + + populate: function() { + var self=this; + if (this.populated) + return; + this.buildAdd(); + this.fetch('ajax.php/i18n/translate/' + this.$element.data('translateTag')) + .then(function(json) { + $.each(json, function(k,v) { + self.add(k, v); + }); + if (!Object.keys(json).length) { + self.$status.text('Not currently translated'); + } + else + self.$status.remove(); + }); + self.populated = true; + }, + + define: function(e) { + this.add($('option:selected', this.$select).val()); + }, + + add: function(lang, text) { + this.$translations.append( + $('<li>') + .append($('<label class="language">').text(this.langs[lang]) + .append($('<input type="text" data-lang="'+lang+'">') + .on('change', $.proxy(this.showCommit, this)) + .val(text) + ) + ) + ); + $('option[value='+lang+']', this.$select).remove(); + if (!$('option', this.$select).length) + this.$footer.hide(); + }, + + showCommit: function(e) { + if (this.$commit) + return this.$commit.show(); + + return this.$commit = $('<div class="language-commit"></div>') + .insertAfter(this.$translations) + .append($('<button type="button" class="commit"><i class="fa fa-save icon-save"></i> Save</button>') + .on('click', $.proxy(this.commit, this)) + ); + }, + + commit: function(e) { + var changes = {}, self = this; + $('input[type=text]', this.$translations).each(function() { + changes[$(this).data('lang')] = $(this).val(); + }); + $.ajax('ajax.php/i18n/translate/' + this.$element.data('translateTag'), { + type: 'post', + data: changes, + success: function() { + self.$commit.hide(); + } + }); + }, + + toggle: function(e) { + e.stopPropagation(); + e.preventDefault(); + + if (this.shown) + this.hide(); + else + this.show(); + }, + + show: function() { + if (this.shown) + return this; + + var pos = $.extend({}, this.$element.offset(), { + height: this.$element[0].offsetHeight + }) + + this.$menu.css({ + top: pos.top + pos.height + , left: pos.left + }); + + this.populate(); + + this.$menu.show(); + this.shown = true; + return this; + }, + + hide: function() { + if (this.shown) { + this.$menu.hide(); + this.shown = false; + } + return this; + } + + + }; + + /* PLUGIN DEFINITION + * =========================== */ + + $.fn.translatable = function ( option ) { + return this.each(function () { + var $this = $(this), + data = $this.data('translatable'), + options = typeof option == 'object' && option; + if (!data) $this.data('translatable', (data = new Translatable(this, options))); + if (typeof option == 'string') data[option](); + }); + }; + + $.fn.translatable.defaults = { + menu: '<div class="translatable dropdown-menu"></div>', + button: '<button class="translatable"><i class="fa fa-globe icon-globe"></i></button>' + }; + + $.fn.translatable.Constructor = Translatable; + +}( window.jQuery );