"use strict";
define(["md5", "uuid", "common/ErrorUtilities"], (
md5,
uuid,
ErrorUtilities,
) => {
const KIBIBYTE = 1024;
const MEBIBYTE = KIBIBYTE * 1024;
const GIBIBYTE = MEBIBYTE * 1024;
const TEBIBYTE = GIBIBYTE * 1024;
const CHECKSUM_CHUNK_SIZE = 2 ** 20;
/**
* Generic helpers for normalizing, comparing, and serializing values.
* @namespace ValueUtilities
* @classcategory Common
* @since 2.39.0
*/
const ValueUtilities = {
/**
* Return the first value that is not `undefined`.
* @param {...*} values Candidate values.
* @returns {*} First defined value, or `undefined`.
*/
firstDefined(...values) {
for (let i = 0; i < values.length; i += 1) {
if (values[i] !== undefined) return values[i];
}
return undefined;
},
/**
* Collapse `undefined`, `null`, and the empty string to `null`.
* @param {*} value Value to normalize.
* @returns {*|null} `null` for empty input, otherwise the original value.
*/
nullIfEmpty(value) {
return value === undefined || value === null || value === ""
? null
: value;
},
/**
* Normalize a value to trimmed text.
* @param {*} value Value to normalize.
* @returns {string|null} Trimmed string or `null` for nullish values.
*/
normalizeText(value) {
if (value === undefined || value === null) return null;
return String(value).trim();
},
/**
* Normalize boolean-like values while preserving invalid non-empty input.
* @param {*} value Value to normalize.
* @returns {boolean|null|*} Boolean, `null`, or original invalid value.
*/
normalizeBoolean(value) {
if (ValueUtilities.nullIfEmpty(value) === null) return null;
if (typeof value === "boolean") return value;
const normalized = ValueUtilities.normalizeText(value)?.toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0") return false;
return value;
},
/**
* Normalize known string choices while preserving unknown non-empty input.
* @param {*} value Value to normalize.
* @param {Object<string, string>} canonicalByLowercase Canonical lookup
* keyed by lowercase text.
* @returns {string|null} Canonical known value, normalized unknown value,
* or `null` for nullish input.
*/
normalizeStringChoice(value, canonicalByLowercase = {}) {
const normalized = ValueUtilities.normalizeText(value);
if (normalized === null) return null;
return canonicalByLowercase[normalized.toLowerCase()] || normalized;
},
/**
* Serialize a boolean to canonical XML text.
* @param {*} value Value to serialize.
* @returns {string|null} `"true"`, `"false"`, or `null` when invalid.
*/
serializeBoolean(value) {
if (value === null || value === undefined) return null;
const normalized = ValueUtilities.normalizeBoolean(value);
if (normalized === true) return "true";
if (normalized === false) return "false";
return String(value);
},
/**
* Serialize an integer-like value to XML text.
* @param {*} value Value to serialize.
* @returns {string|null} Integer text, or `null` when invalid.
*/
serializeInteger(value) {
const normalized = ValueUtilities.normalizeInteger(value);
if (Number.isInteger(normalized)) return String(normalized);
if (value === null || value === undefined) return null;
return String(value);
},
/**
* Normalize any value into an array of trimmed strings.
* @param {*} value Value or array of values.
* @returns {Array<string|null>} Normalized string array.
*/
normalizeStringArray(value) {
const list = ValueUtilities.listify(value);
return list
.map((entry) => ValueUtilities.normalizeText(entry))
.filter((entry) => entry !== null);
},
/**
* Normalize, drop empty entries, and deduplicate string-like values while
* accepting either a single value or an array of values.
* @param {*} value Value or array of values to normalize and dedupe.
* @returns {string[]} Deduplicated normalized strings.
*/
normalizeStringList(value) {
return ValueUtilities.dedupeStrings(ValueUtilities.listify(value));
},
/**
* Normalize, drop empty entries, and deduplicate string-like values while
* preserving first-seen order.
* @param {Array<*>} values Values to normalize and dedupe.
* @returns {string[]} Deduplicated normalized strings.
*/
dedupeStrings(values) {
return ValueUtilities.dedupeBy(
(Array.isArray(values) ? values : [])
.map((value) => ValueUtilities.normalizeText(value))
.filter((value) => value !== null && value !== ""),
(value) => value,
);
},
/**
* Deduplicate array values while preserving first-seen order.
* @param {Array<*>} values Values to dedupe.
* @returns {Array<*>} Deduplicated array.
*/
dedupeArray(values) {
return Array.from(new Set(Array.isArray(values) ? values : []));
},
/**
* Deduplicate values by a derived key while preserving first-seen order.
* @param {Array<*>} values Values to dedupe.
* @param {Function} keyFn Function that returns a unique key per value.
* @returns {Array<*>} Deduplicated values.
*/
dedupeBy(values, keyFn) {
const list = Array.isArray(values) ? values : [];
if (typeof keyFn !== "function") {
return ValueUtilities.dedupeArray(list);
}
const seen = new Set();
const deduped = [];
list.forEach((value) => {
const key = keyFn(value);
if (seen.has(key)) return;
seen.add(key);
deduped.push(value);
});
return deduped;
},
/**
* Append a non-null value to an array stored under a string map key.
* @param {Map<string, Array<*>>} map Target map
* @param {string} key Lookup key
* @param {*} value Value to append
*/
addMapArrayValue(map, key, value) {
if (!ValueUtilities.isNonEmptyString(key) || value == null) return;
if (!map.has(key)) map.set(key, []);
map.get(key).push(value);
},
/**
* Add a non-null value to a set stored under a string map key.
* @param {Map<string, Set<*>>} map Target map
* @param {string} key Lookup key
* @param {*} value Value to add
*/
addMapSetValue(map, key, value) {
if (!ValueUtilities.isNonEmptyString(key) || value == null) return;
if (!map.has(key)) map.set(key, new Set());
map.get(key).add(value);
},
/**
* Return a sorted copy of string-like values.
* @param {Array<*>} values Values to sort as strings.
* @returns {Array<*>} Sorted values.
*/
sortStrings(values) {
return [...(Array.isArray(values) ? values : [])].sort((a, b) =>
String(a).localeCompare(String(b)),
);
},
/**
* Return a sorted copy of values using a derived string key.
* @param {Array<*>} values Values to sort.
* @param {Function} keyFn Function that returns a comparable key.
* @returns {Array<*>} Sorted values.
*/
sortBy(values, keyFn) {
const getKey = typeof keyFn === "function" ? keyFn : (value) => value;
return [...(Array.isArray(values) ? values : [])].sort((a, b) =>
String(getKey(a)).localeCompare(String(getKey(b))),
);
},
/**
* Return a shallow copy of an object with keys sorted alphabetically.
* @param {object} record Source object.
* @returns {object} Object copy with sorted keys.
*/
sortObjectKeys(record) {
const source = ValueUtilities.isPlainObject(record) ? record : {};
return Object.fromEntries(
ValueUtilities.sortStrings(Object.keys(source)).map((key) => [
key,
source[key],
]),
);
},
/**
* Clone an arbitrary value using structuredClone when available, or a
* JSON-based fallback for older browsers (note: the fallback won't handle
* functions, Dates, etc.).
* @param {*} value Value to clone.
* @returns {*} Deeply cloned value.
*/
deepClone(value) {
// First check if structuredClone is available (modern browsers)
if (typeof structuredClone === "function") {
return structuredClone(value);
}
if (value === undefined) {
return undefined;
}
// Fallback to JSON-based cloning for simple objects (note: this won't
// handle functions, Dates, etc.)
return JSON.parse(JSON.stringify(value));
},
/**
* Ensure a value is an array, wrapping it if necessary.
* @param {*} value Value or array of values.
* @returns {Array<*>} Array of values, or empty array for nullish input.
* @example
* ValueUtilities.listify("a"); // ["a"]
* ValueUtilities.listify(["a", "b"]); // ["a", "b"]
* ValueUtilities.listify(null); // []
* ValueUtilities.listify(undefined); // []
*/
listify(value) {
if (value === undefined || value === null) return [];
if (Array.isArray(value)) return [...value];
return [value];
},
/**
* Convert an array of strings into a human-readable list with separators.
* @param {Array<*>} array Values to format as a list.
* @param {object} [options] Formatting options.
* @param {string} [options.separator] Separator between items (default: ",
* ").
* @param {string} [options.finalSeparator] Separator before the last item
* (default: " and ").
* @param {boolean} [options.oxfordComma] Whether to include a separator
* before the final separator in lists of 3 or more items (default: true).
* @param {boolean} [options.quoteStrings] Whether to wrap items in quotes
* @returns {string} Formatted list string.
*/
arrayToString(
array,
{
separator = ",",
finalSeparator = "and",
oxfordComma = true,
quoteStrings = false,
} = {},
) {
const list = ValueUtilities.normalizeStringArray(array);
const displayList = quoteStrings ? list.map((item) => `"${item}"`) : list;
if (displayList.length === 0) return "";
if (displayList.length === 1) return String(displayList[0]);
if (displayList.length === 2) {
return `${displayList[0]} ${finalSeparator} ${displayList[1]}`;
}
const allButLast = displayList.slice(0, -1).join(`${separator} `);
const last = displayList[displayList.length - 1];
const oxford = oxfordComma ? "," : "";
return `${allButLast}${oxford} ${finalSeparator} ${last}`;
},
/**
* Normalize a string-like value and require that it matches one allowed
* choice.
* @param {*} value Candidate value.
* @param {string[]} allowedValues Canonical allowed string values.
* @param {object} [options] Validation options.
* @param {string} [options.fieldName] Field name used in error messages.
* @param {boolean} [options.caseInsensitive] Whether to match choices
* case-insensitively.
* @param {*} [options.fallback] Optional fallback value returned when input
* is empty or invalid, after normalization. Must be one of the allowed
* values when defined.
* @returns {string} Matching canonical allowed value.
* @throws {Error} When the value is missing or not one of the allowed
* choices.
* @example
* ValueUtilities.requireStringChoice(
* " Sources ",
* ["sources", "derivations"],
* { fieldName: "chartType" }
* );
* // "sources"
*/
requireStringChoice(
value,
allowedValues,
{
fieldName = "value",
caseInsensitive = true,
fallback = undefined,
} = {},
) {
const normalizedAllowedValues =
ValueUtilities.normalizeStringArray(allowedValues);
if (!normalizedAllowedValues.length) {
throw new Error(
"ValueUtilities.requireStringChoice: allowedValues must include at least one non-empty string",
);
}
const allowedText = ValueUtilities.arrayToString(
normalizedAllowedValues,
{ finalSeparator: "or", quoteStrings: true },
);
const normalizeChoice = (entry) =>
caseInsensitive ? entry.toLowerCase() : entry;
let normalizedFallback = null;
// Fallback must also be an allowed value when defined
if (fallback !== undefined) {
try {
normalizedFallback = ValueUtilities.requireStringChoice(
fallback,
normalizedAllowedValues,
{ fieldName, caseInsensitive, fallback: undefined },
);
} catch (error) {
throw new Error(`Invalid Fallback: ${error.message}`);
}
}
let normalizedValue;
try {
normalizedValue = ValueUtilities.requireNonEmptyString(value);
} catch (error) {
if (fallback !== undefined) {
normalizedValue = normalizedFallback;
} else {
throw error;
}
}
const comparableValue = normalizeChoice(normalizedValue);
const matchedValue = normalizedAllowedValues.find(
(allowedValue) => normalizeChoice(allowedValue) === comparableValue,
);
if (matchedValue !== undefined) {
return matchedValue;
}
if (normalizedFallback !== null) {
return normalizedFallback;
}
throw new Error(
`"${value}" is not a valid ${fieldName}. Must be ${allowedText}.`,
);
},
/**
* Check whether a value is a plain non-array object.
* @param {*} value Candidate value.
* @returns {boolean} True when the value is a plain object.
*/
isPlainObject(value) {
return (
value !== null && typeof value === "object" && !Array.isArray(value)
);
},
/**
* Check whether an object defines one own property.
* @param {*} value Candidate object.
* @param {string} key Property name to check.
* @returns {boolean} True when the object defines the property directly.
*/
hasOwn(value, key) {
return ValueUtilities.isPlainObject(value)
? Object.prototype.hasOwnProperty.call(value, key)
: false;
},
/**
* Check whether a value is a non-empty string.
* @param {*} value Candidate value.
* @returns {boolean} True when value is a non-empty string.
*/
isNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0;
},
/**
* Check whether an array contains at least one non-empty value.
* @param {*} value Candidate array.
* @returns {boolean} True when the array contains a non-empty value.
*/
isNonEmptyArray(value) {
if (!Array.isArray(value) || value.length === 0) return false;
const nonEmptyItems = value
.map((item) => ValueUtilities.nullIfEmpty(item))
.filter((item) => item !== null);
return nonEmptyItems.length > 0;
},
/**
* Require a non-empty string value and throw when missing.
* @param {*} value Candidate value.
* @param {string} [message] Error message to throw when invalid.
* @returns {string} Original value when valid.
* @throws {Error} When value is not a non-empty string.
*/
requireNonEmptyString(
value,
message = "ValueUtilities: value must be a non-empty string",
) {
const normalized = ValueUtilities.normalizeText(value);
if (!ValueUtilities.isNonEmptyString(value)) {
throw new Error(message);
}
return normalized;
},
/**
* Check whether a value is a non-negative integer (aka unsigned integer).
* @param {*} value Candidate value.
* @returns {boolean} True when value is an unsigned integer.
*/
isNonNegativeInteger(value) {
return Number.isInteger(value) && value >= 0;
},
/**
* Check whether a value is a positive integer (aka natural number).
* @param {*} value Candidate value.
* @returns {boolean} True when value is a natural number.
*/
isPositiveInteger(value) {
return Number.isInteger(value) && value > 0;
},
/**
* Require a non-negative integer and throw when invalid.
* @param {*} value Candidate index value.
* @param {string} [message] Error message to throw when invalid.
* @returns {number} Original integer index when valid.
* @throws {Error} When the value is not a non-negative integer.
*/
requireNonNegativeInteger(
value,
message = "Value must be a non-negative integer index",
) {
if (!ValueUtilities.isNonNegativeInteger(value)) {
throw new Error(message);
}
return value;
},
/**
* Require a positive integer and throw when invalid.
* @param {*} value Candidate index value.
* @param {string} [message] Error message to throw when invalid.
* @returns {number} Original integer index when valid.
* @throws {Error} When the value is not a positive integer.
*/
requirePositiveInteger(
value,
message = "Value must be a positive integer",
) {
if (!ValueUtilities.isPositiveInteger(value)) {
throw new Error(message);
}
return value;
},
/**
* Normalize integer-like values while preserving invalid non-empty input.
* @param {*} value Value to normalize.
* @returns {number|null|*} Integer, `null`, or original invalid value.
*/
normalizeInteger(value) {
if (ValueUtilities.nullIfEmpty(value) === null) return null;
if (typeof value === "number") return value;
const normalized = ValueUtilities.normalizeText(value);
if (/^-?\d+$/.test(normalized)) {
return Number.parseInt(normalized, 10);
}
return value;
},
/**
* Normalize positive integer-like values while falling back when invalid.
* @param {*} value Value to normalize.
* @param {number} fallback Fallback returned for invalid input.
* @returns {number} Positive integer or fallback.
*/
normalizePositiveInteger(value, fallback) {
if (Number.isInteger(value) && value > 0) {
return value;
}
const normalized = ValueUtilities.normalizeInteger(value);
if (Number.isInteger(normalized) && normalized > 0) {
return normalized;
}
return fallback;
},
/**
* Format a number for display based on the magnitude of its range.
* @param {number} value Number to format.
* @param {number} range Data range used to pick precision.
* @returns {string} Formatted number string.
*/
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();
},
/**
* Calculate display precision based on numeric range.
* @param {number} range The range of values.
* @returns {number|null} Decimal places or null for scientific notation.
*/
getNumDecimalPlaces(range) {
if (range < 0.0001 || range > 100000) {
return null;
}
if (range < 0.001) {
return 5;
}
if (range < 0.01) {
return 4;
}
if (range < 0.1) {
return 3;
}
if (range < 1) {
return 2;
}
if (range <= 100) {
return 1;
}
return 0;
},
/**
* Convert bytes to a human-readable size string.
* @param {number} bytes Number of bytes.
* @param {number} [precision] Decimal places to include.
* @returns {string} 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`;
},
/**
* Check if two values are deeply equal.
* @param {*} a First value.
* @param {*} b Second value.
* @returns {boolean} True when values are deeply equal.
*/
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) =>
ValueUtilities.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) && ValueUtilities.deepEqual(a[key], b[key]),
);
}
return false;
},
/**
* Deterministically stringify a value so order does not affect the result.
* @param {*} val Value to stringify.
* @param {object} [options] Stringification options.
* @param {boolean} [options.ignoreCase] Normalize strings to lowercase.
* @param {boolean} [options.orderMatters] Preserve array order.
* @param {WeakSet} [options.processed] Internal set of seen objects.
* @returns {string} Stable string representation.
* @throws {Error} Throws on circular references.
*/
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 ValueUtilities.stableStringify(`${type}:${newString}`, {
ignoreCase,
processed: seen,
});
}
if (seen.has(val)) {
throw new Error("ValueUtilities.stableStringify: circular reference");
}
seen.add(val);
let normalized;
if (val instanceof Map) {
const entries = [];
val.forEach((mapValue, mapKey) => {
entries.push([
ValueUtilities.stableStringify(mapKey, nextOpts),
ValueUtilities.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) =>
ValueUtilities.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) =>
ValueUtilities.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 = ValueUtilities.stableStringify(key, nextOpts);
result[newKey] = ValueUtilities.stableStringify(val[key], nextOpts);
});
normalized = result;
}
seen.delete(val);
return JSON.stringify(normalized);
},
/**
* Decode a URI component safely and fall back to the original value when
* decoding fails.
* @param {*} value Encoded value.
* @returns {string|null} Decoded value, original string, or null.
*/
safeDecodeURIComponent(value) {
if (value === undefined || value === null) return null;
const normalized = String(value);
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
},
/**
* Build a unique key from selected fields in an options object.
* @param {object} options Source object.
* @param {string[]} keys Field names to include.
* @param {object} [normalizers] Optional field normalizers.
* @param {string} [separator] Separator between key parts.
* @param {boolean} [encode] Whether to md5-hash the result.
* @returns {string} Generated key.
* @throws {Error} When keys is not a non-empty array
*/
buildInstanceKey(
options = {},
keys = [],
normalizers = {},
separator = "|",
encode = true,
) {
if (!Array.isArray(keys) || keys.length === 0) {
throw new Error(
"ValueUtilities.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 keyed by normalized options.
* @param {Function} ClassRef Class constructor.
* @param {object} [options] Options passed to the constructor.
* @param {Function} buildInstanceKey Function that builds a unique key.
* @returns {object} Singleton instance.
* @throws {Error} When the class or key builder is invalid, or when the
* class has an invalid instance cache
*/
getSingleton(ClassRef, options, buildInstanceKey) {
if (!ClassRef) {
throw new Error("ValueUtilities.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("ValueUtilities.getSingleton: instances must be a Map");
}
if (typeof buildInstanceKey !== "function") {
throw new Error(
"ValueUtilities.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);
},
/**
* Remove default values from a model's JSON representation.
* @param {Backbone.Model} model Source model.
* @param {string[]} [removeProps] Additional properties to remove.
* @returns {object} JSON with default-valued fields removed.
*/
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 (ValueUtilities.deepEqual(json[key], defaults[key])) {
delete json[key];
}
});
return json;
},
/**
* Convert a wildcard pattern such as `eml*` to a RegExp.
* @param {string} pattern Wildcard pattern.
* @returns {RegExp} Case-insensitive regex.
*/
wildcardToRegex(pattern) {
const escaped = String(pattern).replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&");
const regexString = `^${escaped.replace(/\*/g, ".*")}$`;
return new RegExp(regexString, "i");
},
/**
* Get a value from an object using a case-insensitive key lookup.
* @param {object} obj Source object.
* @param {string} keyName Target key name.
* @param {Function} [normalizeValue] Optional value normalizer.
* @returns {*} Matched value or undefined.
*/
getCaseInsensitive(obj, keyName, normalizeValue) {
if (!obj || !keyName) return undefined;
const target = String(keyName).toLowerCase();
const key = Object.keys(obj).find(
(candidateKey) => String(candidateKey).toLowerCase() === target,
);
if (!key) return undefined;
const value = obj[key];
return normalizeValue ? normalizeValue(value) : value;
},
/**
* Calculate a checksum for a Blob without loading the entire Blob at once.
* @param {Blob} blob Blob to checksum.
* @param {object} [options] Checksum options.
* @param {string} [options.algorithm] Checksum algorithm.
* @param {AbortSignal} [options.signal] Abort signal.
* @param {Function} [options.onProgress] Progress callback.
* @returns {Promise<{value: string, algorithm: string}>} Checksum result.
* @throws {Error} When the input, algorithm, or checksum operation is
* invalid, fails, or is aborted
*/
async calculateBlobChecksum(
blob,
{ algorithm = "MD5", signal, onProgress } = {},
) {
if (!(blob instanceof Blob)) {
throw new Error("ValueUtilities.calculateBlobChecksum requires a Blob");
}
const normalizedAlgorithm = String(algorithm).toUpperCase();
if (normalizedAlgorithm !== "MD5") {
throw new Error(`Unsupported checksum algorithm: ${algorithm}`);
}
ErrorUtilities.throwIfAborted(signal);
const hash = md5.create();
let loaded = 0;
while (loaded < blob.size) {
ErrorUtilities.throwIfAborted(signal);
// Chunks must be read and hashed in byte order.
// eslint-disable-next-line no-await-in-loop
const buffer = await blob
.slice(loaded, loaded + CHECKSUM_CHUNK_SIZE)
.arrayBuffer();
ErrorUtilities.throwIfAborted(signal);
hash.update(buffer);
loaded += buffer.byteLength;
onProgress?.({
loaded,
total: blob.size,
percent: (loaded / blob.size) * 100,
});
}
return {
value: hash.hex(),
algorithm: normalizedAlgorithm,
};
},
/**
* Generate a UUID with an optional prefix.
* @param {object} [options] UUID generation options.
* @param {string} options.prefix Optional prefix for the UUID.
* @param {string} options.scheme URI scheme (default: "urn").
* @param {string} options.nid Namespace identifier (default: "uuid").
* @param {string} options.sep Separator between components (default: ":").
* @returns {string} Generated UUID string.
*/
makeUUID({ prefix = "", scheme = "urn", nid = "uuid", sep = ":" } = {}) {
return `${prefix}${scheme}${sep}${nid}${sep}${uuid.v4()}`;
},
/**
* Generate a random hexadecimal ID.
* @param {number} [digits] Number of hexadecimal digits (default: 12)
* @returns {string} The generated ID
* @since 2.41.0
*/
makeShortId(digits = 12) {
return Array.from({ length: digits }, () =>
Math.floor(Math.random() * 16).toString(16),
).join("");
},
/**
* Extract the file extension from a filename, returning it in lowercase.
* @param {string} filename Filename string.
* @returns {string} File extension in lowercase, or empty string if none
* found.
*/
extractFileExtension(filename) {
if (!filename) return "";
const lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex === -1) return "";
return filename.substring(lastDotIndex + 1).toLowerCase();
},
/**
* Check if a value matches a wildcard pattern.
* @param {string} value Value to check. If not a string, returns false.
* @param {string} pattern Wildcard pattern that includes `*` as a wildcard
* character.
* @returns {boolean} True if the value matches the pattern, false
* otherwise.
*/
matchWildcard(value, pattern) {
if (typeof value !== "string" || typeof pattern !== "string") {
return false;
}
const regex = ValueUtilities.wildcardToRegex(pattern);
return regex.test(value);
},
};
return ValueUtilities;
});