baja/obj/FacetsMap.js

/**
 * @copyright 2021 Tridium, Inc. All Rights Reserved.
 */

/**
 * Defines {@link baja.FacetsMap}.
 * @module baja/obj/FacetsMap
 */
define([ 'bajaScript/sys',
  'bajaScript/baja/obj/Simple',
  'bajaPromises' ], function (
  baja,
  Simple,
  Promise) {

  'use strict';

  /**
   * Represents a `baja:FacetsMap` in BajaScript. `FacetsMap` is simply a
   * mapping of String names to `Facets` instances. It is mostly intended for
   * internal use by the framework.
   *
   * When creating a `Simple`, always use the `make()` method instead of
   * creating a new Object.
   *
   * @class
   * @alias baja.FacetsMap
   * @extends baja.Simple
   */
  class FacetsMap extends Simple {
    constructor(obj = {}) {
      super();

      if (typeof obj !== 'object') { throw new Error('object required'); }

      Object.values(obj).forEach((facets) => {
        if (!baja.hasType(facets, 'baja:Facets')) { throw new Error('Facets required'); }
      });

      this.$obj = obj;
    }

    /**
     * @param {object} obj a mapping of string keys to `baja.Facets` instances
     * @returns {baja.FacetsMap}
     */
    static make(obj) {
      const facetsMap = new FacetsMap(obj);
      return facetsMap.list().length ? facetsMap : DEFAULT;
    }

    /**
     * @param {object} obj a mapping of string keys to `baja.Facets` instances
     * @returns {baja.FacetsMap}
     */
    make(obj) {
      return FacetsMap.make(...arguments);
    }

    /**
     * @returns {string[]} all string keys in this `FacetsMap` instance
     */
    list() {
      return Object.keys(this.$obj);
    }

    /**
     * @param {string} key
     * @returns {baja.Facets} the `Facets` at that key, or `baja.Facets.NULL` if
     * not found
     */
    get(key) {
      return this.$obj[key] || baja.Facets.NULL;
    }

    /**
     * @param {string} str
     * @param {Object} [params] 
     * @param {Boolean} [params.unsafe=false] if set to true, this will allow
     * decodeFromString to continue. If not, decodeFromString will throw an error. This flag is for
     * internal bajaScript use only. All external implementations should use decodeAsync instead.
     * @returns {baja.FacetsMap}
     */
    decodeFromString(str, { unsafe = false } = {}) {
      if (!unsafe) { throw new Error('FacetsMap#decodeAsync should be called instead to ensure all types are loaded for the decode'); }
      const obj = parseEncoding(str);

      Object.keys(obj).forEach((key) => {
        obj[key] = baja.Facets.DEFAULT.decodeFromString(obj[key], baja.Simple.$unsafeDecode);
      });

      return FacetsMap.make(obj);
    }

    /**
     * @param {string} str
     * @param {baja.comm.Batch} [batch]
     * @returns {Promise<baja.FacetsMap>}
     */
    decodeAsync(str, batch) {
      const obj = parseEncoding(str);

      return Promise.all(Object.keys(obj).map((key) => {
        return Promise.resolve(baja.Facets.DEFAULT.decodeAsync(obj[key], batch))
          .then((facets) => { obj[key] = facets; });
      }))
        .then(() => FacetsMap.make(obj));
    }

    /**
     * @returns {string}
     */
    encodeToString() {
      return '{' + this.list().map((key) => {
        return `${ escape(key) }=${ escape(this.get(key).encodeToString()) };`;
      }).join('') + '}';
    }

    /**
     * @returns {boolean} true if empty
     */
    isNull() {
      return !this.list().length;
    }

    /**
     * @returns {baja.FacetsMap} the DEFAULT instance
     */
    static get DEFAULT() { return DEFAULT; }

    /**
     * @returns {baja.FacetsMap} the NULL instance
     */
    static get NULL() { return DEFAULT; }
  }

  const DEFAULT = new FacetsMap({});

  function escape(str) {
    return str.replace(/[{}=;]/g, (s) => '\\' + s);
  }

  function unescape(str) {
    return str.replace(/\\(.)/g, (m, c) => c);
  }

  /**
   * @param {string} str string encoding from `BFacetsMap#encodeToString`
   * @returns {object} mapping of string names to their corresponding
   * `baja.Facets` string encodings
   */
  function parseEncoding(str) {
    const obj = {};

    const [ , body ] = str.match(/^{(.*)}$/);
    const escapedFacetsEncodings = splitOnUnescapedSeparator(body, ';');
    const pairs = escapedFacetsEncodings.map((str) => splitOnUnescapedSeparator(str, '='));

    pairs.forEach(([ key, value ]) => {
      obj[unescape(key)] = unescape(value);
    });

    return obj;
  }

  // must be something that can never be in the actual string, or at least is vanishingly unlikely
  // to be in production data. watch out for tech support complaints from Penguins Inc.
  const SEPARATOR_REPLACEMENT = '🐧\ue000🐧\ue000🐧';
  const replaceSeparator = (match, charBeforeSeparator) => charBeforeSeparator + SEPARATOR_REPLACEMENT;

  function splitOnUnescapedSeparator(str, separator) {
    // first match is the "real" character before the unescaped separator which we will put back in
    const unescapedSeparatorRegex = new RegExp(`([^\\\\])${ separator }`, 'g');

    const withRealSeparatorsReplaced = str.replace(unescapedSeparatorRegex, replaceSeparator);

    const actualEncodings = withRealSeparatorsReplaced.split(SEPARATOR_REPLACEMENT);

    return actualEncodings.filter((s) => s); // if last char is a separator, last string will be empty
  }


  return FacetsMap;
});