/**
* @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
*/