/**
* @copyright 2015 Tridium, Inc. All Rights Reserved.
* @author Gareth Johnson
*/
/**
* Defines {@link baja.Format}.
* @module baja/obj/Format
*/
define([
"bajaScript/sys",
"bajaScript/baja/obj/Simple",
"bajaScript/baja/obj/objUtil",
"bajaPromises",
"lex!" ], function (
baja,
Simple,
objUtil,
Promise,
lexjs) {
"use strict";
const { callSuper, def: bajaDef, hasType: bajaHasType, objectify, strictArg, subclass } = baja;
const { capitalizeFirstLetter } = objUtil;
const FUNCTION_PARAMS_REGEX = /^\s*(\w+)\s*\((.*)\)/;
const LEXICON_REGEX = /^lexicon\(([a-zA-Z0-9]+):([a-zA-Z0-9.\-_]+)((?::[a-zA-Z0-9$_($?:\s)*]+)*)\)$/;
// region baja.Format
/**
* `Format` is used to format `Object`s into `String`s using
* a standardized formatting pattern language. The format String is normal
* text with embedded scripts denoted by the percent (%) character. Use
* "%%" to escape a real %.
*
* A script is one or more calls chained together using the dot (.) operator.
* Calls are mapped to methods using the order given below.
*
* If a script cannot be processed successfully, an error will be returned.
*
* To define an alternate output to use if an error is encountered, include a
* ? followed by another script within the same % pair. More than one fallback
* can be defined by delimiting the fallbacks with a ?.
*
* Given the call "foo" the order of attempted resolutions is:
* <ol>
* <li>special call (see below)</li>
* <li>getFoo(Context)</li>
* <li>foo(Context)</li>
* <li>get("foo")</li>
* </ol>
*
* The following special functions are available to use in a script:
* <ol>
* <li>lexicon(module:key:<escaped string args separated by ':'>)</li>
* <li>time() returns the current time as an AbsTime</li>
* <li>user() returns gets the current user's name</li>
* <li>decodeFromString(<module>:<type>:<escaped string encoding>) returns the toString of the
* encoded value for specified escaped string encoding for the specified type in
* the given module.
* </li>
* <li>escape() returns the escaped text value of the given objects toString()</li>
* <li>unescape() returns the unescaped text value of the given objects toString()</li>
* <li>substring() returns a substring value of a given objects toString()</li>
* </ol>
*
* This Constructor shouldn't be invoked directly. Please use the `make()`
* methods to create an instance of a `Format`.
*
* Examples of formats:
* <pre>
* "hello world"
* "my name is %displayName%"
* "my parent's name is %parent.displayName%"
* "%parent.value?lexicon(bajaui:dialog.error)%"
* "%out.value?out.status?lexicon(bajaui:dialog.error)%"
* %lexicon(bajaui:fileSearch.scanningFiles:5:10)% // Scanning files (found 5 of 10)...
* "The escaped value %out.value.escape%"
* "The unescaped valued %out.value.unescape%"
* "The first two characters %out.value.substring(2)%"
* "The first five characters %out.value.substring(5)%"
* "The first five characters %out.value.substring(0, 5)%"
* "The last five characters %out.value.substring(-5)%"
* "The toString of a decoded baja:AbsTime from %decodeFromString(baja:AbsTime:$32016$2d04$2d10T13$3a37$3a00$2e000$2d04$3a00)%"
* </pre>
*
* @example <caption>Formats can use getter functions from an object as well
* as function names</caption>
* var obj = {
* getFoo: function () {
* return {
* getBar: function () {
* return {
* value: function () {
* return 3.1415;
* }
* }
* }
* }
* }
* }
*
* var fmt = baja.Format.make("%foo.bar.value%");
* return fmt.format( { object: obj } )
* .then(function (value) {
* // prints 3.1415
* console.log(value);
* });
*
* @class
* @alias baja.Format
* @extends baja.Simple
*/
const Format = function Format(pattern) {
callSuper(Format, this, arguments);
this.$pattern = strictArg(pattern, String);
};
subclass(Format, Simple);
/**
* Default Format instance.
* @type {baja.Format}
*/
Format.DEFAULT = new Format("");
/**
* Make a `Format`.
*
* @param {String} [pattern] the `Format` Pattern `String`.
* @returns {baja.Format}
*/
Format.make = function (pattern) {
pattern = pattern || "";
if (pattern === "") {
return Format.DEFAULT;
}
strictArg(pattern, String);
return new Format(pattern);
};
/**
* Make a `Format`.
*
* @param {String} [pattern] the `Format` Pattern `String`.
* @returns {baja.Format}
*/
Format.prototype.make = function (pattern) {
return Format.make.apply(Format, arguments);
};
/**
* Decode a `String` to a `Format`.
*
* @param {String} str
* @returns {baja.Format}
*/
Format.prototype.decodeFromString = function (str) {
return Format.make(str);
};
/**
* Encode `Format` to a `String`.
*
* @returns {String}
*/
Format.prototype.encodeToString = function () {
return this.$pattern;
};
/**
* Return a `String` representation of the `Format`.
*
* @returns {String}
*/
Format.prototype.toString = function () {
return this.$pattern;
};
/**
* Return the inner value of the `Format`.
*
* @returns {String}
*/
Format.prototype.valueOf = Format.prototype.toString;
/**
* Format the specified object using the format pattern.
*
* This method can take an object literal or a single pattern `String`
* argument.
*
* @param {Object} obj
* @param {String} obj.pattern the format pattern to process.
* @param {Object|baja.Component} [obj.object] JavaScript Object or baja:Component
* referenced by the format scripts.
* @param {Boolean} [obj.display] if true, the display string of a Property value is used.
* If false, the `toString` version of a Property value is used.
* By default, this value is true (in BajaScript, most of the time
* we're dealing with mounted Components in a Proxy Component Space).
* @param {Function} [obj.ok] (Deprecated: use Promise) the ok callback
* called when the Format string has been processed. The resultant String
* will be passed to this function as an argument
* @param {Function} [obj.fail] (Deprecated: use Promise) the fail callback
* called if there's a fatal error processing the format.
* @param {Object} [obj.cx] the designated context to be passed down to the toString method. Defaults to
* an empty object.
* @returns {Promise.<string>}
*/
Format.format = function (obj) {
const { ok, fail } = obj;
// TODO: Currently format processing doesn't work in exactly the same way as Niagara.
// Certainly it can never be 100% accurate. However, we can try to cover most common use cases
// that we want to support.
const formatContext = makeFormatContext(obj);
const cb = new baja.comm.Callback(ok, fail || baja.fail);
return processAllScripts(formatContext)
.then((result) => {
cb.ok(result);
return cb.promise();
})
.catch((err) => {
baja.error("Could not format object: " + err);
cb.fail(err);
});
};
/**
* Format the specified object using the format pattern.
*
* @see baja.Format.format
*
* @param {Object} [obj]
* @param {Function} [obj.ok] (Deprecated: use Promise) the ok callback called
* when the Format string has been processed. The resultant String will be
* passed to this function as an argument
* @param {Function} [obj.fail] (Deprecated: use Promise) the fail callback
* called if there's a fatal error processing the format.
* @returns {Promise.<String>}
*/
Format.prototype.format = function (obj) {
obj = objectify(obj);
obj.pattern = this.$pattern;
return Format.format(obj);
};
// endregion baja.Format
//region Formatters
// %% -> %
/** @implements baja.Format~Formatter */
class EscapedPercentSignFormatter {
canFormat(script) {
return script === '';
}
doFormat() {
return '%';
}
}
// %.% -> identity string
/** @implements baja.Format~Formatter */
class IdentityFormatter {
canFormat(script) {
return script === '.';
}
doFormat(script, obj) {
const { target, display } = obj;
let { cx, object } = obj;
if (target) {
const { container, propertyPath } = target;
cx = Object.assign(target.getFacets().toObject(), cx);
if (container) {
if (propertyPath && propertyPath.length) {
let parent = container;
let val = parent;
let slot;
for (var i = 0; i < propertyPath.length; ++i) {
slot = propertyPath[i];
parent = val;
val = val.get(slot);
}
cx = prepareContext(parent.get(slot), cx);
return display ? parent.getDisplay(slot, cx) : parent.get(slot).toString(cx);
} else {
return display ? container.getDisplay(target.slot) : container.get(target.slot).toString(cx);
}
} else {
object = target.getObject();
return display && isComplex(object) ? object.getDisplay(undefined, cx) : object.toString(cx);
}
} else if (object !== undefined && object !== null) {
cx = prepareContext(object, cx);
return display && isComplex(object) ? object.getDisplay(undefined, cx) : object.toString(cx);
}
}
}
// current user name
/** @implements baja.Format~Formatter */
class UserFormatter {
canFormat(script) {
return script === 'user()';
}
doFormat() {
return baja.getUserName();
}
}
// decodeFromString()
/** @implements baja.Format~Formatter */
class DecodeFromStringFormatter {
canFormat(script) {
return script.split('(')[0] === 'decodeFromString';
}
doFormat(script, formatContext) {
const { cx } = formatContext;
const [ , , params ] = FUNCTION_PARAMS_REGEX.exec(script);
const [ moduleName, typeName, ...args ] = params.split(':');
const stringEncoding = baja.SlotPath.unescape(args.join(':'));
const decodedValue = baja.$(moduleName + ":" + typeName).decodeFromString(stringEncoding);
return Promise.resolve(decodedValue.toString(cx));
}
}
// lexicon(lexArgs)
/** @implements baja.Format~Formatter */
class LexiconFormatter {
canFormat(script) {
return script.match(LEXICON_REGEX);
}
doFormat(script) {
const [ , moduleName, key, argsString ] = LEXICON_REGEX.exec(script);
LEXICON_REGEX.lastIndex = 0;
// Asynchronously request the lexicon value
return lexjs.module(moduleName)
.then((lex) => {
if (argsString) {
const args = argsString.substring(1).split(':').map(baja.SlotPath.unescape);
return lex.get({ key, args });
} else {
return lex.get(key);
}
}, (err) => {
return 'error: ' + err + ' + ' + script;
});
}
}
// escape()/unescape()
/** @implements baja.Format~Formatter */
class EscapeUnescapeFormatter {
canFormat(script) {
const [ functionName ] = script.split('(');
return functionName === 'escape' || functionName === 'unescape';
}
doFormat(script, formatContext) {
const { object, cx } = formatContext;
function getMethodOperationsFromText(operationString) {
const [ , operation, nextOperation ] = FUNCTION_PARAMS_REGEX.exec(operationString);
if (nextOperation === '') { // base case.. we're done here.
return [ operation ];
}
return getMethodOperationsFromText(nextOperation).concat(operation);
}
const operations = getMethodOperationsFromText(script);
return Promise.resolve(object.toString(cx))
.then((objectText) => {
return operations.reduce((currentText, currentOperation) => {
if (currentText === null) {
return null;
}
if (currentOperation === 'escape') {
return baja.SlotPath.escape(currentText);
}
if (currentOperation === 'unescape') {
return baja.SlotPath.unescape(currentText);
}
return null;
}, objectText);
});
}
}
/** @implements baja.Format~Formatter */
class ReflectCallFormatter {
canFormat() {
return true; // always last in the list
}
doFormat(script, formatContext, suggestError) {
const { display, object } = formatContext;
const pieces = script.split(/\./g);
let cx = extend(formatContext.cx);
let mostRecentLeftValueText = "";
let parent = object;
let val = parent;
let slot = null;
function getErrorMessage(leftObject, leftText, rightText) {
const type = baja.hasType(leftObject) ? leftObject.getType().toString() : "";
return "%err:" + type + ":" + rightText + "%";
}
// leftValue.rightValue that was split over the period
function evaluateLeftToRight(leftValue, rightValue) {
if (rightValue === "time()") {
return baja.AbsTime.now();
}
if (rightValue === "user()") {
return baja.getUserName();
}
const valueIsComplex = isComplex(leftValue);
if (leftValue === null || leftValue === undefined) {
return null;
}
if (valueIsComplex) {
if (!canRead(leftValue, null)) {
suggestError(getErrorMessage(leftValue, mostRecentLeftValueText, rightValue));
return null;
}
}
return Promise.resolve()
.then(() => {
// First try looking for the Slot
if (valueIsComplex && leftValue.has(rightValue)) {
slot = leftValue.getSlot(rightValue);
if (!canRead(leftValue, slot)) {
suggestError(getErrorMessage(leftValue, mostRecentLeftValueText, rightValue));
return null;
}
parent = leftValue;
Object.assign(cx, leftValue.getFacets(rightValue).toObject());
return leftValue.get(slot);
}
// If there's no Slot then see if a function exists
// Nullify this since at this point we're no longer looking up a Slot chain
slot = null;
parent = null;
//If pattern starts with . returns the object itself for first empty split
if (rightValue === "") {
return object;
} else if (isFunctionWithParameters(rightValue, "substring")) {
return resolveSubstringReplace(leftValue, rightValue, formatContext);
} else if (isFunctionWithParameters(rightValue, "escape")) {
return resolveStringEscape(leftValue, rightValue, formatContext);
} else if (isFunctionWithParameters(rightValue, "unescape")) {
return resolveStringUnescape(leftValue, rightValue, formatContext);
} else if (typeof leftValue["get" + capitalizeFirstLetter(rightValue)] === "function") {
return reflectCall(leftValue, "get" + capitalizeFirstLetter(rightValue), cx);
} else if (typeof leftValue[rightValue] === "function") {
return reflectCall(leftValue, rightValue, cx);
} else if (canCallGet(leftValue)) {
return baja.def(leftValue.get(rightValue), null);
} else {
suggestError(getErrorMessage(leftValue, mostRecentLeftValueText, rightValue));
return null;
}
})
.then((result) => {
return Promise.resolve(isComplex(result) && result.isAncestorOf(leftValue) && result.loadSlots())
.then(() => result);
});
}
return pieces.reduce((prom, rightValueText) => {
return prom
.then((leftValue) => evaluateLeftToRight(leftValue, rightValueText))
.then((evaluatedValue) => {
mostRecentLeftValueText = rightValueText;
return evaluatedValue;
});
}, Promise.resolve(val))
.then((val) => {
cx = prepareContext(val, cx);
if (val !== null && val !== undefined && slot && parent) {
return display ? parent.getDisplay(slot, cx) : parent.get(slot).toString(cx);
}
if (isComplex(val)) {
parent = val.getParent();
if (parent) {
slot = val.getPropertyInParent();
Object.assign(cx, parent.getFacets(slot).toObject());
return display ? parent.getDisplay(slot, cx) : parent.get(slot).toString(cx);
}
}
// As a last resort, just call toString
if (val !== null && val !== undefined) {
return val.toString(cx);
}
return val;
});
}
}
/** @type baja.Format~Formatter[] */
const FORMATTERS = [
new EscapedPercentSignFormatter(),
new IdentityFormatter(),
new UserFormatter(),
new DecodeFromStringFormatter(),
new LexiconFormatter(),
new EscapeUnescapeFormatter(),
new ReflectCallFormatter()
];
// endregion Formatters
// region helper functions
/**
* This method will take the given object and attempt to format it by applying
* the different format matchers. It also handles conditional formats
* that have multiple fallback values.
*
* @param {string[]} scriptCandidates all potential fallbacks in a piece of a
* format string. This is the text inside of percent signs. Length will be
* more than 1 if conditional formatting is used; e.g. if the piece is
* `displayName?typeDisplayName` then this will be
* `[ 'displayName', 'typeDisplayName' ]`.
* @param {baja.Format~FormatContext} formatContext
* @param {string} [currentError]
* @returns {Promise<string>}
*/
function processScript(scriptCandidates, formatContext, currentError) {
if (!scriptCandidates.length) {
throw new Error(currentError || '');
}
const [ script, ...rest ] = scriptCandidates;
let suggestedErrorForPiece = currentError;
return FORMATTERS.reduce((formatProm, formatter) => {
return formatProm
.then((formattedString) => {
if (formattedString !== null) {
return formattedString;
}
if (!formatter.canFormat(script)) {
return null;
}
return formatter.doFormat(script, formatContext, (suggestedError) => {
suggestedErrorForPiece = suggestedError;
});
})
.catch((ignore) => null);
}, Promise.resolve(null))
.then((formattedString) => {
if (formattedString === null) {
return processScript(rest, formatContext, suggestedErrorForPiece);
} else {
return formattedString;
}
});
}
/**
* @param {baja.Format~FormatContext} formatContext
* @returns {Promise.<string>} the format string, with all scripts processed and replaced with
* their computed string values
*/
function processAllScripts(formatContext) {
const { pattern } = formatContext;
const scripts = findScripts(formatContext);
return Promise.all(scripts.map((script) => {
const scriptCandidates = script.split('?');
return processScript(scriptCandidates, formatContext)
.catch((err) => err.message || 'error: ' + script);
})).then((replacedTexts) => {
if (replacedTexts.indexOf(undefined) >= 0) {
return null;
} else {
let index = 0;
return pattern.replace(findScriptsRegex(), () => replacedTexts[index++]);
}
});
}
/**
* @param {baja.Format~FormatContext} formatContext
* @returns {string[]} all scripts found within the format pattern string (the stuff between the
* matching "%" pairs)
*/
function findScripts(formatContext) {
const { pattern } = formatContext;
const regex = findScriptsRegex();
const scripts = [];
for (;;) {
const res = regex.exec(pattern);
if (res) {
// Add data (remove start and end % characters)
scripts.push(trimPercents(res[0]));
} else {
break;
}
}
return scripts;
}
function trimPercents(script) {
return script.substring(1, script.length - 1);
}
/**
* @param {*} leftValue
* @param {string} rightValue
* @param {baja.Format~FormatContext} formatContext
* @returns {Promise.<string>}
*/
function resolveSubstringReplace(leftValue, rightValue, formatContext) {
return Promise.resolve(leftValue.toString(formatContext.cx))
.then((stringValue) => {
const [ , , paramsString ] = FUNCTION_PARAMS_REGEX.exec(rightValue);
const params = paramsString.split(',');
if (params.length === 1) {
const param1 = parseInt(params[0].trim());
if (param1 >= 0) {
return stringValue.substring(param1);
}
return stringValue.substring(
stringValue.length + param1,
stringValue.length
);
} else {
return stringValue.substring(
parseInt(params[0].trim()),
parseInt(params[1].trim())
);
}
});
}
/**
* @param {*} leftValue
* @param {string} rightValue
* @param {baja.Format~FormatContext} formatContext
* @returns {Promise.<string>} escaped string
*/
function resolveStringEscape(leftValue, rightValue, formatContext) {
return Promise.resolve(leftValue.toString(formatContext.cx))
.then((stringValue) => {
return baja.SlotPath.escape(stringValue);
});
}
/**
* @param {*} leftValue
* @param {string} rightValue
* @param {baja.Format~FormatContext} formatContext
* @returns {Promise.<string>} unescaped string
*/
function resolveStringUnescape(leftValue, rightValue, formatContext) {
return Promise.resolve(leftValue.toString(formatContext.cx))
.then((stringValue) => {
return baja.SlotPath.unescape(stringValue);
});
}
function isFunctionWithParameters(textToTestForFunctionName, functionNameToCheckFor) {
const match = FUNCTION_PARAMS_REGEX.exec(textToTestForFunctionName);
if (!match || match.length < 2) {
return false;
}
const extractedFunctionName = match[1];
return extractedFunctionName === functionNameToCheckFor;
}
/**
* @param {baja.Complex} complex component or struct to be checked permission for
* @param {baja.Slot} slot slot to be checked permission for
* @returns {boolean} true if the component/slot is readable
*/
function canRead(complex, slot) {
if (complex.getType().isComponent()) {
return complex.$canRead(slot);
}
return true;
}
function prepareContext(value, cx) {
if (baja.hasType(value, "baja:Number") && cx &&
cx.precision === undefined &&
cx.trimTrailingZeros === undefined) {
return extend(cx, { trimTrailingZeros: true });
}
return cx;
}
/**
* @param {baja.Value} obj
* @param {string} functionName
* @param {object} [cx]
* @returns {Promise}
*/
function reflectCall(obj, functionName, cx) {
const contextForCall = getContextForCall(obj, functionName);
if (contextForCall) {
cx = extend(cx, contextForCall);
return obj[functionName](prepareContext(obj, cx));
} else {
return obj[functionName]();
}
}
/**
* @param {baja.Value} obj
* @param {string} functionName
* @returns {object|undefined} a default context to use for this function
* call - e.g. slot facets when calling get(Slot)Display, or an empty context
* for toString. If we don't know we need a context for this function call,
* return undefined.
*/
function getContextForCall(obj, functionName) {
if (isComplex(obj)) {
const match = functionName.match(/^get(.*)Display$/);
const slotName = match && toSlotName(match[1]);
if (slotName && obj.has(slotName)) {
return obj.getFacets(slotName).toObject();
}
}
switch (functionName) {
case 'getTypeDisplayName':
case 'getValueWithFacets':
case 'toString':
case 'valueToString':
return {};
}
}
function toSlotName(getterString) {
return getterString[0].toLowerCase() + getterString.substring(1);
}
function canCallGet(obj) {
if (!obj || typeof obj.get !== 'function') { return false; }
return !bajaHasType(obj, 'baja:Ord');
}
function extend(...objects) { return Object.assign({}, ...objects); }
function isComplex(obj) { return bajaHasType(obj, 'baja:Complex'); }
function findScriptsRegex() { return /%[^%]*%/g; }
/**
* @param {object|string} obj arguments to Format.format()
* @returns {baja.Format~FormatContext}
*/
function makeFormatContext(obj) {
const formatContext = extend(objectify(obj, "pattern"));
formatContext.display = bajaDef(formatContext.display, true);
formatContext.cx = formatContext.cx || {};
return formatContext;
}
// endregion helper functions
// region typedefs
/**
* A context object used for performing one Format replacement of a string.
*
* @private
* @typedef baja.Format~FormatContext
* @property {string} pattern the String pattern being Format-ted
* @property {boolean} display true if `getDisplay` should be used to format slots of a Complex -
* otherwise values will just be toString()ed.
* @property {baja.Value|*} object the object the Format string is being evaluated against
* (commonly a Component).
* @property {boolean} [loadSlots] not yet publicly documented - set to true to cause a format
* to call loadSlots on any components it tries to do a reflect call on.
* @property {object} cx context object used for formatting strings (can contain
* trueText/falseText etc).
*/
/**
* An object that knows how to replace a bit of inline script in a Format string, with an
* evaluated string. The Java analog to these are the Call subclasses in BFormat.java.
*
* @private
* @interface baja.Format~Formatter
*/
/**
* @function
* @name baja.Format~Formatter#canFormat
* @param {string} script the current bit of script being evaluated.
* @returns {boolean} true if this matcher can perform a Format replacement on the given string
*/
/**
* @function
* @name baja.Format~Formatter#doFormat
* @param {string} script the current bit of script being evaluated. This is the `out.value` in
* `%out.value%`. Will be called multiple times for formats with multiple scripts. BFormat.java
* confusingly calls this "id".
* @param {baja.Format~FormatContext} formatContext
* @param {function} suggestError if this formatter is not capable of correctly formatting the
* script, it can call this given function with an error to be inserted into the formatted string
* explaining why.
* @returns {string|Promise.<string>}
*/
// endregion typedefs
return Format;
});