baja/obj/Format.js

/**
 * @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);
  }

});