Source: i18n/i18n.js

Object.assign(pc, (function () {
    // Maps locale to function that returns the plural index
    // based on the CLDR rules. See here for reference
    // https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html
    // and http://unicode.org/reports/tr35/tr35-numbers.html#Operands .
    // An initial set of locales is supported and we can keep adding more as we go.
    var PLURALS = {};

    // Helper function to define the plural function for an array of locales
    var definePluralFn = function (locales, fn) {
        for (var i = 0, len = locales.length; i < len; i++) {
            PLURALS[locales[i]] = fn;
        }
    };

    // Gets the language portion form a locale
    var getLang = function (locale) {
        var idx = locale.indexOf('-');
        if (idx !== -1) {
            return locale.substring(0, idx);
        }

        return locale;
    };

    var DEFAULT_LOCALE = 'en-US';

    // default locale fallbacks if a specific locale
    // was not found. E.g. if the desired locale is en-AS but we
    // have en-US and en-GB then pick en-US. If a fallback does not exist either
    // then pick the first that satisfies the language.
    var DEFAULT_LOCALE_FALLBACKS = {
        'en': 'en-US',
        'es': 'en-ES',
        'zh': 'zh-CN',
        'fr': 'fr-FR',
        'de': 'de-DE',
        'it': 'it-IT',
        'ru': 'ru-RU',
        'ja': 'ja-JP'
    };

    // Only OTHER
    definePluralFn([
        'ja',
        'ko',
        'th',
        'vi',
        'zh'
    ], function (n) {
        return 0;
    });

    // ONE, OTHER
    definePluralFn([
        'fa',
        'hi'
    ], function (n) {
        if (n >= 0 && n <= 1) {
            return 0; // one
        }

        return 1; // other
    });

    definePluralFn([
        'fr'
    ], function (n) {
        if (n >= 0 && n < 2) {
            return 0; // one
        }

        return 1; // other
    });

    definePluralFn([
        'de',
        'en',
        'it',
        'el',
        'es',
        'tr'
    ], function (n) {
        if (n === 1)  {
            return 0; // one
        }

        return 1; // other
    });

    // ONE, FEW, MANY, OTHER
    definePluralFn([
        'ru',
        'uk'
    ], function (n) {
        if (Number.isInteger(n)) {
            var mod10 = n % 10;
            var mod100 = n % 100;

            if (mod10 === 1 && mod100 !== 11) {
                return 0; // one
            } else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
                return 1; // few
            } else if (mod10 === 0 || mod10 >= 5 && mod10 <= 9 || mod100 >= 11 && mod100 <= 14) {
                return 2; // many
            }
        }

        return 3; // other
    });

    // ZERO, ONE, TWO, FEW, MANY, OTHER
    definePluralFn([
        'ar'
    ], function (n) {
        if (n === 0)  {
            return 0; // zero
        } else if (n === 1) {
            return 1; // one
        } else if (n === 2) {
            return 2; // two
        }

        if (Number.isInteger(n)) {
            var mod100 = n % 100;
            if (mod100 >= 3 && mod100 <= 10) {
                return 3; // few
            } else if (mod100 >= 11 && mod100 <= 99) {
                return 4; // many
            }
        }

        return 5; // other
    });

    var DEFAULT_PLURAL_FN = PLURALS[getLang(DEFAULT_LOCALE)];

    // Gets the function that converts to plural for a language
    var getPluralFn = function (lang) {
        return PLURALS[lang] || DEFAULT_PLURAL_FN;
    };

    /**
     * @private
     * @constructor
     * @name pc.I18n
     * @classdesc Handles localization. Responsible for loading localization assets
     * and returning translations for a certain key. Can also handle plural forms. To override
     * its default behaviour define a different implementation for {@link pc.I18n#getText} and {@link pc.I18n#getPluralText}.
     * @param {pc.Application} app The application.
     * @property {String} locale The current locale for example "en-US". Changing the locale will raise an event which will cause localized Text Elements to
     * change language to the new locale.
     * @property {Number[]|pc.Asset[]} assets An array of asset ids or assets that contain localization data in the expected format. I18n will automatically load
     * translations from these assets as the assets are loaded and it will also automatically unload translations if the assets get removed or unloaded at runtime.
     */
    var I18n = function (app) {
        pc.events.attach(this);

        this.locale = DEFAULT_LOCALE;
        this._translations = {};
        this._availableLangs = {};
        this._app = app;
        this._assets = [];
        this._parser = new pc.I18nParser();
    };

    /**
     * @private
     * @function
     * @name pc.I18n#findAvailableLocale
     * @description Returns the first available locale based on the desired locale specified. First
     * tries to find the desired locale and then tries to find an alternative locale based on the language.
     * @param {String} desiredLocale The desired locale e.g. en-US.
     * @param {Object} availableLocales A dictionary where each key is an available locale.
     * @returns {String} The locale found or if no locale is available returns the default en-US locale.
     */
    I18n.findAvailableLocale = function (desiredLocale, availableLocales) {
        if (availableLocales[desiredLocale]) {
            return desiredLocale;
        }

        var lang = getLang(desiredLocale);

        var fallback = DEFAULT_LOCALE_FALLBACKS[lang];
        if (availableLocales[fallback]) {
            return fallback;
        }

        if (availableLocales[lang]) {
            return lang;
        }

        return DEFAULT_LOCALE;
    };

    /**
     * @private
     * @function
     * @name pc.I18n#getText
     * @description Returns the translation for the specified key and locale. If the locale is not specified
     * it will use the current locale.
     * @param {String} key The localization key
     * @param {String} [locale] The desired locale.
     * @returns {String} The translated text. If no translations are found at all for the locale then it will return
     * the en-US translation. If no translation exists for that key then it will return the localization key.
     * @example
     * var localized = this.app.i18n.getText('localization-key');
     * var localizedFrench = this.app.i18n.getText('localization-key', 'fr-FR');
     */
    I18n.prototype.getText = function (key, locale) {
        // default translation is the key
        var result = key;

        var lang;
        if (!locale) {
            locale = this._locale;
            lang = this._lang;
        }

        var translations = this._translations[locale];
        if (!translations) {
            if (!lang) {
                lang = getLang(locale);
            }

            locale = this._findFallbackLocale(lang);
            translations = this._translations[locale];
        }

        if (translations && translations.hasOwnProperty(key)) {
            result = translations[key];

            // if this is a plural key then return the first entry in the array
            if (Array.isArray(result)) {
                result = result[0];
            }

            // if null or undefined switch back to the key (empty string is allowed)
            if (result === null || result === undefined) {
                result = key;
            }
        }

        return result;
    };

    /**
     * @private
     * @function
     * @name pc.I18n#getPluralText
     * @description Returns the pluralized translation for the specified key, number n and locale. If the locale is not specified
     * it will use the current locale.
     * @param {String} key The localization key
     * @param {Nubmer} n The number used to determine which plural form to use. E.g. for the phrase "5 Apples" n equals 5.
     * @param {String} [locale] The desired locale.
     * @returns {String} The translated text. If no translations are found at all for the locale then it will return
     * the en-US translation. If no translation exists for that key then it will return the localization key.
     * @example
     * // manually replace {number} in the resulting translation with our number
     * var localized = this.app.i18n.getPluralText('{number} apples', number).replace("{number}", number);
     */
    I18n.prototype.getPluralText = function (key, n, locale) {
        // default translation is the key
        var result = key;

        var pluralFn;
        var lang;

        if (!locale) {
            locale = this._locale;
            lang = this._lang;
            pluralFn = this._pluralFn;
        } else {
            lang = getLang(locale);
            pluralFn = getPluralFn(lang);
        }

        var translations = this._translations[locale];
        if (!translations) {
            locale = this._findFallbackLocale(lang);
            lang = getLang(locale);
            pluralFn = getPluralFn(lang);
            translations = this._translations[locale];
        }

        if (translations && translations[key] && pluralFn) {
            var index = pluralFn(n);
            result = translations[key][index];

            // if null or undefined switch back to the key (empty string is allowed)
            if (result === null || result === undefined) {
                result = key;
            }
        }

        return result;
    };

    /**
     * @private
     * @function
     * @name pc.I18n#addData
     * @description Adds localization data. If the locale and key for a translation already exists it will be overwritten.
     * @param {Object} data The localization data. See example for the expected format of the data.
     * @example
     * this.app.i18n.addData({
     *   header: {
     *      version: 1
     *   },
     *   data: [{
     *      info: {
     *          locale: 'en-US'
     *      },
     *      messages: {
     *          "key": "translation",
     *           // The number of plural forms depends on the locale. See the manual for more information.
     *          "plural_key": ["one item", "more than one items"]
     *      }
     *   }, {
     *      info: {
     *          locale: 'fr-FR'
     *      },
     *      messages: {
     *         // ...
     *      }
     *   }]
     * });
     */
    I18n.prototype.addData = function (data) {
        var parsed;
        try {
            parsed = this._parser.parse(data);
        } catch (err) {
            console.error(err);
            return;
        }

        for (var i = 0, len = parsed.length; i < len; i++) {
            var entry = parsed[i];
            var locale = entry.info.locale;
            var messages = entry.messages;
            if (!this._translations[locale]) {
                this._translations[locale] = {};
                var lang = getLang(locale);

                // remember the first locale we've found for that language
                // in case we need to fall back to it
                if (!this._availableLangs[lang]) {
                    this._availableLangs[lang] = locale;
                }
            }

            Object.assign(this._translations[locale], messages);

            this.fire('data:add', locale, messages);
        }
    };

    /**
     * @private
     * @function
     * @name pc.I18n#removeData
     * @description Removes localization data.
     * @param {Object} data The localization data. The data is expected to be in the same format as {@link pc.I18n#addData}.
     */
    I18n.prototype.removeData = function (data) {
        var parsed;
        var key;
        try {
            parsed = this._parser.parse(data);
        } catch (err) {
            console.error(err);
            return;
        }

        for (var i = 0, len = parsed.length; i < len; i++) {
            var entry = parsed[i];
            var locale = entry.info.locale;
            var translations = this._translations[locale];
            if (!translations) continue;

            var messages = entry.messages;
            for (key in messages) {
                delete translations[key];
            }

            // if no more entries for that locale then
            // delete the locale
            var hasAny = false;
            for (key in translations) {
                hasAny = true;
                break;
            }

            if (!hasAny) {
                delete this._translations[locale];
                delete this._availableLangs[getLang(locale)];
            }

            this.fire('data:remove', locale, messages);
        }
    };

    /**
     * @private
     * @function
     * @name pc.I18n#destroy
     * @description Frees up memory.
     */
    I18n.prototype.destroy = function () {
        this._translations = null;
        this._availableLangs = null;
        this._assets = null;
        this._parser = null;
        this.off();
    };

    Object.defineProperty(I18n.prototype, 'locale', {
        get: function () {
            return this._locale;
        },
        set: function (value) {
            if (this._locale === value) {
                return;
            }

            var old = this._locale;
            // cache locale, lang and plural function
            this._locale = value;
            this._lang = getLang(value);
            this._pluralFn = getPluralFn(this._lang);

            // raise event
            this.fire('set:locale', value, old);
        }
    });

    Object.defineProperty(I18n.prototype, 'assets', {
        get: function () {
            return this._assets;
        },
        set: function (value) {
            var i;
            var len;
            var id;
            var asset;

            var index = {};

            // convert array to dict
            for (i = 0, len = value.length; i < len; i++) {
                id = value[i] instanceof pc.Asset ? value[i].id : value[i];
                index[id] = true;
            }

            // remove assets not in value
            i = this._assets.length;
            while (i--) {
                id = this._assets[i];
                if (!index[id]) {
                    this._app.assets.off('add:' + id, this._onAssetAdd, this);
                    asset = this._app.assets.get(id);
                    if (asset) {
                        this._onAssetRemove(asset);
                    }
                    this._assets.splice(i, 1);
                }
            }

            // add assets in value that do not already exist here
            for (id in index) {
                id = parseInt(id, 10);
                if (this._assets.indexOf(id) !== -1) continue;

                this._assets.push(id);
                asset = this._app.assets.get(id);
                if (!asset) {
                    this._app.assets.once('add:' + id, this._onAssetAdd, this);
                } else {
                    this._onAssetAdd(asset);
                }
            }
        }
    });

    // Finds a fallback locale for the specified language.
    // 1) First tries DEFAULT_LOCALE_FALLBACKS
    // 2) If no translation exists for that locale return the first locale available for that language.
    // 3) If no translation exists for that either then return the DEFAULT_LOCALE
    I18n.prototype._findFallbackLocale = function (lang) {
        var result = DEFAULT_LOCALE_FALLBACKS[lang];
        if (result && this._translations[result]) {
            return result;
        }

        result = this._availableLangs[lang];
        if (result && this._translations[result]) {
            return result;
        }

        return DEFAULT_LOCALE;
    };

    I18n.prototype._onAssetAdd = function (asset) {
        asset.on('load', this._onAssetLoad, this);
        asset.on('change', this._onAssetChange, this);
        asset.on('remove', this._onAssetRemove, this);
        asset.on('unload', this._onAssetUnload, this);

        if (asset.resource) {
            this._onAssetLoad(asset);
        }
    };

    I18n.prototype._onAssetLoad = function (asset) {
        this.addData(asset.resource);
    };

    I18n.prototype._onAssetChange = function (asset) {
        if (asset.resource) {
            this.addData(asset.resource);
        }
    };

    I18n.prototype._onAssetRemove = function (asset) {
        asset.off('load', this._onAssetLoad, this);
        asset.off('change', this._onAssetChange, this);
        asset.off('remove', this._onAssetRemove, this);
        asset.off('unload', this._onAssetUnload, this);

        if (asset.resource) {
            this.removeData(asset.resource);
        }

        this._app.assets.once('add:' + asset.id, this._onAssetAdd, this);
    };

    I18n.prototype._onAssetUnload = function (asset) {
        if (asset.resource) {
            this.removeData(asset.resource);
        }
    };

    return {
        I18n: I18n
    };
}()));