Source: src/js/common/Utilities.js

"use strict";

define(["md5"], (md5) => {
  const KIBIBYTE = 1024;
  const MEBIBYTE = KIBIBYTE * 1024;
  const GIBIBYTE = MEBIBYTE * 1024;
  const TEBIBYTE = GIBIBYTE * 1024;

  /**
   * @namespace Utilities
   * @description A generic utility object that contains functions used throughout MetacatUI to perform useful functions,
   * but not used to store or manipulate any state about the application.
   * @type {object}
   * @since 2.14.0
   */
  const Utilities = /** @lends Utilities.prototype */ {
    /**
     * HTML-encodes the given string so it can be inserted into an HTML page without running
     * any embedded Javascript.
     * @param {string} s String to be encoded.
     * @returns {string} HTML encoded string.
     */
    encodeHTML(s) {
      if (!s || typeof s !== "string") {
        return "";
      }

      return s
        .replace(/&/g, "&")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/'/g, "&apos;")
        .replace(/\//g, "/")
        .replace(/"/g, "&quot;");
    },

    /**
     * Validates that the given string is a valid DOI
     * @param {string} identifier String to be validated.
     * @returns {boolean} True if identifier is a valid DOI.
     * @since 2.15.0
     */
    isValidDOI(identifier) {
      // generate doi regex
      const doiRGEX =
        /^\s*(http:\/\/|https:\/\/)?(doi.org\/|dx.doi.org\/)?(doi: ?|DOI: ?)?(10\.\d{4,}(\.\d)*)\/(\w+).*$/gi;

      return doiRGEX.test(identifier);
    },

    /**
     * Read the first part of a file
     * @param {File} file - A reference to a file
     * @param {Backbone.View} context - The View to bind `callback` to
     * @param {Function} callback - A function to run after the read is
     *   complete. The function is bound to `context`.
     * @param {number} bytes - The number of bytes to read from the start of the
     *   file
     * @since 2.15.0
     */
    readSlice(file, context, callback, bytes = 1024) {
      if (typeof callback !== "function") {
        return;
      }

      const reader = new FileReader();
      const blob = file.slice(0, bytes);

      reader.onloadend = callback.bind(context);
      reader.readAsBinaryString(blob);
    },
    /**
     * Attempt to parse the header/column names from a chunk of a CSV file
     *
     * Doesn't handle:
     * - UTF BOM (garbles first col name)
     * - Commas inside quoted headers
     * @param {string} text - A chunk of a file
     * @returns {Array} A list of names
     * @since 2.15.0
     */
    tryParseCSVHeader(text) {
      // The order is important here
      const strategies = ["\r\\n", "\r", "\n"];

      let index = -1;

      for (let i = 1; i < strategies.length; i += 1) {
        const result = text.indexOf(strategies[i]);

        if (result >= 0) {
          index = result;

          break;
        }
      }

      if (index === -1) {
        return [];
      }

      const headerLine = text.slice(0, index);
      let names = headerLine.split(",");

      // Remove surrounding parens and double-quotes
      names = names.map((name) => name.replaceAll(/^["']|["']$/gm, ""));

      // Filter out zero-length values (headers like a,b,c,,,,,)
      names = names.filter((name) => name.length > 0);

      return names;
    },

    /**
     * Format the number into a string with better readability, based on the manitude of a
     * range this number falls in.
     * @param {number} value The number value to be formatted.
     * @param {number} range The range of numerics this value can fall in.
     * @returns {string} A formatted number based on the magnitude of `range`.
     * @since 2.30.0
     */
    formatNumber(value, range) {
      if (typeof value !== "number") {
        return "";
      }
      if (typeof range !== "number") {
        return value.toString();
      }

      const numDecimalPlaces = Utilities.getNumDecimalPlaces(range);
      if (numDecimalPlaces !== null) {
        return value.toFixed(numDecimalPlaces);
      }
      return value.toExponential(2).toString();
    },

    /**
     * Format a finite number using a fixed number of decimal places.
     * @param {number} value The number value to be formatted.
     * @param {number} [digits=2] The number of decimal places to display.
     * @param {string} [fallback=""] The value to return when `value` is not finite.
     * @returns {string} A fixed-decimal number string or the fallback value.
     * @since 2.37.0
     */
    formatFixedNumber(value, digits = 2, fallback = "") {
      return Number.isFinite(value) ? value.toFixed(digits) : fallback;
    },

    /**
     * Calculate the number of decimal places we should use based on the range of the data.
     * @param {number} range The range of data values.
     * @returns {number} The number of decimal places we should use.
     * @since 2.30.0
     */
    getNumDecimalPlaces(range) {
      if (range < 0.0001 || range > 100000) {
        return null; // Will use scientific notation
      }
      if (range < 0.001) {
        return 5; // Allow 5 decimal places
      }
      if (range < 0.01) {
        return 4; // Allow 4 decimal places
      }
      if (range < 0.1) {
        return 3; // Allow 3 decimal places
      }
      if (range < 1) {
        return 2; // Allow 2 decimal places
      }
      if (range <= 100) {
        return 1; // Allow 1 decimal places
      }
      return 0; // No decimal places
    },

    /**
     * Checks if two objects are deeply equal. Simpler than the _.isEqual function.
     * @param {object} a - The first object to compare
     * @param {object} b - The second object to compare
     * @returns {boolean} True if the objects are deeply equal
     * @since 2.31.0
     */
    deepEqual(a, b) {
      if (a === b) return true;

      if (Array.isArray(a) && Array.isArray(b)) {
        // Quick check for empty arrays
        if (a.length === 0 && b.length === 0) return true;
        if (a.length !== b.length) return false;
        return a.every((value, index) => this.deepEqual(value, b[index]));
      }

      if (
        typeof a === "object" &&
        a !== null &&
        typeof b === "object" &&
        b !== null
      ) {
        const keysA = Object.keys(a);
        const keysB = Object.keys(b);

        if (keysA.length !== keysB.length) return false;

        return keysA.every(
          (key) => keysB.includes(key) && this.deepEqual(a[key], b[key]),
        );
      }

      return false;
    },

    /**
     * Stringify any value deterministically, where order doesn't impact the
     * result. All object keys and array items are sorted in a consistent way.
     * Normalizes undefined to null. Useful for creating unique keys based on
     * the content of an object, e.g. for keying a cache or singleton instance.
     * @param {*} val The value to stringify.
     * @param {object} [options] Options object.
     * @param {boolean} [options.ignoreCase] Whether to convert all string
     * values to lower case before stringifying, for case-insensitive
     * comparisons. Default is true. Note when ignoreCase is true, normalized
     * object keys can collide (e.g., "A" and "a").
     * @param {WeakSet} [options.processed] WeakSet tracking visited objects.
     * @param {boolean} [options.orderMatters] Whether to preserve array order
     * during stringification. Default is false (arrays are sorted).
     * @returns {string} A string that is consistent regardless of the order of
     * keys in objects or items in arrays, etc.
     * @throws {Error} If a circular reference is detected.
     * @since 2.37.0
     */
    stableStringify(
      val,
      options = {
        ignoreCase: true,
        orderMatters: false,
        processed: new WeakSet(),
      },
    ) {
      const { ignoreCase = true, orderMatters = false, processed } = options;
      const seen = processed instanceof WeakSet ? processed : new WeakSet();
      const nextOpts = { ignoreCase, orderMatters, processed: seen };
      // Object.prototype.toString (-> e.g., "[object Date]") is more reliable
      // than typeof or val.constructor.name, e.g. when minified
      const rawTag = Object.prototype.toString.call(val);
      const type = rawTag.slice(8, -1).toLowerCase();
      let newString;

      if (val === undefined || val === null) {
        return "null";
      }
      if (type === "string") {
        const trimmed = val.trim();
        return ignoreCase ? trimmed.toLowerCase() : trimmed;
      }

      switch (type) {
        case "number":
        case "boolean":
        case "bigint":
          newString = String(val);
          break;
        case "function":
        case "generatorfunction":
        case "asyncfunction":
          newString = `${md5(Function.prototype.toString.call(val))}`;
          break;
        case "symbol": {
          const key = Symbol.keyFor(val);
          const desc = val.description || "";
          newString = key ? `global:${key}` : `local:${desc}`;
          break;
        }
        case "date":
          newString = val.toISOString();
          break;
        case "regexp":
        case "url":
          newString = val.toString();
          break;
        case "error":
          newString = val.toString();
          // Errors get prefixed with "Error:" in toString
          newString = newString.toLowerCase().startsWith("error:")
            ? newString.slice(6).trim()
            : newString;
          break;
        default:
          if (typeof val !== "object") {
            newString =
              typeof val.toString === "function" ? val.toString() : String(val);
          }
      }

      if (newString && newString !== "[object Object]") {
        return Utilities.stableStringify(`${type}:${newString}`, {
          ignoreCase,
          processed: seen,
        });
      }

      // For objects, we need to recursively normalize properties/items. We also
      // need to track seen objects to detect circular references and avoid
      // infinite recursion.
      if (seen.has(val)) {
        throw new Error("Utilities.stableStringify: circular reference");
      }

      seen.add(val);

      let normalized;
      if (val instanceof Map) {
        const entries = [];
        val.forEach((mapValue, mapKey) => {
          entries.push([
            Utilities.stableStringify(mapKey, nextOpts),
            Utilities.stableStringify(mapValue, nextOpts),
          ]);
        });
        entries.sort((a, b) =>
          JSON.stringify(a).localeCompare(JSON.stringify(b)),
        );
        normalized = { type: "Map", entries };
      } else if (val instanceof Set) {
        const values = Array.from(val, (item) =>
          Utilities.stableStringify(item, nextOpts),
        );
        values.sort((a, b) =>
          JSON.stringify(a).localeCompare(JSON.stringify(b)),
        );
        normalized = { type: "Set", values };
      } else if (Array.isArray(val)) {
        const normalizedItems = val.map((item) =>
          Utilities.stableStringify(item, nextOpts),
        );
        if (!orderMatters) {
          normalizedItems.sort((a, b) =>
            JSON.stringify(a).localeCompare(JSON.stringify(b)),
          );
        }
        normalized = normalizedItems;
      } else {
        // Plain object
        const keys = Object.keys(val).sort();
        const result = {};
        keys.forEach((key) => {
          const newKey = Utilities.stableStringify(key, nextOpts);
          result[newKey] = Utilities.stableStringify(val[key], nextOpts);
        });
        normalized = result;
      }

      seen.delete(val);
      return JSON.stringify(normalized);
    },

    /**
     * Normalize a URL string by trimming whitespace and removing trailing slashes.
     * @param {string} url The URL to normalize.
     * @param {string} [fallback] A fallback URL to use if url is empty.
     * @returns {string} Normalized URL, or empty string if not available.
     * @since 2.37.0
     */
    normalizeUrl(url, fallback = "") {
      let resolved = url;
      if (typeof resolved === "string" && !resolved.trim()) {
        resolved = "";
      }
      if (!resolved) {
        resolved = fallback;
      }
      if (!resolved) return "";
      let urlString =
        typeof resolved === "string" ? resolved : String(resolved);
      urlString = urlString.trim();
      if (!urlString) return "";
      // Remove trailing slashes (including before query/hash)
      return urlString.replace(/\/+(?=($|[?#]))/, "");
    },

    /**
     * Build a unique string to represent an instance of a class, or generally,
     * to uniquely identify a set of options or properties. The key is built
     * from an object (e.g. config options for a class instance) by
     * concatenating the values of specified fields. Only includes fields that
     * have non-null/undefined values.
     * @param {object} options The object containing the keys and values to
     * build the key from.
     * @param {string[]} keys The names of fields required to build the instance
     * key. If a value for the key is available, it will be included. Otherwise
     * it will be skipped.
     * @param {object} [normalizers] Optional object mapping field names to
     * normalizer functions that take the raw value and return a normalized
     * value to use for the key, for example, to normalize URLs or make strings
     * case-insensitive.
     * @param {string} [separator] Separator to use between key parts. Default
     * is "|".
     * @param {boolean} [encode] If true, the resulting key will be run through
     * md5 to shorten it and ensure it's a valid string for use as a key.
     * Default is true.
     * @returns {string} The generated instance key, which is a concatenation of
     * the specified fields and their values, with non-string values converted
     * to strings. If encode is true, this will be an md5 hash of the
     * concatenated string.
     * @throws {Error} If keys is not a non-empty array, or if buildInstanceKey
     * is not a function.
     * @since 2.37.0
     */
    buildInstanceKey(
      options = {},
      keys = [],
      normalizers = {},
      separator = "|",
      encode = true,
    ) {
      if (!Array.isArray(keys) || keys.length === 0) {
        throw new Error(
          "Utilities.buildInstanceKey: keys must be a non-empty array",
        );
      }
      const parts = keys.map((field) => {
        const normalizer = normalizers[field];
        const raw = options?.[field];
        const value =
          typeof normalizer === "function" ? normalizer(raw, options) : raw;
        let normalizedValue = value;
        if (typeof normalizedValue === "string") {
          normalizedValue = normalizedValue.trim();
        }
        if (normalizedValue !== null && normalizedValue !== undefined) {
          return `${field}:${String(normalizedValue)}`;
        }
        return null;
      });
      const longKey = parts.filter(Boolean).join(separator);
      if (encode) return md5(longKey);
      return longKey;
    },

    /**
     * Get or create a singleton instance for a class.
     * @param {Function} ClassRef Class reference.
     * @param {object} [options] Options passed to the class constructor for
     * this instance.
     * @param {Function} buildInstanceKey Function that builds a unique key for
     * the instance based on the options. This is used to allow multiple
     * singletons for the same class with different options. The function will
     * be passed the options object and should return a string key.
     * @returns {object} The singleton instance.
     * @since 2.37.0
     */
    getSingleton(ClassRef, options, buildInstanceKey) {
      if (!ClassRef) {
        throw new Error("Utilities.getSingleton: ClassRef is required");
      }
      // eslint-disable-next-line no-param-reassign
      if (!ClassRef.instances) ClassRef.instances = new Map();
      if (!(ClassRef.instances instanceof Map)) {
        throw new Error("Utilities.getSingleton: instances must be a Map");
      }
      if (typeof buildInstanceKey !== "function") {
        throw new Error(
          "Utilities.getSingleton: buildInstanceKey must be a function",
        );
      }
      const key = buildInstanceKey(options);
      if (!ClassRef.instances.has(key)) {
        const instance = new ClassRef(options);
        instance.singletonInstanceKey = key;
        ClassRef.instances.set(key, instance);
      }
      return ClassRef.instances.get(key);
    },

    /**
     * Wait for the global MetacatUI object to be available, and optionally for
     * a specific property on it to be defined. Useful when needing to access
     * the app user model or other properties that may not be available
     * immediately when a module is loaded.
     * @param {object} [options] Options object.
     * @param {number} [options.maxAttempts] Maximum number of attempts.
     * @param {number} [options.delay] Delay between attempts in ms.
     * @param {string} [options.property] Optional property name to wait for on
     * the MetacatUI object. If provided, the Promise won't resolve until that
     * property is available and not undefined. Otherwise, just waits for the
     * global MetacatUI object itself.
     * @returns {Promise<object>} Promise resolving to the app user model.
     * @throws {Error} If the user model is not available in time.
     * @since 2.37.0
     */
    async awaitMetacatUI({
      maxAttempts = 20,
      delay = 200,
      property = "",
    } = {}) {
      let attempts = 0;
      while (attempts < maxAttempts) {
        attempts += 1;
        if (typeof MetacatUI !== "undefined" && MetacatUI !== null) {
          // If we're just waiting for the global object, return it now
          if (!property) {
            return MetacatUI;
          }
          const value =
            MetacatUI[property] || (MetacatUI.get && MetacatUI.get(property));
          // If we're waiting for a specific property, check if it's defined
          // yet and if not, continue waiting
          if (value !== "undefined") {
            return value;
          }
        }
        // Otherwise, wait the attempt delay and try again.
        // eslint-disable-next-line no-await-in-loop
        await new Promise((resolve) => {
          setTimeout(resolve, delay);
        });
      }

      // If we reach here, we failed to get the appUserModel
      let message = "Unable to retrieve MetacatUI";
      if (property) {
        message += `.${property}`;
      }
      throw new Error(message);
    },

    /**
     * Removes default values from a model's JSON representation
     * @param {Backbone.Model} model - The model to remove defaults from
     * @param {string[]} [removeProps] - An array of additional properties to remove from the model
     * @returns {object} The JSON representation of the model with defaults removed
     * @since 2.31.0
     */
    toJSONWithoutDefaults(model, removeProps = []) {
      const json = model.toJSON();
      const defaults = model.defaults();

      Object.keys(defaults).forEach((key) => {
        if (removeProps.includes(key)) {
          delete json[key];
        } else if (this.deepEqual(json[key], defaults[key])) {
          delete json[key];
        }
      });

      return json;
    },

    /**
     * Convert number of bytes into human readable format
     * @param {number} bytes - The number of bytes
     * @param {number} [precision] - The number of decimal places to include
     * @returns {string} The formatted size string
     */
    bytesToSize(bytes, precision = 0) {
      if (typeof bytes === "undefined") return `0 B`;

      if (bytes >= 0 && bytes < KIBIBYTE) {
        return `${bytes} B`;
      }
      if (bytes >= KIBIBYTE && bytes < MEBIBYTE) {
        return `${(bytes / KIBIBYTE).toFixed(precision)} KiB`;
      }
      if (bytes >= MEBIBYTE && bytes < GIBIBYTE) {
        return `${(bytes / MEBIBYTE).toFixed(precision)} MiB`;
      }
      if (bytes >= GIBIBYTE && bytes < TEBIBYTE) {
        return `${(bytes / GIBIBYTE).toFixed(precision)} GiB`;
      }
      if (bytes >= TEBIBYTE) {
        return `${(bytes / TEBIBYTE).toFixed(precision)} TiB`;
      }
      return `${bytes} B`;
    },

    /**
     * Convert a wildcard pattern (e.g. "eml*", "*iso*") to a safe RegExp.
     * Escapes all regex special chars except '*' which becomes '.*'
     * @param {string} pattern - A simple wildcard pattern
     * @returns {RegExp} Regex for case-insensitive matching
     */
    wildcardToRegex(pattern) {
      // Escape regex special characters, except "*"
      const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&");
      // Replace "*" with ".*" for multi-character wildcard
      const regexString = `^${escaped.replace(/\*/g, ".*")}$`;
      return new RegExp(regexString, "i"); // "i" = case-insensitive, if desired
    },

    /**
     * Get a value from a plain object using a case-insensitive key.
     * @param {object} obj Source object
     * @param {string} keyName Key name to look up (case-insensitive)
     * @param {Function} [normalizeValue] Optional value normalizer
     * @returns {*} The matched value, or undefined if not found
     * @since 2.37.0
     */
    getCaseInsensitive(obj, keyName, normalizeValue) {
      if (!obj || !keyName) return undefined;

      const target = String(keyName).toLowerCase();
      const key = Object.keys(obj).find(
        (k) => String(k).toLowerCase() === target,
      );

      if (!key) return undefined;

      const value = obj[key];
      return normalizeValue ? normalizeValue(value) : value;
    },
  };

  return Utilities;
});