lex/lex.js

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

/* eslint-env browser */
/*global 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";

  const LEXICON_KEY = "{lexicon:";

  // RequireJS Config.
  let config = module.config();
  
      // Database of stored Lexicons
  let lexicons = {};
      
      // Cached promises so multiple network calls for the same
      // Lexicon aren't made
  let lexiconDataPromises = {};
      
      // Deferred promise used in initialization
  let initPromise;

  let Lexicon;
      
      // User language (Niagara, not IETF)
  let lang = config.lang;
      
      // Storage is used to cache Lexicon information
  let storage;
  let storageId = config.storageId;
  let storageKeyName = "niagaraLex";

      // Load the lexicons using Workbench.
  let wbutil;

  let exports;

      // Used for writing out to localStorage on unload
  let 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 it 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 };

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

    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 $uxxxx
    str = str.replace(/\$u[0-9a-fA-F]{4}/g, function (s) {
      return String.fromCharCode(parseInt(s.substring(2, s.length), 16));
    });

    // Convert from $xx
    str = str.replace(/\$[0-9a-fA-F]{2}/g, function (s) {
      return String.fromCharCode(parseInt(s.substring(1, 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 startup 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 lexicon 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) {
      return exports.$getLexiconData(moduleName)
        .then((data) => {
          return (lexicons[moduleName] = new Lexicon(moduleName, data));
        });
    },

    /**
     * Calls the method to retrieve the lexicon module and returns either the existing promise
     *  from a previous call or new promise
     * @since Niagara 4.14
     * @private
     * @param {String} moduleName the name of the current lexicon module
     * @returns {Promise<Object>}
     */
    $getLexiconData: function (moduleName) {
      let promise = lexiconDataPromises[moduleName];
      if (!promise) {
        promise = lexiconDataPromises[moduleName] = exports.$retrieveLexiconData(moduleName);
      }

      return promise;
    },

    /**
     * Makes the actual call to get the lexicon data from either the work bench or via RPC
     * @since Niagara 4.14
     * @private
     * @param {String} moduleName the name of the current lexicon module
     * @returns {Promise<Object>} the retrieved lexicon data in JSON format
     */
    $retrieveLexiconData: function (moduleName) {
      let lexData;

       return initPromise
         .then(function () {
            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 JSON.parse(lexData);
              } else {
                throw new Error("Lexicon module not found: " + moduleName);
              }
            } else {
              // Request the Lexicon via an AJAX Call
              return doRpc("getLexicon", [ moduleName, lang ]);
            }
          })
         .then((data) => {
           return exports.$parseLexiconData(moduleName, data);
         });
    },

    /**
     * Looks through the retrieved lexicon data and replaces any linked lexicon entries with
     *  the values from the referenced lexicon entries, after retrieving them.
     * @since Niagara 4.14
     * @private
     * @param {String} moduleName the name of the current lexicon module being parsed
     * @param {Object} lexiconData the lexicon data to be parsed
     * @returns {Promise<Object>} the updated lexicon data with lookup values replace
     */
    $parseLexiconData: function (moduleName, lexiconData) {
      const promises = {};
      const lexiconKeysToUpdate = [];

      Object.entries(lexiconData).forEach(([ key, value ]) => {
        let indexPos = 0;
        while (indexPos !== -1) {
          indexPos = value.indexOf(LEXICON_KEY, indexPos);
          if (indexPos !== -1) {
            const replaceParams = exports.$parseEntry(moduleName, value, indexPos);
            if (replaceParams && replaceParams.module && replaceParams.lexiconKey) {
              const module = replaceParams.module;

              // if the current module and the one we want to load are the same,
              //  we do not want to try to get it again, because we are in the process of
              //  currently getting it
              if (!promises[module] && module !== moduleName) {
                promises[module] = exports.module(module).catch((ignore) => {});
              }

              lexiconKeysToUpdate.push({ key, replaceParams });
            }

            indexPos = indexPos + 1;
          }
        }
      });

      const promiseArr = Object.keys(promises).map((key) => {
        return promises[key];
      });

      return Promise.all(promiseArr)
        .then(() => {
          return exports.$updateLexiconData(moduleName, lexiconData, lexiconKeysToUpdate);
        });
    },

    /**
     * Parses a lexicon entry returning the lexiconKey and module used to look up the new value
     *  along with the starting and ending position in the string to be replace with the new value
     * @since Niagara 4.14
     * @private
     * @param {String} moduleName the name of the current lexicon module being parsed
     * @param {String} lexiconValue The lexicon string value to be parsed
     * @param {Number} startPos The starting position in the lexiconValue to start the parsing at
     * @returns {module:nmodule/js/rc/lex/lex~ReplaceParam}
     */
    $parseEntry: function (moduleName, lexiconValue, startPos) {
      const endPos = lexiconValue.indexOf("}", startPos);
      if (endPos === -1) {
        return;
      }

      const lookupKeys = lexiconValue.substring(startPos + LEXICON_KEY.length, endPos).split(":");

      // if we just have the lookup lexicion key and not the module, assume we are looking up in
      //  the current lexicon module
      if (lookupKeys.length === 1) {
        lookupKeys.push(lookupKeys[0]);
        lookupKeys[0] = moduleName;
      }
      return { startPos, endPos, module: lookupKeys[0], lexiconKey: lookupKeys[1] };
    },

    /**
     * Applies the values from the referenced lexicon entries to the current lexicon entry by
     *  looking up the referenced key and replacing the reference link with the referenced entry
     *  value
     * @since Niagara 4.14
     * @private
     * @param {String} moduleName the current lexicon module being processed
     * @param {String} lexiconData the string to be updated with the retrieved new lexicon value
     * @param {Array<module:nmodule/js/rc/lex/lex~LexiconUpdateEntry>}lexiconKeysToUpdate an array
     *  of objects that specify the lexicon values that need to be updated, the module and lexicon
     *  key to be used to find the new value and, the starting and ending position of where in the
     *  current string value to place the new value
     * @returns {Object} the updated lexicon data
     */
    $updateLexiconData: function (moduleName, lexiconData, lexiconKeysToUpdate) {
      //Creating a temporary lexicon for the current data so that we can search that if needed
      const tempLexicon = new Lexicon(moduleName, lexiconData);
      const anotherGoAtUpdating = [];

      // Moving backwards through the array so that we update positions at the end of each entry
      //  first so that our positions values do not change as we replace values and change the
      //  length of the lexicon string value
      for (let i = lexiconKeysToUpdate.length - 1; i >= 0; i--) {
        let newValue;
        const arrayEntry = lexiconKeysToUpdate[i];
        const key = arrayEntry.key;
        let lexiconEntry = lexiconData[key];
        const replaceParams = arrayEntry.replaceParams;
        let startPos = replaceParams.startPos;
        let endPos = replaceParams.endPos;
        let module = replaceParams.module;
        let lexiconKey = replaceParams.lexiconKey;


        //if the module references the current module look in the tempLexicon for the results
        if (module === moduleName) {
          newValue = tempLexicon.get(lexiconKey);

          // if we are in the same module and the new value references a unresolved lexicion entry
          //  set it up so that we can process it again when done here
          if (newValue && newValue.indexOf(LEXICON_KEY) !== -1) {
            newValue = '';
            anotherGoAtUpdating.push(arrayEntry);
          }
        } else {
          const lexicon = lexicons[module];

          if (lexicon) {
            newValue = lexicon.get(lexiconKey);
          }
        }

        if (newValue) {
          lexiconEntry = lexiconEntry.substring(0, startPos) + newValue + lexiconEntry.substring(endPos + 1);
          lexiconData[key] = lexiconEntry;
        }
      }

      // if we have any entries that need to take a second look at, do that here
      if (anotherGoAtUpdating.length !== 0) {
        lexiconData = this.$updateLexiconData(moduleName, lexiconData, anotherGoAtUpdating);
      }

      return lexiconData;
    },

    /**
     * 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 = {};
      lexiconDataPromises = {};
      checkWbEnv();
    },

    /**
     * Get a lexicon value without making any additional network calls. The requested lexicon module
     * must have already been loaded. If it is not, `def` will be returned.
     *
     * Any asynchronous formatting, like `%lexicon()%` calls, will _not_ be performed.
     *
     * Use `.module()` instead to ensure that the actual lexicon data is retrieved and correctly
     * formatted.
     *
     * @private
     * @param {object} params
     * @param {string} params.module lexicon module to look up
     * @param {string} params.key key desired key in the requested lexicon module
     * @param {Array.<*>} [params.args] any additional arguments to use to parameterize the lexicon string
     * @param {string} [params.def] the default lexicon string to use if the module or key is not found
     * @returns {string} the lexicon string, parameterized for display
     * @since Niagara 4.14
     */
    $getSync: function ({ module, key, args, def } = {}) {
      const lex = module && exports.getLexiconFromCache(module);
      if (!lex) { return parameterize(def, args); }
      return lex.get({ key, def, args });
    }
  };

  function parameterize(lexString, args) {
    if (!args || !args.length) {
      return lexString;
    }

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

/**
 * @private
 * @typedef {Object} module:nmodule/js/rc/lex/lex~LexiconUpdateEntry a entry that defines what
 *  lexicon entries need to be updated and the values necessary to update that entry
 * @property {String} key the key for the entry to be updated
 * @property {module:nmodule/js/rc/lex/lex~ReplaceParam} replaceParams the parameters used to
 *  find the new lexicon value and the starting and ending positions of the value in the original
 *  string to replace
 */

/**
 * @private
 * @typedef {Object} module:nmodule/js/rc/lex/lex~ReplaceParam the parameters used to find the new
 *  lexicon value and the starting and ending positions of the value in the original string
 *  to replace
 * @property {String} module the module that the new lexicon string value is to be found in
 * @property {String} lexiconKey the key to look up the new lexicon value with
 * @property {Number} startPos the starting position in the entry that is to be replaced with the
 *  new value
 * @property {Number} endPos the ending position in the entry that is to be replaced with the new
 *  value
 */