qik.utils.js

import _get from "lodash.get";
import _camelCase from "lodash.camelcase";
import { isBrowser, isNode } from "browser-or-node";

///////////////////////////////////////////////////////////////////////////////

/**
 * @name utils
 * @classdesc Utility helper functions — id, ids, hashing, parsing, cleaning, and other general-purpose helpers
 * @class
 */

const service = {};

////////////////////////////////////

const loadedExternalScripts = {};

/**
 * A function that dynamically include an external javascript resource
 * ensuring that it will only be included once
 * @alias utils.loadExternalScript
 * @param  {String} url The url of the external script
 * @return {Promise}    A promise that will be resolved once the script has been loaded
 * @example
 *
 * await sdk.utils.loadExternalScript('https://cdn.javascript.com/external/script.js');
 */

service.loadExternalScript = function (url) {
  // If we already have a promise
  if (loadedExternalScripts[url]) {
    // return the existing promise
    return loadedExternalScripts[url];
  }

  // Create a new promise
  const promise = (loadedExternalScripts[url] = new Promise(createNewScript));

  function createNewScript(resolve, reject) {
    if (!document) {
      delete loadedExternalScripts[url];
      return reject("document is undefined");
    }

    //////////////////////////////////////

    var script = document.createElement("script");
    script.type = "text/javascript";
    script.async = true;
    script.onload = function () {
      console.log("Loaded external script", url);
      return resolve(url);
    };
    script.src = url;

    // Inject it into the document's <head> tag
    document.head.appendChild(script);
  }

  return promise;
};

const loadedExternalStyles = {};

/**
 * A function that dynamically include an external css resource
 * ensuring that it will only be included once
 * @alias utils.loadExternalStyle
 * @param  {String} url The url of the external css
 * @return {Promise}    A promise that will be resolved once the css has been loaded
 * @example
 *
 * await sdk.utils.loadExternalStyle('https://cdn.css.com/external/style.css');
 */
service.loadExternalStyle = function (url) {
  const promise = new Promise(createNewScript);

  function createNewScript(resolve, reject) {
    if (!document) {
      return reject("document is undefined");
    }

    if (loadedExternalStyles[url]) {
      return resolve(url);
    }

    // Avoid duplicate scripts
    loadedExternalStyles[url] = true;

    //////////////////////////////////////

    var script = document.createElement("link");
    script.setAttribute("rel", "stylesheet");
    script.setAttribute("type", "text/css");
    script.setAttribute("href", url);
    script.onload = function () {
      console.log("Loaded external stylesheet", url);
      return resolve(url);
    };

    // Inject it into the document's <head> tag
    document.head.appendChild(script);
  }

  return promise;
};

///////////////////////////////////////////////////////////////////////////////

/**
 * A helper function for checking whether a value is truthy/falsy
 * @alias utils.exists
 * @param  {Anything} value The value to check
 * @return {Boolean}  whether the value is truthy
 * @example
 *
 * sdk.utils.exists('undefined'); // false
 * sdk.utils.exists([]); // true
 * sdk.utils.exists(''); // false
 * sdk.utils.exists(undefined); // false
 */
service.exists = function (value) {
  var isUndefinedOrNull;

  ////////////////////////

  if (Array.isArray(value)) {
    return true;
  }

  ////////////////////////

  if (value === 0) {
    return true;
  }

  ////////////////////////

  switch (typeof value) {
    case "undefined":
    case "null":
      isUndefinedOrNull = true;
      break;
    default:
      var string = String(value).toLowerCase();
      switch (string) {
        case "":
        case "undefined":
        case "null":
          isUndefinedOrNull = true;
          break;
      }
      break;
  }

  return !!!isUndefinedOrNull;
};

//////////////////////////////////

/**
 * A helper function for getting a javascript date object from some input
 * returns undefined if it can not be parsed
 * @alias utils.parseDate
 * @param  {String|Number} input The value to parse as a date
 * @return {Date}  the javascript date object
 * @example
 *
 * sdk.utils.parseDate(input);
 */
service.parseDate = function (input) {
  if (!input) {
    return;
  }

  if (input instanceof Date) {
    return input;
  }

  //Attempt to create a date
  var d = new Date(input);
  var isValid = d instanceof Date && !isNaN(d);

  if (isValid) {
    return d;
  }

  return;
};

/**
 * A helper function for getting a full url string from some input
 * @alias utils.parseURL
 * @param  {String} input The input value
 * @return {String}  the fully parsed URL
 * @example
 *
 * sdk.utils.parseURL('google.com'); // Returns https://google.com
 * sdk.utils.parseURL('mailto:hello@qik.dev'); // Returns 'mailto:hello@qik.dev'
 * sdk.utils.parseURL('://hello.com'); // Returns '://hello.com'
 * sdk.utils.parseURL('hello@email.com'); // Returns 'mailto:hello@email.com'
 */
service.parseURL = function (string) {
  if (!string) {
    return false;
  }

  const relative = string.startsWith("/");
  if (relative) {
    return string;
  }

  if (string.startsWith("://")) {
    return string;
  }

  if (string.startsWith("mailto:")) {
    return string;
  }

  if (string.startsWith("tel:")) {
    return string;
  }

  if (string.startsWith("sms:")) {
    return string;
  }

  //////////////////////////////////
  //If someone entered an email by accident
  var email = service.parseEmail(string);
  //Convert it to a mailto link
  if (email && email === string) {
    return `mailto:${email}`;
  }

  //////////////////////////////////

  const withHttp = (string) =>
    !/^https?:\/\//i.test(string) ? `http://${string}` : string;
  string = withHttp(string);
  //TODO: test this more.
  string = string.replace(/\s/g, "");

  var valid = RegExp(
    "(https?:\\/\\/)?((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|((\\d{1,3}\\.){3}\\d{1,3}))(\\:\\d+)?(\\/[-a-z\\d%_.~+@]*)*(\\?[;&a-z\\d%_.~+=-@]*)?(\\#[-a-z\\d_@]*)?$",
    "i",
  ).test(string);

  if (!valid) {
    return false;
  }

  ///////////////////////////

  return withHttp(string);
};

///////////////////////////////////////////////

/**
 * A helper function for getting a number from some input
 * @alias utils.parseNumber
 * @param  {String} value The input value
 * @param  {Number} decimalPoints The number of decimal points to round to
 * @return {Number}  the parsed number value
 * @example
 *
 * sdk.utils.parseNumber('123'); // Returns 123
 * sdk.utils.parseNumber('75.501', 2); // Returns 75.5
 * sdk.utils.parseNumber(''); // Returns 0
 * sdk.utils.parseNumber(null); // Returns 0
 * sdk.utils.parseNumber(); // Returns 0
 */
service.parseNumber = function (input, decimalPoints) {
  if (!input) {
    return 0;
  }

  input = Number(input);
  if (isNaN(input)) {
    return 0;
  }

  if (decimalPoints) {
    var str = input.toFixed(decimalPoints);
    input = service.parseNumber(str);
  }

  return input;
};

/**
 * A helper function for getting a number from some input
 * @alias utils.isValidEmailAddress
 * @param  {String} emailAddress The email to validate
 * @return {Boolean} whether or not the input is a valid email address
 * @example
 *
 * sdk.utils.isValidEmailAddress('123.com'); // Returns false
 * sdk.utils.isValidEmailAddress('hello@world.com'); // Returns true
 * sdk.utils.isValidEmailAddress('something@special.io'); // Returns true
 */
service.isValidEmailAddress = function (email) {
  var tester =
    /^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;

  if (!email) return false;

  var emailParts = email.split("@");

  if (emailParts.length !== 2) return false;

  var account = emailParts[0];
  var address = emailParts[1];

  if (account.length > 64) return false;
  else if (address.length > 255) return false;

  var domainParts = address.split(".");
  if (
    domainParts.some(function (part) {
      return part.length > 63;
    })
  )
    return false;

  if (!tester.test(email)) return false;

  return true;
};

/**
 * A helper function for getting a formatted email address
 * @alias utils.parseEmail
 * @param  {String} emailAddress The input to parse as an email
 * @return {String|Boolean} a valid email address in lowercase, or false if parse is not possible
 * @example
 *
 * sdk.utils.parseEmail('ToMack@gmail.com'); // returns 'tomack@gmail.com'
 * sdk.utils.isValidEmailAddress('hello.world.com'); // Returns false
 */
service.parseEmail = function (input) {
  if (!input) {
    return false;
  }

  var lowercase = String(input).toLowerCase();
  var valid = service.isValidEmailAddress(lowercase);

  if (!valid) {
    return false;
  } else {
    return lowercase;
  }
};

//////////////////////////////////

/**
 * A helper function for getting an integer
 * @alias utils.parseInt
 * @param  {String|Number} input The input to parse as an integer
 * @return {Integer} the resulting integer or 0
 * @example
 *
 * sdk.utils.parseInt('134'); // returns 134
 * sdk.utils.parseInt('cows'); // returns 0
 * sdk.utils.parseInt(); // returns 0
 */
service.parseInt = function (input) {
  if (!input) {
    return 0;
  }

  input = parseInt(input);
  if (isNaN(input)) {
    return 0;
  }

  return input;
};

/**
 * A helper function for cleaning an input value to match
 * a required type
 * @alias utils.cleanValue
 * @param  {*} data The input to clean
 * @param  {String} type The data type to parse
 * @param  {Object} options Additional options for parsing
 * @return {*} the resulting cleaned value
 * @example
 *
 * sdk.utils.cleanValue({_id:'1234', title:'Item'...}, 'reference'); // returns '1234'
 * sdk.utils.cleanValue('true', 'boolean'); // returns true
 * sdk.utils.cleanValue('Mr Rogers House', 'key'); // returns 'mrRogersHouse';
 * sdk.utils.cleanValue('Hello.World@email.COM', 'email'); // returns 'hello.world@email.com';
 */
service.cleanValue = function (data, type, options) {
  if (!options) {
    options = {};
  }

  ////////////////////////

  var noInputValue = service.exists(data);
  if (options.strict && noInputValue && type != "boolean") {
    return data;
  }

  ////////////////////////

  switch (type) {
    case "reference":
      return service.id(data, true);
      break;
    case "boolean":
      return service.parseBoolean(data);
      break;
    case "url":
      return service.parseURL(data);
      break;
    case "key":
      return service.machineName(data);
      break;
    case "email":
      return service.parseEmail(data);
      break;
    case "date":
      var parsed = service.parseDate(data, options);

      if (parsed === undefined) {
        return;
      } else {
        return parsed;
      }
      break;
    case "number":
    case "decimal":
    case "float":
      var val = service.parseNumber(data);
      if (String(val) == String(Number(data))) {
        return val;
      }
      return;
      break;
    case "integer":
      var val = service.parseInt(data);
      if (String(val) == String(parseInt(data))) {
        return val;
      }
      return;
      break;
    case "group":
      return data;
      break;
    case "object":
      if (typeof data === "object" && data !== null && !Array.isArray(data)) {
        return data;
      }

      return;
      break;
    case "string":
      if (service.exists(data)) {
        var stringed = String(data);
        if (stringed == "[object Object]") {
          return;
        }

        return stringed;
      } else {
        return;
      }

    // if(!data) {
    //     return '';
    // }

    // if(Array.isArray(data)) {
    //      return JSON.stringify(data);
    // }

    // if(Object.isObject(data)) {
    //     return JSON.stringify(data);
    // }

    // return String(data);

    // break;
    default:
      return String(data);
      break;
  }
};

service.isValidValue = function (value, dataType, strict) {
  var isValue = service.exists(value);
  var valueIsNumber = typeof value == "number";

  ////////////////////////

  var isValidEntry = false;

  if (!isValue) {
    return false;
  }

  ////////////////////////

  switch (dataType) {
    case "url":
      if (strict) {
        isValidEntry =
          value.startsWith("/") ||
          value.startsWith("://") ||
          value.startsWith("http://") ||
          value.startsWith("https://");
      } else {
        isValidEntry = String(value) === service.parseURL(value);
      }
      break;
    case "key":
      isValidEntry = String(value) === service.machineName(value);
      break;
    case "date":
      //If we are being strict
      if (strict) {
        //And the input is not a javascript date object
        if (typeof value != "object" || !(value instanceof Date)) {
          return false;
        }
      }

      ///////////////////////////////
      var parsed = service.parseDate(value);
      return parsed !== undefined;
      break;
    case "email":
      if (strict) {
        isValidEntry = value === service.parseEmail(value);
      } else {
        isValidEntry =
          String(value).toLowerCase() === service.parseEmail(value);
      }

      break;
    case "number":
    case "decimal":
    case "float":
      if (strict) {
        isValidEntry =
          valueIsNumber && Number(value) === service.parseNumber(value);
      } else {
        isValidEntry = Number(value) === service.parseNumber(value);
      }
      break;
    case "integer":
      if (strict) {
        isValidEntry =
          valueIsNumber && Number(value) === service.parseInt(value);
      } else {
        isValidEntry = Number(value) === service.parseInt(value);
      }
      break;
    case "boolean":
      var parsed = service.parseBoolean(value);

      //Only accept true booleans
      if (strict) {
        if (value === true || value === false) {
          isValidEntry = value === parsed;
        }
      } else {
        isValidEntry = parsed === true || parsed === false;
      }
      break;
    case "reference":
      if (strict) {
        isValidEntry = String(value) === service.id(value);
      } else {
        isValidEntry = !!service.id(value);
      }
      break;
    case "string":
      var checkString = String(value);
      if (strict) {
        if (typeof value != "string") {
          return false;
        }
      }

      if (checkString == "[object Object]") {
        return false;
      }

      isValidEntry = true;
      break;
    // case 'group':
    //     isValidEntry = _isObject(value);
    //     break;
    case "object":
      isValidEntry =
        typeof value === "object" && value !== null && !Array.isArray(value);
      break;
    case "array":
      isValidEntry = Array.isArray(value);
      break;
  }

  ////////////////////////

  return isValidEntry;
};

/**
 * A helper function for parsing input as boolean values
 * @alias utils.parseBoolean
 * @param  {*} value The input to parse
 * @return {Boolean} the resulting true/false value
 * @example
 *
 * sdk.utils.parseBoolean('true'); // returns true
 * sdk.utils.parseBoolean('y'); // returns true
 * sdk.utils.parseBoolean('YES'); // returns true
 * sdk.utils.parseBoolean('1'); // returns true
 * sdk.utils.parseBoolean('t'); // returns true
 * sdk.utils.parseBoolean(''); // returns false
 * sdk.utils.parseBoolean('0'); // returns false
 * sdk.utils.parseBoolean('n'); // returns false
 * sdk.utils.parseBoolean('no'); // returns false
 * sdk.utils.parseBoolean('f'); // returns false
 * sdk.utils.parseBoolean('null'); // returns false
 */
service.parseBoolean = function (value) {
  switch (String(value).toLowerCase()) {
    case "true":
    case "y":
    case "yes":
    case "1":
    case "t":
      value = true;
      break;
    case "false":
    case "n":
    case "no":
    case "0":
    case "f":
    case "undefined":
    case "null":
    case "":
    case "-1":
      value = false;
      break;
  }

  return !!value;
};

///////////////////////////////////////////////////////////////////////////////

service.clone = function (input) {
  return JSON.parse(JSON.stringify(input));
};

///////////////////////////////////////////////////////////////////////////////

service.getAllFields = function (actualDefinition) {
  const self = this;
  const isProfile =
    actualDefinition.definesType === "profile" ||
    actualDefinition.key === "profile";
  const isFormSubmission = actualDefinition.definesType === "submission";
  var allFields = [...actualDefinition.fields];
  var definedFields = actualDefinition.definedFields || [];

  if (definedFields.length) {
    if (isFormSubmission) {
      var formDataFields = {
        title: `Form Data`,
        minimum: 1,
        maximum: 1,
        key: "formData",
        asObject: true,
        type: "group",
        fields: definedFields,
      };

      allFields.push(formDataFields);

      const cleanedDataFields = definedFields.map(function (field) {
        if (field.type === "reference") {
          field = JSON.parse(JSON.stringify(field));
          delete field.fields;
        }

        return field;
      });

      var dataFields = {
        title: `Data`,
        minimum: 1,
        maximum: 1,
        key: "data",
        asObject: true,
        type: "group",
        fields: cleanedDataFields,
      };

      allFields.push(dataFields);
    } else {
      var dataFields = {
        title: `${actualDefinition.title}`,
        minimum: 1,
        maximum: 1,
        key: "data",
        asObject: true,
        type: "group",
        fields: definedFields,
      };

      allFields.push(dataFields);
    }
  }

  if (isProfile) {
    allFields.push({
      title: "Age",
      key: "_age",
      minimum: 1,
      maximum: 1,
      type: "integer",
    });

    allFields.push({
      title: "Date of birth",
      key: "_dob",
      minimum: 1,
      maximum: 1,
      type: "date",
    });
  }

  var mapped = service
    .mapFields(allFields)
    .filter(function (field) {
      var isObject =
        field.type == "group" &&
        field.asObject &&
        field.minimum == 1 &&
        field.maximum == 1;
      return !isObject;
    })
    .map(function (field) {
      field.title =
        field.filterTitle || field.titles.filter(Boolean).join(" › ");

      return field;
    })
    .sort(function (a, b) {
      return a.title < b.title ? -1 : 1;
    });

  return mapped;
};
///////////////////////////////////////////////////////////////////////////////

service.mapFields = function (fields, options) {
  if (!options) {
    options = {};
  }

  //////////////////////////////

  var output = [];
  var trail = [];
  var titles = [];
  var currentDepth = 1;
  var depthLimit = options.depth;
  var delimiter = options.arrayDelimeter || "[]";

  //Loop through each field
  fields.forEach(mapField);

  //////////////////////////////

  //Recursively map the fields
  function mapField(field, i) {
    var fieldKey = field.key;
    var isGroup = field.type == "group";
    var singleValue = field.minimum === field.maximum && field.minimum === 1;
    var asObject = field.asObject; // || (isGroup && singleValue);
    var isLayout = isGroup && !asObject;

    //////////////////////////////////

    // Whether to clone the field or use the existing reference
    var mapped = options.original ? field : service.clone(field);

    mapped.trail = trail.slice();
    mapped.trail.push(fieldKey);

    mapped.path = mapped.trail.join(".");
    mapped.titles = titles.slice();
    mapped.titles.push(field.filterTitle || field.title || "");

    const isNotLayoutOrIsAllowed = !isLayout || options.includeLayout;

    //If its an actual element or we've asked to include
    //layout only elements
    if (isNotLayoutOrIsAllowed) {
      //Add it to the mix
      output.push(mapped);
    }

    //////////////////////////////////

    //Now see if there are child fields and should we go further
    var limitHit = depthLimit && currentDepth >= depthLimit;

    //If there are child fields for this field
    if (field.fields && field.fields.length) {
      //If it's just a group with no extra key
      if (isLayout) {
        //Loop through fields as if they are at the same depth
        field.fields.forEach(mapField);
      } else {
        //We don't need to traverse any further
        //because we hit the limit
        if (limitHit) {
          return;
        }

        //Move down a level and add to the object
        //Include the key in the path trail
        currentDepth++;

        let injectKey = fieldKey;
        if (options.includeArrayDelimeter) {
          if (!singleValue) {
            injectKey = `${fieldKey}${delimiter}`;
          }
        }
        trail.push(injectKey);
        titles.push(field.filterTitle || field.title);

        //Loop through each field
        field.fields.forEach(mapField);

        //Move back up a level before we go to the next field
        currentDepth--;
        trail.pop();
        titles.pop();
      }
    }
  }

  return output;
};

///////////////////////////////////////////////////////////////////////////////

/**
 * A helpful function that can take a keyed object literal and map it to url query string parameters
 * @alias utils.mapParameters
 * @param  {Object} parameters The object you want to transalte
 * @return {String}            The query string
 * @example
 * //Returns &this=that&hello=world
 * sdk.utils.mapParameters({"this":"that", "hello":"world"})
 */
service.mapParameters = function (parameters) {
  parameters = parameters || {};

  var array = [];

  Object.entries(parameters).forEach(function ([key, value]) {
    if (value === undefined || value === null || value == false) {
      return;
    }

    if (Array.isArray(value)) {
      value.forEach(function (v) {
        array.push(`${key}=${encodeURIComponent(v)}`);
      });
    } else {
      array.push(encodeURIComponent(key) + "=" + encodeURIComponent(value));
    }
  });

  return array.join("&");
};

///////////////////////////////////////////////////////////////////////////////

/**
 * A function that will take an integer and a currency string and return a formatted numeric amount rounded to 2 decimal places
 * @alias utils.formatCurrency
 * @param  {Integer} value The amount in cents
 * @param  {String} currency The currency to format
 * @return {String}            The formatted value
 * @example
 *
 * //Returns £10.00
 * sdk.utils.formatCurrency(1000, 'gbp');
 *
 * //Returns $10.00
 * sdk.utils.formatCurrency(1000, 'usd');
 *
 */
service.formatCurrency = function (value, currency, decimalPoints) {
  if (!value || isNaN(value)) {
    value = 0;
  }

  decimalPoints = decimalPoints || 2;

  var currencyPrefix = service.currencySymbol(currency);
  return `${currencyPrefix}${parseFloat(Number(value) / 100).toFixed(decimalPoints)}`;
};

/**
 * A function that will take an id and return the type key
 * @alias utils.getTypeFromID
 * @param  {String} id The id of an object
 * @return {String}    The key
 * @example
 *
 * // Returns 'user'
 * sdk.utils.getTypeFromID('52b523f775beea960013f6cd');
 *
 * // Returns 'role'
 * sdk.utils.getTypeFromID('62b59cb572fb4772e7b5fa93');
 *
 */
service.getTypeFromID = function (id) {
  id = service.id(id);

  if (!id) {
    return;
  }

  const lookup = {
    21: "organisation",
    23: "comment",
    24: "persona",
    25: "audio",
    26: "campaign",
    28: "smartlist",
    29: "cache",
    40: "application",
    41: "action",
    42: "billinginvoice",
    43: "componentsnapshot",
    44: "diff",
    45: "email",
    46: "submission",
    49: "import",
    53: "socket",
    54: "timetrigger",
    61: "article",
    63: "component",
    65: "event",
    66: "file",
    67: "tag",
    69: "image",
    70: "profile",
    72: "role",
    73: "scope",
    74: "transaction",
    75: "user",
    76: "video",
    77: "workflowcard",
    78: "variable",
    "3c": "patoken",
    "7b": "uatoken",
    "7d": "urtoken",
    "2d": "systemtask",
    "3d": "export",
    "5f": "systemflag",
    "2b": "batch",
    "3a": "log",
    "2e": "stat",
    "2f": "unsubscribe",
    "6b": "intent",
    "7c": "upload",
    "2c": "systemtrigger",
    "6e": "interface",
    "4e": "interfacesnapshot",
    "4f": "notification",
    "2a": "definition",
    "7e": "definitionsnapshot",
    "5e": "integration",
    "4c": "listener",
    "6d": "topic",
    "7a": "code",
    "4d": "paymentmethod",
    "6c": "sslcertificate",
  };

  var hexKey = id.slice(8, 10);
  return lookup[hexKey];
};

/**
 * A function that will take a currency string and return the symbol
 * @alias utils.currencySymbol
 * @param  {String} currency The currency
 * @return {String}            The symbol
 * @example
 *
 * //Returns £
 * sdk.utils.currencySymbol('gbp');
 *
 * //Returns $
 * sdk.utils.currencySymbol('usd');
 *
 */
const CURRENCY_SYMBOLS = { gbp: "\u00A3", eur: "\u20AC" };

service.currencySymbol = function (currency) {
  return CURRENCY_SYMBOLS[String(currency).toLowerCase()] || "$";
};

const SUPPORTED_CURRENCIES = [
  { code: "usd", countries: ["US"] },
  { code: "gbp", countries: ["GB", "UK"] },
  { code: "cad", countries: ["CA"] },
  { code: "aud", countries: ["AU"] },
  { code: "nzd", countries: ["NZ"] },
  { code: "sgd", countries: ["SG"] },
];

service.getAvailableCurrencies = function (defaultCountryID) {
  let currencies = SUPPORTED_CURRENCIES.map(({ code, countries }) => ({
    name: `${code.toUpperCase()} (${service.currencySymbol(code)})`,
    value: code,
    countryCode: Object.fromEntries(countries.map((c) => [c, true])),
  }));

  if (defaultCountryID) {
    const idx = currencies.findIndex((c) => c.countryCode[defaultCountryID]);
    if (idx > 0) {
      const [match] = currencies.splice(idx, 1);
      currencies.unshift(match);
    }
  }

  return currencies;
};

///////////////////////////////////////////////////////////////////////////////

/**
 * Creates a fast keyed hash object from an array of items
 * @alias utils.hash
 * @param  {Array} array The array of items to convert into a hash
 * @param  {String} key The key or path to the property on each item to use as the hashed key
 * @return {Object} A key/value paired object
 * @example
 * //Returns { jimbo:{id:'jimbo', title:'Jim Jones'}, {id:'roger', title:'Roger Fellow'} }
 * sdk.utils.hash([{id:'jimbo', title:'Jim Jones'}, {id:'roger', title:'Roger Fellow'}], 'id');
 *
 */
service.hash = function (items, key) {
  items = !Array.isArray(items) ? [] : items;
  return items.reduce(function (memo, item) {
    const k = key ? _get(item, key) : item;
    memo[k] = item;
    return memo;
  }, {});
};

//////////////////////////////////////////////////

/**
 * Returns a subset of values in an array that match a provided rule
 * @alias utils.extractFromArray
 * @param  {Array} array The array you want to extract values from
 * @param  {String} key The path to the child property you want to extract
 * @param  {Boolean} sum Whether to sum the extracted values together in total
 * @param  {Boolean} flatten Whether to flatten nested child arrays
 * @param  {Boolean} unique Whether to only return unique values
 * @param  {Boolean} exclude Whether to exclude null or undefined values
 * @param  {Object} options Pass through extra options for how to extract the values
 * @return {Array} An array of all values retrieved from the array, unless provided arguments require otherwise
 * @example
 * //Returns [12, 45] as all the values
 * sdk.utils.extractFromArray([{name:'Wendy', age:12}, {name:'Roger', age:45}], 'age');
 *
 * //Returns 32
 * sdk.utils.extractFromArray([{name:'Wendy', age:12}, {name:'Roger', age:20}], 'age', {sum:true});
 *
 */
service.extractFromArray = function (
  array,
  key,
  sum,
  flatten,
  unique,
  exclude,
  options,
) {
  options = options || {};

  if (sum) {
    options.sum = sum;
  }

  if (flatten) {
    options.flatten = true;
  }

  if (unique) {
    options.unique = true;
  }

  if (exclude) {
    options.excludeNull = true;
  }

  /////////////////

  //Filter the array options by a certain value and operator
  var extractedValues = array.reduce(function (set, entry) {
    //Get the value from the object
    var retrievedValue = _get(entry, key);

    var isNull =
      !retrievedValue && retrievedValue !== false && retrievedValue !== 0;
    if (options.excludeNull && isNull) {
      return set;
    }

    set.push(retrievedValue);
    return set;
  }, []);

  if (options.flatten) {
    extractedValues = extractedValues.flat();
  }

  if (options.unique) {
    extractedValues = [...new Set(extractedValues)];
  }

  if (options.sum) {
    extractedValues = extractedValues.reduce(function (a, b) {
      return a + b;
    }, 0);
  }

  return extractedValues;
};

//////////////////////////////////////////////////////

/**
 * A function that can return a selection of values that were found in an array that match a specific rule,
 * This is often used to evaluate expressions within form fields
 * @alias utils.matchInArray
 * @param  {Array} array The array to check
 * @param  {String} key The javascript dot notation path to extract
 * @param  {String} value The value to compare against
 * @param  {String} comparator The logical operator to use to compare the extracted value with the provided value ('>', '<', '>=', '<=', 'in', '==') Defaults to '==' (Is equal to)
 * @return {Array} Returns an array of matching values
 * @example
 * //Returns [{name:'Michael', age:45}] as that is only item in the array that matches the criteria
 * sdk.utils.matchInArray([{name:'Wendy', age:12}, {name:'Michael', age:45}], 'age', 45, '>=');
 *
 */
service.matchInArray = function (array, key, v, comparator) {
  //Filter the array options by a certain v and comparator
  var matches = array.filter(function (entry) {
    //Get the v from the object
    var extracted = _get(entry, key);
    var found;

    switch (comparator) {
      case "in":
        found = extracted.includes(v);
        break;
      case ">=":
        found = extracted >= v;
        break;
      case "<=":
        found = extracted <= v;
        break;
      case "<":
        found = extracted < v;
        break;
      case ">":
        found = extracted > v;
        break;
      case "==":
      default:
        if (v === undefined) {
          found = extracted;
        } else {
          found = extracted == v;
        }
        break;
    }

    return found;
  });

  return matches;
};

///////////////////////////////////////////////////////////////////////////////

/**
 * A helpful class that can take an array of values and return them as a comma seperated
 * string, If the values are objects, then a property to use as the string representation can be specified
 * @alias utils.comma
 * @param  {Array} array The array of values to translate
 * @param  {String} path  An optional property key to use for each value
 * @return {String}       The resulting comma seperated string
 * @example
 * //Returns 'cat, dog, bird'
 * sdk.utils.comma(['cat', 'dog', 'bird']);
 *
 * //Returns 'cat, dog, bird'
 * sdk.utils.comma([{title:'cat'}, {title:'dog'}, {title:'bird'}], 'title');
 */
service.comma = function (array, path, limit) {
  if (limit) {
    array = array.slice(0, limit);
  }

  return array
    .filter(function (item) {
      return item != null && item !== undefined && item !== "";
    })
    .map(function (item) {
      if (path && path.length) {
        return _get(item, path);
      }

      return item;
    })
    .join(", ");
};

///////////////////////////////////////////////////////////////////////////////

//Helper function to get an id of an object

/**
 * Returns a specified _id for an object
 * @alias utils.id
 * @param  {Object} input      An object that is or has an _id property
 * @param  {Boolean} asObjectID Whether to convert to a Mongo ObjectId
 * @return {String}            Will return either a string or a Mongo ObjectId
 *
 * @example
 *
 * //Returns '5cb3d8b3a2219970e6f86927'
 * sdk.utils.id('5cb3d8b3a2219970e6f86927')
 *
 * //Returns true
 * typeof service.id({_id:'5cb3d8b3a2219970e6f86927', title, ...}) == 'string';

 * //Returns true
 * typeof service.id({_id:'5cb3d8b3a2219970e6f86927'}, true) == 'object';
 */
service.id = function (source) {
  if (!source) {
    return;
  }

  /////////////////////////////////

  var output;

  if (source._id) {
    output = String(source._id);
  } else {
    output = String(source);
  }

  var isValid = service.isValidID(output);
  if (!isValid) {
    return;
  }

  return output;
};

///////////////////////////////////////////////////////////////////////////////

/**
 * Cleans and maps an array of objects to an array of IDs
 * @alias utils.ids
 * @param  {Array} array      An array of objects or object ids
 * @param  {Boolean} asObjectID Whether or not to map the ids as Mongo ObjectIds
 * @return {Array}            An array of Ids
 *
 * @example
 * //Returns ['5cb3d8b3a2219970e6f86927', '5cb3d8b3a2219970e6f86927', '5cb3d8b3a2219970e6f86927']
 * sdk.utils.ids([{_id:'5cb3d8b3a2219970e6f86927'}, {_id:'5cb3d8b3a2219970e6f86927'}, null, '5cb3d8b3a2219970e6f86927'])
 */

service.ids = function (array) {
  if (!array) {
    return [];
  }

  /////////////////////////////////

  var ids = Object.keys(
    array.reduce(function (set, entry) {
      if (!entry) {
        return set;
      }

      var cleaned = service.id(entry);

      if (cleaned) {
        set[cleaned] = 1;
      }

      return set;
    }, {}),
  );

  ///////////////////////////////

  return ids;
};

///////////////////////////////////////////////////////////////////////////////

service.isValidID = function (input) {
  var checkForHexRegExp = new RegExp("^[0-9a-fA-F]{24}$");
  return checkForHexRegExp.test(String(input));
};

///////////////////////////////////////////////////////////////////////////////

/**
 * Helper function for retrieving a human readable error message from server error response objects
 * @alias utils.errorMessage
 * @param  {Object} error The error object to translate
 * @return {String}     The resulting human readable error message
 */
service.errorMessage = function (err) {
  if (!err) {
    return;
  }

  if (Array.isArray(err)) {
    err = err[0];
  }

  // Walk through the error object to find the most useful message
  let message =
    err?.response?.data?.message ||
    (typeof err?.response?.data === "string" ? err.response.data : null) ||
    err?.message ||
    null;

  if (Array.isArray(message)) {
    message = message[0];
  }

  return message || JSON.stringify(err);
};

////////////////////////////////////

/**
 * Generates a globally unique ID, helpful for adding unique keys for iterable loops
 * @alias utils.guid
 * @return {String}            The new globally unique identifier
 * @example
 * //Returns 4323a78br-z16h-289j-zwl1-938lda334asd
 * sdk.utils.guid()
 */
service.guid = function () {
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
    return crypto.randomUUID();
  }
  // Fallback for environments without crypto.randomUUID
  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
    const r = (Math.random() * 16) | 0;
    return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
  });
};

////////////////////////////////////
////////////////////////////////////
////////////////////////////////////

/**
 * Helper function for cleaning strings to use as database ids
 * @alias utils.machineName
 * @param  {String} string The string to clean eg. (Awesome Event!)
 * @return {String}     A cleaned and formatted string eg. (awesomeEvent)
 */
service.machineName = function (string) {
  if (!string || !string.length) {
    return;
  }

  var regexp = /[^a-zA-Z0-9-_]+/g;
  return String(string)
    .replace(regexp, " ")
    .split("_")
    .map((part) => _camelCase(part))
    .join("_");
};

/////////////////////////////////////////////
/////////////////////////////////////////////
/////////////////////////////////////////////

export default service;

/////////////////////////////////////////////

/**
 * A lightweight event emitter that can be attached to any service object
 * to provide pub/sub event capabilities. Can be called with or without `new`.
 */
export function EventDispatcher() {
  const handlers = new Map();

  const emitter = {
    dispatch(event, details) {
      const fns = handlers.get(event);
      if (fns) {
        for (const fn of fns) {
          fn(details);
        }
      }
    },

    addEventListener(event, callback) {
      if (typeof callback !== "function") {
        throw new TypeError(
          `Expected a function for addEventListener, received ${typeof callback}`,
        );
      }

      if (!handlers.has(event)) {
        handlers.set(event, new Set());
      }

      handlers.get(event).add(callback);
    },

    removeEventListener(event, callback) {
      const fns = handlers.get(event);
      if (fns) {
        fns.delete(callback);
        if (fns.size === 0) {
          handlers.delete(event);
        }
      }
    },

    removeAllListeners() {
      handlers.clear();
    },

    bootstrap(target) {
      if (!target) return;
      target.dispatch = emitter.dispatch;
      target.addEventListener = emitter.addEventListener;
      target.removeEventListener = emitter.removeEventListener;
      target.removeAllListeners = emitter.removeAllListeners;
    },
  };

  return emitter;
}