diff --git a/.gitignore b/.gitignore index fc6236fd..273a0962 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ coverage/ # dont push generated js translations to repository /app/assets/javascripts/i18n/*.js +/app/javascript/packs/legacy/i18n.js # every fucking time, dolphin .directory diff --git a/Gemfile b/Gemfile index 5b87b288..d87518fc 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ source 'https://rubygems.org' gem 'rails', '~> 5.2' gem 'rails-i18n', '~> 5.0' -gem 'i18n-js', '= 3.0.0.rc10' +gem 'i18n-js', '= 3.6' gem 'pg' diff --git a/Gemfile.lock b/Gemfile.lock index d27a37a2..318255db 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -59,7 +59,7 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) - active_model_otp (2.0.1) + active_model_otp (2.1.0) activemodel rotp (~> 5.0.0) activejob (5.2.4.4) @@ -88,7 +88,7 @@ GEM public_suffix (>= 2.0.2, < 5.0) arel (9.0.0) ast (2.4.1) - autoprefixer-rails (9.8.5) + autoprefixer-rails (10.1.0.0) execjs bcrypt (3.1.16) better_errors (2.9.1) @@ -109,11 +109,11 @@ GEM bootstrap_form (4.5.0) actionpack (>= 5.2) activemodel (>= 5.2) - brakeman (4.10.0) + brakeman (4.10.1) buftok (0.2.0) builder (3.2.4) byebug (11.1.3) - capybara (3.33.0) + capybara (3.34.0) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) @@ -128,7 +128,7 @@ GEM image_processing (~> 1.1) mimemagic (>= 0.3.0) mini_mime (>= 0.1.3) - chunky_png (1.3.14) + chunky_png (1.3.15) cliver (0.3.2) coderay (1.1.3) coffee-rails (4.2.2) @@ -156,12 +156,12 @@ GEM devise-i18n (1.9.2) devise (>= 4.7.1) diff-lcs (1.4.4) - docile (1.3.2) + docile (1.3.4) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) equalizer (0.0.11) erubi (1.10.0) - excon (0.78.0) + excon (0.78.1) execjs (2.7.0) factory_bot (6.1.0) activesupport (>= 5.0.0) @@ -171,18 +171,18 @@ GEM fake_email_validator (1.0.11) activemodel mail - faker (2.2.1) - i18n (>= 0.8) - faraday (1.1.0) + faker (2.15.1) + i18n (>= 1.6, < 2) + faraday (1.2.0) multipart-post (>= 1.2, < 3) ruby2_keywords faraday_middleware (1.0.0) faraday (~> 1.0) - ffi (1.13.1) + ffi (1.14.2) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake - fog-aws (3.6.7) + fog-aws (3.7.0) fog-core (~> 2.1) fog-json (~> 1.1) fog-xml (~> 0.1) @@ -221,7 +221,7 @@ GEM guard (>= 2.0.0) guard-compat (~> 1.0) guard-compat (1.2.1) - haml (5.2.0) + haml (5.2.1) temple (>= 0.8.0) tilt haml_lint (0.36.0) @@ -245,10 +245,10 @@ GEM httparty (0.18.1) mime-types (~> 3.0) multi_xml (>= 0.5.2) - i18n (0.9.5) + i18n (1.8.5) concurrent-ruby (~> 1.0) - i18n-js (3.0.0.rc10) - i18n (~> 0.6) + i18n-js (3.6.0) + i18n (>= 0.6.6) image_processing (1.12.1) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) @@ -267,7 +267,7 @@ GEM turbolinks jquery-ui-rails (6.0.1) railties (>= 3.2.16) - json (2.3.1) + json (2.5.1) kaminari (1.2.1) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.1) @@ -284,10 +284,10 @@ GEM addressable (~> 2.7) letter_opener (1.7.0) launchy (~> 2.2) - listen (3.3.1) + listen (3.3.3) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.7.0) + loofah (2.8.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) lumberjack (1.2.8) @@ -316,11 +316,11 @@ GEM naught (1.1.0) nenv (0.3.0) nested_form (0.3.2) - newrelic_rpm (6.13.1) + newrelic_rpm (6.14.0) nio4r (2.5.4) nokogiri (1.10.10) mini_portile2 (~> 2.4.0) - nokogumbo (2.0.2) + nokogumbo (2.0.4) nokogiri (~> 1.8, >= 1.8.4) notiffany (0.1.3) nenv (~> 0.1) @@ -340,11 +340,11 @@ GEM omniauth-oauth (~> 1.1) rack orm_adapter (0.5.0) - parallel (1.20.0) + parallel (1.20.1) parser (2.7.2.0) ast (~> 2.4.1) pg (1.2.3) - pghero (2.7.2) + pghero (2.7.3) activerecord (>= 5) poltergeist (1.18.1) capybara (>= 2.1, < 4) @@ -355,7 +355,7 @@ GEM coderay (~> 1.1) method_source (~> 1.0) public_suffix (4.0.6) - puma (5.0.4) + puma (5.1.1) nio4r (~> 2.0) rack (2.2.3) rack-pjax (1.1.0) @@ -414,11 +414,11 @@ GEM rake (>= 0.8.7) thor (>= 0.19.0, < 2.0) rainbow (3.0.0) - rake (13.0.1) + rake (13.0.3) rb-fsevent (0.10.4) rb-inotify (0.10.1) ffi (~> 1.0) - redcarpet (3.5.0) + redcarpet (3.5.1) redis (4.1.4) regexp_parser (1.8.2) remotipart (1.4.4) @@ -456,16 +456,16 @@ GEM rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) rspec-support (3.9.4) - rubocop (1.3.1) + rubocop (1.7.0) parallel (~> 1.10) parser (>= 2.7.1.5) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8) + regexp_parser (>= 1.8, < 3.0) rexml - rubocop-ast (>= 1.1.1) + rubocop-ast (>= 1.2.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 2.0) - rubocop-ast (1.1.1) + rubocop-ast (1.3.0) parser (>= 2.7.1.5) ruby-progressbar (1.10.1) ruby-vips (2.0.17) @@ -502,15 +502,17 @@ GEM rack-protection (>= 1.5.0) redis (>= 3.3.5, < 4.2) simple_oauth (0.3.1) - simplecov (0.19.1) + simplecov (0.20.0) docile (~> 1.1) simplecov-html (~> 0.11) - simplecov-cobertura (1.4.1) + simplecov_json_formatter (~> 0.1) + simplecov-cobertura (1.4.2) simplecov (~> 0.8) simplecov-html (0.12.3) simplecov-json (0.2.3) json simplecov + simplecov_json_formatter (0.1.2) spring (2.1.1) sprockets (3.7.2) concurrent-ruby (~> 1.0) @@ -542,7 +544,7 @@ GEM multipart-post (~> 2.0) naught (~> 1.0) simple_oauth (~> 0.3.0) - tzinfo (1.2.8) + tzinfo (1.2.9) thread_safe (~> 0.1) uglifier (4.2.0) execjs (>= 0.3.0, < 3) @@ -603,7 +605,7 @@ DEPENDENCIES haml_lint hcaptcha (~> 6.0)! httparty - i18n-js (= 3.0.0.rc10) + i18n-js (= 3.6) jbuilder (~> 2.10) jquery-minicolors-rails jquery-rails diff --git a/app/javascript/packs/legacy/application.coffee b/app/javascript/packs/legacy/application.coffee index 1cd39cd2..43194f9f 100644 --- a/app/javascript/packs/legacy/application.coffee +++ b/app/javascript/packs/legacy/application.coffee @@ -11,6 +11,9 @@ import 'jquery.growl' import NProgress from 'nprogress' import Cookies from 'js-cookie' +# this file is generated by Rails +import I18n from './i18n' + import './answerbox' import './questionbox' import './inbox' diff --git a/config/application.rb b/config/application.rb index 68c8d943..80dfbed0 100644 --- a/config/application.rb +++ b/config/application.rb @@ -20,7 +20,7 @@ module Justask config.active_job.queue_adapter = :sidekiq config.i18n.default_locale = "en" - config.i18n.fallbacks = true + config.i18n.fallbacks = [I18n.default_locale] config.i18n.enforce_available_locales = false config.after_initialize do diff --git a/config/environments/production.rb b/config/environments/production.rb index 603fdcc4..6ef06270 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -66,7 +66,7 @@ Rails.application.configure do # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). - config.i18n.fallbacks = true + config.i18n.fallbacks = [I18n.default_locale] # Send deprecation notices to registered listeners. config.active_support.deprecation = :notify diff --git a/config/i18n-js.yml b/config/i18n-js.yml index 2839d25a..8de53b36 100644 --- a/config/i18n-js.yml +++ b/config/i18n-js.yml @@ -24,5 +24,7 @@ fallbacks: :default_locale translations: - - file: 'app/assets/javascripts/i18n/translations.js' + - file: 'app/javascript/packs/legacy/i18n.js' only: ['*.frontend.*', '*.views.actions.*'] + prefix: "import I18n from 'i18n-js'\n" + suffix: "\nexport default I18n" diff --git a/package.json b/package.json index 594631c4..46c20448 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "bootstrap": "^4.5.3", "cheet.js": "^0.3.3", "core-js": "^3.8.1", + "i18n-js": "^3.8.0", "jquery": "^3.5.1", "jquery-minicolors": "^2.1.10", "jquery-ujs": "^1.2.2", diff --git a/public/javascripts/i18n.js b/public/javascripts/i18n.js new file mode 100644 index 00000000..0adb3e9f --- /dev/null +++ b/public/javascripts/i18n.js @@ -0,0 +1,1092 @@ +// I18n.js +// ======= +// +// This small library provides the Rails I18n API on the Javascript. +// You don't actually have to use Rails (or even Ruby) to use I18n.js. +// Just make sure you export all translations in an object like this: +// +// I18n.translations.en = { +// hello: "Hello World" +// }; +// +// See tests for specific formatting like numbers and dates. +// + +// Using UMD pattern from +// https://github.com/umdjs/umd#regular-module +// `returnExports.js` version +;(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define("i18n", function(){ return factory(root);}); + } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(root); + } else { + // Browser globals (root is window) + root.I18n = factory(root); + } +}(this, function(global) { + "use strict"; + + // Use previously defined object if exists in current scope + var I18n = global && global.I18n || {}; + + // Just cache the Array#slice function. + var slice = Array.prototype.slice; + + // Apply number padding. + var padding = function(number) { + return ("0" + number.toString()).substr(-2); + }; + + // Improved toFixed number rounding function with support for unprecise floating points + // JavaScript's standard toFixed function does not round certain numbers correctly (for example 0.105 with precision 2). + var toFixed = function(number, precision) { + return decimalAdjust('round', number, -precision).toFixed(precision); + }; + + // Is a given variable an object? + // Borrowed from Underscore.js + var isObject = function(obj) { + var type = typeof obj; + return type === 'function' || type === 'object' + }; + + var isFunction = function(func) { + var type = typeof func; + return type === 'function' + }; + + // Check if value is different than undefined and null; + var isSet = function(value) { + return typeof(value) !== 'undefined' && value !== null; + }; + + // Is a given value an array? + // Borrowed from Underscore.js + var isArray = function(val) { + if (Array.isArray) { + return Array.isArray(val); + } + return Object.prototype.toString.call(val) === '[object Array]'; + }; + + var isString = function(val) { + return typeof val === 'string' || Object.prototype.toString.call(val) === '[object String]'; + }; + + var isNumber = function(val) { + return typeof val === 'number' || Object.prototype.toString.call(val) === '[object Number]'; + }; + + var isBoolean = function(val) { + return val === true || val === false; + }; + + var isNull = function(val) { + return val === null; + }; + + var decimalAdjust = function(type, value, exp) { + // If the exp is undefined or zero... + if (typeof exp === 'undefined' || +exp === 0) { + return Math[type](value); + } + value = +value; + exp = +exp; + // If the value is not a number or the exp is not an integer... + if (isNaN(value) || !(typeof exp === 'number' && exp % 1 === 0)) { + return NaN; + } + // Shift + value = value.toString().split('e'); + value = Math[type](+(value[0] + 'e' + (value[1] ? (+value[1] - exp) : -exp))); + // Shift back + value = value.toString().split('e'); + return +(value[0] + 'e' + (value[1] ? (+value[1] + exp) : exp)); + }; + + var lazyEvaluate = function(message, scope) { + if (isFunction(message)) { + return message(scope); + } else { + return message; + } + }; + + var merge = function (dest, obj) { + var key, value; + for (key in obj) if (obj.hasOwnProperty(key)) { + value = obj[key]; + if (isString(value) || isNumber(value) || isBoolean(value) || isArray(value) || isNull(value)) { + dest[key] = value; + } else { + if (dest[key] == null) dest[key] = {}; + merge(dest[key], value); + } + } + return dest; + }; + + // Set default days/months translations. + var DATE = { + day_names: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + , abbr_day_names: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + , month_names: [null, "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] + , abbr_month_names: [null, "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + , meridian: ["AM", "PM"] + }; + + // Set default number format. + var NUMBER_FORMAT = { + precision: 3 + , separator: "." + , delimiter: "," + , strip_insignificant_zeros: false + }; + + // Set default currency format. + var CURRENCY_FORMAT = { + unit: "$" + , precision: 2 + , format: "%u%n" + , sign_first: true + , delimiter: "," + , separator: "." + }; + + // Set default percentage format. + var PERCENTAGE_FORMAT = { + unit: "%" + , precision: 3 + , format: "%n%u" + , separator: "." + , delimiter: "" + }; + + // Set default size units. + var SIZE_UNITS = [null, "kb", "mb", "gb", "tb"]; + + // Other default options + var DEFAULT_OPTIONS = { + // Set default locale. This locale will be used when fallback is enabled and + // the translation doesn't exist in a particular locale. + defaultLocale: "en" + // Set the current locale to `en`. + , locale: "en" + // Set the translation key separator. + , defaultSeparator: "." + // Set the placeholder format. Accepts `{{placeholder}}` and `%{placeholder}`. + , placeholder: /(?:\{\{|%\{)(.*?)(?:\}\}?)/gm + // Set if engine should fallback to the default locale when a translation + // is missing. + , fallbacks: false + // Set the default translation object. + , translations: {} + // Set missing translation behavior. 'message' will display a message + // that the translation is missing, 'guess' will try to guess the string + , missingBehaviour: 'message' + // if you use missingBehaviour with 'message', but want to know that the + // string is actually missing for testing purposes, you can prefix the + // guessed string by setting the value here. By default, no prefix! + , missingTranslationPrefix: '' + }; + + // Set default locale. This locale will be used when fallback is enabled and + // the translation doesn't exist in a particular locale. + I18n.reset = function() { + var key; + for (key in DEFAULT_OPTIONS) { + this[key] = DEFAULT_OPTIONS[key]; + } + }; + + // Much like `reset`, but only assign options if not already assigned + I18n.initializeOptions = function() { + var key; + for (key in DEFAULT_OPTIONS) if (!isSet(this[key])) { + this[key] = DEFAULT_OPTIONS[key]; + } + }; + I18n.initializeOptions(); + + // Return a list of all locales that must be tried before returning the + // missing translation message. By default, this will consider the inline option, + // current locale and fallback locale. + // + // I18n.locales.get("de-DE"); + // // ["de-DE", "de", "en"] + // + // You can define custom rules for any locale. Just make sure you return a array + // containing all locales. + // + // // Default the Wookie locale to English. + // I18n.locales["wk"] = function(locale) { + // return ["en"]; + // }; + // + I18n.locales = {}; + + // Retrieve locales based on inline locale, current locale or default to + // I18n's detection. + I18n.locales.get = function(locale) { + var result = this[locale] || this[I18n.locale] || this["default"]; + + if (isFunction(result)) { + result = result(locale); + } + + if (isArray(result) === false) { + result = [result]; + } + + return result; + }; + + // The default locale list. + I18n.locales["default"] = function(locale) { + var locales = [] + , list = [] + ; + + // Handle the inline locale option that can be provided to + // the `I18n.t` options. + if (locale) { + locales.push(locale); + } + + // Add the current locale to the list. + if (!locale && I18n.locale) { + locales.push(I18n.locale); + } + + // Add the default locale if fallback strategy is enabled. + if (I18n.fallbacks && I18n.defaultLocale) { + locales.push(I18n.defaultLocale); + } + + // Locale code format 1: + // According to RFC4646 (http://www.ietf.org/rfc/rfc4646.txt) + // language codes for Traditional Chinese should be `zh-Hant` + // + // But due to backward compatibility + // We use older version of IETF language tag + // @see http://www.w3.org/TR/html401/struct/dirlang.html + // @see http://en.wikipedia.org/wiki/IETF_language_tag + // + // Format: `language-code = primary-code ( "-" subcode )*` + // + // primary-code uses ISO639-1 + // @see http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + // @see http://www.iso.org/iso/home/standards/language_codes.htm + // + // subcode uses ISO 3166-1 alpha-2 + // @see http://en.wikipedia.org/wiki/ISO_3166 + // @see http://www.iso.org/iso/country_codes.htm + // + // @note + // subcode can be in upper case or lower case + // defining it in upper case is a convention only + + + // Locale code format 2: + // Format: `code = primary-code ( "-" region-code )*` + // primary-code uses ISO 639-1 + // script-code uses ISO 15924 + // region-code uses ISO 3166-1 alpha-2 + // Example: zh-Hant-TW, en-HK, zh-Hant-CN + // + // It is similar to RFC4646 (or actually the same), + // but seems to be limited to language, script, region + + // Compute each locale with its country code. + // So this will return an array containing + // `de-DE` and `de` + // or + // `zh-hans-tw`, `zh-hans`, `zh` + // locales. + locales.forEach(function(locale) { + var localeParts = locale.split("-"); + var firstFallback = null; + var secondFallback = null; + if (localeParts.length === 3) { + firstFallback = [ + localeParts[0], + localeParts[1] + ].join("-"); + secondFallback = localeParts[0]; + } + else if (localeParts.length === 2) { + firstFallback = localeParts[0]; + } + + if (list.indexOf(locale) === -1) { + list.push(locale); + } + + if (! I18n.fallbacks) { + return; + } + + [ + firstFallback, + secondFallback + ].forEach(function(nullableFallbackLocale) { + // We don't want null values + if (typeof nullableFallbackLocale === "undefined") { return; } + if (nullableFallbackLocale === null) { return; } + // We don't want duplicate values + // + // Comparing with `locale` first is faster than + // checking whether value's presence in the list + if (nullableFallbackLocale === locale) { return; } + if (list.indexOf(nullableFallbackLocale) !== -1) { return; } + + list.push(nullableFallbackLocale); + }); + }); + + // No locales set? English it is. + if (!locales.length) { + locales.push("en"); + } + + return list; + }; + + // Hold pluralization rules. + I18n.pluralization = {}; + + // Return the pluralizer for a specific locale. + // If no specify locale is found, then I18n's default will be used. + I18n.pluralization.get = function(locale) { + return this[locale] || this[I18n.locale] || this["default"]; + }; + + // The default pluralizer rule. + // It detects the `zero`, `one`, and `other` scopes. + I18n.pluralization["default"] = function(count) { + switch (count) { + case 0: return ["zero", "other"]; + case 1: return ["one"]; + default: return ["other"]; + } + }; + + // Return current locale. If no locale has been set, then + // the current locale will be the default locale. + I18n.currentLocale = function() { + return this.locale || this.defaultLocale; + }; + + // Check if value is different than undefined and null; + I18n.isSet = isSet; + + // Find and process the translation using the provided scope and options. + // This is used internally by some functions and should not be used as an + // public API. + I18n.lookup = function(scope, options) { + options = options || {}; + + var locales = this.locales.get(options.locale).slice() + , locale + , scopes + , fullScope + , translations + ; + + fullScope = this.getFullScope(scope, options); + + while (locales.length) { + locale = locales.shift(); + scopes = fullScope.split(this.defaultSeparator); + translations = this.translations[locale]; + + if (!translations) { + continue; + } + while (scopes.length) { + translations = translations[scopes.shift()]; + + if (translations === undefined || translations === null) { + break; + } + } + + if (translations !== undefined && translations !== null) { + return translations; + } + } + + if (isSet(options.defaultValue)) { + return lazyEvaluate(options.defaultValue, scope); + } + }; + + // lookup pluralization rule key into translations + I18n.pluralizationLookupWithoutFallback = function(count, locale, translations) { + var pluralizer = this.pluralization.get(locale) + , pluralizerKeys = pluralizer(count) + , pluralizerKey + , message; + + if (isObject(translations)) { + while (pluralizerKeys.length) { + pluralizerKey = pluralizerKeys.shift(); + if (isSet(translations[pluralizerKey])) { + message = translations[pluralizerKey]; + break; + } + } + } + + return message; + }; + + // Lookup dedicated to pluralization + I18n.pluralizationLookup = function(count, scope, options) { + options = options || {}; + var locales = this.locales.get(options.locale).slice() + , locale + , scopes + , translations + , message + ; + scope = this.getFullScope(scope, options); + + while (locales.length) { + locale = locales.shift(); + scopes = scope.split(this.defaultSeparator); + translations = this.translations[locale]; + + if (!translations) { + continue; + } + + while (scopes.length) { + translations = translations[scopes.shift()]; + if (!isObject(translations)) { + break; + } + if (scopes.length === 0) { + message = this.pluralizationLookupWithoutFallback(count, locale, translations); + } + } + if (typeof message !== "undefined" && message !== null) { + break; + } + } + + if (typeof message === "undefined" || message === null) { + if (isSet(options.defaultValue)) { + if (isObject(options.defaultValue)) { + message = this.pluralizationLookupWithoutFallback(count, options.locale, options.defaultValue); + } else { + message = options.defaultValue; + } + translations = options.defaultValue; + } + } + + return { message: message, translations: translations }; + }; + + // Rails changed the way the meridian is stored. + // It started with `date.meridian` returning an array, + // then it switched to `time.am` and `time.pm`. + // This function abstracts this difference and returns + // the correct meridian or the default value when none is provided. + I18n.meridian = function() { + var time = this.lookup("time"); + var date = this.lookup("date"); + + if (time && time.am && time.pm) { + return [time.am, time.pm]; + } else if (date && date.meridian) { + return date.meridian; + } else { + return DATE.meridian; + } + }; + + // Merge serveral hash options, checking if value is set before + // overwriting any value. The precedence is from left to right. + // + // I18n.prepareOptions({name: "John Doe"}, {name: "Mary Doe", role: "user"}); + // #=> {name: "John Doe", role: "user"} + // + I18n.prepareOptions = function() { + var args = slice.call(arguments) + , options = {} + , subject + ; + + while (args.length) { + subject = args.shift(); + + if (typeof(subject) != "object") { + continue; + } + + for (var attr in subject) { + if (!subject.hasOwnProperty(attr)) { + continue; + } + + if (isSet(options[attr])) { + continue; + } + + options[attr] = subject[attr]; + } + } + + return options; + }; + + // Generate a list of translation options for default fallbacks. + // `defaultValue` is also deleted from options as it is returned as part of + // the translationOptions array. + I18n.createTranslationOptions = function(scope, options) { + var translationOptions = [{scope: scope}]; + + // Defaults should be an array of hashes containing either + // fallback scopes or messages + if (isSet(options.defaults)) { + translationOptions = translationOptions.concat(options.defaults); + } + + // Maintain support for defaultValue. Since it is always a message + // insert it in to the translation options as such. + if (isSet(options.defaultValue)) { + translationOptions.push({ message: options.defaultValue }); + } + + return translationOptions; + }; + + // Translate the given scope with the provided options. + I18n.translate = function(scope, options) { + options = options || {}; + + var translationOptions = this.createTranslationOptions(scope, options); + + var translation; + var usedScope = scope; + + var optionsWithoutDefault = this.prepareOptions(options) + delete optionsWithoutDefault.defaultValue + + // Iterate through the translation options until a translation + // or message is found. + var translationFound = + translationOptions.some(function(translationOption) { + if (isSet(translationOption.scope)) { + usedScope = translationOption.scope; + translation = this.lookup(usedScope, optionsWithoutDefault); + } else if (isSet(translationOption.message)) { + translation = lazyEvaluate(translationOption.message, scope); + } + + if (translation !== undefined && translation !== null) { + return true; + } + }, this); + + if (!translationFound) { + return this.missingTranslation(scope, options); + } + + if (typeof(translation) === "string") { + translation = this.interpolate(translation, options); + } else if (isArray(translation)) { + translation = translation.map(function(t) { + return (typeof(t) === "string" ? this.interpolate(t, options) : t); + }, this); + } else if (isObject(translation) && isSet(options.count)) { + translation = this.pluralize(options.count, usedScope, options); + } + + return translation; + }; + + // This function interpolates the all variables in the given message. + I18n.interpolate = function(message, options) { + if (message == null) { + return message; + } + + options = options || {}; + var matches = message.match(this.placeholder) + , placeholder + , value + , name + , regex + ; + + if (!matches) { + return message; + } + + while (matches.length) { + placeholder = matches.shift(); + name = placeholder.replace(this.placeholder, "$1"); + + if (isSet(options[name])) { + value = options[name].toString().replace(/\$/gm, "_#$#_"); + } else if (name in options) { + value = this.nullPlaceholder(placeholder, message, options); + } else { + value = this.missingPlaceholder(placeholder, message, options); + } + + regex = new RegExp(placeholder.replace(/{/gm, "\\{").replace(/}/gm, "\\}")); + message = message.replace(regex, value); + } + + return message.replace(/_#\$#_/g, "$"); + }; + + // Pluralize the given scope using the `count` value. + // The pluralized translation may have other placeholders, + // which will be retrieved from `options`. + I18n.pluralize = function(count, scope, options) { + options = this.prepareOptions({count: String(count)}, options) + var pluralizer, result; + + result = this.pluralizationLookup(count, scope, options); + if (typeof result.translations === "undefined" || result.translations == null) { + return this.missingTranslation(scope, options); + } + + if (typeof result.message !== "undefined" && result.message != null) { + return this.interpolate(result.message, options); + } + else { + pluralizer = this.pluralization.get(options.locale); + return this.missingTranslation(scope + '.' + pluralizer(count)[0], options); + } + }; + + // Return a missing translation message for the given parameters. + I18n.missingTranslation = function(scope, options) { + //guess intended string + if(this.missingBehaviour === 'guess'){ + //get only the last portion of the scope + var s = scope.split('.').slice(-1)[0]; + //replace underscore with space && camelcase with space and lowercase letter + return (this.missingTranslationPrefix.length > 0 ? this.missingTranslationPrefix : '') + + s.replace('_',' ').replace(/([a-z])([A-Z])/g, + function(match, p1, p2) {return p1 + ' ' + p2.toLowerCase()} ); + } + + var localeForTranslation = (options != null && options.locale != null) ? options.locale : this.currentLocale(); + var fullScope = this.getFullScope(scope, options); + var fullScopeWithLocale = [localeForTranslation, fullScope].join(this.defaultSeparator); + + return '[missing "' + fullScopeWithLocale + '" translation]'; + }; + + // Return a missing placeholder message for given parameters + I18n.missingPlaceholder = function(placeholder, message, options) { + return "[missing " + placeholder + " value]"; + }; + + I18n.nullPlaceholder = function() { + return I18n.missingPlaceholder.apply(I18n, arguments); + }; + + // Format number using localization rules. + // The options will be retrieved from the `number.format` scope. + // If this isn't present, then the following options will be used: + // + // - `precision`: `3` + // - `separator`: `"."` + // - `delimiter`: `","` + // - `strip_insignificant_zeros`: `false` + // + // You can also override these options by providing the `options` argument. + // + I18n.toNumber = function(number, options) { + options = this.prepareOptions( + options + , this.lookup("number.format") + , NUMBER_FORMAT + ); + + var negative = number < 0 + , string = toFixed(Math.abs(number), options.precision).toString() + , parts = string.split(".") + , precision + , buffer = [] + , formattedNumber + , format = options.format || "%n" + , sign = negative ? "-" : "" + ; + + number = parts[0]; + precision = parts[1]; + + while (number.length > 0) { + buffer.unshift(number.substr(Math.max(0, number.length - 3), 3)); + number = number.substr(0, number.length -3); + } + + formattedNumber = buffer.join(options.delimiter); + + if (options.strip_insignificant_zeros && precision) { + precision = precision.replace(/0+$/, ""); + } + + if (options.precision > 0 && precision) { + formattedNumber += options.separator + precision; + } + + if (options.sign_first) { + format = "%s" + format; + } + else { + format = format.replace("%n", "%s%n"); + } + + formattedNumber = format + .replace("%u", options.unit) + .replace("%n", formattedNumber) + .replace("%s", sign) + ; + + return formattedNumber; + }; + + // Format currency with localization rules. + // The options will be retrieved from the `number.currency.format` and + // `number.format` scopes, in that order. + // + // Any missing option will be retrieved from the `I18n.toNumber` defaults and + // the following options: + // + // - `unit`: `"$"` + // - `precision`: `2` + // - `format`: `"%u%n"` + // - `delimiter`: `","` + // - `separator`: `"."` + // + // You can also override these options by providing the `options` argument. + // + I18n.toCurrency = function(number, options) { + options = this.prepareOptions( + options + , this.lookup("number.currency.format") + , this.lookup("number.format") + , CURRENCY_FORMAT + ); + + return this.toNumber(number, options); + }; + + // Localize several values. + // You can provide the following scopes: `currency`, `number`, or `percentage`. + // If you provide a scope that matches the `/^(date|time)/` regular expression + // then the `value` will be converted by using the `I18n.toTime` function. + // + // It will default to the value's `toString` function. + // + I18n.localize = function(scope, value, options) { + options || (options = {}); + + 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: + var localizedValue; + + if (scope.match(/^(date|time)/)) { + localizedValue = this.toTime(scope, value); + } else { + localizedValue = value.toString(); + } + + return this.interpolate(localizedValue, options); + } + }; + + // Parse a given `date` string into a JavaScript Date object. + // This function is time zone aware. + // + // The following string formats are recognized: + // + // 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 + // yyyy-mm-dd[ T]hh:mm::ss+00:00 + // yyyy-mm-dd[ T]hh:mm::ss.123Z + // + I18n.parseDate = function(date) { + var matches, convertedDate, fraction; + // A date input of `null` or `undefined` will be returned as-is + if (date == null) { + return date; + } + // we have a date, so just return it. + if (typeof(date) === "object") { + return date; + } + + matches = date.toString().match(/(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})([\.,]\d{1,3})?)?(Z|\+00:?00)?/); + + if (matches) { + for (var i = 1; i <= 6; i++) { + matches[i] = parseInt(matches[i], 10) || 0; + } + + // month starts on 0 + matches[2] -= 1; + + fraction = matches[7] ? 1000 * ("0" + matches[7]) : null; + + if (matches[8]) { + convertedDate = new Date(Date.UTC(matches[1], matches[2], matches[3], matches[4], matches[5], matches[6], fraction)); + } else { + convertedDate = new Date(matches[1], matches[2], matches[3], matches[4], matches[5], matches[6], fraction); + } + } else if (typeof(date) == "number") { + // UNIX timestamp + convertedDate = new Date(); + convertedDate.setTime(date); + } else if (date.match(/([A-Z][a-z]{2}) ([A-Z][a-z]{2}) (\d+) (\d+:\d+:\d+) ([+-]\d+) (\d+)/)) { + // This format `Wed Jul 20 13:03:39 +0000 2011` is parsed by + // webkit/firefox, but not by IE, so we must parse it manually. + convertedDate = new Date(); + convertedDate.setTime(Date.parse([ + RegExp.$1, RegExp.$2, RegExp.$3, RegExp.$6, RegExp.$4, RegExp.$5 + ].join(" "))); + } 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; + }; + + // Formats time according to the directives in the given format string. + // The directives begins with a percent (%) character. Any text not listed as a + // directive will be passed through to the output string. + // + // The accepted formats are: + // + // %a - The abbreviated weekday name (Sun) + // %A - The full weekday name (Sunday) + // %b - The abbreviated month name (Jan) + // %B - The full month name (January) + // %c - The preferred local date and time representation + // %d - Day of the month (01..31) + // %-d - Day of the month (1..31) + // %H - Hour of the day, 24-hour clock (00..23) + // %-H/%k - Hour of the day, 24-hour clock (0..23) + // %I - Hour of the day, 12-hour clock (01..12) + // %-I/%l - Hour of the day, 12-hour clock (1..12) + // %m - Month of the year (01..12) + // %-m - Month of the year (1..12) + // %M - Minute of the hour (00..59) + // %-M - Minute of the hour (0..59) + // %p - Meridian indicator (AM or PM) + // %P - Meridian indicator (am or pm) + // %S - Second of the minute (00..60) + // %-S - Second of the minute (0..60) + // %w - Day of the week (Sunday is 0, 0..6) + // %y - Year without a century (00..99) + // %-y - Year without a century (0..99) + // %Y - Year with century + // %z/%Z - Timezone offset (+0545) + // + I18n.strftime = function(date, format) { + var options = this.lookup("date") + , meridianOptions = I18n.meridian() + ; + + if (!options) { + options = {}; + } + + options = this.prepareOptions(options, DATE); + + if (isNaN(date.getTime())) { + throw new Error('I18n.strftime() requires a valid date object, but received an invalid date.'); + } + + 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; + } + + format = format.replace("%a", options.abbr_day_names[weekDay]); + format = format.replace("%A", options.day_names[weekDay]); + format = format.replace("%b", options.abbr_month_names[month]); + format = format.replace("%B", options.month_names[month]); + format = format.replace("%d", padding(day)); + format = format.replace("%e", day); + format = format.replace("%-d", day); + format = format.replace("%H", padding(hour)); + format = format.replace("%-H", hour); + format = format.replace("%k", hour); + format = format.replace("%I", padding(hour12)); + format = format.replace("%-I", hour12); + format = format.replace("%l", hour12); + format = format.replace("%m", padding(month)); + format = format.replace("%-m", month); + format = format.replace("%M", padding(mins)); + format = format.replace("%-M", mins); + format = format.replace("%p", meridianOptions[meridian]); + format = format.replace("%P", meridianOptions[meridian].toLowerCase()); + format = format.replace("%S", padding(secs)); + format = format.replace("%-S", secs); + format = format.replace("%w", weekDay); + format = format.replace("%y", padding(year)); + format = format.replace("%-y", padding(year).replace(/^0+/, "")); + format = format.replace("%Y", year); + format = format.replace("%z", timezoneoffset); + format = format.replace("%Z", timezoneoffset); + + return format; + }; + + // Convert the given dateString into a formatted date. + I18n.toTime = function(scope, dateString) { + var date = this.parseDate(dateString) + , format = this.lookup(scope) + ; + + // A date input of `null` or `undefined` will be returned as-is + if (date == null) { + return date; + } + + var date_string = date.toString() + if (date_string.match(/invalid/i)) { + return date_string; + } + + if (!format) { + return date_string; + } + + return this.strftime(date, format); + }; + + // Convert a number into a formatted percentage value. + I18n.toPercentage = function(number, options) { + options = this.prepareOptions( + options + , this.lookup("number.percentage.format") + , this.lookup("number.format") + , PERCENTAGE_FORMAT + ); + + return this.toNumber(number, options); + }; + + // Convert a number into a readable size representation. + I18n.toHumanSize = function(number, options) { + var kb = 1024 + , size = number + , iterations = 0 + , unit + , precision + ; + + while (size >= kb && iterations < 4) { + size = size / kb; + iterations += 1; + } + + if (iterations === 0) { + unit = this.t("number.human.storage_units.units.byte", {count: size}); + precision = 0; + } else { + unit = this.t("number.human.storage_units.units." + SIZE_UNITS[iterations]); + precision = (size - Math.floor(size) === 0) ? 0 : 1; + } + + options = this.prepareOptions( + options + , {unit: unit, precision: precision, format: "%n%u", delimiter: ""} + ); + + return this.toNumber(size, options); + }; + + I18n.getFullScope = function(scope, options) { + options = options || {}; + + // Deal with the scope as an array. + if (isArray(scope)) { + scope = scope.join(this.defaultSeparator); + } + + // Deal with the scope option provided through the second argument. + // + // I18n.t('hello', {scope: 'greetings'}); + // + if (options.scope) { + scope = [options.scope, scope].join(this.defaultSeparator); + } + + return scope; + }; + /** + * Merge obj1 with obj2 (shallow merge), without modifying inputs + * @param {Object} obj1 + * @param {Object} obj2 + * @returns {Object} Merged values of obj1 and obj2 + * + * In order to support ES3, `Object.prototype.hasOwnProperty.call` is used + * Idea is from: + * https://stackoverflow.com/questions/8157700/object-has-no-hasownproperty-method-i-e-its-undefined-ie8 + */ + I18n.extend = function ( obj1, obj2 ) { + if (typeof(obj1) === "undefined" && typeof(obj2) === "undefined") { + return {}; + } + return merge(obj1, obj2); + }; + + // Set aliases, so we can save some typing. + I18n.t = I18n.translate.bind(I18n); + I18n.l = I18n.localize.bind(I18n); + I18n.p = I18n.pluralize.bind(I18n); + + return I18n; +})); diff --git a/yarn.lock b/yarn.lock index 725bfb5f..c21ab504 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3540,6 +3540,11 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= +i18n-js@^3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/i18n-js/-/i18n-js-3.8.0.tgz#b8fd6b12e1d88cb71f9806c29bca7c31c012e504" + integrity sha512-hDsGgPuvw/2P+lXSbOafAwspK8Ste8YrwuuUg17W3wEcO1JkQxBlPgsN1t2+852nTnz4YSYTjZc/1nAA2PC/nw== + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"