lex/lex.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/*jshint browser: true */
/*global define, niagara */

/**
 * A JavaScript library used to access translated Lexicon values from the Niagara
 * Framework.
 * 
 * This library will make network calls back to a Web Server to access translated 
 * values.
 * 
 * Attempts will also be made to use local storage to cache recorded Lexicon
 * values. If a user logs on with a different locale or the registry has been updated,
 * this storage will be automatically cleared.
 *
 * Please try out the examples from the `BajauxExamples` folder available from the
 * `docDeveloper` palette to see how this gets used. Also there are some more code 
 * examples embedded into the method comments below.
 * 
 * RequireJS configuration options can be specified for this library...
 * 
 * - **noStorage**: if truthy, no attempt at using storage will be used.
 * - **forceInit**: if truthy, an 'init' network call will be always be made
 *   the first time this library loads.
 * - **lang**: if specified, the user locale for the Lexicons. If this
 *   isn't specified the library will make a network call
 *   for it when it first loads.
 * - **storageId**: if specified, the id that helps determine whether
 *   the storage database currently being used is out of date.
 *
 * This library is designed to be used through the RequireJS Lexicon plugin...
 *
 * @example 
 * <caption>
 *   Access the Lexicon JS library using RequireJS.
 * </caption>
 * require(["lex!"], function (lexjs) {
 *   lexjs.module("js")
 *        .then(function (lex) {
 *          console.log("The Dialog OK button text: " + lex.get("dialogs.ok"));
 *        });
 * });
 *
 * @example 
 * <caption>
 *   Directly access a module's Lexicon using the plug-in syntax for RequireJS
 * </caption>
 * require(["lex!js,bajaui"], function (lexicons) {
 *   // The lexicon's array holds the Lexicon for both the js and bajaui modules.
 *   console.log("The Dialog OK button text: " + lexicons[0].get("dialogs.ok"));
 * });
 * 
 * @module nmodule/js/rc/lex/lex
 * @requires Promise, underscore, jquery
 * @see {@link module:lex}
 */
define([
  'module', 
  'Promise', 
  'underscore', 
  'jquery', 
  'nmodule/js/rc/rpc/rpc' ], function (
  module, 
  Promise, 
  _, 
  $,
  rpc) {
  
  "use strict";
            
  // RequireJS Config.
  var config = module.config(),
  
      // Database of stored Lexicons
      lexicons = {},       
      
      // Cached promises so multiple network calls for the same
      // Lexicon aren't made at the same time.
      openModulePromises = {},
      
      // Deferred promise used in initialization
      initPromise,

      Lexicon,
      
      // User language
      lang = config.lang,
      
      // Storage is used to cache Lexicon information
      storage,
      storageId = config.storageId,
      storageKeyName = "niagaraLex",

      // Load the lexicons using Workbench.
      wbutil,

      exports,

      // Used for writing out to localStorage on unload
      isLocalStorageEmptyAtLoad = true;
   
  // If available, attempt to use storage
  if (window && !config.noStorage) {
    try {
      storage = window.localStorage;
    } catch (ignore) {}
  }
          
  ////////////////////////////////////////////////////////////////
  // Lexicon
  ////////////////////////////////////////////////////////////////   
  
  /**
   * A Lexicon is a map of locale specific name/value pairs for a module.
   *
   * An instance of a Lexicon can be accessed indirectly by use of the 
   * module method.
   *
   * @class
   * @inner
   * @public
   *
   * @param {String} moduleName The name of the Niagara Module this Lexicon relates too.
   * @param {Object} data An object contained key values pairs for the Lexicon.
   *
   * @example
   *   <caption>Access a module's Lexicon</caption>
   *   lexjs.module("js")
   *        .then(function (lex) {
   *          console.log("Some text from a lexicon: " + lex.get("dialogs.ok"));
   *        });
   */   
  Lexicon = function Lexicon(moduleName, data) {  
    this.$moduleName = moduleName;
    this.$data = data;
  };

  /**
   * Return a value from the Lexicon for a given key.
   * 
   * The argument for this method can be either a String key followed by arguments or an Object Literal.
   *
   * @param {Object|String} obj the Object Literal that contains the method's arguments or a String key.
   * @param {String} obj.key the key to look up.
   * @param {String} obj.def the default value to return if the key can't be found.
   *                         By default this is null.
   * @param {Array|String} obj.args arguments used for String formatting. If the first parameter
   *                                is a String key, this list can just be further arguments for the function.
   * 
   * @returns {String} the value for the Lexicon or return def if can't be found.
   *
   * @example
   *   <caption>Access a Lexicon value via its key name.</caption>
   *   lexjs.module("js")
   *        .then(function (lex) {
   *          console.log(lex.get("dialogs.ok"));
   *        });
   *        
   * @example
   *   <caption>Access a Lexicon value via its key name with some formatted parameters.</caption>
   *   lexjs.module("bajaui")
   *        .then(function (lex) {
   *           var val = lex.get("fileSearch.scanningFiles", "alpha", "omega"))
   *           // Prints out: Scanning files (found alpha of omega)...
   *           console.log(val);
   *        })); 
   *        
   * @example
   *   <caption>Provide a default value if the key can't be found and use an Object Literal
   *   instead</caption>
   *   lexjs.module("bajaui")
   *        .then(function (lex) {
   *          // Use an Object Literal instead of multiple arguments and provide a default value.
   *          var val = lex.get({
   *            key: "fileSearch.scanningFiles",
   *            def: "Return this if the key can't be found in the Lexicon",
   *            args: ["alpha", "omega"]
   *          });
   *          console.log(val);
   *        });
   */
  Lexicon.prototype.get = function get(obj) {
    obj = obj && obj.constructor === Object ? obj : { key: obj };
    obj.def = obj.def || "";

    var val = this.$data[obj.key] || obj.def,
        regex,
        args = obj.args;
    
    if (args || arguments.length > 1) {  
      args = args || [];    
      args = args.concat(Array.prototype.slice.call(arguments, 1));

      // If we have some message formatting arguments them use them
      if (args.length > 0) {

        // Replace {number} with value from args
        regex = /\{[0-9]+\}/g;
        val = val.replace(regex, function (entry) {
          var i = parseInt(entry.substring(1, entry.length - 1), 10);
          return args[i] !== undefined ? args[i] : entry;
        });
      }
    }

    return val;
  };

  /**
   * Return escaped value of the key which is safe to display.
   * @see Lexicon.get
   *
   * @since Niagara 4.8
   *
   * @param {Object|String} obj the Object Literal that contains the method's arguments or a String key.
   * @param {String} obj.key the key to look up.
   * @param {String} obj.def the default value to return if the key can't be found.
   *                         By default this is null.
   * @param {Array|String} obj.args arguments used for String formatting. If the first parameter
   *                                is a String key, this list can just be further arguments for the function.
   *
   * @returns {String}
   */
  Lexicon.prototype.getSafe = function getSafe(obj) {
    var raw = Lexicon.prototype.get.apply(this, arguments);
    return _.escape(raw);
  };

  /**
   * Return the raw and unescaped value of the key which is not safe to display.
   * @see Lexicon.get
   *
   * @since Niagara 4.8
   *
   * @param {Object|String} obj the Object Literal that contains the method's arguments or a String key.
   * @param {String} obj.key the key to look up.
   * @param {String} obj.def the default value to return if the key can't be found.
   *                         By default this is null.
   * @param {Array|String} obj.args arguments used for String formatting. If the first parameter
   *                                is a String key, this list can just be further arguments for the function.
   * @returns {String}
   */
  Lexicon.prototype.getRaw = function getRaw(obj) {
    return Lexicon.prototype.get.apply(this, arguments);
  };

  /**
   * Return the Lexicon's module name.
   *
   * @returns {String}
   *
   * @example
   *   <caption>Return a Lexicon's module name</caption>
   *   lexjs.module("bajaui")
   *        .then(function (lex) {
   *           // Prints out: bajaui
   *           console.log(lex.getModuleName());
   *        })); 
   */
  Lexicon.prototype.getModuleName = function getModuleName() {
    return this.$moduleName;
  };
  
  ////////////////////////////////////////////////////////////////
  // Util
  //////////////////////////////////////////////////////////////// 
    
  function doRpc(methodName, args) {
    var params = {
      typeSpec: 'web:LexiconRpc',
      methodName: methodName,
      args: args
    };
    
    if (!config.noBajaScript && require.specified('baja')) {
      return rpc.baja(params);
    } else {
      return rpc.ajax(params);
    }
  }

  function checkWbEnv() {
    wbutil = (
      typeof niagara !== "undefined" && 
      niagara.env && 
      niagara.env.useLocalWbRc && 
      niagara.wb && 
      niagara.wb.util) ? niagara.wb.util : null;
  }

  checkWbEnv();

  /**
   * Unescape the string so all escaped characters become readable.
   * Note: Any change to SlotPath.unescape needs to trickle in here as well.
   * This copy is to avoid requiring baja.SlotPath here.
   * @see baja.SlotPath.escape
   *
   * @param {String} str the string to be unescaped.
   *
   * @returns {String} the unescaped String.
   */
  function unescape(str) {
    if (str.length === 0) {
      return str;
    }

    // Convert from $xx
    str = str.replace(/\$[0-9a-fA-F]{2}/g, function (s) {
      return String.fromCharCode(parseInt(s.substring(1, s.length), 16));
    });

    // Convert from $uxxxx
    str = str.replace(/\$u[0-9a-fA-F]{4}/g, function (s) {
      return String.fromCharCode(parseInt(s.substring(2, s.length), 16));
    });

    return str;
  }
  
  ////////////////////////////////////////////////////////////////
  // Storage
  //////////////////////////////////////////////////////////////// 
  
  /**
   * If storage is available, clear the storage for the Lexicon database.
   */
  function clear() {
    if (storage) {
      try {
        storage.removeItem(storageKeyName);
      } catch (ignore) {}
    }
  }
  
  /**
   * If storage is available and the library has fully initialized, 
   * load the Lexicons from the storage database.
   */
  function load() {
    var db;
    if (storage && lang && storageId && !wbutil) {
      try {
        db = storage.getItem(storageKeyName);
          
        if (db) {
          isLocalStorageEmptyAtLoad = false;
          db = JSON.parse(db);
          
          if (db && db.modules) {
            if (db.lang !== lang || db.storageId !== storageId) {
              // If the language or registry database has changed, clear the storage since
              // the db has potentially become out of date.
             clear();
            } else {
              // Load the Lexicons from the storage database.
              _.each(db.modules, function (data, moduleName) {
                lexicons[moduleName] = new Lexicon(moduleName, data);
              });
            }
          } else {
            // If we have an invalid storage database then clear it.
            clear();
          }
        }
      } catch (e) {
        // If any errors occur whilst trying to read the database then clear it.
        clear();
      }  
    }
  }
      
  /**
   * If storage is available, attempt to save the Lexicons to the storage database.
   */
  function save() {
    if (storage && lang && storageId && !wbutil) {
      if (!storage.getItem(storageKeyName) && !isLocalStorageEmptyAtLoad) {
        return; // don't write since it is a cache clear operation
      }
      try {
        // When saving the database, note down the language and last registry
        // build time so we can test to see if the database is out of date 
        // next time we load it.
        var db = {
          lang: lang,
          storageId: storageId,
          modules: {}
        };
        _.each(lexicons, function (lex, key) {
          db.modules[key] = lex.$data;          
        });
        storage.setItem(storageKeyName, JSON.stringify(db));
      } catch (e) {
        // If there are any problems saving the storage then attempt to 
        // clear it.
        clear();
      }
    }
  }
  
  // Try to automatically save the Lexicon database to local storage
  if (window) {
    $(window).on("pagehide unload", save);
  }
  
  ////////////////////////////////////////////////////////////////
  // Initialization
  //////////////////////////////////////////////////////////////// 
  
  /**
   * Initialization happens when the library is first loaded.
   * <p>
   * If the last registry build time and language haven't been 
   * specified in the start up options, a network call will
   * be made to request this information.
   * <p>
   * If the 'forceInit' configuration option is true, an 'init'
   * network call will always be made.
   */
  (function init() {
    if (storageId && lang && !config.forceInit) {
      initPromise = Promise.resolve(load());
    } else {
      initPromise = doRpc('init')
        .then(function (data) {
          lang = lang || data.lang;
          storageId = data.storageId;
          return load();
        });
    }
  }());

  ////////////////////////////////////////////////////////////////
  // Exports
  //////////////////////////////////////////////////////////////// 
  
  exports = {

   /**
    * Asynchronously resolve a Lexicon via module name. A promise is returned and resolved once
    * Lexicon has been found. If the Lexicon can't be found or there's a network error,
    * the promise will reject.
    *
    * @param {String} moduleName the name of the module being requested.
    * @returns {Promise}
    *
    * @example
    *   <caption>Access a Lexicon via its module name</caption>
    *   lexjs.module("myModule")
    *        .then(function (lex) {
    *          // Access the Lexicon entry called 'foo' from 'myModule'
    *          console.log(lex.get("foo"));
    *        });
    */  
    module: function bajaModule(moduleName) {
      var promise = openModulePromises[moduleName],
          lexData;

      if (!promise) {
        promise = openModulePromises[moduleName] = initPromise
          .then(function () {
            if (lexicons.hasOwnProperty(moduleName)) {
              return lexicons[moduleName]; 
            } else if (wbutil && typeof wbutil.getLexicon === "function") {
              lexData = wbutil.getLexicon(moduleName, lang);
              if (lexData) {
                // Cache the call but in Workbench mode the save and load never happens.
                return (lexicons[moduleName] = new Lexicon(moduleName, JSON.parse(lexData)));
              } else {
                return Promise.reject(new Error("Lexicon module not found: " +
                  moduleName));
              }
            } else {
              lexData = module.config()["lexicon/" + moduleName + "/" + lang];

              if (lexData) {
                // If the Lexicon is already defined in requirejs then use the data
                // but don't bother trying to cache the data as we don't want this saved
                // to storage.
                return new Lexicon(moduleName, lexData);
              } else {
                // Request the Lexicon via an AJAX Call
                return doRpc("getLexicon", [ moduleName, lang ])
                  .then(function (data) {
                    return (lexicons[moduleName] = new Lexicon(moduleName, data));
                  });
              }
            }
          })
          .finally(function () { 
            delete openModulePromises[moduleName]; 
          }); 
      }
      
      return promise;
    },

    /**
     * If the Lexicon is loaded and cached then return it. Otherwise return null.
     * Please note, this will not result in any network calls.
     *
     * @param {String} moduleName The name of the module.
     * 
     * @return {Lexicon} The Lexicon or null if it can't be found.
     */
    getLexiconFromCache: function (moduleName) {
      return lexicons[moduleName] || null;
    },
      
    /**
     * Asynchronously format a String using Niagara's BFormat conventions.
     *
     * @param {String} str the string that contains the BFormat style text
     * to use. The syntax should be `%lexicon(moduleName:keyName)%` or
     * `%lexicon(moduleName:keyName:formatString1:formatString2)%`.
     * @returns {Promise}
     *
     * @example
     *   lexjs.format("%lexicon(bajaui:dialog.ok)% and %lexicon(bajaui:menu.new.label)%")
     *        .then(function (str) {
     *          // Prints: "OK and New"
     *          console.log(str);
     *        });
     *   lexjs.format("%lexicon(bajaui:fileSearch.scanningFiles:arg1:arg2)%")
     *        .then(function (str) {
     *          // Prints: "Scanning files (found arg1 of arg2)..."
     *          console.log(str);
     *        });
     */
    format: function format(str) {
      var that = this,
          regex = /\%lexicon\(([a-zA-Z0-9]+)\:([a-zA-Z0-9.\-_]+)((?:\:[a-zA-Z0-9\$(?:\s)*]+)*)\)\%/g,
          res,
          lexiconPromises = [];

      // Asynchronously access the Lexicon's for each module
      res = regex.exec(str);
      while (res) {
        // Build up a list of the promises used to access the modules
        lexiconPromises.push(that.module(res[1]/*moduleName*/));
        
        res = regex.exec(str);
      }

      // When we have all the Lexicons, process the result and resolve the String
      return Promise.all(lexiconPromises)
        .then(function (args) {
          var i = 0;

          // Now we have all the Lexicons make the String replace       
          str = str.replace(regex, function (match, module, key, fmtArgsStr) {
            return args[i++].get({
              key: key,
              def: key,
              args: _.chain(fmtArgsStr.substring(1).split(":"))
                .map(function (fmtArg) {
                  return unescape(fmtArg);
                })
                .value()
            });
          });

          return str;
        });       
    },

    /**
     * A private API to reset the Lexicon. This may clear out cached data
     * and reinitialize some internal variables. This should only be used
     * by Tridium developers.
     * 
     * @private
     */
    $reset: function () {
      clear();
      lexicons = {};
      openModulePromises = {};
      checkWbEnv();
    }
  };
    
  return exports;
});