From e0f36963c2696938dfcc6bf052589a6179e6c42c Mon Sep 17 00:00:00 2001 From: Olivier Bellemare Date: Mon, 20 Nov 2017 09:08:53 -0500 Subject: [PATCH 1/2] Add support for multiple fallback languages (fallback waterfall) --- dist/angular-gettext.js | 86 +++++++++++++++++++++++++++++-------- dist/angular-gettext.min.js | 2 +- src/catalog.js | 68 ++++++++++++++++++++++++----- test/unit/catalog.js | 22 +++++++++- 4 files changed, 147 insertions(+), 31 deletions(-) diff --git a/dist/angular-gettext.js b/dist/angular-gettext.js index 280de24..16dcc3e 100644 --- a/dist/angular-gettext.js +++ b/dist/angular-gettext.js @@ -49,7 +49,7 @@ angular.module('gettext').constant('gettext', function (str) { */ return str; }); - + /** * @ngdoc service * @module gettext @@ -60,7 +60,7 @@ angular.module('gettext').constant('gettext', function (str) { * @requires https://docs.angularjs.org/api/ng/service/$cacheFactory $cacheFactory * @requires https://docs.angularjs.org/api/ng/service/$interpolate $interpolate * @requires https://docs.angularjs.org/api/ng/service/$rootScope $rootScope - * @description Provides set of method to translate stings + * @description Provides set of method to translate strings */ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextFallbackLanguage", "$http", "$cacheFactory", "$interpolate", "$rootScope", function (gettextPlurals, gettextFallbackLanguage, $http, $cacheFactory, $interpolate, $rootScope) { var catalog; @@ -172,6 +172,14 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF * @description Active language. */ currentLanguage: 'en', + /** + * @ngdoc property + * @name gettextCatalog#fallbackLanguages + * @public + * @type {Object..} + * @description Fallback languages. + */ + fallbackLanguages: {}, /** * @ngdoc property * @name gettextCatalog#cache @@ -204,6 +212,28 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF return this.currentLanguage; }, + /** + * @ngdoc method + * @name gettextCatalog#setFallbackLanguages + * @public + * @param {Object..} fallbacks set of string arrays where the key is the source language, and the strings in the array the fallback langauges to try, in order + * @description Sets the fallback languages. + */ + setFallbackLanguages: function (fallbacks) { + this.fallbackLanguages = fallbacks || {}; + }, + + /** + * @ngdoc method + * @name gettextCatalog#getFallbackLanguages + * @public + * @returns {Object..} fallback languages + * @description Returns the fallback languages. + */ + getFallbackLanguages: function () { + return this.fallbackLanguages; + }, + /** * @ngdoc method * @name gettextCatalog#setStrings @@ -258,7 +288,7 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF * @protected * @param {String} language language name * @param {String} string translation key - * @param {Number=} n number to build sting form for + * @param {Number=} n number to build string form for * @param {String=} context translation key context, e.g. {@link doc:context Verb, Noun} * @returns {String|Null} translated or annotated string or null if language is not set * @description Translate a string with the given language, count and context. @@ -273,6 +303,32 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF return plurals[gettextPlurals(language, n)]; }, + /** + * @ngdoc method + * @name gettextCatalog#getFallbackStringFormFor + * @protected + * @param {String} language language name + * @param {String} string translation key + * @param {Number=} n number to build string form for + * @param {String=} context translation key context, e.g. {@link doc:context Verb, Noun} + * @param {String=} stringPlural plural translation key + * @returns {String|Null} translated or annotated string or null if language is not set + * @description Translate a string with the given language, count and context. + * + * First it tries a language (e.g. `en-US`) then {@link gettextCatalog#fallbackLanguages language}, if any, then {@link gettextFallbackLanguage fallback} (e.g. `en`). + */ + getFallbackStringFormFor: function (language, string, n, context, stringPlural) { + var fallbackLanguages = this.fallbackLanguages[language] || []; + var defaultFallbackLanguage = gettextFallbackLanguage(language); + if (defaultFallbackLanguage) { fallbackLanguages.push(defaultFallbackLanguage); } + + var output = this.getStringFormFor(language, string, n, context); + for (var i = 0; i < fallbackLanguages.length; i++) { + output = output || this.getStringFormFor(fallbackLanguages[i], string, n, context); + } + return output || prefixDebug(n === 1 ? string : stringPlural); + }, + /** * @ngdoc method * @name gettextCatalog#getString @@ -283,8 +339,6 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF * @returns {String} translated or annotated string * @description Translate a string with the given scope and context. * - * First it tries {@link gettextCatalog#currentLanguage gettextCatalog#currentLanguage} (e.g. `en-US`) then {@link gettextFallbackLanguage fallback} (e.g. `en`). - * * When `scope` is supplied it uses Angular.JS interpolation, so something like this will do what you expect: * ```js * var hello = gettextCatalog.getString("Hello {{name}}!", { name: "Ruben" }); @@ -293,10 +347,7 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF * Avoid using scopes - this skips interpolation and is a lot faster. */ getString: function (string, scope, context) { - var fallbackLanguage = gettextFallbackLanguage(this.currentLanguage); - string = this.getStringFormFor(this.currentLanguage, string, 1, context) || - this.getStringFormFor(fallbackLanguage, string, 1, context) || - prefixDebug(string); + string = this.getFallbackStringFormFor(this.currentLanguage, string, 1, context); string = scope ? $interpolate(string)(scope) : string; return addTranslatedMarkers(string); }, @@ -305,7 +356,7 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF * @ngdoc method * @name gettextCatalog#getPlural * @public - * @param {Number} n number to build sting form for + * @param {Number} n number to build string form for * @param {String} string translation key * @param {String} stringPlural plural translation key * @param {$rootScope.Scope=} scope scope to do interpolation against @@ -315,10 +366,7 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF * @description Translate a plural string with the given context. */ getPlural: function (n, string, stringPlural, scope, context) { - var fallbackLanguage = gettextFallbackLanguage(this.currentLanguage); - string = this.getStringFormFor(this.currentLanguage, string, n, context) || - this.getStringFormFor(fallbackLanguage, string, n, context) || - prefixDebug(n === 1 ? string : stringPlural); + string = this.getFallbackStringFormFor(this.currentLanguage, string, n, context, stringPlural); if (scope) { scope.$count = n; string = $interpolate(string)(scope); @@ -353,7 +401,7 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF return catalog; }]); - + /** * @ngdoc directive * @module gettext @@ -531,7 +579,7 @@ angular.module('gettext').directive('translate', ["gettextCatalog", "$parse", "$ } }; }]); - + /** * @ngdoc factory * @module gettext @@ -564,7 +612,7 @@ angular.module("gettext").factory("gettextFallbackLanguage", function () { return null; }; -}); +}); /** * @ngdoc filter * @module gettext @@ -593,7 +641,7 @@ angular.module('gettext').filter('translate', ["gettextCatalog", function (gette filter.$stateful = true; return filter; }]); - + // Do not edit this file, it is autogenerated using genplurals.py! angular.module("gettext").factory("gettextPlurals", function () { var languageCodes = { @@ -725,7 +773,7 @@ angular.module("gettext").factory("gettextPlurals", function () { return languageCodes[langCode]; } }); - + /** * @ngdoc factory * @module gettext diff --git a/dist/angular-gettext.min.js b/dist/angular-gettext.min.js index 4b50a04..a15e330 100644 --- a/dist/angular-gettext.min.js +++ b/dist/angular-gettext.min.js @@ -1 +1 @@ -angular.module("gettext",[]),angular.module("gettext").constant("gettext",function(a){return a}),angular.module("gettext").factory("gettextCatalog",["gettextPlurals","gettextFallbackLanguage","$http","$cacheFactory","$interpolate","$rootScope",function(a,b,c,d,e,f){function g(){f.$broadcast("gettextLanguageChanged")}var h,i="$$noContext",j='test',k=angular.element(""+j+"").html()!==j,l=function(a){return h.debug&&h.currentLanguage!==h.baseLanguage?h.debugPrefix+a:a},m=function(a){return h.showTranslatedMarkers?h.translatedMarkerPrefix+a+h.translatedMarkerSuffix:a};return h={debug:!1,debugPrefix:"[MISSING]: ",showTranslatedMarkers:!1,translatedMarkerPrefix:"[",translatedMarkerSuffix:"]",strings:{},baseLanguage:"en",currentLanguage:"en",cache:d("strings"),setCurrentLanguage:function(a){this.currentLanguage=a,g()},getCurrentLanguage:function(){return this.currentLanguage},setStrings:function(b,c){this.strings[b]||(this.strings[b]={});var d=a(b,1);for(var e in c){var f=c[e];if(k&&(e=angular.element(""+e+"").html()),angular.isString(f)||angular.isArray(f)){var h={};h[i]=f,f=h}this.strings[b][e]||(this.strings[b][e]={});for(var j in f){var l=f[j];angular.isArray(l)?this.strings[b][e][j]=l:(this.strings[b][e][j]=[],this.strings[b][e][j][d]=l)}}g()},getStringFormFor:function(b,c,d,e){if(!b)return null;var f=this.strings[b]||{},g=f[c]||{},h=g[e||i]||[];return h[a(b,d)]},getString:function(a,c,d){var f=b(this.currentLanguage);return a=this.getStringFormFor(this.currentLanguage,a,1,d)||this.getStringFormFor(f,a,1,d)||l(a),a=c?e(a)(c):a,m(a)},getPlural:function(a,c,d,f,g){var h=b(this.currentLanguage);return c=this.getStringFormFor(this.currentLanguage,c,a,g)||this.getStringFormFor(h,c,a,g)||l(1===a?c:d),f&&(f.$count=a,c=e(c)(f)),m(c)},loadRemote:function(a){return c({method:"GET",url:a,cache:h.cache}).then(function(a){var b=a.data;for(var c in b)h.setStrings(c,b[c]);return a})}}}]),angular.module("gettext").directive("translate",["gettextCatalog","$parse","$animate","$compile","$window","gettextUtil",function(a,b,c,d,e,f){function g(a){return f.lcFirst(a.replace(j,""))}function h(a,b,c){var d=Object.keys(b).filter(function(a){return f.startsWith(a,j)&&a!==j});if(!d.length)return null;var e=angular.extend({},a),h=[];return d.forEach(function(d){var f=a.$watch(b[d],function(a){var b=g(d);e[b]=a,c(e)});h.push(f)}),a.$on("$destroy",function(){h.forEach(function(a){a()})}),e}var i=parseInt((/msie (\d+)/.exec(angular.lowercase(e.navigator.userAgent))||[])[1],10),j="translateParams";return{restrict:"AE",terminal:!0,compile:function(e,g){f.assert(!g.translatePlural||g.translateN,"translate-n","translate-plural"),f.assert(!g.translateN||g.translatePlural,"translate-plural","translate-n");var j=f.trim(e.html()),k=g.translatePlural,l=g.translateContext;return 8>=i&&""===j.slice(-13)&&(j=j.slice(0,-13)),{post:function(e,g,i){function m(b){b=b||null;var h;k?(e=o||(o=e.$new()),e.$count=n(e),h=a.getPlural(e.$count,j,k,b,l)):h=a.getString(j,b,l);var i=g.contents();if(i||h){if(h===f.trim(i.html()))return void(p&&d(i)(e));var m=angular.element(""+h+"");d(m.contents())(e);var q=m.contents();c.enter(q,g),c.leave(i)}}var n=b(i.translateN),o=null,p=!0,q=h(e,i,m);m(q),p=!1,i.translateN&&e.$watch(i.translateN,function(){m(q)}),e.$on("gettextLanguageChanged",function(){m(q)})}}}}}]),angular.module("gettext").factory("gettextFallbackLanguage",function(){var a={},b=/([^_]+)_[^_]+$/;return function(c){if(a[c])return a[c];var d=b.exec(c);return d?(a[c]=d[1],d[1]):null}}),angular.module("gettext").filter("translate",["gettextCatalog",function(a){function b(b,c){return a.getString(b,null,c)}return b.$stateful=!0,b}]),angular.module("gettext").factory("gettextPlurals",function(){function a(a){return b[a]||(b[a]=a.split(/\-|_/).shift()),b[a]}var b={pt_BR:"pt_BR","pt-BR":"pt_BR"};return function(b,c){switch(a(b)){case"ay":case"bo":case"cgg":case"dz":case"fa":case"id":case"ja":case"jbo":case"ka":case"kk":case"km":case"ko":case"ky":case"lo":case"ms":case"my":case"sah":case"su":case"th":case"tt":case"ug":case"vi":case"wo":case"zh":return 0;case"is":return c%10!=1||c%100==11?1:0;case"jv":return 0!=c?1:0;case"mk":return 1==c||c%10==1?0:1;case"ach":case"ak":case"am":case"arn":case"br":case"fil":case"fr":case"gun":case"ln":case"mfe":case"mg":case"mi":case"oc":case"pt_BR":case"tg":case"ti":case"tr":case"uz":case"wa":case"zh":return c>1?1:0;case"lv":return c%10==1&&c%100!=11?0:0!=c?1:2;case"lt":return c%10==1&&c%100!=11?0:c%10>=2&&(10>c%100||c%100>=20)?1:2;case"be":case"bs":case"hr":case"ru":case"sr":case"uk":return c%10==1&&c%100!=11?0:c%10>=2&&4>=c%10&&(10>c%100||c%100>=20)?1:2;case"mnk":return 0==c?0:1==c?1:2;case"ro":return 1==c?0:0==c||c%100>0&&20>c%100?1:2;case"pl":return 1==c?0:c%10>=2&&4>=c%10&&(10>c%100||c%100>=20)?1:2;case"cs":case"sk":return 1==c?0:c>=2&&4>=c?1:2;case"sl":return c%100==1?1:c%100==2?2:c%100==3||c%100==4?3:0;case"mt":return 1==c?0:0==c||c%100>1&&11>c%100?1:c%100>10&&20>c%100?2:3;case"gd":return 1==c||11==c?0:2==c||12==c?1:c>2&&20>c?2:3;case"cy":return 1==c?0:2==c?1:8!=c&&11!=c?2:3;case"kw":return 1==c?0:2==c?1:3==c?2:3;case"ga":return 1==c?0:2==c?1:7>c?2:11>c?3:4;case"ar":return 0==c?0:1==c?1:2==c?2:c%100>=3&&10>=c%100?3:c%100>=11?4:5;default:return 1!=c?1:0}}}),angular.module("gettext").factory("gettextUtil",function(){function a(a,b,c){if(!a)throw new Error("You should add a "+b+" attribute whenever you add a "+c+" attribute.")}function b(a,b){return 0===a.indexOf(b)}function c(a){var b=a.charAt(0).toLowerCase();return b+a.substr(1)}var d=function(){return String.prototype.trim?function(a){return"string"==typeof a?a.trim():a}:function(a){return"string"==typeof a?a.replace(/^\s*/,"").replace(/\s*$/,""):a}}();return{trim:d,assert:a,startsWith:b,lcFirst:c}}); \ No newline at end of file +angular.module("gettext",[]),angular.module("gettext").constant("gettext",function(a){return a}),angular.module("gettext").factory("gettextCatalog",["gettextPlurals","gettextFallbackLanguage","$http","$cacheFactory","$interpolate","$rootScope",function(a,b,c,d,e,f){function g(){f.$broadcast("gettextLanguageChanged")}var h,i="$$noContext",j='test',k=angular.element(""+j+"").html()!==j,l=function(a){return h.debug&&h.currentLanguage!==h.baseLanguage?h.debugPrefix+a:a},m=function(a){return h.showTranslatedMarkers?h.translatedMarkerPrefix+a+h.translatedMarkerSuffix:a};return h={debug:!1,debugPrefix:"[MISSING]: ",showTranslatedMarkers:!1,translatedMarkerPrefix:"[",translatedMarkerSuffix:"]",strings:{},baseLanguage:"en",currentLanguage:"en",fallbackLanguages:{},cache:d("strings"),setCurrentLanguage:function(a){this.currentLanguage=a,g()},getCurrentLanguage:function(){return this.currentLanguage},setFallbackLanguages:function(a){this.fallbackLanguages=a||{}},getFallbackLanguages:function(){return this.fallbackLanguages},setStrings:function(b,c){this.strings[b]||(this.strings[b]={});var d=a(b,1);for(var e in c){var f=c[e];if(k&&(e=angular.element(""+e+"").html()),angular.isString(f)||angular.isArray(f)){var h={};h[i]=f,f=h}this.strings[b][e]||(this.strings[b][e]={});for(var j in f){var l=f[j];angular.isArray(l)?this.strings[b][e][j]=l:(this.strings[b][e][j]=[],this.strings[b][e][j][d]=l)}}g()},getStringFormFor:function(b,c,d,e){if(!b)return null;var f=this.strings[b]||{},g=f[c]||{},h=g[e||i]||[];return h[a(b,d)]},getFallbackStringFormFor:function(a,c,d,e,f){var g=this.fallbackLanguages[a]||[],h=b(a);h&&g.push(h);for(var i=this.getStringFormFor(a,c,d,e),j=0;j"===j.slice(-13)&&(j=j.slice(0,-13)),{post:function(e,g,i){function m(b){b=b||null;var h;k?(e=o||(o=e.$new()),e.$count=n(e),h=a.getPlural(e.$count,j,k,b,l)):h=a.getString(j,b,l);var i=g.contents();if(i||h){if(h===f.trim(i.html()))return void(p&&d(i)(e));var m=angular.element(""+h+"");d(m.contents())(e);var q=m.contents();c.enter(q,g),c.leave(i)}}var n=b(i.translateN),o=null,p=!0,q=h(e,i,m);m(q),p=!1,i.translateN&&e.$watch(i.translateN,function(){m(q)}),e.$on("gettextLanguageChanged",function(){m(q)})}}}}}]),angular.module("gettext").factory("gettextFallbackLanguage",function(){var a={},b=/([^_]+)_[^_]+$/;return function(c){if(a[c])return a[c];var d=b.exec(c);return d?(a[c]=d[1],d[1]):null}}),angular.module("gettext").filter("translate",["gettextCatalog",function(a){function b(b,c){return a.getString(b,null,c)}return b.$stateful=!0,b}]),angular.module("gettext").factory("gettextPlurals",function(){function a(a){return b[a]||(b[a]=a.split(/\-|_/).shift()),b[a]}var b={pt_BR:"pt_BR","pt-BR":"pt_BR"};return function(b,c){switch(a(b)){case"ay":case"bo":case"cgg":case"dz":case"fa":case"id":case"ja":case"jbo":case"ka":case"kk":case"km":case"ko":case"ky":case"lo":case"ms":case"my":case"sah":case"su":case"th":case"tt":case"ug":case"vi":case"wo":case"zh":return 0;case"is":return c%10!=1||c%100==11?1:0;case"jv":return 0!=c?1:0;case"mk":return 1==c||c%10==1?0:1;case"ach":case"ak":case"am":case"arn":case"br":case"fil":case"fr":case"gun":case"ln":case"mfe":case"mg":case"mi":case"oc":case"pt_BR":case"tg":case"ti":case"tr":case"uz":case"wa":case"zh":return c>1?1:0;case"lv":return c%10==1&&c%100!=11?0:0!=c?1:2;case"lt":return c%10==1&&c%100!=11?0:c%10>=2&&(c%100<10||c%100>=20)?1:2;case"be":case"bs":case"hr":case"ru":case"sr":case"uk":return c%10==1&&c%100!=11?0:c%10>=2&&c%10<=4&&(c%100<10||c%100>=20)?1:2;case"mnk":return 0==c?0:1==c?1:2;case"ro":return 1==c?0:0==c||c%100>0&&c%100<20?1:2;case"pl":return 1==c?0:c%10>=2&&c%10<=4&&(c%100<10||c%100>=20)?1:2;case"cs":case"sk":return 1==c?0:c>=2&&c<=4?1:2;case"sl":return c%100==1?1:c%100==2?2:c%100==3||c%100==4?3:0;case"mt":return 1==c?0:0==c||c%100>1&&c%100<11?1:c%100>10&&c%100<20?2:3;case"gd":return 1==c||11==c?0:2==c||12==c?1:c>2&&c<20?2:3;case"cy":return 1==c?0:2==c?1:8!=c&&11!=c?2:3;case"kw":return 1==c?0:2==c?1:3==c?2:3;case"ga":return 1==c?0:2==c?1:c<7?2:c<11?3:4;case"ar":return 0==c?0:1==c?1:2==c?2:c%100>=3&&c%100<=10?3:c%100>=11?4:5;default:return 1!=c?1:0}}}),angular.module("gettext").factory("gettextUtil",function(){function a(a,b,c){if(!a)throw new Error("You should add a "+b+" attribute whenever you add a "+c+" attribute.")}function b(a,b){return 0===a.indexOf(b)}function c(a){var b=a.charAt(0).toLowerCase();return b+a.substr(1)}var d=function(){return String.prototype.trim?function(a){return"string"==typeof a?a.trim():a}:function(a){return"string"==typeof a?a.replace(/^\s*/,"").replace(/\s*$/,""):a}}();return{trim:d,assert:a,startsWith:b,lcFirst:c}}); \ No newline at end of file diff --git a/src/catalog.js b/src/catalog.js index e24a865..6893961 100644 --- a/src/catalog.js +++ b/src/catalog.js @@ -120,6 +120,14 @@ angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, ge * @description Active language. */ currentLanguage: 'en', + /** + * @ngdoc property + * @name gettextCatalog#fallbackLanguages + * @public + * @type {Object..} + * @description Fallback languages. + */ + fallbackLanguages: {}, /** * @ngdoc property * @name gettextCatalog#cache @@ -152,6 +160,28 @@ angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, ge return this.currentLanguage; }, + /** + * @ngdoc method + * @name gettextCatalog#setFallbackLanguages + * @public + * @param {Object..} fallbacks set of string arrays where the key is the source language, and the strings in the array the fallback langauges to try, in order + * @description Sets the fallback languages. + */ + setFallbackLanguages: function (fallbacks) { + this.fallbackLanguages = fallbacks || {}; + }, + + /** + * @ngdoc method + * @name gettextCatalog#getFallbackLanguages + * @public + * @returns {Object..} fallback languages + * @description Returns the fallback languages. + */ + getFallbackLanguages: function () { + return this.fallbackLanguages; + }, + /** * @ngdoc method * @name gettextCatalog#setStrings @@ -221,6 +251,32 @@ angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, ge return plurals[gettextPlurals(language, n)]; }, + /** + * @ngdoc method + * @name gettextCatalog#getFallbackStringFormFor + * @protected + * @param {String} language language name + * @param {String} string translation key + * @param {Number=} n number to build string form for + * @param {String=} context translation key context, e.g. {@link doc:context Verb, Noun} + * @param {String=} stringPlural plural translation key + * @returns {String|Null} translated or annotated string or null if language is not set + * @description Translate a string with the given language, count and context. + * + * First it tries a language (e.g. `en-US`) then {@link gettextCatalog#fallbackLanguages language}, if any, then {@link gettextFallbackLanguage fallback} (e.g. `en`). + */ + getFallbackStringFormFor: function (language, string, n, context, stringPlural) { + var fallbackLanguages = this.fallbackLanguages[language] || []; + var defaultFallbackLanguage = gettextFallbackLanguage(language); + if (defaultFallbackLanguage) { fallbackLanguages.push(defaultFallbackLanguage); } + + var output = this.getStringFormFor(language, string, n, context); + for (var i = 0; i < fallbackLanguages.length; i++) { + output = output || this.getStringFormFor(fallbackLanguages[i], string, n, context); + } + return output || prefixDebug(n === 1 ? string : stringPlural); + }, + /** * @ngdoc method * @name gettextCatalog#getString @@ -231,8 +287,6 @@ angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, ge * @returns {String} translated or annotated string * @description Translate a string with the given scope and context. * - * First it tries {@link gettextCatalog#currentLanguage gettextCatalog#currentLanguage} (e.g. `en-US`) then {@link gettextFallbackLanguage fallback} (e.g. `en`). - * * When `scope` is supplied it uses Angular.JS interpolation, so something like this will do what you expect: * ```js * var hello = gettextCatalog.getString("Hello {{name}}!", { name: "Ruben" }); @@ -241,10 +295,7 @@ angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, ge * Avoid using scopes - this skips interpolation and is a lot faster. */ getString: function (string, scope, context) { - var fallbackLanguage = gettextFallbackLanguage(this.currentLanguage); - string = this.getStringFormFor(this.currentLanguage, string, 1, context) || - this.getStringFormFor(fallbackLanguage, string, 1, context) || - prefixDebug(string); + string = this.getFallbackStringFormFor(this.currentLanguage, string, 1, context); string = scope ? $interpolate(string)(scope) : string; return addTranslatedMarkers(string); }, @@ -263,10 +314,7 @@ angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, ge * @description Translate a plural string with the given context. */ getPlural: function (n, string, stringPlural, scope, context) { - var fallbackLanguage = gettextFallbackLanguage(this.currentLanguage); - string = this.getStringFormFor(this.currentLanguage, string, n, context) || - this.getStringFormFor(fallbackLanguage, string, n, context) || - prefixDebug(n === 1 ? string : stringPlural); + string = this.getFallbackStringFormFor(this.currentLanguage, string, n, context, stringPlural); if (scope) { scope.$count = n; string = $interpolate(string)(scope); diff --git a/test/unit/catalog.js b/test/unit/catalog.js index fafa394..8f0e833 100644 --- a/test/unit/catalog.js +++ b/test/unit/catalog.js @@ -162,6 +162,26 @@ describe("Catalog", function () { }); it("Should return string from fallback language if current language has no translation", function () { + var strings = { Hello: "Hallo" }; + catalog.setStrings("nl", strings); + catalog.setCurrentLanguage("de"); + catalog.setFallbackLanguages({ de: ["nl"] }); + assert.equal(catalog.getString("Bye"), "Bye"); + assert.equal(catalog.getString("Hello"), "Hallo"); + }); + + it("Should return string from first available fallback language if current language has no translation", function () { + var stringsNl = { Hello: "Hallo" }; + var stringsFr = { Hello: "Bonjour" }; + catalog.setStrings("nl", stringsNl); + catalog.setStrings("fr", stringsFr); + catalog.setCurrentLanguage("de"); + catalog.setFallbackLanguages({ de: ["en", "fr", "nl"] }); + assert.equal(catalog.getString("Bye"), "Bye"); + assert.equal(catalog.getString("Hello"), "Bonjour"); + }); + + it("Should return string from default fallback language if current language has no translation", function () { var strings = { Hello: "Hallo" }; catalog.setStrings("nl", strings); catalog.setCurrentLanguage("nl_NL"); @@ -169,7 +189,7 @@ describe("Catalog", function () { assert.equal(catalog.getString("Hello"), "Hallo"); }); - it("Should not return string from fallback language if current language has translation", function () { + it("Should not return string from default fallback language if current language has translation", function () { var stringsEn = { Baggage: "Baggage" }; var stringsEnGB = { Baggage: "Luggage" }; catalog.setStrings("en", stringsEn); From 45a08b642d9a1b01d213e4c571f04b0042b487fb Mon Sep 17 00:00:00 2001 From: Olivier Bellemare Date: Tue, 28 Nov 2017 11:37:40 -0500 Subject: [PATCH 2/2] Ensure no references are kept to the original objects, and that internal fallback object is never modified when translating --- .jshintrc | 3 ++- dist/angular-gettext.js | 31 ++++++++++++++++++++++++++----- dist/angular-gettext.min.js | 2 +- src/catalog.js | 8 ++++---- src/util.js | 23 ++++++++++++++++++++++- test/unit/catalog.js | 15 +++++++++++++++ 6 files changed, 70 insertions(+), 12 deletions(-) diff --git a/.jshintrc b/.jshintrc index 06a2aa2..89d6403 100644 --- a/.jshintrc +++ b/.jshintrc @@ -27,6 +27,7 @@ "element", "expect", "inject", - "it" + "it", + "JSON" ] } diff --git a/dist/angular-gettext.js b/dist/angular-gettext.js index 16dcc3e..7d82317 100644 --- a/dist/angular-gettext.js +++ b/dist/angular-gettext.js @@ -62,7 +62,7 @@ angular.module('gettext').constant('gettext', function (str) { * @requires https://docs.angularjs.org/api/ng/service/$rootScope $rootScope * @description Provides set of method to translate strings */ -angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextFallbackLanguage", "$http", "$cacheFactory", "$interpolate", "$rootScope", function (gettextPlurals, gettextFallbackLanguage, $http, $cacheFactory, $interpolate, $rootScope) { +angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextFallbackLanguage", "gettextUtil", "$http", "$cacheFactory", "$interpolate", "$rootScope", function (gettextPlurals, gettextFallbackLanguage, gettextUtil, $http, $cacheFactory, $interpolate, $rootScope) { var catalog; var noContext = '$$noContext'; @@ -220,7 +220,7 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF * @description Sets the fallback languages. */ setFallbackLanguages: function (fallbacks) { - this.fallbackLanguages = fallbacks || {}; + this.fallbackLanguages = gettextUtil.copy(fallbacks || {}); }, /** @@ -231,7 +231,7 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF * @description Returns the fallback languages. */ getFallbackLanguages: function () { - return this.fallbackLanguages; + return gettextUtil.copy(this.fallbackLanguages); }, /** @@ -318,7 +318,7 @@ angular.module('gettext').factory('gettextCatalog', ["gettextPlurals", "gettextF * First it tries a language (e.g. `en-US`) then {@link gettextCatalog#fallbackLanguages language}, if any, then {@link gettextFallbackLanguage fallback} (e.g. `en`). */ getFallbackStringFormFor: function (language, string, n, context, stringPlural) { - var fallbackLanguages = this.fallbackLanguages[language] || []; + var fallbackLanguages = (this.fallbackLanguages[language] || []).slice(); var defaultFallbackLanguage = gettextFallbackLanguage(language); if (defaultFallbackLanguage) { fallbackLanguages.push(defaultFallbackLanguage); } @@ -864,10 +864,31 @@ angular.module('gettext').factory('gettextUtil', function gettextUtil() { return first + target.substr(1); } + /** + * @ngdoc method + * @name gettextUtil#copy + * @public + * @param {object} o Object to copy. + * @returns {object} A copy of the object. + * @description Makes a deep copy of an object, making sure to not keep any references to the original object. + */ + function copy(o) { + var output; + var v; + var key; + output = Array.isArray(o) ? [] : {}; + for (key in o) { + v = o[key]; + output[key] = (typeof v === 'object') ? copy(v) : v; + } + return output; + } + return { trim: trim, assert: assert, startsWith: startsWith, - lcFirst: lcFirst + lcFirst: lcFirst, + copy: copy }; }); diff --git a/dist/angular-gettext.min.js b/dist/angular-gettext.min.js index a15e330..a1a06f3 100644 --- a/dist/angular-gettext.min.js +++ b/dist/angular-gettext.min.js @@ -1 +1 @@ -angular.module("gettext",[]),angular.module("gettext").constant("gettext",function(a){return a}),angular.module("gettext").factory("gettextCatalog",["gettextPlurals","gettextFallbackLanguage","$http","$cacheFactory","$interpolate","$rootScope",function(a,b,c,d,e,f){function g(){f.$broadcast("gettextLanguageChanged")}var h,i="$$noContext",j='test',k=angular.element(""+j+"").html()!==j,l=function(a){return h.debug&&h.currentLanguage!==h.baseLanguage?h.debugPrefix+a:a},m=function(a){return h.showTranslatedMarkers?h.translatedMarkerPrefix+a+h.translatedMarkerSuffix:a};return h={debug:!1,debugPrefix:"[MISSING]: ",showTranslatedMarkers:!1,translatedMarkerPrefix:"[",translatedMarkerSuffix:"]",strings:{},baseLanguage:"en",currentLanguage:"en",fallbackLanguages:{},cache:d("strings"),setCurrentLanguage:function(a){this.currentLanguage=a,g()},getCurrentLanguage:function(){return this.currentLanguage},setFallbackLanguages:function(a){this.fallbackLanguages=a||{}},getFallbackLanguages:function(){return this.fallbackLanguages},setStrings:function(b,c){this.strings[b]||(this.strings[b]={});var d=a(b,1);for(var e in c){var f=c[e];if(k&&(e=angular.element(""+e+"").html()),angular.isString(f)||angular.isArray(f)){var h={};h[i]=f,f=h}this.strings[b][e]||(this.strings[b][e]={});for(var j in f){var l=f[j];angular.isArray(l)?this.strings[b][e][j]=l:(this.strings[b][e][j]=[],this.strings[b][e][j][d]=l)}}g()},getStringFormFor:function(b,c,d,e){if(!b)return null;var f=this.strings[b]||{},g=f[c]||{},h=g[e||i]||[];return h[a(b,d)]},getFallbackStringFormFor:function(a,c,d,e,f){var g=this.fallbackLanguages[a]||[],h=b(a);h&&g.push(h);for(var i=this.getStringFormFor(a,c,d,e),j=0;j"===j.slice(-13)&&(j=j.slice(0,-13)),{post:function(e,g,i){function m(b){b=b||null;var h;k?(e=o||(o=e.$new()),e.$count=n(e),h=a.getPlural(e.$count,j,k,b,l)):h=a.getString(j,b,l);var i=g.contents();if(i||h){if(h===f.trim(i.html()))return void(p&&d(i)(e));var m=angular.element(""+h+"");d(m.contents())(e);var q=m.contents();c.enter(q,g),c.leave(i)}}var n=b(i.translateN),o=null,p=!0,q=h(e,i,m);m(q),p=!1,i.translateN&&e.$watch(i.translateN,function(){m(q)}),e.$on("gettextLanguageChanged",function(){m(q)})}}}}}]),angular.module("gettext").factory("gettextFallbackLanguage",function(){var a={},b=/([^_]+)_[^_]+$/;return function(c){if(a[c])return a[c];var d=b.exec(c);return d?(a[c]=d[1],d[1]):null}}),angular.module("gettext").filter("translate",["gettextCatalog",function(a){function b(b,c){return a.getString(b,null,c)}return b.$stateful=!0,b}]),angular.module("gettext").factory("gettextPlurals",function(){function a(a){return b[a]||(b[a]=a.split(/\-|_/).shift()),b[a]}var b={pt_BR:"pt_BR","pt-BR":"pt_BR"};return function(b,c){switch(a(b)){case"ay":case"bo":case"cgg":case"dz":case"fa":case"id":case"ja":case"jbo":case"ka":case"kk":case"km":case"ko":case"ky":case"lo":case"ms":case"my":case"sah":case"su":case"th":case"tt":case"ug":case"vi":case"wo":case"zh":return 0;case"is":return c%10!=1||c%100==11?1:0;case"jv":return 0!=c?1:0;case"mk":return 1==c||c%10==1?0:1;case"ach":case"ak":case"am":case"arn":case"br":case"fil":case"fr":case"gun":case"ln":case"mfe":case"mg":case"mi":case"oc":case"pt_BR":case"tg":case"ti":case"tr":case"uz":case"wa":case"zh":return c>1?1:0;case"lv":return c%10==1&&c%100!=11?0:0!=c?1:2;case"lt":return c%10==1&&c%100!=11?0:c%10>=2&&(c%100<10||c%100>=20)?1:2;case"be":case"bs":case"hr":case"ru":case"sr":case"uk":return c%10==1&&c%100!=11?0:c%10>=2&&c%10<=4&&(c%100<10||c%100>=20)?1:2;case"mnk":return 0==c?0:1==c?1:2;case"ro":return 1==c?0:0==c||c%100>0&&c%100<20?1:2;case"pl":return 1==c?0:c%10>=2&&c%10<=4&&(c%100<10||c%100>=20)?1:2;case"cs":case"sk":return 1==c?0:c>=2&&c<=4?1:2;case"sl":return c%100==1?1:c%100==2?2:c%100==3||c%100==4?3:0;case"mt":return 1==c?0:0==c||c%100>1&&c%100<11?1:c%100>10&&c%100<20?2:3;case"gd":return 1==c||11==c?0:2==c||12==c?1:c>2&&c<20?2:3;case"cy":return 1==c?0:2==c?1:8!=c&&11!=c?2:3;case"kw":return 1==c?0:2==c?1:3==c?2:3;case"ga":return 1==c?0:2==c?1:c<7?2:c<11?3:4;case"ar":return 0==c?0:1==c?1:2==c?2:c%100>=3&&c%100<=10?3:c%100>=11?4:5;default:return 1!=c?1:0}}}),angular.module("gettext").factory("gettextUtil",function(){function a(a,b,c){if(!a)throw new Error("You should add a "+b+" attribute whenever you add a "+c+" attribute.")}function b(a,b){return 0===a.indexOf(b)}function c(a){var b=a.charAt(0).toLowerCase();return b+a.substr(1)}var d=function(){return String.prototype.trim?function(a){return"string"==typeof a?a.trim():a}:function(a){return"string"==typeof a?a.replace(/^\s*/,"").replace(/\s*$/,""):a}}();return{trim:d,assert:a,startsWith:b,lcFirst:c}}); \ No newline at end of file +angular.module("gettext",[]),angular.module("gettext").constant("gettext",function(a){return a}),angular.module("gettext").factory("gettextCatalog",["gettextPlurals","gettextFallbackLanguage","gettextUtil","$http","$cacheFactory","$interpolate","$rootScope",function(a,b,c,d,e,f,g){function h(){g.$broadcast("gettextLanguageChanged")}var i,j="$$noContext",k='test',l=angular.element(""+k+"").html()!==k,m=function(a){return i.debug&&i.currentLanguage!==i.baseLanguage?i.debugPrefix+a:a},n=function(a){return i.showTranslatedMarkers?i.translatedMarkerPrefix+a+i.translatedMarkerSuffix:a};return i={debug:!1,debugPrefix:"[MISSING]: ",showTranslatedMarkers:!1,translatedMarkerPrefix:"[",translatedMarkerSuffix:"]",strings:{},baseLanguage:"en",currentLanguage:"en",fallbackLanguages:{},cache:e("strings"),setCurrentLanguage:function(a){this.currentLanguage=a,h()},getCurrentLanguage:function(){return this.currentLanguage},setFallbackLanguages:function(a){this.fallbackLanguages=c.copy(a||{})},getFallbackLanguages:function(){return c.copy(this.fallbackLanguages)},setStrings:function(b,c){this.strings[b]||(this.strings[b]={});var d=a(b,1);for(var e in c){var f=c[e];if(l&&(e=angular.element(""+e+"").html()),angular.isString(f)||angular.isArray(f)){var g={};g[j]=f,f=g}this.strings[b][e]||(this.strings[b][e]={});for(var i in f){var k=f[i];angular.isArray(k)?this.strings[b][e][i]=k:(this.strings[b][e][i]=[],this.strings[b][e][i][d]=k)}}h()},getStringFormFor:function(b,c,d,e){if(!b)return null;var f=this.strings[b]||{},g=f[c]||{},h=g[e||j]||[];return h[a(b,d)]},getFallbackStringFormFor:function(a,c,d,e,f){var g=(this.fallbackLanguages[a]||[]).slice(),h=b(a);h&&g.push(h);for(var i=this.getStringFormFor(a,c,d,e),j=0;j"===j.slice(-13)&&(j=j.slice(0,-13)),{post:function(e,g,i){function m(b){b=b||null;var h;k?(e=o||(o=e.$new()),e.$count=n(e),h=a.getPlural(e.$count,j,k,b,l)):h=a.getString(j,b,l);var i=g.contents();if(i||h){if(h===f.trim(i.html()))return void(p&&d(i)(e));var m=angular.element(""+h+"");d(m.contents())(e);var q=m.contents();c.enter(q,g),c.leave(i)}}var n=b(i.translateN),o=null,p=!0,q=h(e,i,m);m(q),p=!1,i.translateN&&e.$watch(i.translateN,function(){m(q)}),e.$on("gettextLanguageChanged",function(){m(q)})}}}}}]),angular.module("gettext").factory("gettextFallbackLanguage",function(){var a={},b=/([^_]+)_[^_]+$/;return function(c){if(a[c])return a[c];var d=b.exec(c);return d?(a[c]=d[1],d[1]):null}}),angular.module("gettext").filter("translate",["gettextCatalog",function(a){function b(b,c){return a.getString(b,null,c)}return b.$stateful=!0,b}]),angular.module("gettext").factory("gettextPlurals",function(){function a(a){return b[a]||(b[a]=a.split(/\-|_/).shift()),b[a]}var b={pt_BR:"pt_BR","pt-BR":"pt_BR"};return function(b,c){switch(a(b)){case"ay":case"bo":case"cgg":case"dz":case"fa":case"id":case"ja":case"jbo":case"ka":case"kk":case"km":case"ko":case"ky":case"lo":case"ms":case"my":case"sah":case"su":case"th":case"tt":case"ug":case"vi":case"wo":case"zh":return 0;case"is":return c%10!=1||c%100==11?1:0;case"jv":return 0!=c?1:0;case"mk":return 1==c||c%10==1?0:1;case"ach":case"ak":case"am":case"arn":case"br":case"fil":case"fr":case"gun":case"ln":case"mfe":case"mg":case"mi":case"oc":case"pt_BR":case"tg":case"ti":case"tr":case"uz":case"wa":case"zh":return c>1?1:0;case"lv":return c%10==1&&c%100!=11?0:0!=c?1:2;case"lt":return c%10==1&&c%100!=11?0:c%10>=2&&(c%100<10||c%100>=20)?1:2;case"be":case"bs":case"hr":case"ru":case"sr":case"uk":return c%10==1&&c%100!=11?0:c%10>=2&&c%10<=4&&(c%100<10||c%100>=20)?1:2;case"mnk":return 0==c?0:1==c?1:2;case"ro":return 1==c?0:0==c||c%100>0&&c%100<20?1:2;case"pl":return 1==c?0:c%10>=2&&c%10<=4&&(c%100<10||c%100>=20)?1:2;case"cs":case"sk":return 1==c?0:c>=2&&c<=4?1:2;case"sl":return c%100==1?1:c%100==2?2:c%100==3||c%100==4?3:0;case"mt":return 1==c?0:0==c||c%100>1&&c%100<11?1:c%100>10&&c%100<20?2:3;case"gd":return 1==c||11==c?0:2==c||12==c?1:c>2&&c<20?2:3;case"cy":return 1==c?0:2==c?1:8!=c&&11!=c?2:3;case"kw":return 1==c?0:2==c?1:3==c?2:3;case"ga":return 1==c?0:2==c?1:c<7?2:c<11?3:4;case"ar":return 0==c?0:1==c?1:2==c?2:c%100>=3&&c%100<=10?3:c%100>=11?4:5;default:return 1!=c?1:0}}}),angular.module("gettext").factory("gettextUtil",function(){function a(a,b,c){if(!a)throw new Error("You should add a "+b+" attribute whenever you add a "+c+" attribute.")}function b(a,b){return 0===a.indexOf(b)}function c(a){var b=a.charAt(0).toLowerCase();return b+a.substr(1)}function d(a){var b,c,e;b=Array.isArray(a)?[]:{};for(e in a)c=a[e],b[e]="object"==typeof c?d(c):c;return b}var e=function(){return String.prototype.trim?function(a){return"string"==typeof a?a.trim():a}:function(a){return"string"==typeof a?a.replace(/^\s*/,"").replace(/\s*$/,""):a}}();return{trim:e,assert:a,startsWith:b,lcFirst:c,copy:d}}); \ No newline at end of file diff --git a/src/catalog.js b/src/catalog.js index 6893961..4f2bed2 100644 --- a/src/catalog.js +++ b/src/catalog.js @@ -10,7 +10,7 @@ * @requires https://docs.angularjs.org/api/ng/service/$rootScope $rootScope * @description Provides set of method to translate strings */ -angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, gettextFallbackLanguage, $http, $cacheFactory, $interpolate, $rootScope) { +angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, gettextFallbackLanguage, gettextUtil, $http, $cacheFactory, $interpolate, $rootScope) { var catalog; var noContext = '$$noContext'; @@ -168,7 +168,7 @@ angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, ge * @description Sets the fallback languages. */ setFallbackLanguages: function (fallbacks) { - this.fallbackLanguages = fallbacks || {}; + this.fallbackLanguages = gettextUtil.copy(fallbacks || {}); }, /** @@ -179,7 +179,7 @@ angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, ge * @description Returns the fallback languages. */ getFallbackLanguages: function () { - return this.fallbackLanguages; + return gettextUtil.copy(this.fallbackLanguages); }, /** @@ -266,7 +266,7 @@ angular.module('gettext').factory('gettextCatalog', function (gettextPlurals, ge * First it tries a language (e.g. `en-US`) then {@link gettextCatalog#fallbackLanguages language}, if any, then {@link gettextFallbackLanguage fallback} (e.g. `en`). */ getFallbackStringFormFor: function (language, string, n, context, stringPlural) { - var fallbackLanguages = this.fallbackLanguages[language] || []; + var fallbackLanguages = (this.fallbackLanguages[language] || []).slice(); var defaultFallbackLanguage = gettextFallbackLanguage(language); if (defaultFallbackLanguage) { fallbackLanguages.push(defaultFallbackLanguage); } diff --git a/src/util.js b/src/util.js index 5165dff..0021c12 100644 --- a/src/util.js +++ b/src/util.js @@ -88,10 +88,31 @@ angular.module('gettext').factory('gettextUtil', function gettextUtil() { return first + target.substr(1); } + /** + * @ngdoc method + * @name gettextUtil#copy + * @public + * @param {object} o Object to copy. + * @returns {object} A copy of the object. + * @description Makes a deep copy of an object, making sure to not keep any references to the original object. + */ + function copy(o) { + var output; + var v; + var key; + output = Array.isArray(o) ? [] : {}; + for (key in o) { + v = o[key]; + output[key] = (typeof v === 'object') ? copy(v) : v; + } + return output; + } + return { trim: trim, assert: assert, startsWith: startsWith, - lcFirst: lcFirst + lcFirst: lcFirst, + copy: copy }; }); diff --git a/test/unit/catalog.js b/test/unit/catalog.js index 8f0e833..78b690e 100644 --- a/test/unit/catalog.js +++ b/test/unit/catalog.js @@ -181,6 +181,21 @@ describe("Catalog", function () { assert.equal(catalog.getString("Hello"), "Bonjour"); }); + it("Should not modify original object used to set fallback languages when translating", function () { + var fallbackLanguages = { en_US: ["fr"] }; + var originalFallbacks = JSON.stringify(fallbackLanguages); + catalog.setFallbackLanguages(fallbackLanguages); + catalog.getFallbackStringFormFor("en_US", "Hello"); + assert.equal(JSON.stringify(fallbackLanguages), originalFallbacks); + }); + + it("Should not modify internal fallback languages object when translating", function () { + var fallbackLanguages = { en_US: ["fr"] }; + catalog.setFallbackLanguages(fallbackLanguages); + catalog.getFallbackStringFormFor("en_US", "Hello"); + assert.equal(JSON.stringify(catalog.getFallbackLanguages()), JSON.stringify(fallbackLanguages)); + }); + it("Should return string from default fallback language if current language has no translation", function () { var strings = { Hello: "Hallo" }; catalog.setStrings("nl", strings);