"use strict";
define(["collections/ObjectFormats", "common/ValueUtilities", "md5"], (
ObjectFormats,
ValueUtilities,
md5,
) => {
const DEFAULT_MAX_CONCURRENT = 4;
/**
* @namespace Utilities
* @description Miscellaneous app/browser helpers that do not yet fit better
* specialized common modules.
* @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, "<")
.replace(/>/g, ">")
.replace(/'/g, "'")
.replace(/"/g, """);
},
DEFAULT_MAX_CONCURRENT,
/**
* Run work with one concurrency limit and no batch abstraction.
* @param {Array<*>} items Items to process.
* @param {Function} worker Async item worker.
* @param {object} [options] Processing options.
* @param {number} [options.maxConcurrent] Worker limit.
* @param {AbortSignal} [options.signal] Abort signal.
* @param {boolean} [options.stopOnError] Stop scheduling after an error.
* @param {Function} [options.onItemComplete] Called after each item
* settles.
* @returns {Promise<object>} Collected errors.
* @since 2.39.0
*/
async processConcurrently(
items,
worker,
{
maxConcurrent = DEFAULT_MAX_CONCURRENT,
signal,
stopOnError = true,
onItemComplete,
} = {},
) {
let nextIndex = 0;
let stopScheduling = false;
const errors = [];
const runWorker = async () => {
while (
!signal?.aborted &&
!stopScheduling &&
nextIndex < items.length
) {
const index = nextIndex;
nextIndex += 1;
try {
// Workers in each slot run serially to enforce maxConcurrent.
// eslint-disable-next-line no-await-in-loop
await worker(items[index], index);
} catch (error) {
errors.push({ item: items[index], error, index });
if (stopOnError) stopScheduling = true;
} finally {
if (typeof onItemComplete === "function") {
onItemComplete(items[index], index);
}
}
}
};
await Promise.all(
Array.from({ length: Math.min(maxConcurrent, items.length) }, () =>
runWorker(),
),
);
errors.sort((a, b) => a.index - b.index);
return { errors };
},
/**
* Resolve a positive concurrency limit from a caller value or the project
* default.
* @param {number} [maxConcurrent] Preferred limit
* @returns {number} Positive integer limit.
* @since 2.39.0
*/
getMaxConcurrent(maxConcurrent) {
return ValueUtilities.normalizePositiveInteger(
maxConcurrent,
DEFAULT_MAX_CONCURRENT,
);
},
/**
* 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) {
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:
* - Commas inside quoted headers
* @param {string} text - A chunk of a file
* @returns {Array} A list of names
* @since 2.15.0
*/
tryParseCSVHeader(text) {
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(",");
names = names.map((name) => name.replaceAll(/^["']|["']$/gm, ""));
names = names.filter((name) => name.length > 0);
return names;
},
/**
* Format the number into a string with better readability, based on the
* magnitude 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 = ValueUtilities.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;
},
/**
* 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)) {
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.
* @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.
* @param {WeakSet} [options.processed] WeakSet tracking visited objects.
* @param {boolean} [options.orderMatters] Whether to preserve array order
* during stringification.
* @returns {string} A stable string representation.
* @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 };
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();
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,
});
}
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 {
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 "";
return urlString.replace(/\/+(?=($|[?#]))/, "");
},
/**
* Build a unique string to represent a class instance or options set.
* @param {object} options The object containing key values.
* @param {string[]} keys Field names required to build the key.
* @param {object} [normalizers] Optional field normalizers.
* @param {string} [separator] Separator to use between key parts.
* @param {boolean} [encode] Whether to md5-encode the key.
* @returns {string} The generated instance key.
* @throws {Error} If keys is not a non-empty array.
* @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.
* @param {Function} buildInstanceKey Function that builds a unique 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);
},
/**
* Read a MetacatUI property directly, via Backbone's `get`, or from a
* nested appModel.
* @param {string} property Property name to retrieve.
* @param {object} [app] MetacatUI object.
* @returns {*} Property value, or `undefined` when not present.
* @since 2.39.0
*/
getMetacatUIProperty(property, app) {
const normalizedApp = app || globalThis.MetacatUI?.appModel;
if (!normalizedApp || !property) return undefined;
if (normalizedApp[property] !== undefined) {
return normalizedApp[property];
}
if (typeof normalizedApp.get === "function") {
const value = normalizedApp.get(property);
if (value !== undefined) return value;
}
return normalizedApp.appModel
? Utilities.getMetacatUIProperty(property, normalizedApp.appModel)
: undefined;
},
/**
* Wait for the global MetacatUI object to be available, and optionally for
* a specific property on it to be defined.
* @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.
* @param {string} [options.appName] Optional MetacatUI property containing
* the object to wait for.
* @returns {Promise<*>} Promise resolving to the requested global object or
* property value.
* @throws {Error} If the requested value is not available in time.
* @since 2.39.0
*/
async awaitMetacatUI({
maxAttempts = 20,
delay = 200,
property = "",
appName = "",
} = {}) {
let attempts = 0;
while (attempts < maxAttempts) {
attempts += 1;
const app = appName
? globalThis.MetacatUI?.[appName]
: globalThis.MetacatUI;
if (app != null) {
if (!property) {
return app;
}
const value = Utilities.getMetacatUIProperty(property, app);
if (value !== undefined) {
return value;
}
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
}
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] - Additional properties to remove
* @returns {object} The JSON representation 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 a wildcard pattern to a safe RegExp.
* @param {string} pattern - A simple wildcard pattern
* @returns {RegExp} Regex for case-insensitive matching
*/
wildcardToRegex(pattern) {
const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&");
const regexString = `^${escaped.replace(/\*/g, ".*")}$`;
return new RegExp(regexString, "i");
},
/**
* Get the list of object formats from the Coordinating Node.
* @returns {Promise<ObjectFormats>} Promise resolving to the object
* formats collection.
* @since 2.39.0
*/
async awaitObjectFormats() {
const app = await Utilities.awaitMetacatUI();
if (!app.objectFormats) {
app.objectFormats = new ObjectFormats(undefined, {
formatsServiceUrl: Utilities.getMetacatUIProperty(
"formatsServiceUrl",
app,
),
});
}
const formats = app.objectFormats;
if (formats.hasRemoteFormats) return formats;
await formats.fetchPromise();
return formats;
},
};
return Utilities;
});