1 /* 2 * ResBundle.js - Resource bundle definition 3 * 4 * Copyright © 2012-2016, 2018, JEDLSoft 5 * 6 * Licensed under the Apache License, Version 2.0 (the "License"); 7 * you may not use this file except in compliance with the License. 8 * You may obtain a copy of the License at 9 * 10 * http://www.apache.org/licenses/LICENSE-2.0 11 * 12 * Unless required by applicable law or agreed to in writing, software 13 * distributed under the License is distributed on an "AS IS" BASIS, 14 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 * 16 * See the License for the specific language governing permissions and 17 * limitations under the License. 18 */ 19 20 // !data pseudomap 21 22 var ilib = require("./ilib.js"); 23 var Utils = require("./Utils.js"); 24 var JSUtils = require("./JSUtils.js"); 25 26 var Locale = require("./Locale.js"); 27 var LocaleInfo = require("./LocaleInfo.js"); 28 29 var IString = require("./IString.js"); 30 31 /** 32 * @class 33 * Create a new resource bundle instance. The resource bundle loads strings 34 * appropriate for a particular locale and provides them via the getString 35 * method.<p> 36 * 37 * The options object may contain any (or none) of the following properties: 38 * 39 * <ul> 40 * <li><i>locale</i> - The locale of the strings to load. If not specified, the default 41 * locale is the the default for the web page or app in which the bundle is 42 * being loaded. 43 * 44 * <li><i>name</i> - Base name of the resource bundle to load. If not specified the default 45 * base name is "resources". 46 * 47 * <li><i>type</i> - Name the type of strings this bundle contains. Valid values are 48 * "xml", "html", "text", "c", or "raw". The default is "text". If the type is "xml" or "html", 49 * then XML/HTML entities and tags are not pseudo-translated. During a real translation, 50 * HTML character entities are translated to their corresponding characters in a source 51 * string before looking that string up in the translations. Also, the characters "<", ">", 52 * and "&" are converted to entities again in the output, but characters are left as they 53 * are. If the type is "xml", "html", or "text" types, then the replacement parameter names 54 * are not pseudo-translated as well so that the output can be used for formatting with 55 * the IString class. If the type is "c" then all C language style printf replacement 56 * parameters (eg. "%s" and "%d") are skipped automatically. If the type is raw, all characters 57 * are pseudo-translated, including replacement parameters as well as XML/HTML tags and entities. 58 * 59 * <li><i>lengthen</i> - when pseudo-translating the string, tell whether or not to 60 * automatically lengthen the string to simulate "long" languages such as German 61 * or French. This is a boolean value. Default is false. 62 * 63 * <li><i>missing</i> - what to do when a resource is missing. The choices are: 64 * <ul> 65 * <li><i>source</i> - return the source string unchanged 66 * <li><i>pseudo</i> - return the pseudo-translated source string, translated to the 67 * script of the locale if the mapping is available, or just the default Latin 68 * pseudo-translation if not 69 * <li><i>empty</i> - return the empty string 70 * </ul> 71 * The default behaviour is the same as before, which is to return the source string 72 * unchanged. 73 * 74 * <li><i>onLoad</i> - a callback function to call when the resources are fully 75 * loaded. When the onLoad option is given, this class will attempt to 76 * load any missing locale data using the ilib loader callback. 77 * When the constructor is done (even if the data is already preassembled), the 78 * onLoad function is called with the current instance as a parameter, so this 79 * callback can be used with preassembled or dynamic loading or a mix of the two. 80 * 81 * <li>sync - tell whether to load any missing locale data synchronously or 82 * asynchronously. If this option is given as "false", then the "onLoad" 83 * callback must be given, as the instance returned from this constructor will 84 * not be usable for a while. 85 * 86 * <li><i>loadParams</i> - an object containing parameters to pass to the 87 * loader callback function when locale data is missing. The parameters are not 88 * interpretted or modified in any way. They are simply passed along. The object 89 * may contain any property/value pairs as long as the calling code is in 90 * agreement with the loader callback function as to what those parameters mean. 91 * </ul> 92 * 93 * The locale option may be given as a locale spec string or as an 94 * Locale object. If the locale option is not specified, then strings for 95 * the default locale will be loaded.<p> 96 * 97 * The name option can be used to put groups of strings together in a 98 * single bundle. The strings will then appear together in a JS object in 99 * a JS file that can be included before the ilib.<p> 100 * 101 * A resource bundle with a particular name is actually a set of bundles 102 * that are each specific to a language, a language plus a region, etc. 103 * All bundles with the same base name should 104 * contain the same set of source strings, but with different translations for 105 * the given locale. The user of the bundle does not need to be aware of 106 * the locale of the bundle, as long as it contains values for the strings 107 * it needs.<p> 108 * 109 * Strings in bundles for a particular locale are inherited from parent bundles 110 * that are more generic. In general, the hierarchy is as follows (from 111 * least locale-specific to most locale-specific): 112 * 113 * <ol> 114 * <li> language 115 * <li> region 116 * <li> language_script 117 * <li> language_region 118 * <li> region_variant 119 * <li> language_script_region 120 * <li> language_region_variant 121 * <li> language_script_region_variant 122 * </ol> 123 * 124 * That is, if the translation for a string does not exist in the current 125 * locale, the more-generic parent locale is searched for the string. In the 126 * worst case scenario, the string is not found in the base locale's strings. 127 * In this case, the missing option guides this class on what to do. If 128 * the missing option is "source", then the original source is returned as 129 * the translation. If it is "empty", the empty string is returned. If it 130 * is "pseudo", then the pseudo-translated string that is appropriate for 131 * the default script of the locale is returned.<p> 132 * 133 * This allows developers to create code with new or changed strings in it and check in that 134 * code without waiting for the translations to be done first. The translated 135 * version of the app or web site will still function properly, but will show 136 * a spurious untranslated string here and there until the translations are 137 * done and also checked in.<p> 138 * 139 * The base is whatever language your developers use to code in. For 140 * a German web site, strings in the source code may be written in German 141 * for example. Often this base is English, as many web sites are coded in 142 * English, but that is not required.<p> 143 * 144 * The strings can be extracted with the ilib localization tool (which will be 145 * shipped at some future time.) Once the strings 146 * have been translated, the set of translated files can be generated with the 147 * same tool. The output from the tool can be used as input to the ResBundle 148 * object. It is up to the web page or app to make sure the JS file that defines 149 * the bundle is included before creating the ResBundle instance.<p> 150 * 151 * A special locale "zxx-XX" is used as the pseudo-translation locale because 152 * zxx means "no linguistic information" in the ISO 639 standard, and the region 153 * code XX is defined to be user-defined in the ISO 3166 standard. 154 * Pseudo-translation is a locale where the translations are generated on 155 * the fly based on the contents of the source string. Characters in the source 156 * string are replaced with other characters and returned. 157 * 158 * Example. If the source string is: 159 * 160 * <pre> 161 * "This is a string" 162 * </pre> 163 * 164 * then the pseudo-translated version might look something like this: 165 * 166 * <pre> 167 * "Ţħïş ïş á şţřïñĝ" 168 * </pre> 169 * <p> 170 * 171 * Pseudo-translation can be used to test that your app or web site is translatable 172 * before an actual translation has happened. These bugs can then be fixed 173 * before the translation starts, avoiding an explosion of bugs later when 174 * each language's tester registers the same bug complaining that the same 175 * string is not translated. When pseudo-localizing with 176 * the Latin script, this allows the strings to be readable in the UI in the 177 * source language (if somewhat funky-looking), 178 * so that a tester can easily verify that the string is properly externalized 179 * and loaded from a resource bundle without the need to be able to read a 180 * foreign language.<p> 181 * 182 * If one of a list of script tags is given in the pseudo-locale specifier, then the 183 * pseudo-localization can map characters to very rough transliterations of 184 * characters in the given script. For example, zxx-Hebr-XX maps strings to 185 * Hebrew characters, which can be used to test your UI in a right-to-left 186 * language to catch bidi bugs before a translation is done. Currently, the 187 * list of target scripts includes Hebrew (Hebr), Chinese Simplified Han (Hans), 188 * and Cyrillic (Cyrl) with more to be added later. If no script is explicitly 189 * specified in the locale spec, or if the script is not supported, 190 * then the default mapping maps Latin base characters to accented versions of 191 * those Latin characters as in the example above. 192 * 193 * When the "lengthen" property is set to true in the options, the 194 * pseudotranslation code will add digits to the end of the string to simulate 195 * the lengthening that occurs when translating to other languages. The above 196 * example will come out like this: 197 * 198 * <pre> 199 * "Ţħïş ïş á şţřïñĝ76543210" 200 * </pre> 201 * 202 * The string is lengthened according to the length of the source string. If 203 * the source string is less than 20 characters long, the string is lengthened 204 * by 50%. If the source string is 20-40 205 * characters long, the string is lengthened by 33%. If te string is greater 206 * than 40 characters long, the string is lengthened by 20%.<p> 207 * 208 * The pseudotranslation always ends a string with the digit "0". If you do 209 * not see the digit "0" in the UI for your app, you know that truncation 210 * has occurred, and the number you see at the end of the string tells you 211 * how many characters were truncated.<p> 212 * 213 * 214 * @constructor 215 * @param {?Object} options Options controlling how the bundle is created 216 */ 217 var ResBundle = function (options) { 218 var lookupLocale, spec; 219 220 this.locale = new Locale(); // use the default locale 221 this.baseName = "strings"; 222 this.type = "text"; 223 this.loadParams = {}; 224 this.missing = "source"; 225 this.sync = true; 226 227 if (options) { 228 if (options.locale) { 229 this.locale = (typeof(options.locale) === 'string') ? 230 new Locale(options.locale) : 231 options.locale; 232 } 233 if (options.name) { 234 this.baseName = options.name; 235 } 236 if (options.type) { 237 this.type = options.type; 238 } 239 this.lengthen = options.lengthen || false; 240 241 if (typeof(options.sync) !== 'undefined') { 242 this.sync = !!options.sync; 243 } 244 245 if (typeof(options.loadParams) !== 'undefined') { 246 this.loadParams = options.loadParams; 247 } 248 if (typeof(options.missing) !== 'undefined') { 249 if (options.missing === "pseudo" || options.missing === "empty") { 250 this.missing = options.missing; 251 } 252 } 253 } else { 254 options = {sync: true}; 255 } 256 257 this.map = {}; 258 259 lookupLocale = this.locale.isPseudo() ? new Locale("en-US") : this.locale; 260 var object = "ResBundle-" + this.baseName; 261 262 Utils.loadData({ 263 object: object, 264 locale: lookupLocale, 265 name: this.baseName + ".json", 266 sync: this.sync, 267 loadParams: this.loadParams, 268 callback: ilib.bind(this, function (map) { 269 if (!map) { 270 map = ilib.data[this.baseName] || {}; 271 } 272 this.map = map; 273 if (this.locale.isPseudo()) { 274 this._loadPseudo(this.locale, options.onLoad); 275 } else if (this.missing === "pseudo") { 276 new LocaleInfo(this.locale, { 277 sync: this.sync, 278 loadParams: this.loadParams, 279 onLoad: ilib.bind(this, function (li) { 280 var pseudoLocale = new Locale("zxx", "XX", undefined, li.getDefaultScript()); 281 this._loadPseudo(pseudoLocale, options.onLoad); 282 }) 283 }); 284 } else { 285 if (typeof(options.onLoad) === 'function') { 286 options.onLoad(this); 287 } 288 } 289 }) 290 }); 291 292 // console.log("Merged resources " + this.locale.toString() + " are: " + JSON.stringify(this.map)); 293 //if (!this.locale.isPseudo() && JSUtils.isEmpty(this.map)) { 294 // console.log("Resources for bundle " + this.baseName + " locale " + this.locale.toString() + " are not available."); 295 //} 296 }; 297 298 ResBundle.defaultPseudo = ilib.data.pseudomap || { 299 "a": "à", 300 "e": "ë", 301 "i": "í", 302 "o": "õ", 303 "u": "ü", 304 "y": "ÿ", 305 "A": "Ã", 306 "E": "Ë", 307 "I": "Ï", 308 "O": "Ø", 309 "U": "Ú", 310 "Y": "Ŷ" 311 }; 312 313 ResBundle.prototype = { 314 /** 315 * @protected 316 */ 317 _loadPseudo: function (pseudoLocale, onLoad) { 318 Utils.loadData({ 319 object: "ResBundle", 320 locale: pseudoLocale, 321 name: "pseudomap.json", 322 sync: this.sync, 323 loadParams: this.loadParams, 324 callback: ilib.bind(this, function (map) { 325 this.pseudomap = (!map || JSUtils.isEmpty(map)) ? ResBundle.defaultPseudo : map; 326 if (typeof(onLoad) === 'function') { 327 onLoad(this); 328 } 329 }) 330 }); 331 }, 332 333 /** 334 * Return the locale of this resource bundle. 335 * @return {Locale} the locale of this resource bundle object 336 */ 337 getLocale: function () { 338 return this.locale; 339 }, 340 341 /** 342 * Return the name of this resource bundle. This corresponds to the name option 343 * given to the constructor. 344 * @return {string} name of the the current instance 345 */ 346 getName: function () { 347 return this.baseName; 348 }, 349 350 /** 351 * Return the type of this resource bundle. This corresponds to the type option 352 * given to the constructor. 353 * @return {string} type of the the current instance 354 */ 355 getType: function () { 356 return this.type; 357 }, 358 359 percentRE: new RegExp("%(\\d+\\$)?([\\-#\\+ 0,\\(])?(\\d+)?(\\.\\d+)?[bBhHsScCdoxXeEfgGaAtT%n]"), 360 361 /** 362 * @private 363 * Pseudo-translate a string 364 */ 365 _pseudo: function (str) { 366 if (!str) { 367 return undefined; 368 } 369 var ret = "", i; 370 for (i = 0; i < str.length; i++) { 371 if (this.type !== "raw") { 372 if (this.type === "html" || this.type === "xml") { 373 if (str.charAt(i) === '<') { 374 ret += str.charAt(i++); 375 while (i < str.length && str.charAt(i) !== '>') { 376 ret += str.charAt(i++); 377 } 378 } else if (str.charAt(i) === '&') { 379 ret += str.charAt(i++); 380 while (i < str.length && str.charAt(i) !== ';' && str.charAt(i) !== ' ') { 381 ret += str.charAt(i++); 382 } 383 } else if (str.charAt(i) === '\\' && str.charAt(i+1) === "u") { 384 ret += str.substring(i, i+6); 385 i += 6; 386 } 387 } else if (this.type === "c") { 388 if (str.charAt(i) === "%") { 389 var m = this.percentRE.exec(str.substring(i)); 390 if (m && m.length) { 391 // console.log("Match found: " + JSON.stringify(m[0].replace("%", "%%"))); 392 ret += m[0]; 393 i += m[0].length; 394 } 395 } 396 397 } 398 if (i < str.length) { 399 if (str.charAt(i) === '{') { 400 ret += str.charAt(i++); 401 while (i < str.length && str.charAt(i) !== '}') { 402 ret += str.charAt(i++); 403 } 404 if (i < str.length) { 405 ret += str.charAt(i); 406 } 407 } else { 408 ret += this.pseudomap[str.charAt(i)] || str.charAt(i); 409 } 410 } 411 } else { 412 ret += this.pseudomap[str.charAt(i)] || str.charAt(i); 413 } 414 } 415 if (this.lengthen) { 416 var add; 417 if (ret.length <= 20) { 418 add = Math.round(ret.length / 2); 419 } else if (ret.length > 20 && ret.length <= 40) { 420 add = Math.round(ret.length / 3); 421 } else { 422 add = Math.round(ret.length / 5); 423 } 424 for (i = add-1; i >= 0; i--) { 425 ret += (i % 10); 426 } 427 } 428 if (this.locale.getScript() === "Hans" || this.locale.getScript() === "Hant" || 429 this.locale.getScript() === "Hani" || 430 this.locale.getScript() === "Hrkt" || this.locale.getScript() === "Jpan" || 431 this.locale.getScript() === "Hira" || this.locale.getScript() === "Kana" ) { 432 // simulate Asian languages by getting rid of all the spaces 433 ret = ret.replace(/ /g, ""); 434 } 435 return ret; 436 }, 437 438 /** 439 * @private 440 * Escape html characters in the output. 441 */ 442 _escapeXml: function (str) { 443 str = str.replace(/&/g, '&'); 444 str = str.replace(/</g, '<'); 445 str = str.replace(/>/g, '>'); 446 return str; 447 }, 448 449 /** 450 * @private 451 * @param {string} str the string to unescape 452 */ 453 _unescapeXml: function (str) { 454 str = str.replace(/&/g, '&'); 455 str = str.replace(/</g, '<'); 456 str = str.replace(/>/g, '>'); 457 return str; 458 }, 459 460 /** 461 * @private 462 * Create a key name out of a source string. All this does so far is 463 * compress sequences of white space into a single space on the assumption 464 * that this doesn't really change the meaning of the string, and therefore 465 * all such strings that compress to the same thing should share the same 466 * translation. 467 * @param {null|string=} source the source string to make a key out of 468 */ 469 _makeKey: function (source) { 470 if (!source) return undefined; 471 var key = source.replace(/\s+/gm, ' '); 472 return (this.type === "xml" || this.type === "html") ? this._unescapeXml(key) : key; 473 }, 474 475 /** 476 * @private 477 */ 478 _getStringSingle: function(source, key, escapeMode) { 479 if (!source && !key) return new IString(""); 480 481 var trans; 482 if (this.locale.isPseudo()) { 483 var str = source ? source : this.map[key]; 484 trans = this._pseudo(str || key); 485 } else { 486 var keyName = key || this._makeKey(source); 487 if (typeof(this.map[keyName]) !== 'undefined') { 488 trans = this.map[keyName]; 489 } else if (this.missing === "pseudo") { 490 trans = this._pseudo(source || key); 491 } else if (this.missing === "empty") { 492 trans = ""; 493 } else { 494 trans = source; 495 } 496 } 497 498 if (escapeMode && escapeMode !== "none") { 499 if (escapeMode == "default") { 500 escapeMode = this.type; 501 } 502 if (escapeMode === "xml" || escapeMode === "html") { 503 trans = this._escapeXml(trans); 504 } else if (escapeMode == "js" || escapeMode === "attribute") { 505 trans = trans.replace(/'/g, "\\\'").replace(/"/g, "\\\""); 506 } 507 } 508 if (trans === undefined) { 509 return undefined; 510 } else { 511 var ret = new IString(trans); 512 ret.setLocale(this.locale.getSpec(), true, this.loadParams); // no callback 513 return ret; 514 } 515 }, 516 517 /** 518 * Return a localized string, array, or object. This method can localize individual 519 * strings or arrays of strings.<p> 520 * 521 * If the source parameter is a string, the translation of that string is looked 522 * up and returned. If the source parameter is an array of strings, then the translation 523 * of each of the elements of that array is looked up, and an array of translated strings 524 * is returned. <p> 525 * 526 * If any string is not found in the loaded set of 527 * resources, the original source string is returned. If the key is not given, 528 * then the source string itself is used as the key. In the case where the 529 * source string is used as the key, the whitespace is compressed down to 1 space 530 * each, and the whitespace at the beginning and end of the string is trimmed.<p> 531 * 532 * The escape mode specifies what type of output you are escaping the returned 533 * string for. Modes are similar to the types: 534 * 535 * <ul> 536 * <li>"html" -- prevents HTML injection by escaping the characters < > and & 537 * <li>"xml" -- currently same as "html" mode 538 * <li>"js" -- prevents breaking Javascript syntax by backslash escaping all quote and 539 * double-quote characters 540 * <li>"attribute" -- meant for HTML attribute values. Currently this is the same as 541 * "js" escape mode. 542 * <li>"default" -- use the type parameter from the constructor as the escape mode as well 543 * <li>"none" or undefined -- no escaping at all. 544 * </ul> 545 * 546 * The type parameter of the constructor specifies what type of strings this bundle 547 * is operating upon. This allows pseudo-translation and automatic key generation 548 * to happen properly by telling this class how to parse the string. The escape mode 549 * for this method is different in that it specifies how this string will be used in 550 * the calling code and therefore how to escape it properly.<p> 551 * 552 * For example, a section of Javascript code may be constructing an HTML snippet in a 553 * string to add to the web page. In this case, the type parameter in the constructor should 554 * be "html" so that the source string can be parsed properly, but the escape mode should 555 * be "js" so that the output string can be used in Javascript without causing syntax 556 * errors. 557 * 558 * @param {?string|Array.<string>=} source the source string or strings to translate 559 * @param {?string|Array.<string>=} key optional name of the key, if any 560 * @param {?string=} escapeMode escape mode, if any 561 * @return {IString|Array.<IString>|undefined} the translation of the given source/key or undefined 562 * if the translation is not found and the source is undefined 563 */ 564 getString: function (source, key, escapeMode) { 565 if (!source && !key) return new IString(""); 566 567 //if (typeof(source) === "object") { 568 // TODO localize objects 569 //} else 570 571 if (ilib.isArray(source)) { 572 return source.map(ilib.bind(this, function(str) { 573 return typeof(str) === "string" ? this._getStringSingle(str, key, escapeMode) : str; 574 })); 575 } else { 576 return this._getStringSingle(source, key, escapeMode); 577 } 578 }, 579 580 /** 581 * Return a localized string as an intrinsic Javascript String object. This does the same thing as 582 * the getString() method, but it returns a regular Javascript string instead of 583 * and IString instance. This means it cannot be formatted with the format() 584 * method without being wrapped in an IString instance first. 585 * 586 * @param {?string|Array.<string>=} source the source string to translate 587 * @param {?string|Array.<string>=} key optional name of the key, if any 588 * @param {?string=} escapeMode escape mode, if any 589 * @return {string|Array.<string>|undefined} the translation of the given source/key or undefined 590 * if the translation is not found and the source is undefined 591 */ 592 getStringJS: function(source, key, escapeMode) { 593 if (typeof(source) === 'undefined' && typeof(key) === 'undefined') { 594 return undefined; 595 } 596 //if (typeof(source) === "object") { 597 // TODO localize objects 598 //} else 599 600 if (ilib.isArray(source)) { 601 return this.getString(source, key, escapeMode).map(function(str) { 602 return (str && str instanceof IString) ? str.toString() : str; 603 }); 604 } else { 605 var s = this.getString(source, key, escapeMode); 606 return s ? s.toString() : undefined; 607 } 608 }, 609 610 /** 611 * Return true if the current bundle contains a translation for the given key and 612 * source. The 613 * getString method will always return a string for any given key and source 614 * combination, so it cannot be used to tell if a translation exists. Either one 615 * or both of the source and key must be specified. If both are not specified, 616 * this method will return false. 617 * 618 * @param {?string=} source source string to look up 619 * @param {?string=} key key to look up 620 * @return {boolean} true if this bundle contains a translation for the key, and 621 * false otherwise 622 */ 623 containsKey: function(source, key) { 624 if (typeof(source) === 'undefined' && typeof(key) === 'undefined') { 625 return false; 626 } 627 628 var keyName = key || this._makeKey(source); 629 return typeof(this.map[keyName]) !== 'undefined'; 630 }, 631 632 /** 633 * Return the merged resources as an entire object. When loading resources for a 634 * locale that are not just a set of translated strings, but instead an entire 635 * structured javascript object, you can gain access to that object via this call. This method 636 * will ensure that all the of the parts of the object are correct for the locale.<p> 637 * 638 * For pre-assembled data, it starts by loading <i>ilib.data[name]</i>, where 639 * <i>name</i> is the base name for this set of resources. Then, it successively 640 * merges objects in the base data using progressively more locale-specific data. 641 * It loads it in this order from <i>ilib.data</i>: 642 * 643 * <ol> 644 * <li> language 645 * <li> region 646 * <li> language_script 647 * <li> language_region 648 * <li> region_variant 649 * <li> language_script_region 650 * <li> language_region_variant 651 * <li> language_script_region_variant 652 * </ol> 653 * 654 * For dynamically loaded data, the code attempts to load the same sequence as 655 * above, but with slash path separators instead of underscores.<p> 656 * 657 * Loading the resources this way allows the program to share resources between all 658 * locales that share a common language, region, or script. As a 659 * general rule-of-thumb, resources should be as generic as possible in order to 660 * cover as many locales as possible. 661 * 662 * @return {Object} returns the object that is the basis for this resources instance 663 */ 664 getResObj: function () { 665 return this.map; 666 } 667 }; 668 669 module.exports = ResBundle; 670