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