core/language.js

/*
 * Your installation or use of this SugarCRM file is subject to the applicable
 * terms available at
 * http://support.sugarcrm.com/Resources/Master_Subscription_Agreements/.
 * If you do not agree to all of the applicable terms or do not have the
 * authority to bind the entity as an authorized representative, then do not
 * install or use this SugarCRM file.
 *
 * Copyright (C) SugarCRM Inc. All rights reserved.
 */

const Template = require('../view/template');
const User = require('./user');

/**
 * Language Helper.
 *
 * Provides convenient functions to pull language strings out of a
 * language label cache.
 *
 * @module Core/Language
 */

/**
 * Retrieves a string of a given type.
 *
 * If the label is a template, it will be compiled and executed with the
 * given `context`.
 *
 * @param {string} type Type of string pack: `app_strings`,
 *   `app_list_strings` or `mod_strings`.
 * @param {string} key Key of the string to retrieve.
 * @param {string} [module] Module the label belongs to.
 * @param {string|boolean|number|Array|Object} [context] Template context.
 * @return {string|undefined} String for the given key.
 * @private
 */
function get(type, key, module, context) {
    var str,
        bundle = SUGAR.App.metadata.getStrings(type);

    bundle = module ? bundle[module] : bundle;
    if (!bundle || !_.isString(bundle[key])) {
        return;
    }

    str = sanitize(bundle[key]);

    if (!_.isUndefined(context) && (str.indexOf('{{') > -1)) {
        key = 'lang.' + (module ? key + '.' + module : key);
        var tpl = Handlebars.templates[key];
        str = tpl ? tpl(context) : Template.compile(key, str)(context);
    }
    return str;
};

/**
 * Sanitizes (strips a trailing colon from) a string.
 *
 * @param {string} str String to sanitize.
 * @return {string} Sanitized string or `str` parameter if it's not a
 *   string.
 * @private
 */
function sanitize(str) {
    let colonCharacters = [58, 760, 1795, 1796, 6148, 65043, 65109, 65306];
    return colonCharacters.includes(str.charCodeAt(str.length - 1)) ? str.slice(0, -1) : str;
};

/**
 * The default language defined by
 * {@link #setDefaultLanguage}. Use {@link #getDefaultLanguage} to
 * retrieve this setting.
 *
 * @property {string} defaultLanguage
 * @private
 */
let defaultLanguage;

/**
 * The language that is loaded by the app and defined by
 * {@link #setCurrentLanguage}. Use {@link #getLanguage} to retrieve
 * this setting.
 *
 * @property {string} currentLanguage
 * @private
 */
let currentLanguage;

/**
 * @alias module:Core/Language
 */
module.exports = {
    /**
     * The display direction for the current language.
     *
     * @type {string}
     */
    direction: 'ltr',

    /**
     * Retrieves a string for a given key.
     *
     * This function searches the module strings first and falls back to the
     * app strings.
     *
     * If the label is a template, it will be compiled and executed with the
     * given `context`.
     *
     * @param {string} key Key of the string to retrieve.
     * @param {string|Array} [module] Module the label belongs to. If an
     *   array is passed, it will look through each module by the given
     *   order, returning the first string whose key is found in the
     *   module's language strings.
     * @param {string|boolean|number|Array|Object} [context] The template
     *   context to pass to the template in order to populate template variables.
     * @return {string} String for the given key or the `key` parameter if
     *   the key is not found in language pack.
     */
    get: function(key, module, context) {
        var str = this.getModString(key, module, context) ||
            this.getAppString(key, context) ||
            key;

        return str;
    },

    /**
     * Searches the module strings for a given key.
     *
     * @param {string} key Key of the string to retrieve.
     * @param {string|Array} [module] Module the label belongs to. If an
     *   array is passed, it will look through each module by the given
     *   order, returning the first string whose key is found in the
     *   module's language strings.
     * @param {string|boolean|number|Array|Object} [context] The template
     *   context to pass to the template in order to populate template variables.
     * @return {string|undefined} String for the given key from the module
     *   language strings. `undefined` if not found.
     */
    getModString: function(key, module, context) {
        var moduleString;

        if (_.isArray(module)) {
            _.find(module, function(moduleName) {
                moduleString = get('mod_strings', key, moduleName, context);
                return !_.isEmpty(moduleString);
            }, this);
        } else {
            moduleString = get('mod_strings', key, module, context);
        }

        return moduleString;
    },

    /**
     * Retrieves an application string for a given key.
     *
     * @param {string} key Key of the string to retrieve.
     * @param {string|boolean|number|Array|Object} [context] The template
     *   context to pass to the string/template in order to populate template
     *   variables.
     * @return {string|undefined} String for the given key from language
     *   strings. `undefined` if not found.
     */
    getAppString: function(key, context) {
        return get('app_strings', key, null, context);
    },

    /**
     * Retrieves an application list string or object.
     *
     * @param {string} key Key of the string to retrieve.
     * @return {string|Object} String or object for the given key. If key
     *   is not found, an empty object is returned.
     */
    getAppListStrings: function(key) {
        var list = SUGAR.App.metadata.getStrings('app_list_strings')[key] || {};
        if (_.isArray(list)) {
            var obj = {};
            _.each(list, function(tuple) {
                if (_.isString(tuple)) {
                    obj[tuple] = tuple;
                } else if (_.isArray(tuple) && tuple.length === 2) {
                    obj[tuple[0]] = tuple[1];
                }
            });
            list = obj;
        }
        return list;
    },

    /**
     * Gets the translated module name (by default, in singular form).
     *
     * Falls back to the plural form if the singular form is not found, and
     * eventually falls back to the `module` passed in.
     *
     * @param {string} module The module.
     * @param {Object} [options] Options object for `getModuleName`.
     * @param {boolean} [options.plural] Returns the plural form if `true`,
     *   singular otherwise.
     * @param {string} [options.defaultValue] Value to be returned if the
     *   module language string is not found.
     * @return {string} The module name.
     */
    getModuleName: function(module, options) {
        options = options || {};
        var name = !options.plural &&
            this.getModString('LBL_MODULE_NAME_SINGULAR', module) ||
            this.getModString('LBL_MODULE_NAME', module);

        if (!name && !_.isUndefined(options.defaultValue)) {
            name = this.get(options.defaultValue);
        }

        return name || module;
    },

    /**
     * Returns the correct ordered array of strings for a given list.
     *
     * @param {string} listName Name of the strings array to retrieve.
     * @return {Array} The array of strings.
     */
    getAppListKeys: function(listName) {
        var keys = [],
            list = SUGAR.App.metadata.getStrings('app_list_strings')[listName] || {};
        if (_.isArray(list)) {
            _.each(list, function(tuple) {
                if (tuple.length === 2) {
                    keys.push(tuple[0]);
                }
            });
        } else if (_.isObject(list)) {
            keys = _.keys(list);
        }
        return keys;
    },

    /**
     * Gets the IETF's BCP 47 language code for the current app language.
     *
     * @return {string} The IETF's BCP 47 language code of the default language.
     *   (e.g. `en_us`, `pt_PT`). Note: We use `_` instead of `-`.
     */
    getLanguage: function() {
        return currentLanguage;
    },

    /**
     * Sets app language code and syncs it with the server.
     *
     * @param {string} language language code such as `en_us`.
     * @param {Function} [callback] Callback function to be called on
     *   language set completes.
     * @param {Object} [options] Extra options.
     * @param {boolean} [options.noSync=false] `true` if you don't need to
     *   fetch /metadata.
     * @param {boolean} [options.noUserUpdate=false] `true` if you don't need
     *   to update /me.
     */
    setLanguage: function(language, callback, options) {
        var self = this;
        options = options || {};
        _.each(Handlebars.templates, function(value, key) {
            if (key.indexOf('lang.') === 0) {
                delete Handlebars.templates[key];
            }
        });
        if (options.noSync === true) {
            this.updateLanguage(language);
            return;
        }

        SUGAR.App.sync({
            callback: function(err) {
                var langHasChanged = false;
                if (!err) {
                    self.updateLanguage(language);
                    langHasChanged = !SUGAR.App.api.isAuthenticated() && !options.noUserUpdate;
                    SUGAR.App.cache.set('langHasChanged', langHasChanged);//persist even after reloads
                }
                if (callback) callback(err);
            },
            getPublic: !SUGAR.App.api.isAuthenticated(),
            noUserUpdate: options.noUserUpdate || false,
            language: language,
            forceRefresh: true,  // Needed to make sure new labels are injected
            metadataTypes: ['labels']
        });
    },

    /**
     * Updates language code and the display direction.
     *
     * @param {string} language Language code as defined in Sugar.
     *   (e.g. `en_us`, `pt_PT`)
     */
    updateLanguage: function(language) {
        SUGAR.App.cache.set('lang', language);
        User.setPreference('language', language);
        this.setCurrentLanguage(language);
        SUGAR.App.trigger('app:locale:change', language);

        // Add the current language for improved screen reader accessibility
        if (document && document.documentElement) {
            var currentLanguageForDom = _.first(language.split('_'));
            // Use the simple language code as per HTML qualifications
            document.documentElement.lang = currentLanguageForDom;
        }
    },

    /**
     * Sets the app default language to the language specified. Use
     * {@link #getDefaultLanguage} to get the current language.
     *
     * @param {string} language The IETF's BCP 47 language code to set the
     *   default language to. (e.g. `en_us`, `pt_PT`).
     *   Note: We use `_` instead of `-`.
     */
    setDefaultLanguage: function(language) {
        defaultLanguage = language;
    },

    /**
     * Gets the default language set in the system.
     *
     * @return {string} The IETF's BCP 47 language code of the default language.
     *   (e.g. `en_us`, `pt_PT`). Note: We use `_` instead of `-`.
     */
    getDefaultLanguage: function() {
        return defaultLanguage;
    },

    /**
     * Sets the current language to the language specified. Use
     * {@link #getLanguage} to get the current language.
     *
     * Calls {@link #setDirection} with the new language code.
     *
     * @param {string} language The language to set the current language to.
     */
    setCurrentLanguage: function(language) {
        currentLanguage = language;
        this.setDirection(language);
    },

    /**
     * Sets the {@link #direction} to the desired direction according to
     * the language code specified.
     *
     * @fires 'lang:direction:change' if the language direction has
     *   changed.
     *
     * @param {string} lang Language code that the direction is based on.
     */
    setDirection: function(lang) {
        //FIXME: SC-3358 Should be getting the RTL languages from metadata.
        var rtlLanguages = ['he_IL', 'ar_SA'],
            isRTL = _.contains(rtlLanguages, lang),
            prevDirection = this.direction;

        this.direction = isRTL ? 'rtl' : 'ltr';

        if (this.direction !== prevDirection) {
            SUGAR.App.trigger('lang:direction:change');
        }
    },

    _get: function(type, key, module, context) {
        if (!SUGAR.App.config.sidecarCompatMode) {
            SUGAR.App.logger.error('Core.Language#_get is a private method that you are not allowed ' +
                'to access. Please use only the public API.');
            return;
        }

        SUGAR.App.logger.warn('Core.Language#_get is a private method that you should not access to. ' +
            'You will NOT be allowed to access it in the next release. Please update your code to rely on the public ' +
            'API only.');

        return get(type, key, module, context);
    },

    _sanitize: function(str) {
        if (!SUGAR.App.config.sidecarCompatMode) {
            SUGAR.App.logger.error('Core.Language#_sanitize is a private method that you are not allowed ' +
                'to access. Please use only the public API.');
            return;
        }

        SUGAR.App.logger.warn('Core.Language#_sanitize is a private method that you should not access. ' +
            'You will NOT be allowed to access it in the next release. Please update your code to rely on the public ' +
            'API only.');

        return sanitize(str);
    }
};