From a2c04be71821e508a46d79650678f7d2db0dafd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Fri, 24 Feb 2017 11:31:21 +0100 Subject: [PATCH] FIX: eradicate I18n fallback issues :bomb: FIX: client's translation overrides were not working when the current locale was missing a key FIX: ExtraLocalesController.show was not properly handling multiple translations FIX: JsLocaleHelper#output_locale was not properly handling multiple translations FIX: ExtraLocalesController.show's spec which was randomly failing FIX: JsLocaleHelper#output_locale was muting cached translations hashes REFACTOR: move 'enableVerboseLocalization' to the 'localization' initializer REFACTOR: remove unused I18n.js methods (getFallbacks, localize, parseDate, toTime, strftime, toCurrency, toPercentage) REFACTOR: remove all I18n.pluralizationRules and instead use MessageFormat's pluralization rules TEST: add tests for localization initializer TEST: add tests for I18n.js --- .../initializers/localization.js.es6 | 39 +- app/assets/javascripts/locales/ar.js.erb | 9 - app/assets/javascripts/locales/cs.js.erb | 7 - app/assets/javascripts/locales/fa_IR.js.erb | 4 - app/assets/javascripts/locales/i18n.js | 362 ++---------------- app/assets/javascripts/locales/ja.js.erb | 4 - app/assets/javascripts/locales/ro.js.erb | 6 - app/assets/javascripts/locales/ru.js.erb | 6 - app/assets/javascripts/locales/sk.js.erb | 6 - app/assets/javascripts/locales/tr_TR.js.erb | 2 - app/assets/javascripts/locales/uk.js.erb | 7 - app/controllers/extra_locales_controller.rb | 25 +- lib/freedom_patches/translate_accelerator.rb | 4 +- lib/i18n/backend/discourse_i18n.rb | 4 +- lib/js_locale_helper.rb | 80 ++-- spec/components/js_locale_helper_spec.rb | 148 +++---- .../extra_locales_controller_spec.rb | 34 +- .../initializers/localization-test.js.es6 | 43 +++ test/javascripts/lib/i18n-test.js.es6 | 55 +++ test/javascripts/test_helper.js | 1 - 20 files changed, 314 insertions(+), 532 deletions(-) create mode 100644 test/javascripts/initializers/localization-test.js.es6 create mode 100644 test/javascripts/lib/i18n-test.js.es6 diff --git a/app/assets/javascripts/discourse/initializers/localization.js.es6 b/app/assets/javascripts/discourse/initializers/localization.js.es6 index 6477c5e4f93..5d3ce55f984 100644 --- a/app/assets/javascripts/discourse/initializers/localization.js.es6 +++ b/app/assets/javascripts/discourse/initializers/localization.js.es6 @@ -4,10 +4,31 @@ export default { name: 'localization', after: 'inject-objects', - initialize: function(container) { + enableVerboseLocalization() { + let counter = 0; + let keys = {}; + let t = I18n.t; + + I18n.noFallbacks = true; + + I18n.t = I18n.translate = function(scope, value){ + let current = keys[scope]; + if (!current) { + current = keys[scope] = ++counter; + let message = "Translation #" + current + ": " + scope; + if (!_.isEmpty(value)) { + message += ", parameters: " + JSON.stringify(value); + } + Em.Logger.info(message); + } + return t.apply(I18n, [scope, value]) + " (#" + current + ")"; + }; + }, + + initialize(container) { const siteSettings = container.lookup('site-settings:main'); if (siteSettings.verbose_localization) { - I18n.enable_verbose_localization(); + this.enableVerboseLocalization(); } // Merge any overrides into our object @@ -16,24 +37,26 @@ export default { const v = overrides[k]; // Special case: Message format keys are functions - if (/\_MF$/.test(k)) { + if (/_MF$/.test(k)) { k = k.replace(/^[a-z_]*js\./, ''); I18n._compiledMFs[k] = new Function('transKey', `return (${v})(transKey);`); - return; } k = k.replace('admin_js', 'js'); + const segs = k.split('.'); + let node = I18n.translations[I18n.locale]; let i = 0; - for (; node && i - -I18n.pluralizationRules['ar'] = function (n) { - if (n == 0) return "zero"; - if (n == 1) return "one"; - if (n == 2) return "two"; - if (n%100 >= 3 && n%100 <= 10) return "few"; - if (n%100 >= 11 && n%100 <= 99) return "many"; - return "other"; -}; diff --git a/app/assets/javascripts/locales/cs.js.erb b/app/assets/javascripts/locales/cs.js.erb index 14a5e7623e2..36dfbb499d0 100644 --- a/app/assets/javascripts/locales/cs.js.erb +++ b/app/assets/javascripts/locales/cs.js.erb @@ -1,10 +1,3 @@ //= depend_on 'client.cs.yml' //= require locales/i18n <%= JsLocaleHelper.output_locale(:cs) %> - -I18n.pluralizationRules['cs'] = function (n) { - if (n == 0) return ["zero", "none", "other"]; - if (n == 1) return "one"; - if (n >= 2 && n <= 4) return "few"; - return "other"; -}; diff --git a/app/assets/javascripts/locales/fa_IR.js.erb b/app/assets/javascripts/locales/fa_IR.js.erb index ddad1cad48b..9ce1c17cd5c 100644 --- a/app/assets/javascripts/locales/fa_IR.js.erb +++ b/app/assets/javascripts/locales/fa_IR.js.erb @@ -1,7 +1,3 @@ //= depend_on 'client.fa_IR.yml' //= require locales/i18n <%= JsLocaleHelper.output_locale(:fa_IR) %> - -I18n.pluralizationRules['fa_IR'] = function (n) { - return "other"; -}; diff --git a/app/assets/javascripts/locales/i18n.js b/app/assets/javascripts/locales/i18n.js index d5320f628d9..3722d746f07 100644 --- a/app/assets/javascripts/locales/i18n.js +++ b/app/assets/javascripts/locales/i18n.js @@ -1,48 +1,17 @@ /*global I18n:true */ -// https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/indexOf -if (!Array.prototype.indexOf) { - Array.prototype.indexOf = function (searchElement, fromIndex) { - if ( this === undefined || this === null ) { - throw new TypeError( '"this" is null or not defined' ); - } - - var length = this.length >>> 0; // Hack to convert object.length to a UInt32 - - fromIndex = +fromIndex || 0; - - if (Math.abs(fromIndex) === Infinity) { - fromIndex = 0; - } - - if (fromIndex < 0) { - fromIndex += length; - if (fromIndex < 0) { - fromIndex = 0; - } - } - - for (;fromIndex < length; fromIndex++) { - if (this[fromIndex] === searchElement) { - return fromIndex; - } - } - - return -1; - }; -} - // Instantiate the object var I18n = I18n || {}; // Set default locale to english I18n.defaultLocale = "en"; -// Set default handling of translation fallbacks to false -I18n.fallbacks = false; - -// Set default separator -I18n.defaultSeparator = "."; +// Set default pluralization rule +I18n.pluralizationRules = { + en: function(n) { + return n === 0 ? ["zero", "none", "other"] : n === 1 ? "one" : "other"; + } +}; // Set current locale to null I18n.locale = null; @@ -50,44 +19,10 @@ I18n.locale = null; // Set the placeholder format. Accepts `{{placeholder}}` and `%{placeholder}`. I18n.PLACEHOLDER = /(?:\{\{|%\{)(.*?)(?:\}\}?)/gm; -I18n.fallbackRules = {}; +I18n.SEPARATOR = "."; I18n.noFallbacks = false; -I18n.pluralizationRules = { - en: function(n) { - return n === 0 ? ["zero", "none", "other"] : n === 1 ? "one" : "other"; - }, - "zh_CN": function(n) { - return n === 0 ? ["zero", "none", "other"] : "other"; - }, - "zh_TW": function(n) { - return n === 0 ? ["zero", "none", "other"] : "other"; - }, - "ko": function(n) { - return n === 0 ? ["zero", "none", "other"] : "other"; - } -}; - -I18n.getFallbacks = function(locale) { - if (locale === I18n.defaultLocale) { - return []; - } else if (!I18n.fallbackRules[locale]) { - var rules = [], - components = locale.split("-"); - - for (var l = 1; l < components.length; l++) { - rules.push(components.slice(0, l).join("-")); - } - - rules.push(I18n.defaultLocale); - - I18n.fallbackRules[locale] = rules; - } - - return I18n.fallbackRules[locale]; -}; - I18n.isValidNode = function(obj, node, undefined) { return obj[node] !== null && obj[node] !== undefined; }; @@ -95,25 +30,24 @@ I18n.isValidNode = function(obj, node, undefined) { function checkExtras(origScope, sep, extras) { if (!extras || extras.length === 0) { return; } - for (var i=0; i 0) { currentScope = scope.shift(); messages = messages[currentScope]; } - if (messages !== undefined) { - return messages; - } + + if (messages !== undefined) { return messages; } } } I18n.lookup = function(scope, options) { options = options || {}; + var lookupInitialScope = scope, translations = this.prepareOptions(I18n.translations), locale = options.locale || I18n.currentLocale(), @@ -123,42 +57,23 @@ I18n.lookup = function(scope, options) { options = this.prepareOptions(options); if (typeof scope === "object") { - scope = scope.join(this.defaultSeparator); + scope = scope.join(this.SEPARATOR); } if (options.scope) { - scope = options.scope.toString() + this.defaultSeparator + scope; + scope = options.scope.toString() + this.SEPARATOR + scope; } var origScope = "" + scope; - scope = origScope.split(this.defaultSeparator); + scope = origScope.split(this.SEPARATOR); while (messages && scope.length > 0) { currentScope = scope.shift(); messages = messages[currentScope]; } - if (messages === undefined) { - messages = checkExtras(origScope, this.defaultSeparator, this.extras); - } - - - if (messages === undefined) { - if (I18n.fallbacks) { - var fallbacks = this.getFallbacks(locale); - for (var fallback = 0; fallback < fallbacks.length; fallbacks++) { - messages = I18n.lookup(lookupInitialScope, this.prepareOptions({locale: fallbacks[fallback]}, options)); - if (messages !== undefined) { - break; - } - } - } - - if (messages === undefined && this.isValidNode(options, "defaultValue")) { - messages = options.defaultValue; - } - } + messages = messages || checkExtras(origScope, this.SEPARATOR, this.extras) || options.defaultValue; return messages; }; @@ -193,14 +108,13 @@ I18n.prepareOptions = function() { I18n.interpolate = function(message, options) { options = this.prepareOptions(options); + var matches = message.match(this.PLACEHOLDER), placeholder, value, name; - if (!matches) { - return message; - } + if (!matches) { return message; } for (var i = 0; placeholder = matches[i]; i++) { name = placeholder.replace(this.PLACEHOLDER, "$1"); @@ -219,18 +133,19 @@ I18n.interpolate = function(message, options) { }; I18n.translate = function(scope, options) { - options = this.prepareOptions(options); var translation = this.lookup(scope, options); - // Fallback to the default locale - if (!translation && this.currentLocale() !== this.defaultLocale && !this.noFallbacks) { - options.locale = this.defaultLocale; - translation = this.lookup(scope, options); - } - if (!translation && this.currentLocale() !== 'en' && !this.noFallbacks) { - options.locale = 'en'; - translation = this.lookup(scope, options); + + if (!this.noFallbacks) { + if (!translation && this.currentLocale() !== this.defaultLocale) { + options.locale = this.defaultLocale; + translation = this.lookup(scope, options); + } + if (!translation && this.currentLocale() !== 'en') { + options.locale = 'en'; + translation = this.lookup(scope, options); + } } try { @@ -248,158 +163,16 @@ I18n.translate = function(scope, options) { } }; -I18n.localize = function(scope, value) { - switch (scope) { - case "currency": - return this.toCurrency(value); - case "number": - scope = this.lookup("number.format"); - return this.toNumber(value, scope); - case "percentage": - return this.toPercentage(value); - default: - if (scope.match(/^(date|time)/)) { - return this.toTime(scope, value); - } else { - return value.toString(); - } - } -}; - -I18n.parseDate = function(date) { - var matches, convertedDate; - - // we have a date, so just return it. - if (typeof date === "object") { - return date; - } - - // it matches the following formats: - // yyyy-mm-dd - // yyyy-mm-dd[ T]hh:mm::ss - // yyyy-mm-dd[ T]hh:mm::ss - // yyyy-mm-dd[ T]hh:mm::ssZ - // yyyy-mm-dd[ T]hh:mm::ss+0000 - // - matches = date.toString().match(/(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2}))?(Z|\+0000)?/); - - if (matches) { - for (var i = 1; i <= 6; i++) { - matches[i] = parseInt(matches[i], 10) || 0; - } - - // month starts on 0 - matches[2] -= 1; - - if (matches[7]) { - convertedDate = new Date(Date.UTC(matches[1], matches[2], matches[3], matches[4], matches[5], matches[6])); - } else { - convertedDate = new Date(matches[1], matches[2], matches[3], matches[4], matches[5], matches[6]); - } - } else if (typeof date === "number") { - // UNIX timestamp - convertedDate = new Date(); - convertedDate.setTime(date); - } else if (date.match(/\d+ \d+:\d+:\d+ [+-]\d+ \d+/)) { - // a valid javascript format with timezone info - convertedDate = new Date(); - convertedDate.setTime(Date.parse(date)); - } else { - // an arbitrary javascript string - convertedDate = new Date(); - convertedDate.setTime(Date.parse(date)); - } - - return convertedDate; -}; - -I18n.toTime = function(scope, d) { - var date = this.parseDate(d), - format = this.lookup(scope); - - if (date.toString().match(/invalid/i)) { - return date.toString(); - } - - if (!format) { - return date.toString(); - } - - return this.strftime(date, format); -}; - -I18n.strftime = function(date, format) { - var options = this.lookup("date"); - - if (!options) { - return date.toString(); - } - - options.meridian = options.meridian || ["AM", "PM"]; - - var weekDay = date.getDay(), - day = date.getDate(), - year = date.getFullYear(), - month = date.getMonth() + 1, - hour = date.getHours(), - hour12 = hour, - meridian = hour > 11 ? 1 : 0, - secs = date.getSeconds(), - mins = date.getMinutes(), - offset = date.getTimezoneOffset(), - absOffsetHours = Math.floor(Math.abs(offset / 60)), - absOffsetMinutes = Math.abs(offset) - (absOffsetHours * 60), - timezoneoffset = (offset > 0 ? "-" : "+") + (absOffsetHours.toString().length < 2 ? "0" + absOffsetHours : absOffsetHours) + (absOffsetMinutes.toString().length < 2 ? "0" + absOffsetMinutes : absOffsetMinutes); - - if (hour12 > 12) { - hour12 = hour12 - 12; - } else if (hour12 === 0) { - hour12 = 12; - } - - var padding = function(n) { - var s = "0" + n.toString(); - return s.substr(s.length - 2); - }; - - var f = format; - f = f.replace("%a", options.abbr_day_names[weekDay]); - f = f.replace("%A", options.day_names[weekDay]); - f = f.replace("%b", options.abbr_month_names[month]); - f = f.replace("%B", options.month_names[month]); - f = f.replace("%d", padding(day)); - f = f.replace("%e", day); - f = f.replace("%-d", day); - f = f.replace("%H", padding(hour)); - f = f.replace("%-H", hour); - f = f.replace("%I", padding(hour12)); - f = f.replace("%-I", hour12); - f = f.replace("%m", padding(month)); - f = f.replace("%-m", month); - f = f.replace("%M", padding(mins)); - f = f.replace("%-M", mins); - f = f.replace("%p", options.meridian[meridian]); - f = f.replace("%S", padding(secs)); - f = f.replace("%-S", secs); - f = f.replace("%w", weekDay); - f = f.replace("%y", padding(year)); - f = f.replace("%-y", padding(year).replace(/^0+/, "")); - f = f.replace("%Y", year); - f = f.replace("%z", timezoneoffset); - - return f; -}; - I18n.toNumber = function(number, options) { options = this.prepareOptions( options, this.lookup("number.format"), - {precision: 3, separator: ".", delimiter: ",", strip_insignificant_zeros: false} + {precision: 3, separator: this.SEPARATOR, delimiter: ",", strip_insignificant_zeros: false} ); var negative = number < 0, string = Math.abs(number).toFixed(options.precision).toString(), - parts = string.split("."), + parts = string.split(this.SEPARATOR), precision, buffer = [], formattedNumber; @@ -437,23 +210,6 @@ I18n.toNumber = function(number, options) { return formattedNumber; }; -I18n.toCurrency = function(number, options) { - options = this.prepareOptions( - options, - this.lookup("number.currency.format"), - this.lookup("number.format"), - {unit: "$", precision: 2, format: "%u%n", delimiter: ",", separator: "."} - ); - - number = this.toNumber(number, options); - number = options.format - .replace("%u", options.unit) - .replace("%n", number) - ; - - return number; -}; - I18n.toHumanSize = function(number, options) { var kb = 1024, size = number, @@ -488,18 +244,6 @@ I18n.toHumanSize = function(number, options) { return number; }; -I18n.toPercentage = function(number, options) { - options = this.prepareOptions( - options, - this.lookup("number.percentage.format"), - this.lookup("number.format"), - {precision: 3, separator: ".", delimiter: ""} - ); - - number = this.toNumber(number, options); - return number + "%"; -}; - I18n.pluralizer = function(locale) { var pluralizer = this.pluralizationRules[locale]; if (pluralizer !== undefined) return pluralizer; @@ -534,52 +278,14 @@ I18n.pluralize = function(count, scope, options) { }; I18n.missingTranslation = function(scope, key) { - var message = '[' + this.currentLocale() + "." + scope; - if (key) { message += "." + key; } + var message = '[' + this.currentLocale() + this.SEPARATOR + scope; + if (key) { message += this.SEPARATOR + key; } return message + ']'; }; I18n.currentLocale = function() { - return (I18n.locale || I18n.defaultLocale); + return I18n.locale || I18n.defaultLocale; }; // shortcuts I18n.t = I18n.translate; -I18n.l = I18n.localize; -I18n.p = I18n.pluralize; - -I18n.enable_verbose_localization = function(){ - var counter = 0; - var keys = {}; - var t = I18n.t; - - I18n.noFallbacks = true; - - I18n.t = I18n.translate = function(scope, value){ - var current = keys[scope]; - if(!current) { - current = keys[scope] = ++counter; - var message = "Translation #" + current + ": " + scope; - if (!_.isEmpty(value)) { - message += ", parameters: " + JSON.stringify(value); - } - Em.Logger.info(message); - } - return t.apply(I18n, [scope, value]) + " (t" + current + ")"; - }; -}; - - -I18n.verbose_localization_session = function(){ - sessionStorage.setItem("verbose_localization", "true"); - I18n.enable_verbose_localization(); - return true; -} - -try { - if(sessionStorage && sessionStorage.getItem("verbose_localization")) { - I18n.enable_verbose_localization(); - } -} catch(e){ - // we don't care really, can happen if cookies disabled -} diff --git a/app/assets/javascripts/locales/ja.js.erb b/app/assets/javascripts/locales/ja.js.erb index b258ba577d5..2bb653952a6 100644 --- a/app/assets/javascripts/locales/ja.js.erb +++ b/app/assets/javascripts/locales/ja.js.erb @@ -1,7 +1,3 @@ //= depend_on 'client.ja.yml' //= require locales/i18n <%= JsLocaleHelper.output_locale(:ja) %> - -I18n.pluralizationRules['ja'] = function (n) { - return n === 0 ? ["zero", "none", "other"] : "other"; -}; diff --git a/app/assets/javascripts/locales/ro.js.erb b/app/assets/javascripts/locales/ro.js.erb index 83dc45c46a2..ff235132335 100644 --- a/app/assets/javascripts/locales/ro.js.erb +++ b/app/assets/javascripts/locales/ro.js.erb @@ -1,9 +1,3 @@ //= depend_on 'client.ro.yml' //= require locales/i18n <%= JsLocaleHelper.output_locale(:ro) %> - -I18n.pluralizationRules['ro'] = function (n) { - if (n == 1) return "one"; - if (n === 0 || n % 100 >= 1 && n % 100 <= 19) return "few"; - return "other"; -}; diff --git a/app/assets/javascripts/locales/ru.js.erb b/app/assets/javascripts/locales/ru.js.erb index f65cbb63b63..1f54e50d8e5 100644 --- a/app/assets/javascripts/locales/ru.js.erb +++ b/app/assets/javascripts/locales/ru.js.erb @@ -1,9 +1,3 @@ //= depend_on 'client.ru.yml' //= require locales/i18n <%= JsLocaleHelper.output_locale(:ru) %> - -I18n.pluralizationRules['ru'] = function (n) { - if (n % 10 == 1 && n % 100 != 11) return "one"; - if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) return "few"; - return "other"; -}; diff --git a/app/assets/javascripts/locales/sk.js.erb b/app/assets/javascripts/locales/sk.js.erb index 42603f0b4f1..0ea8866c6c2 100644 --- a/app/assets/javascripts/locales/sk.js.erb +++ b/app/assets/javascripts/locales/sk.js.erb @@ -1,9 +1,3 @@ //= depend_on 'client.sk.yml' //= require locales/i18n <%= JsLocaleHelper.output_locale(:sk) %> - -I18n.pluralizationRules['sk'] = function (n) { - if (n == 1) return "one"; - if (n >= 2 && n <= 4) return "few"; - return "other"; -}; diff --git a/app/assets/javascripts/locales/tr_TR.js.erb b/app/assets/javascripts/locales/tr_TR.js.erb index f5dc5386446..1f7fa13c9ba 100644 --- a/app/assets/javascripts/locales/tr_TR.js.erb +++ b/app/assets/javascripts/locales/tr_TR.js.erb @@ -1,5 +1,3 @@ //= depend_on 'client.tr_TR.yml' //= require locales/i18n <%= JsLocaleHelper.output_locale(:tr_TR) %> - -I18n.pluralizationRules['tr_TR'] = function(n) { return "other"; } diff --git a/app/assets/javascripts/locales/uk.js.erb b/app/assets/javascripts/locales/uk.js.erb index c6aeb8a7ec5..5422c46f967 100644 --- a/app/assets/javascripts/locales/uk.js.erb +++ b/app/assets/javascripts/locales/uk.js.erb @@ -1,10 +1,3 @@ //= depend_on 'client.uk.yml' //= require locales/i18n <%= JsLocaleHelper.output_locale(:uk) %> - -I18n.pluralizationRules['uk'] = function (n) { - if (n == 0) return ["zero", "none", "other"]; - if (n % 10 == 1 && n % 100 != 11) return "one"; - if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return "few"; - return "other"; // TODO: should be "many" but is not defined in translations -}; diff --git a/app/controllers/extra_locales_controller.rb b/app/controllers/extra_locales_controller.rb index 98f10352e86..a3055b2addd 100644 --- a/app/controllers/extra_locales_controller.rb +++ b/app/controllers/extra_locales_controller.rb @@ -5,25 +5,30 @@ class ExtraLocalesController < ApplicationController def show bundle = params[:bundle] - raise Discourse::InvalidAccess.new unless bundle =~ /^[a-z]+$/ + raise Discourse::InvalidAccess.new unless bundle =~ /^(admin|wizard)$/ locale_str = I18n.locale.to_s + bundle_str = "#{bundle}_js" + translations = JsLocaleHelper.translations_for(locale_str) - for_key = translations[locale_str]["#{bundle}_js"] + + for_key = {} + translations.values.each { |v| for_key.deep_merge!(v[bundle_str]) if v.has_key?(bundle_str) } + js = "" if for_key.present? - if plugin_for_key = JsLocaleHelper.plugin_translations(locale_str)["#{bundle}_js"] + if plugin_for_key = JsLocaleHelper.plugin_translations(locale_str)[bundle_str] for_key.deep_merge!(plugin_for_key) end - js = <<~JS - (function() { - if (window.I18n) { - window.I18n.extras = window.I18n.extras || []; - window.I18n.extras.push(#{for_key.to_json}); - } - })(); + js = <<~JS.squish + (function() { + if (window.I18n) { + window.I18n.extras = window.I18n.extras || []; + window.I18n.extras.push(#{for_key.to_json}); + } + })(); JS end diff --git a/lib/freedom_patches/translate_accelerator.rb b/lib/freedom_patches/translate_accelerator.rb index 8b734f9c89d..0ec215c2ead 100644 --- a/lib/freedom_patches/translate_accelerator.rb +++ b/lib/freedom_patches/translate_accelerator.rb @@ -42,7 +42,7 @@ module I18n end # load it - I18n.backend.load_translations(I18n.load_path.grep Regexp.new("\\.#{locale}\\.yml$")) + I18n.backend.load_translations(I18n.load_path.grep(/\.#{Regexp.escape locale}\.yml$/)) @loaded_locales << locale end @@ -125,7 +125,7 @@ module I18n end def client_overrides_json(locale) - client_json = (overrides_by_locale(locale) || {}).select {|k, _| k.starts_with?('js.') || k.starts_with?('admin_js.')} + client_json = (overrides_by_locale(locale) || {}).select { |k, _| k[/^(admin_js|js)\./] } MultiJson.dump(client_json) end diff --git a/lib/i18n/backend/discourse_i18n.rb b/lib/i18n/backend/discourse_i18n.rb index 8b2ea237598..99b19724b76 100644 --- a/lib/i18n/backend/discourse_i18n.rb +++ b/lib/i18n/backend/discourse_i18n.rb @@ -7,9 +7,6 @@ module I18n include I18n::Backend::Pluralization def available_locales - # in case you are wondering this is: - # Dir.glob( File.join(Rails.root, 'config', 'locales', 'client.*.yml') ) - # .map {|x| x.split('.')[-2]}.sort LocaleSiteSetting.supported_locales.map(&:to_sym) end @@ -53,6 +50,7 @@ module I18n end protected + def find_results(regexp, results, translations, path=nil) return results if translations.blank? diff --git a/lib/js_locale_helper.rb b/lib/js_locale_helper.rb index e8550b1b887..137dc2d4683 100644 --- a/lib/js_locale_helper.rb +++ b/lib/js_locale_helper.rb @@ -7,7 +7,7 @@ module JsLocaleHelper translations = {} Dir["#{Rails.root}/plugins/*/config/locales/client.#{locale_str}.yml"].each do |file| - if plugin_translations = YAML::load(File.open(file))[locale_str] + if plugin_translations = YAML.load_file(file)[locale_str] translations.deep_merge!(plugin_translations) end end @@ -26,7 +26,7 @@ module JsLocaleHelper locale_str = locale.to_s # load default translations - translations = YAML::load(File.open("#{Rails.root}/config/locales/client.#{locale_str}.yml")) + translations = YAML.load_file("#{Rails.root}/config/locales/client.#{locale_str}.yml") # merge translations (plugin translations overwrite default translations) translations[locale_str]['js'].deep_merge!(plugin_translations(locale_str)['js']) if translations[locale_str] && plugin_translations(locale_str) && plugin_translations(locale_str)['js'] @@ -35,23 +35,22 @@ module JsLocaleHelper end end - # purpose-built recursive algorithm ahoy! - def self.deep_delete_matches(deleting_from, *checking_hashes) + # deeply removes keys from "deleting_from" that are already present in "checking_hashes" + def self.deep_delete_matches(deleting_from, checking_hashes) checking_hashes.compact! new_hash = deleting_from.dup deleting_from.each do |key, value| - if value.is_a? Hash - # Recurse - new_at_key = deep_delete_matches(deleting_from[key], *(checking_hashes.map {|h| h[key]})) + if value.is_a?(Hash) + new_at_key = deep_delete_matches(deleting_from[key], checking_hashes.map { |h| h[key] }) if new_at_key.empty? - new_hash.delete key + new_hash.delete(key) else new_hash[key] = new_at_key end else - if checking_hashes.any? {|h| h.include? key} - new_hash.delete key + if checking_hashes.any? { |h| h.include?(key) } + new_hash.delete(key) end end end @@ -66,8 +65,8 @@ module JsLocaleHelper loaded_locales = [] locales.map(&:to_s).each do |locale| - all_translations[locale] = JsLocaleHelper.load_translations locale - merged_translations[locale] = deep_delete_matches(all_translations[locale][locale], *loaded_locales.map { |l| merged_translations[l] }) + all_translations[locale] = load_translations(locale) + merged_translations[locale] = deep_delete_matches(all_translations[locale][locale], loaded_locales.map { |l| merged_translations[l] }) loaded_locales << locale end merged_translations @@ -75,12 +74,11 @@ module JsLocaleHelper end def self.translations_for(locale_str) - locale_sym = locale_str.to_sym - current_locale = I18n.locale - I18n.locale = locale_sym + locale_sym = locale_str.to_sym + site_locale = SiteSetting.default_locale.to_sym - site_locale = SiteSetting.default_locale.to_sym + I18n.locale = locale_sym translations = if Rails.env.development? @@ -100,19 +98,23 @@ module JsLocaleHelper def self.output_locale(locale) locale_str = locale.to_s - translations = translations_for(locale_str).dup + translations = Marshal.load(Marshal.dump(translations_for(locale_str))) - translations[locale_str].keys.each do |k| - translations[locale_str].delete(k) unless k == "js" + translations.keys.each do |locale| + translations[locale].keys.each do |k| + translations[locale].delete(k) unless k == "js" + end end message_formats = strip_out_message_formats!(translations[locale_str]['js']) - result = generate_message_format(message_formats, locale_str) + # I18n result << "I18n.translations = #{translations.to_json};\n" result << "I18n.locale = '#{locale_str}';\n" - # loading moment here cause we must customize it + result << "I18n.pluralizationRules.#{locale_str} = MessageFormat.locale.#{locale_str};\n" if locale_str != "en" + + # moment result << File.read("#{Rails.root}/lib/javascripts/moment.js") result << moment_locale(locale_str) result << moment_formats @@ -136,33 +138,25 @@ module JsLocaleHelper def self.moment_locale(locale_str) # moment.js uses a different naming scheme for locale files locale_str = locale_str.tr('_', '-').downcase - filename = Rails.root + "lib/javascripts/moment_locale/#{locale_str}.js" + filename = "#{Rails.root}/lib/javascripts/moment_locale/#{locale_str}.js" - unless File.exists?(filename) - # try the language without the territory - locale_str = locale_str.partition('-').first - filename = Rails.root + "lib/javascripts/moment_locale/#{locale_str}.js" - end + # try the language without the territory + locale_str = locale_str.split("-")[0] + filename = "#{Rails.root}/lib/javascripts/moment_locale/#{locale_str}.js" unless File.exists?(filename) - if File.exists?(filename) - File.read(filename) << "\n" - end || "" + File.exists?(filename) ? File.read(filename) << "\n" : "" end def self.generate_message_format(message_formats, locale_str) + formats = message_formats.map { |k,v| k.inspect << " : " << compile_message_format(locale_str, v) }.join(", ") + + filename = "#{Rails.root}/lib/javascripts/locale/#{locale_str}.js" + filename = "#{Rails.root}/lib/javascripts/locale/en.js" unless File.exists?(filename) result = "MessageFormat = {locale: {}};\n" - - formats = message_formats.map{|k,v| k.inspect << " : " << compile_message_format(locale_str,v)}.join(", ") - result << "I18n._compiledMFs = {#{formats}};\n\n" - - filename = Rails.root + "lib/javascripts/locale/#{locale_str}.js" - filename = Rails.root + "lib/javascripts/locale/en.js" unless File.exists?(filename) - result << File.read(filename) << "\n\n" - - result << File.read("#{Rails.root}/lib/javascripts/messageformat-lookup.js") - - result + result << "I18n._compiledMFs = {#{formats}};\n" + result << File.read(filename) << "\n" + result << File.read("#{Rails.root}/lib/javascripts/messageformat-lookup.js") << "\n" end def self.reset_context @@ -174,7 +168,7 @@ module JsLocaleHelper @mutex.synchronize do yield @ctx ||= begin ctx = MiniRacer::Context.new - ctx.load(Rails.root + 'lib/javascripts/messageformat.js') + ctx.load("#{Rails.root}/lib/javascripts/messageformat.js") ctx end end @@ -182,7 +176,7 @@ module JsLocaleHelper def self.compile_message_format(locale, format) with_context do |ctx| - path = Rails.root + "lib/javascripts/locale/#{locale}.js" + path = "#{Rails.root}/lib/javascripts/locale/#{locale}.js" ctx.load(path) if File.exists?(path) ctx.eval("mf = new MessageFormat('#{locale}');") ctx.eval("mf.precompile(mf.parse(#{format.inspect}))") diff --git a/spec/components/js_locale_helper_spec.rb b/spec/components/js_locale_helper_spec.rb index a5ce82ed92a..17f0fcecdd6 100644 --- a/spec/components/js_locale_helper_spec.rb +++ b/spec/components/js_locale_helper_spec.rb @@ -15,6 +15,7 @@ describe JsLocaleHelper do @loaded_merges = nil end end + JsLocaleHelper.extend StubLoadTranslations after do @@ -22,64 +23,68 @@ describe JsLocaleHelper do JsLocaleHelper.clear_cache! end - it 'should be able to generate translations' do - expect(JsLocaleHelper.output_locale('en').length).to be > 0 + describe "#output_locale" do + + it "doesn't change the cached translations hash" do + I18n.locale = :fr + expect(JsLocaleHelper.output_locale('fr').length).to be > 0 + expect(JsLocaleHelper.translations_for('fr')['fr'].keys).to contain_exactly("js", "admin_js", "wizard_js") + end + end - def setup_message_format(format) - @ctx = MiniRacer::Context.new - @ctx.eval('MessageFormat = {locale: {}};') - @ctx.load(Rails.root + 'lib/javascripts/locale/en.js') - compiled = JsLocaleHelper.compile_message_format('en', format) - @ctx.eval("var test = #{compiled}") - end + context "message format" do - def localize(opts) - @ctx.eval("test(#{opts.to_json})") - end + def setup_message_format(format) + @ctx = MiniRacer::Context.new + @ctx.eval('MessageFormat = {locale: {}};') + @ctx.load(Rails.root + 'lib/javascripts/locale/en.js') + compiled = JsLocaleHelper.compile_message_format('en', format) + @ctx.eval("var test = #{compiled}") + end - it 'handles plurals' do - setup_message_format('{NUM_RESULTS, plural, - one {1 result} - other {# results} - }') - expect(localize(NUM_RESULTS: 1)).to eq('1 result') - expect(localize(NUM_RESULTS: 2)).to eq('2 results') - end + def localize(opts) + @ctx.eval("test(#{opts.to_json})") + end - it 'handles double plurals' do - setup_message_format('{NUM_RESULTS, plural, - one {1 result} - other {# results} - } and {NUM_APPLES, plural, - one {1 apple} - other {# apples} - }') + it 'handles plurals' do + setup_message_format('{NUM_RESULTS, plural, + one {1 result} + other {# results} + }') + expect(localize(NUM_RESULTS: 1)).to eq('1 result') + expect(localize(NUM_RESULTS: 2)).to eq('2 results') + end - expect(localize(NUM_RESULTS: 1, NUM_APPLES: 2)).to eq('1 result and 2 apples') - expect(localize(NUM_RESULTS: 2, NUM_APPLES: 1)).to eq('2 results and 1 apple') - end + it 'handles double plurals' do + setup_message_format('{NUM_RESULTS, plural, + one {1 result} + other {# results} + } and {NUM_APPLES, plural, + one {1 apple} + other {# apples} + }') - it 'handles select' do - setup_message_format('{GENDER, select, male {He} female {She} other {They}} read a book') - expect(localize(GENDER: 'male')).to eq('He read a book') - expect(localize(GENDER: 'female')).to eq('She read a book') - expect(localize(GENDER: 'none')).to eq('They read a book') - end + expect(localize(NUM_RESULTS: 1, NUM_APPLES: 2)).to eq('1 result and 2 apples') + expect(localize(NUM_RESULTS: 2, NUM_APPLES: 1)).to eq('2 results and 1 apple') + end - it 'can strip out message formats' do - hash = {"a" => "b", "c" => { "d" => {"f_MF" => "bob"} }} - expect(JsLocaleHelper.strip_out_message_formats!(hash)).to eq({"c.d.f_MF" => "bob"}) - expect(hash["c"]["d"]).to eq({}) - end + it 'handles select' do + setup_message_format('{GENDER, select, male {He} female {She} other {They}} read a book') + expect(localize(GENDER: 'male')).to eq('He read a book') + expect(localize(GENDER: 'female')).to eq('She read a book') + expect(localize(GENDER: 'none')).to eq('They read a book') + end - it 'handles message format special keys' do - ctx = MiniRacer::Context.new - ctx.eval("I18n = {};") + it 'can strip out message formats' do + hash = {"a" => "b", "c" => { "d" => {"f_MF" => "bob"} }} + expect(JsLocaleHelper.strip_out_message_formats!(hash)).to eq({"c.d.f_MF" => "bob"}) + expect(hash["c"]["d"]).to eq({}) + end - JsLocaleHelper.set_translations 'en', { - "en" => - { + it 'handles message format special keys' do + JsLocaleHelper.set_translations('en', { + "en" => { "js" => { "hello" => "world", "test_MF" => "{HELLO} {COUNT, plural, one {1 duck} other {# ducks}}", @@ -87,26 +92,29 @@ describe JsLocaleHelper do "simple_MF" => "{COUNT, plural, one {1} other {#}}" } } - } + }) - ctx.eval(JsLocaleHelper.output_locale('en')) + ctx = MiniRacer::Context.new + ctx.eval("I18n = { pluralizationRules: {} };") + ctx.eval(JsLocaleHelper.output_locale('en')) - expect(ctx.eval('I18n.translations["en"]["js"]["hello"]')).to eq("world") - expect(ctx.eval('I18n.translations["en"]["js"]["test_MF"]')).to eq(nil) + expect(ctx.eval('I18n.translations["en"]["js"]["hello"]')).to eq("world") + expect(ctx.eval('I18n.translations["en"]["js"]["test_MF"]')).to eq(nil) - expect(ctx.eval('I18n.messageFormat("test_MF", { HELLO: "hi", COUNT: 3 })')).to eq("hi 3 ducks") - expect(ctx.eval('I18n.messageFormat("error_MF", { HELLO: "hi", COUNT: 3 })')).to match(/Invalid Format/) - expect(ctx.eval('I18n.messageFormat("missing", {})')).to match(/missing/) - expect(ctx.eval('I18n.messageFormat("simple_MF", {})')).to match(/COUNT/) # error - end + expect(ctx.eval('I18n.messageFormat("test_MF", { HELLO: "hi", COUNT: 3 })')).to eq("hi 3 ducks") + expect(ctx.eval('I18n.messageFormat("error_MF", { HELLO: "hi", COUNT: 3 })')).to match(/Invalid Format/) + expect(ctx.eval('I18n.messageFormat("missing", {})')).to match(/missing/) + expect(ctx.eval('I18n.messageFormat("simple_MF", {})')).to match(/COUNT/) # error + end - it 'load pluralizations rules before precompile' do - message = JsLocaleHelper.compile_message_format('ru', 'format') - expect(message).not_to match 'Plural Function not found' + it 'load pluralizations rules before precompile' do + message = JsLocaleHelper.compile_message_format('ru', 'format') + expect(message).not_to match 'Plural Function not found' + end end it 'performs fallbacks to english if a translation is not available' do - JsLocaleHelper.set_translations 'en', { + JsLocaleHelper.set_translations('en', { "en" => { "js" => { "only_english" => "1-en", @@ -115,8 +123,9 @@ describe JsLocaleHelper do "all_three" => "7-en", } } - } - JsLocaleHelper.set_translations 'ru', { + }) + + JsLocaleHelper.set_translations('ru', { "ru" => { "js" => { "only_site" => "2-ru", @@ -125,8 +134,9 @@ describe JsLocaleHelper do "all_three" => "7-ru", } } - } - JsLocaleHelper.set_translations 'uk', { + }) + + JsLocaleHelper.set_translations('uk', { "uk" => { "js" => { "only_user" => "4-uk", @@ -135,7 +145,7 @@ describe JsLocaleHelper do "all_three" => "7-uk", } } - } + }) expected = { "none" => "[uk.js.none]", @@ -157,9 +167,9 @@ describe JsLocaleHelper do ctx.eval(JsLocaleHelper.output_locale(I18n.locale)) ctx.eval('I18n.defaultLocale = "ru";') - # Test - unneeded translations are not emitted - expect(ctx.eval('I18n.translations.en.js').keys).to eq(["only_english"]) - expect(ctx.eval('I18n.translations.ru.js').keys).to eq(["only_site", "english_and_site"]) + expect(ctx.eval('I18n.translations.en.js').keys).to contain_exactly("only_english") + expect(ctx.eval('I18n.translations.ru.js').keys).to contain_exactly("only_site", "english_and_site") + expect(ctx.eval('I18n.translations.uk.js').keys).to contain_exactly("all_three", "english_and_user", "only_user", "site_and_user") expected.each do |key, expect| expect(ctx.eval("I18n.t(#{"js.#{key}".inspect})")).to eq(expect) diff --git a/spec/controllers/extra_locales_controller_spec.rb b/spec/controllers/extra_locales_controller_spec.rb index 6289845f79b..b848f95399c 100644 --- a/spec/controllers/extra_locales_controller_spec.rb +++ b/spec/controllers/extra_locales_controller_spec.rb @@ -3,10 +3,6 @@ require 'rails_helper' describe ExtraLocalesController do context 'show' do - before do - I18n.locale = :en - I18n.reload! - end it "needs a valid bundle" do get :show, bundle: 'made-up-bundle' @@ -19,19 +15,23 @@ describe ExtraLocalesController do expect(response).to_not be_success end - it "should include plugin translations" do - skip "FIXME: Randomly failing" - JsLocaleHelper.expects(:plugin_translations).with(I18n.locale.to_s).returns({ - "admin_js" => { - "admin" => { - "site_settings" => { - "categories" => { - "github_badges" => "Github Badges" - } - } - } - } - }).at_least_once + it "includes plugin translations" do + I18n.locale = :en + I18n.reload! + + JsLocaleHelper.expects(:plugin_translations) + .with(I18n.locale.to_s) + .returns({ + "admin_js" => { + "admin" => { + "site_settings" => { + "categories" => { + "github_badges" => "Github Badges" + } + } + } + } + }).at_least_once get :show, bundle: "admin" diff --git a/test/javascripts/initializers/localization-test.js.es6 b/test/javascripts/initializers/localization-test.js.es6 new file mode 100644 index 00000000000..4f6e0670e52 --- /dev/null +++ b/test/javascripts/initializers/localization-test.js.es6 @@ -0,0 +1,43 @@ +import PreloadStore from 'preload-store'; +import LocalizationInitializer from 'discourse/initializers/localization'; + +module("initializer:localization", { + _locale: I18n.locale, + _translations: I18n.translations, + + setup() { + I18n.locale = "fr"; + + I18n.translations = { + "fr": { + "js": { + "composer": { + "reply": "Répondre" + } + } + }, + "en": { + "js": { + "topic": { + "reply": { + "help": "begin composing a reply to this topic" + } + } + } + } + }; + }, + + teardown() { + I18n.locale = this._locale; + I18n.translations = this._translations; + } +}); + +test("translation overrides", function() { + PreloadStore.store('translationOverrides', {"js.composer.reply":"WAT","js.topic.reply.help":"foobar"}); + LocalizationInitializer.initialize(this.registry); + + equal(I18n.t("composer.reply"), "WAT", "overrides existing translation in current locale"); + equal(I18n.t("topic.reply.help"), "foobar", "overrides translation in default locale"); +}); diff --git a/test/javascripts/lib/i18n-test.js.es6 b/test/javascripts/lib/i18n-test.js.es6 new file mode 100644 index 00000000000..e3f47b8b426 --- /dev/null +++ b/test/javascripts/lib/i18n-test.js.es6 @@ -0,0 +1,55 @@ +module("lib:i18n", { + _locale: I18n.locale, + _translations: I18n.translations, + + setup() { + I18n.locale = "fr"; + + I18n.translations = { + "fr": { + "js": { + "hello": "Bonjour", + "topic": { + "reply": { + "title": "Répondre", + } + } + } + }, + "en": { + "js": { + "hello": { + "world": "Hello World!" + }, + "topic": { + "reply": { + "help": "begin composing a reply to this topic" + } + } + } + } + }; + }, + + teardown() { + I18n.locale = this._locale; + I18n.translations = this._translations; + } +}); + +test("defaults", function() { + equal(I18n.defaultLocale, "en", "it has English as default locale"); + ok(I18n.pluralizationRules["en"], "it has English pluralizer"); +}); + +test("translations", function() { + equal(I18n.t("topic.reply.title"), "Répondre", "uses locale translations when they exist"); + equal(I18n.t("topic.reply.help"), "begin composing a reply to this topic", "fallbacks to English translations"); + equal(I18n.t("hello.world"), "Hello World!", "doesn't break if a key is overriden in a locale"); +}); + +test("extra translations", function() { + I18n.extras = [{ "admin": { "title": "Discourse Admin" }}]; + + equal(I18n.t("admin.title"), "Discourse Admin", "it check extra translations when they exists"); +}); diff --git a/test/javascripts/test_helper.js b/test/javascripts/test_helper.js index f82d4b70653..1834bfe8a7d 100644 --- a/test/javascripts/test_helper.js +++ b/test/javascripts/test_helper.js @@ -41,7 +41,6 @@ // //= require jquery.magnific-popup-min.js -window.TestPreloadStore = require('preload-store').default; window.inTestEnv = true; // Stop the message bus so we don't get ajax calls