/**
* @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",
"bajaPromises",
"lex!" ], function (baja, Simple, Promise, lexjs) {
"use strict";
var subclass = baja.subclass,
callSuper = baja.callSuper,
objectify = baja.objectify,
strictArg = baja.strictArg,
bajaDef = baja.def,
bajaHasType = baja.hasType;
/**
* `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)</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)%"
* "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
*/
var 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;
var FUNCTION_PARAMS_REGEX = /^\s*(\w+)\s*\((.*)\)/;
function resolveSubstringReplace(leftValue, rightValue, obj) {
return Promise.resolve(leftValue.toString(obj.cx))
.then(function (stringValue) {
var match = FUNCTION_PARAMS_REGEX.exec(rightValue);
var params = match[2].split(",");
if (params.length === 1) {
var 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())
);
}
});
}
function isFunctionWithParameters(textToTestForFunctionName, functionNameToCheckFor) {
var match = FUNCTION_PARAMS_REGEX.exec(textToTestForFunctionName);
if (!match || match.length < 2) {
return false;
}
var 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()) {
var permissions = complex.getPermissions();
if (!slot) {
return permissions.hasOperatorRead();
}
if (slot.getFlags() & baja.Flags.OPERATOR) {
return permissions.hasOperatorRead();
}
return permissions.hasAdminRead();
}
return true;
}
function prepareContext(value, cx) {
if (baja.hasType(value, "baja:Number") && cx &&
cx.precision === undefined &&
cx.trimTrailingZeros === undefined) {
var newCx = Object.assign({}, cx);
newCx.trimTrailingZeros = true;
return newCx;
}
return cx;
}
// Scope some of the variables here...
(function () {
var formatMatchers = [],
formatErr = "error: ",
lexiconRegex = /^lexicon\(([a-zA-Z0-9]+):([a-zA-Z0-9.\-_]+)((?::[a-zA-Z0-9$(?:\s)*]+)*)\)$/;
// Replace %% with %
formatMatchers.push({
isMatch: function (content) {
return content === "";
},
replace: function () {
return "%";
}
});
// Identity %.%
formatMatchers.push({
isMatch: function (content) {
return content === ".";
},
replace: function (obj) {
var object = obj.object;
var target = obj.target;
var display = obj.display;
var cx = obj.cx;
if (target) {
var container = target.container;
var propertyPath = target.propertyPath;
cx = Object.assign(target.getFacets().toObject(), cx);
if (container) {
if (propertyPath && propertyPath.length) {
var parent = container;
var val = parent;
var 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(obj) ? object.getDisplay() : object;
}
} else if (object !== undefined && object !== null) {
cx = prepareContext(object, cx);
return Promise.resolve(display && isComplex(obj) ? object.getDisplay() : object.toString(cx));
}
}
});
formatMatchers.push({
isMatch: function (content) {
return content === "user()";
},
replace: function () {
return baja.getUserName();
}
});
// decodeFromString
formatMatchers.push({
isMatch: function (content) {
return content.split("(")[0] === "decodeFromString";
},
replace: function (obj) {
var match = FUNCTION_PARAMS_REGEX.exec(obj.content),
paramPieces = match[2].split(":"),
moduleName = paramPieces[0],
typeName = paramPieces[1],
stringEncoding = baja.SlotPath.unescape(paramPieces.slice(2).join(":"));
var decodedValue = baja.$(moduleName + ":" + typeName).decodeFromString(stringEncoding);
return Promise.resolve(decodedValue.toString(obj.cx));
}
});
// Lexicon %.%
formatMatchers.push({
isMatch: function (content) {
return content.match(lexiconRegex);
},
replace: function (obj) {
var result = lexiconRegex.exec(obj.content);
lexiconRegex.lastIndex = 0;
// Asynchronously request the lexicon value
return lexjs.module(result[1])
.then(function (lex) {
if (result.length > 3 && result[3]) {
var fmtArgs = result[3].substring(1).split(":")
.map(function (fmtArg) {
return baja.SlotPath.unescape(fmtArg);
});
return lex.get({ key: result[2], args: fmtArgs });
} else {
return lex.get(result[2]);
}
}, function (err) {
return formatErr + " : " + err + " + " + obj.content;
});
}
});
// escape() / unescape()
formatMatchers.push({
isMatch: function (content) {
var startsWithFunctionName = content.split("(")[0];
return startsWithFunctionName === "escape" || startsWithFunctionName === "unescape";
},
replace: function (obj) {
function getMethodOperationsFromText(operationString) {
var match = FUNCTION_PARAMS_REGEX.exec(operationString),
currentOperations = [ match[1] ],
nextOperationText = match[2];
if (nextOperationText === "") { // base case.. we're done here.
return currentOperations;
}
return getMethodOperationsFromText(nextOperationText).concat(currentOperations);
}
var operations = getMethodOperationsFromText(obj.content);
return Promise.resolve(obj.object.toString(obj.cx))
.then(function (objectText) {
return operations.reduce(function (currentText, currentOperation) {
if (currentText === null) {
return null;
}
if (currentOperation === "escape") {
return baja.SlotPath.escape(currentText);
} else if (currentOperation === "unescape") {
return baja.SlotPath.unescape(currentText);
} else {
return null;
}
}, objectText);
});
}
});
// Reflect Call
formatMatchers.push({
isMatch: function () {
// Always last in the list so this should always match
return true;
},
replace: function (obj, suggestError) {
var val,
parent,
slot = null,
pieces = obj.content.split(/\./g),
mostRecentLeftValueText = "",
display = obj.display,
cx = Object.assign({}, obj.cx);
function getErrorMessage(leftObject, leftText, rightText) {
var type = baja.hasType(leftObject) ? leftObject.getType().toString() : "";
return "%err:" + type + ":" + rightText + "%";
}
// Process the format text
val = parent = obj.object;
// leftValue.rightValue that was split over the period
function evaluateLeftToRight(leftValue, rightValue) {
if (rightValue === "time()") {
return baja.AbsTime.now();
}
if (leftValue === null || leftValue === undefined) {
return null;
}
var valueIsComplex = isComplex(leftValue);
if (valueIsComplex) {
if (!canRead(leftValue, null)) {
suggestError(getErrorMessage(leftValue, mostRecentLeftValueText, rightValue));
return null;
}
}
// First try looking for the Slot
if (bajaHasType(leftValue, "baja:Complex") && 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 not 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 obj.object;
} else if (isFunctionWithParameters(rightValue, "substring")) {
return resolveSubstringReplace(leftValue, rightValue, obj);
} else if (typeof leftValue["get" + rightValue.capitalizeFirstLetter()] === "function") {
return reflectCall(leftValue, "get" + rightValue.capitalizeFirstLetter(), 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;
}
}
var waitForPiecesEvaluation = pieces.reduce(function (prom, rightValueText) {
return prom.then(function (leftValue) {
var evaluatedValue = evaluateLeftToRight(leftValue, rightValueText);
mostRecentLeftValueText = rightValueText;
return evaluatedValue;
});
}, Promise.resolve(val));
return waitForPiecesEvaluation.then(function (val) {
cx = prepareContext(val, cx);
if (val !== null && val !== undefined && slot && parent) {
return display ? parent.getDisplay(slot, cx) : parent.get(slot).toString(cx);
}
if (bajaHasType(val, "baja:Complex")) {
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;
});
}
});
/**
* 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) {
// 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.
obj = objectify(obj, "pattern");
var pattern = obj.pattern,
regex = /%[^%]*%/g,
matches = [],
res,
cb = new baja.comm.Callback(obj.ok, obj.fail || baja.fail);
obj.display = bajaDef(obj.display, true);
obj.cx = obj.cx || {};
// Find all the matches in the string to process
for (;;) {
res = regex.exec(pattern);
if (res) {
// Add data (remove start and end % characters)
matches.push(res[0].substring(1, res[0].length - 1));
} else {
break;
}
}
/**
* 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[]} fallbacks 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' ]`.
* @returns {Promise<string>}
*/
function processPieceOfFormatString(fallbacks, currentError) {
if (!fallbacks.length) {
throw new Error(currentError || '');
}
var suggestedErrorForPiece = currentError;
var attemptToFormatString = fallbacks[0];
return formatMatchers.reduce(function (formatProm, matcher) {
return formatProm
.then(function (format) {
if (format !== null) {
return format;
}
if (!matcher.isMatch(attemptToFormatString)) {
return null;
}
obj.content = attemptToFormatString;
return matcher.replace(obj, function (suggestedError) {
suggestedErrorForPiece = suggestedError;
});
})
.catch(function (ignore) {
return null;
});
}, Promise.resolve(null))
.then(function (formattedString) {
if (formattedString === null) {
return processPieceOfFormatString(fallbacks.slice(1), suggestedErrorForPiece);
} else {
return formattedString;
}
});
}
function processAllFormatsInString() {
return Promise.all(matches.map(function (match) {
var fallbacks = match.split('?');
return processPieceOfFormatString(fallbacks)
.catch(function (err) {
return err.message || 'error: ' + match;
});
})).then(function (replacedTexts) {
if (replacedTexts.contains(undefined)) {
cb.ok(null);
} else {
var index = 0;
cb.ok(pattern.replace(regex, function () {
return replacedTexts[index++];
}));
}
});
}
return processAllFormatsInString()
.then(function () {
return cb.promise();
})
.catch(function (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);
};
return Format;
function canCallGet(obj) {
if (!obj || typeof obj.get !== 'function') { return false; }
return !bajaHasType(obj, 'baja:Ord');
}
function isComplex(obj) { return baja.hasType(obj, 'baja:Complex'); }
/**
* @param {baja.Value} obj
* @param {string} functionName
* @param {object} [cx]
* @returns {Promise}
*/
function reflectCall(obj, functionName, cx) {
var contextForCall = getContextForCall(obj, functionName);
if (contextForCall) {
cx = Object.assign({}, 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)) {
var match = functionName.match(/^get(.*)Display$/);
var slotName = match && toSlotName(match[1]);
if (slotName && obj.has(slotName)) {
return obj.getFacets(slotName).toObject();
}
}
switch (functionName) {
case 'getTypeDisplayName':
case 'toString':
return {};
}
}
function toSlotName(getterString) {
return getterString[0].toLowerCase() + getterString.substring(1);
}
});