1020 lines
38 KiB
JavaScript
1020 lines
38 KiB
JavaScript
import Color from "./color.mjs";
|
|
import {COMPENDIUM_DOCUMENT_TYPES} from "../constants.mjs";
|
|
|
|
/** @module helpers */
|
|
|
|
/**
|
|
* Benchmark the performance of a function, calling it a requested number of iterations.
|
|
* @param {Function} func The function to benchmark
|
|
* @param {number} iterations The number of iterations to test
|
|
* @param {...any} args Additional arguments passed to the benchmarked function
|
|
*/
|
|
export async function benchmark(func, iterations, ...args) {
|
|
const start = performance.now();
|
|
for ( let i=0; i<iterations; i++ ) {
|
|
await func(...args, i);
|
|
}
|
|
const end = performance.now();
|
|
const t = Math.round((end - start) * 100) / 100;
|
|
const name = func.name ?? "Evaluated Function"
|
|
console.log(`${name} | ${iterations} iterations | ${t}ms | ${t / iterations}ms per`);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* A debugging function to test latency or timeouts by forcibly locking the thread for an amount of time.
|
|
* @param {number} ms A number of milliseconds to lock
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function threadLock(ms, debug=false) {
|
|
const t0 = performance.now();
|
|
let d = 0;
|
|
while ( d < ms ) {
|
|
d = performance.now() - t0;
|
|
if ( debug && (d % 1000 === 0) ) {
|
|
console.debug(`Thread lock for ${d / 1000} of ${ms / 1000} seconds`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Wrap a callback in a debounced timeout.
|
|
* Delay execution of the callback function until the function has not been called for delay milliseconds
|
|
* @param {Function} callback A function to execute once the debounced threshold has been passed
|
|
* @param {number} delay An amount of time in milliseconds to delay
|
|
* @return {Function} A wrapped function which can be called to debounce execution
|
|
*/
|
|
export function debounce(callback, delay) {
|
|
let timeoutId;
|
|
return function(...args) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = setTimeout(() => {
|
|
callback.apply(this, args)
|
|
}, delay);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Wrap a callback in a throttled timeout.
|
|
* Delay execution of the callback function when the last time the function was called was delay milliseconds ago
|
|
* @param {Function} callback A function to execute once the throttled threshold has been passed
|
|
* @param {number} delay A maximum amount of time in milliseconds between to execution
|
|
* @return {Function} A wrapped function which can be called to throttle execution
|
|
*/
|
|
export function throttle(callback, delay) {
|
|
let pending;
|
|
let lastTime = -delay;
|
|
return function(...args) {
|
|
if ( pending ) {
|
|
pending.thisArg = this;
|
|
pending.args = args;
|
|
return;
|
|
}
|
|
pending = {thisArg: this, args};
|
|
setTimeout(() => {
|
|
const {thisArg, args} = pending;
|
|
pending = null;
|
|
callback.apply(thisArg, args);
|
|
lastTime = performance.now();
|
|
}, Math.max(delay - (performance.now() - lastTime), 0));
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* A utility function to reload the page with a debounce.
|
|
* @callback debouncedReload
|
|
*/
|
|
export const debouncedReload = debounce( () => window.location.reload(), 250);
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Quickly clone a simple piece of data, returning a copy which can be mutated safely.
|
|
* This method DOES support recursive data structures containing inner objects or arrays.
|
|
* This method DOES NOT support advanced object types like Set, Map, or other specialized classes.
|
|
* @param {*} original Some sort of data
|
|
* @param {object} [options] Options to configure the behaviour of deepClone
|
|
* @param {boolean} [options.strict=false] Throw an Error if deepClone is unable to clone something instead of
|
|
* returning the original
|
|
* @param {number} [options._d] An internal depth tracker
|
|
* @return {*} The clone of that data
|
|
*/
|
|
export function deepClone(original, {strict=false, _d=0}={}) {
|
|
if ( _d > 100 ) {
|
|
throw new Error("Maximum depth exceeded. Be sure your object does not contain cyclical data structures.");
|
|
}
|
|
_d++;
|
|
|
|
// Simple types
|
|
if ( (typeof original !== "object") || (original === null) ) return original;
|
|
|
|
// Arrays
|
|
if ( original instanceof Array ) return original.map(o => deepClone(o, {strict, _d}));
|
|
|
|
// Dates
|
|
if ( original instanceof Date ) return new Date(original);
|
|
|
|
// Unsupported advanced objects
|
|
if ( original.constructor && (original.constructor !== Object) ) {
|
|
if ( strict ) throw new Error("deepClone cannot clone advanced objects");
|
|
return original;
|
|
}
|
|
|
|
// Other objects
|
|
const clone = {};
|
|
for ( let k of Object.keys(original) ) {
|
|
clone[k] = deepClone(original[k], {strict, _d});
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Deeply difference an object against some other, returning the update keys and values.
|
|
* @param {object} original An object comparing data against which to compare
|
|
* @param {object} other An object containing potentially different data
|
|
* @param {object} [options={}] Additional options which configure the diff operation
|
|
* @param {boolean} [options.inner=false] Only recognize differences in other for keys which also exist in original
|
|
* @param {boolean} [options.deletionKeys=false] Apply special logic to deletion keys. They will only be kept if the
|
|
* original object has a corresponding key that could be deleted.
|
|
* @param {number} [options._d] An internal depth tracker
|
|
* @return {object} An object of the data in other which differs from that in original
|
|
*/
|
|
export function diffObject(original, other, {inner=false, deletionKeys=false, _d=0}={}) {
|
|
if ( _d > 100 ) {
|
|
throw new Error("Maximum depth exceeded. Be careful that your object does not contain a cyclical data structure.")
|
|
}
|
|
_d++;
|
|
|
|
function _difference(v0, v1) {
|
|
|
|
// Eliminate differences in types
|
|
let t0 = getType(v0);
|
|
let t1 = getType(v1);
|
|
if ( t0 !== t1 ) return [true, v1];
|
|
|
|
// null and undefined
|
|
if ( ["null", "undefined"].includes(t0) ) return [v0 !== v1, v1];
|
|
|
|
// If the prototype explicitly exposes an equality-testing method, use it
|
|
if ( v0?.equals instanceof Function ) return [!v0.equals(v1), v1];
|
|
|
|
// Recursively diff objects
|
|
if ( t0 === "Object" ) {
|
|
if ( isEmpty(v1) ) return [false, {}];
|
|
if ( isEmpty(v0) ) return [true, v1];
|
|
let d = diffObject(v0, v1, {inner, deletionKeys, _d});
|
|
return [!isEmpty(d), d];
|
|
}
|
|
|
|
// Differences in primitives
|
|
return [v0.valueOf() !== v1.valueOf(), v1];
|
|
}
|
|
|
|
// Recursively call the _difference function
|
|
return Object.keys(other).reduce((obj, key) => {
|
|
const isDeletionKey = key.startsWith("-=");
|
|
if ( isDeletionKey && deletionKeys ) {
|
|
const otherKey = key.substring(2);
|
|
if ( otherKey in original ) obj[key] = other[key];
|
|
return obj;
|
|
}
|
|
if ( inner && !(key in original) ) return obj;
|
|
let [isDifferent, difference] = _difference(original[key], other[key]);
|
|
if ( isDifferent ) obj[key] = difference;
|
|
return obj;
|
|
}, {});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Test if two objects contain the same enumerable keys and values.
|
|
* @param {object} a The first object.
|
|
* @param {object} b The second object.
|
|
* @returns {boolean}
|
|
*/
|
|
export function objectsEqual(a, b) {
|
|
if ( (a == null) || (b == null) ) return a === b;
|
|
if ( (getType(a) !== "Object") || (getType(b) !== "Object") ) return a === b;
|
|
if ( Object.keys(a).length !== Object.keys(b).length ) return false;
|
|
return Object.entries(a).every(([k, v0]) => {
|
|
const v1 = b[k];
|
|
const t0 = getType(v0);
|
|
const t1 = getType(v1);
|
|
if ( t0 !== t1 ) return false;
|
|
if ( v0?.equals instanceof Function ) return v0.equals(v1);
|
|
if ( t0 === "Object" ) return objectsEqual(v0, v1);
|
|
return v0 === v1;
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* A cheap data duplication trick which is relatively robust.
|
|
* For a subset of cases the deepClone function will offer better performance.
|
|
* @param {Object} original Some sort of data
|
|
*/
|
|
export function duplicate(original) {
|
|
return JSON.parse(JSON.stringify(original));
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Test whether some class is a subclass of a parent.
|
|
* Returns true if the classes are identical.
|
|
* @param {Function} cls The class to test
|
|
* @param {Function} parent Some other class which may be a parent
|
|
* @returns {boolean} Is the class a subclass of the parent?
|
|
*/
|
|
export function isSubclass(cls, parent) {
|
|
if ( typeof cls !== "function" ) return false;
|
|
if ( cls === parent ) return true;
|
|
return parent.isPrototypeOf(cls);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Search up the prototype chain and return the class that defines the given property.
|
|
* @param {Object|Constructor} obj A class instance or class definition which contains a property.
|
|
* If a class instance is passed the property is treated as an instance attribute.
|
|
* If a class constructor is passed the property is treated as a static attribute.
|
|
* @param {string} property The property name
|
|
* @returns {Constructor} The class that defines the property
|
|
*/
|
|
export function getDefiningClass(obj, property) {
|
|
const isStatic = obj.hasOwnProperty("prototype");
|
|
let target = isStatic ? obj : Object.getPrototypeOf(obj);
|
|
while ( target ) {
|
|
if ( target.hasOwnProperty(property) ) return isStatic ? target : target.constructor;
|
|
target = Object.getPrototypeOf(target);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Encode a url-like string by replacing any characters which need encoding
|
|
* To reverse this encoding, the native decodeURIComponent can be used on the whole encoded string, without adjustment.
|
|
* @param {string} path A fully-qualified URL or url component (like a relative path)
|
|
* @return {string} An encoded URL string
|
|
*/
|
|
export function encodeURL(path) {
|
|
|
|
// Determine whether the path is a well-formed URL
|
|
const url = URL.parseSafe(path);
|
|
|
|
// If URL, remove the initial protocol
|
|
if ( url ) path = path.replace(url.protocol, "");
|
|
|
|
// Split and encode each URL part
|
|
path = path.split("/").map(p => encodeURIComponent(p).replace(/'/g, "%27")).join("/");
|
|
|
|
// Return the encoded URL
|
|
return url ? url.protocol + path : path;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Expand a flattened object to be a standard nested Object by converting all dot-notation keys to inner objects.
|
|
* Only simple objects will be expanded. Other Object types like class instances will be retained as-is.
|
|
* @param {object} obj The object to expand
|
|
* @return {object} An expanded object
|
|
*/
|
|
export function expandObject(obj) {
|
|
function _expand(value, depth) {
|
|
if ( depth > 32 ) throw new Error("Maximum object expansion depth exceeded");
|
|
if ( !value ) return value;
|
|
if ( Array.isArray(value) ) return value.map(v => _expand(v, depth+1)); // Map arrays
|
|
if ( value.constructor?.name !== "Object" ) return value; // Return advanced objects directly
|
|
const expanded = {}; // Expand simple objects
|
|
for ( let [k, v] of Object.entries(value) ) {
|
|
setProperty(expanded, k, _expand(v, depth+1));
|
|
}
|
|
return expanded;
|
|
}
|
|
return _expand(obj, 0);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Filter the contents of some source object using the structure of a template object.
|
|
* Only keys which exist in the template are preserved in the source object.
|
|
*
|
|
* @param {object} source An object which contains the data you wish to filter
|
|
* @param {object} template An object which contains the structure you wish to preserve
|
|
* @param {object} [options={}] Additional options which customize the filtration
|
|
* @param {boolean} [options.deletionKeys=false] Whether to keep deletion keys
|
|
* @param {boolean} [options.templateValues=false] Instead of keeping values from the source, instead draw values from the template
|
|
*
|
|
* @example Filter an object
|
|
* ```js
|
|
* const source = {foo: {number: 1, name: "Tim", topping: "olives"}, bar: "baz"};
|
|
* const template = {foo: {number: 0, name: "Mit", style: "bold"}, other: 72};
|
|
* filterObject(source, template); // {foo: {number: 1, name: "Tim"}};
|
|
* filterObject(source, template, {templateValues: true}); // {foo: {number: 0, name: "Mit"}};
|
|
* ```
|
|
*/
|
|
export function filterObject(source, template, {deletionKeys=false, templateValues=false}={}) {
|
|
|
|
// Validate input
|
|
const ts = getType(source);
|
|
const tt = getType(template);
|
|
if ( (ts !== "Object") || (tt !== "Object")) throw new Error("One of source or template are not Objects!");
|
|
|
|
// Define recursive filtering function
|
|
const _filter = function(s, t, filtered) {
|
|
for ( let [k, v] of Object.entries(s) ) {
|
|
let has = t.hasOwnProperty(k);
|
|
let x = t[k];
|
|
|
|
// Case 1 - inner object
|
|
if ( has && (getType(v) === "Object") && (getType(x) === "Object") ) {
|
|
filtered[k] = _filter(v, x, {});
|
|
}
|
|
|
|
// Case 2 - inner key
|
|
else if ( has ) {
|
|
filtered[k] = templateValues ? x : v;
|
|
}
|
|
|
|
// Case 3 - special key
|
|
else if ( deletionKeys && k.startsWith("-=") ) {
|
|
filtered[k] = v;
|
|
}
|
|
}
|
|
return filtered;
|
|
};
|
|
|
|
// Begin filtering at the outer-most layer
|
|
return _filter(source, template, {});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Flatten a possibly multi-dimensional object to a one-dimensional one by converting all nested keys to dot notation
|
|
* @param {object} obj The object to flatten
|
|
* @param {number} [_d=0] Track the recursion depth to prevent overflow
|
|
* @return {object} A flattened object
|
|
*/
|
|
export function flattenObject(obj, _d=0) {
|
|
const flat = {};
|
|
if ( _d > 100 ) {
|
|
throw new Error("Maximum depth exceeded");
|
|
}
|
|
for ( let [k, v] of Object.entries(obj) ) {
|
|
let t = getType(v);
|
|
if ( t === "Object" ) {
|
|
if ( isEmpty(v) ) flat[k] = v;
|
|
let inner = flattenObject(v, _d+1);
|
|
for ( let [ik, iv] of Object.entries(inner) ) {
|
|
flat[`${k}.${ik}`] = iv;
|
|
}
|
|
}
|
|
else flat[k] = v;
|
|
}
|
|
return flat;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Obtain references to the parent classes of a certain class.
|
|
* @param {Function} cls An class definition
|
|
* @return {Array<typeof Object>} An array of parent classes which the provided class extends
|
|
*/
|
|
export function getParentClasses(cls) {
|
|
if ( typeof cls !== "function" ) {
|
|
throw new Error("The provided class is not a type of Function");
|
|
}
|
|
const parents = [];
|
|
let parent = Object.getPrototypeOf(cls);
|
|
while ( parent ) {
|
|
parents.push(parent);
|
|
parent = Object.getPrototypeOf(parent);
|
|
}
|
|
return parents.slice(0, -2)
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Get the URL route for a certain path which includes a path prefix, if one is set
|
|
* @param {string} path The Foundry URL path
|
|
* @param {string|null} [prefix] A path prefix to apply
|
|
* @returns {string} The absolute URL path
|
|
*/
|
|
export function getRoute(path, {prefix}={}) {
|
|
prefix = prefix === undefined ? globalThis.ROUTE_PREFIX : prefix || null;
|
|
path = path.replace(/(^[\/]+)|([\/]+$)/g, ""); // Strip leading and trailing slashes
|
|
let paths = [""];
|
|
if ( prefix ) paths.push(prefix);
|
|
paths = paths.concat([path.replace(/(^\/)|(\/$)/g, "")]);
|
|
return paths.join("/");
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Learn the underlying data type of some variable. Supported identifiable types include:
|
|
* undefined, null, number, string, boolean, function, Array, Set, Map, Promise, Error,
|
|
* HTMLElement (client side only), Object (catchall for other object types)
|
|
* @param {*} variable A provided variable
|
|
* @return {string} The named type of the token
|
|
*/
|
|
export function getType(variable) {
|
|
|
|
// Primitive types, handled with simple typeof check
|
|
const typeOf = typeof variable;
|
|
if ( typeOf !== "object" ) return typeOf;
|
|
|
|
// Special cases of object
|
|
if ( variable === null ) return "null";
|
|
if ( !variable.constructor ) return "Object"; // Object with the null prototype.
|
|
if ( variable.constructor.name === "Object" ) return "Object"; // simple objects
|
|
|
|
// Match prototype instances
|
|
const prototypes = [
|
|
[Array, "Array"],
|
|
[Set, "Set"],
|
|
[Map, "Map"],
|
|
[Promise, "Promise"],
|
|
[Error, "Error"],
|
|
[Color, "number"]
|
|
];
|
|
if ( "HTMLElement" in globalThis ) prototypes.push([globalThis.HTMLElement, "HTMLElement"]);
|
|
for ( const [cls, type] of prototypes ) {
|
|
if ( variable instanceof cls ) return type;
|
|
}
|
|
|
|
// Unknown Object type
|
|
return "Object";
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* A helper function which tests whether an object has a property or nested property given a string key.
|
|
* The method also supports arrays if the provided key is an integer index of the array.
|
|
* The string key supports the notation a.b.c which would return true if object[a][b][c] exists
|
|
* @param {object} object The object to traverse
|
|
* @param {string} key An object property with notation a.b.c
|
|
* @returns {boolean} An indicator for whether the property exists
|
|
*/
|
|
export function hasProperty(object, key) {
|
|
if ( !key || !object ) return false;
|
|
if ( key in object ) return true;
|
|
let target = object;
|
|
for ( let p of key.split('.') ) {
|
|
if ( !target || (typeof target !== "object") ) return false;
|
|
if ( p in target ) target = target[p];
|
|
else return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* A helper function which searches through an object to retrieve a value by a string key.
|
|
* The method also supports arrays if the provided key is an integer index of the array.
|
|
* The string key supports the notation a.b.c which would return object[a][b][c]
|
|
* @param {object} object The object to traverse
|
|
* @param {string} key An object property with notation a.b.c
|
|
* @return {*} The value of the found property
|
|
*/
|
|
export function getProperty(object, key) {
|
|
if ( !key || !object ) return undefined;
|
|
if ( key in object ) return object[key];
|
|
let target = object;
|
|
for ( let p of key.split('.') ) {
|
|
if ( !target || (typeof target !== "object") ) return undefined;
|
|
if ( p in target ) target = target[p];
|
|
else return undefined;
|
|
}
|
|
return target;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* A helper function which searches through an object to assign a value using a string key
|
|
* This string key supports the notation a.b.c which would target object[a][b][c]
|
|
* @param {object} object The object to update
|
|
* @param {string} key The string key
|
|
* @param {*} value The value to be assigned
|
|
* @return {boolean} Whether the value was changed from its previous value
|
|
*/
|
|
export function setProperty(object, key, value) {
|
|
if ( !key ) return false;
|
|
|
|
// Convert the key to an object reference if it contains dot notation
|
|
let target = object;
|
|
if ( key.indexOf('.') !== -1 ) {
|
|
let parts = key.split('.');
|
|
key = parts.pop();
|
|
target = parts.reduce((o, i) => {
|
|
if ( !o.hasOwnProperty(i) ) o[i] = {};
|
|
return o[i];
|
|
}, object);
|
|
}
|
|
|
|
// Update the target
|
|
if ( !(key in target) || (target[key] !== value) ) {
|
|
target[key] = value;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Invert an object by assigning its values as keys and its keys as values.
|
|
* @param {object} obj The original object to invert
|
|
* @returns {object} The inverted object with keys and values swapped
|
|
*/
|
|
export function invertObject(obj) {
|
|
const inverted = {};
|
|
for ( let [k, v] of Object.entries(obj) ) {
|
|
if ( v in inverted ) throw new Error("The values of the provided object must be unique in order to invert it.");
|
|
inverted[v] = k;
|
|
}
|
|
return inverted;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Return whether a target version (v1) is more advanced than some other reference version (v0).
|
|
* Supports either numeric or string version comparison with version parts separated by periods.
|
|
* @param {number|string} v1 The target version
|
|
* @param {number|string} v0 The reference version
|
|
* @return {boolean} Is v1 a more advanced version than v0?
|
|
*/
|
|
export function isNewerVersion(v1, v0) {
|
|
|
|
// Handle numeric versions
|
|
if ( (typeof v1 === "number") && (typeof v0 === "number") ) return v1 > v0;
|
|
|
|
// Handle string parts
|
|
let v1Parts = String(v1).split(".");
|
|
let v0Parts = String(v0).split(".");
|
|
|
|
// Iterate over version parts
|
|
for ( let [i, p1] of v1Parts.entries() ) {
|
|
let p0 = v0Parts[i];
|
|
|
|
// If the prior version doesn't have a part, v1 wins
|
|
if ( p0 === undefined ) return true;
|
|
|
|
// If both parts are numbers, use numeric comparison to avoid cases like "12" < "5"
|
|
if ( Number.isNumeric(p0) && Number.isNumeric(p1) ) {
|
|
if ( Number(p1) !== Number(p0) ) return Number(p1) > Number(p0);
|
|
}
|
|
|
|
// Otherwise, compare as strings
|
|
if ( p1 !== p0 ) return p1 > p0;
|
|
}
|
|
|
|
// If there are additional parts to v0, it is not newer
|
|
if ( v0Parts.length > v1Parts.length ) return false;
|
|
|
|
// If we have not returned false by now, it's either newer or the same
|
|
return !v1Parts.equals(v0Parts);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Test whether a value is empty-like; either undefined or a content-less object.
|
|
* @param {*} value The value to test
|
|
* @returns {boolean} Is the value empty-like?
|
|
*/
|
|
export function isEmpty(value) {
|
|
const t = getType(value);
|
|
switch ( t ) {
|
|
case "undefined":
|
|
return true;
|
|
case "null":
|
|
return true;
|
|
case "Array":
|
|
return !value.length;
|
|
case "Object":
|
|
return !Object.keys(value).length;
|
|
case "Set":
|
|
case "Map":
|
|
return !value.size;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Update a source object by replacing its keys and values with those from a target object.
|
|
*
|
|
* @param {object} original The initial object which should be updated with values from the
|
|
* target
|
|
* @param {object} [other={}] A new object whose values should replace those in the source
|
|
* @param {object} [options={}] Additional options which configure the merge
|
|
* @param {boolean} [options.insertKeys=true] Control whether to insert new top-level objects into the resulting
|
|
* structure which do not previously exist in the original object.
|
|
* @param {boolean} [options.insertValues=true] Control whether to insert new nested values into child objects in
|
|
* the resulting structure which did not previously exist in the
|
|
* original object.
|
|
* @param {boolean} [options.overwrite=true] Control whether to replace existing values in the source, or only
|
|
* merge values which do not already exist in the original object.
|
|
* @param {boolean} [options.recursive=true] Control whether to merge inner-objects recursively (if true), or
|
|
* whether to simply replace inner objects with a provided new value.
|
|
* @param {boolean} [options.inplace=true] Control whether to apply updates to the original object in-place
|
|
* (if true), otherwise the original object is duplicated and the
|
|
* copy is merged.
|
|
* @param {boolean} [options.enforceTypes=false] Control whether strict type checking requires that the value of a
|
|
* key in the other object must match the data type in the original
|
|
* data to be merged.
|
|
* @param {boolean} [options.performDeletions=false] Control whether to perform deletions on the original object if
|
|
* deletion keys are present in the other object.
|
|
* @param {number} [_d=0] A privately used parameter to track recursion depth.
|
|
* @returns {object} The original source object including updated, inserted, or
|
|
* overwritten records.
|
|
*
|
|
* @example Control how new keys and values are added
|
|
* ```js
|
|
* mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: false}); // {k1: "v1"}
|
|
* mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: true}); // {k1: "v1", k2: "v2"}
|
|
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: false}); // {k1: {i1: "v1"}}
|
|
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: true}); // {k1: {i1: "v1", i2: "v2"}}
|
|
* ```
|
|
*
|
|
* @example Control how existing data is overwritten
|
|
* ```js
|
|
* mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: true}); // {k1: "v2"}
|
|
* mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: false}); // {k1: "v1"}
|
|
* ```
|
|
*
|
|
* @example Control whether merges are performed recursively
|
|
* ```js
|
|
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: false}); // {k1: {i2: "v2"}}
|
|
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: true}); // {k1: {i1: "v1", i2: "v2"}}
|
|
* ```
|
|
*
|
|
* @example Deleting an existing object key
|
|
* ```js
|
|
* mergeObject({k1: "v1", k2: "v2"}, {"-=k1": null}, {performDeletions: true}); // {k2: "v2"}
|
|
* ```
|
|
*/
|
|
export function mergeObject(original, other={}, {
|
|
insertKeys=true, insertValues=true, overwrite=true, recursive=true, inplace=true, enforceTypes=false,
|
|
performDeletions=false
|
|
}={}, _d=0) {
|
|
other = other || {};
|
|
if (!(original instanceof Object) || !(other instanceof Object)) {
|
|
throw new Error("One of original or other are not Objects!");
|
|
}
|
|
const options = {insertKeys, insertValues, overwrite, recursive, inplace, enforceTypes, performDeletions};
|
|
|
|
// Special handling at depth 0
|
|
if ( _d === 0 ) {
|
|
if ( Object.keys(other).some(k => /\./.test(k)) ) other = expandObject(other);
|
|
if ( Object.keys(original).some(k => /\./.test(k)) ) {
|
|
const expanded = expandObject(original);
|
|
if ( inplace ) {
|
|
Object.keys(original).forEach(k => delete original[k]);
|
|
Object.assign(original, expanded);
|
|
}
|
|
else original = expanded;
|
|
}
|
|
else if ( !inplace ) original = deepClone(original);
|
|
}
|
|
|
|
// Iterate over the other object
|
|
for ( let k of Object.keys(other) ) {
|
|
const v = other[k];
|
|
if ( original.hasOwnProperty(k) ) _mergeUpdate(original, k, v, options, _d+1);
|
|
else _mergeInsert(original, k, v, options, _d+1);
|
|
}
|
|
return original;
|
|
}
|
|
|
|
/**
|
|
* A helper function for merging objects when the target key does not exist in the original
|
|
* @private
|
|
*/
|
|
function _mergeInsert(original, k, v, {insertKeys, insertValues, performDeletions}={}, _d) {
|
|
// Delete a key
|
|
if ( k.startsWith("-=") && performDeletions ) {
|
|
delete original[k.slice(2)];
|
|
return;
|
|
}
|
|
|
|
const canInsert = ((_d <= 1) && insertKeys) || ((_d > 1) && insertValues);
|
|
if ( !canInsert ) return;
|
|
|
|
// Recursively create simple objects
|
|
if ( v?.constructor === Object ) {
|
|
original[k] = mergeObject({}, v, {insertKeys: true, inplace: true, performDeletions});
|
|
return;
|
|
}
|
|
|
|
// Insert a key
|
|
original[k] = v;
|
|
}
|
|
|
|
/**
|
|
* A helper function for merging objects when the target key exists in the original
|
|
* @private
|
|
*/
|
|
function _mergeUpdate(original, k, v, {
|
|
insertKeys, insertValues, enforceTypes, overwrite, recursive, performDeletions
|
|
}={}, _d) {
|
|
const x = original[k];
|
|
const tv = getType(v);
|
|
const tx = getType(x);
|
|
|
|
// Recursively merge an inner object
|
|
if ( (tv === "Object") && (tx === "Object") && recursive) {
|
|
return mergeObject(x, v, {
|
|
insertKeys, insertValues, overwrite, enforceTypes, performDeletions,
|
|
inplace: true
|
|
}, _d);
|
|
}
|
|
|
|
// Overwrite an existing value
|
|
if ( overwrite ) {
|
|
if ( (tx !== "undefined") && (tv !== tx) && enforceTypes ) {
|
|
throw new Error(`Mismatched data types encountered during object merge.`);
|
|
}
|
|
original[k] = v;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Parse an S3 key to learn the bucket and the key prefix used for the request.
|
|
* @param {string} key A fully qualified key name or prefix path.
|
|
* @returns {{bucket: string|null, keyPrefix: string}}
|
|
*/
|
|
export function parseS3URL(key) {
|
|
const url = URL.parseSafe(key);
|
|
if ( url ) return {
|
|
bucket: url.host.split(".").shift(),
|
|
keyPrefix: url.pathname.slice(1)
|
|
};
|
|
return {
|
|
bucket: null,
|
|
keyPrefix: ""
|
|
};
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Generate a random alphanumeric string ID of a given requested length using `crypto.getRandomValues()`.
|
|
* @param {number} length The length of the random string to generate, which must be at most 16384.
|
|
* @return {string} A string containing random letters (A-Z, a-z) and numbers (0-9).
|
|
*/
|
|
export function randomID(length=16) {
|
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
const cutoff = 0x100000000 - (0x100000000 % chars.length);
|
|
const random = new Uint32Array(length);
|
|
do {
|
|
crypto.getRandomValues(random);
|
|
} while ( random.some(x => x >= cutoff) );
|
|
let id = "";
|
|
for ( let i = 0; i < length; i++ ) id += chars[random[i] % chars.length];
|
|
return id;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Express a timestamp as a relative string
|
|
* @param {Date|string} timeStamp A timestamp string or Date object to be formatted as a relative time
|
|
* @return {string} A string expression for the relative time
|
|
*/
|
|
export function timeSince(timeStamp) {
|
|
timeStamp = new Date(timeStamp);
|
|
const now = new Date();
|
|
const secondsPast = (now - timeStamp) / 1000;
|
|
let since = "";
|
|
|
|
// Format the time
|
|
if (secondsPast < 60) {
|
|
since = secondsPast;
|
|
if ( since < 1 ) return game.i18n.localize("TIME.Now");
|
|
else since = Math.round(since) + game.i18n.localize("TIME.SecondsAbbreviation");
|
|
}
|
|
else if (secondsPast < 3600) since = Math.round(secondsPast / 60) + game.i18n.localize("TIME.MinutesAbbreviation");
|
|
else if (secondsPast <= 86400) since = Math.round(secondsPast / 3600) + game.i18n.localize("TIME.HoursAbbreviation");
|
|
else {
|
|
const hours = Math.round(secondsPast / 3600);
|
|
const days = Math.floor(hours / 24);
|
|
since = `${days}${game.i18n.localize("TIME.DaysAbbreviation")} ${hours % 24}${game.i18n.localize("TIME.HoursAbbreviation")}`;
|
|
}
|
|
|
|
// Return the string
|
|
return game.i18n.format("TIME.Since", {since: since});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Format a file size to an appropriate order of magnitude.
|
|
* @param {number} size The size in bytes.
|
|
* @param {object} [options]
|
|
* @param {number} [options.decimalPlaces=2] The number of decimal places to round to.
|
|
* @param {2|10} [options.base=10] The base to use. In base 10 a kilobyte is 1000 bytes. In base 2 it is
|
|
* 1024 bytes.
|
|
* @returns {string}
|
|
*/
|
|
export function formatFileSize(size, { decimalPlaces=2, base=10 }={}) {
|
|
const units = ["B", "kB", "MB", "GB", "TB"];
|
|
const divisor = base === 2 ? 1024 : 1000;
|
|
let iterations = 0;
|
|
while ( (iterations < units.length) && (size > divisor) ) {
|
|
size /= divisor;
|
|
iterations++;
|
|
}
|
|
return `${size.toFixed(decimalPlaces)} ${units[iterations]}`;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* @typedef {object} ResolvedUUID
|
|
* @property {string} uuid The original UUID.
|
|
* @property {string} [type] The type of Document referenced. Legacy compendium UUIDs will not
|
|
* populate this field if the compendium is not active in the World.
|
|
* @property {string} id The ID of the Document referenced.
|
|
* @property {string} [primaryType] The primary Document type of this UUID. Only present if the Document
|
|
* is embedded.
|
|
* @property {string} [primaryId] The primary Document ID of this UUID. Only present if the Document
|
|
* is embedded.
|
|
* @property {DocumentCollection} [collection] The collection that the primary Document belongs to.
|
|
* @property {string[]} embedded Additional Embedded Document parts.
|
|
* @property {Document} [doc] An already-resolved parent Document.
|
|
* @property {string} [documentType] Either the document type or the parent type. Retained for backwards
|
|
* compatibility.
|
|
* @property {string} [documentId] Either the document id or the parent id. Retained for backwards
|
|
* compatibility.
|
|
*/
|
|
|
|
/**
|
|
* Parse a UUID into its constituent parts, identifying the type and ID of the referenced document.
|
|
* The ResolvedUUID result also identifies a "primary" document which is a root-level document either in the game
|
|
* World or in a Compendium pack which is a parent of the referenced document.
|
|
* @param {string} uuid The UUID to parse.
|
|
* @param {object} [options] Options to configure parsing behavior.
|
|
* @param {foundry.abstract.Document} [options.relative] A document to resolve relative UUIDs against.
|
|
* @returns {ResolvedUUID} Returns the Collection, Document Type, and Document ID to resolve the parent
|
|
* document, as well as the remaining Embedded Document parts, if any.
|
|
* @throws {Error} An error if the provided uuid string is incorrectly structured
|
|
*/
|
|
export function parseUuid(uuid, {relative}={}) {
|
|
if ( !uuid ) throw new Error("A uuid string is required");
|
|
const packs = game.packs;
|
|
|
|
// Relative UUID
|
|
if ( uuid.startsWith(".") && relative ) return _resolveRelativeUuid(uuid, relative);
|
|
|
|
// Split UUID parts
|
|
const parts = uuid.split(".");
|
|
|
|
// Check for redirects.
|
|
if ( game.compendiumUUIDRedirects ) {
|
|
const node = game.compendiumUUIDRedirects.nodeAtPrefix(parts, { hasLeaves: true });
|
|
const [redirect] = node?.[foundry.utils.StringTree.leaves];
|
|
if ( redirect?.length ) parts.splice(0, redirect.length, ...redirect);
|
|
}
|
|
|
|
let id;
|
|
let type;
|
|
let primaryId;
|
|
let primaryType;
|
|
let collection;
|
|
|
|
// Compendium Documents.
|
|
if ( parts[0] === "Compendium" ) {
|
|
const [, scope, packName] = parts.splice(0, 3);
|
|
collection = packs.get(`${scope}.${packName}`);
|
|
|
|
// Re-interpret legacy compendium UUIDs which did not explicitly include their parent document type
|
|
if ( !(COMPENDIUM_DOCUMENT_TYPES.includes(parts[0]) || (parts[0] === "Folder")) ) {
|
|
const type = collection?.documentName;
|
|
parts.unshift(type);
|
|
if ( type ) uuid = ["Compendium", scope, packName, ...parts].filterJoin(".");
|
|
}
|
|
[primaryType, primaryId] = parts.splice(0, 2);
|
|
}
|
|
|
|
// World Documents
|
|
else {
|
|
[primaryType, primaryId] = parts.splice(0, 2);
|
|
collection = globalThis.db?.[primaryType] ?? CONFIG[primaryType]?.collection?.instance;
|
|
}
|
|
|
|
// Embedded Documents
|
|
if ( parts.length ) {
|
|
if ( parts.length % 2 ) throw new Error("Invalid number of embedded UUID parts");
|
|
id = parts.at(-1);
|
|
type = parts.at(-2);
|
|
}
|
|
|
|
// Primary Documents
|
|
else {
|
|
id = primaryId;
|
|
type = primaryType;
|
|
primaryId = primaryType = undefined;
|
|
}
|
|
|
|
// Return resolved UUID
|
|
return {uuid, type, id, collection, embedded: parts, primaryType, primaryId,
|
|
documentType: primaryType ?? type, documentId: primaryId ?? id};
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Resolve a UUID relative to another document.
|
|
* The general-purpose algorithm for resolving relative UUIDs is as follows:
|
|
* 1. If the number of parts is odd, remove the first part and resolve it against the current document and update the
|
|
* current document.
|
|
* 2. If the number of parts is even, resolve embedded documents against the current document.
|
|
* @param {string} uuid The UUID to resolve.
|
|
* @param {foundry.abstract.Document} relative The document to resolve against.
|
|
* @returns {ResolvedUUID} A resolved UUID object
|
|
* @private
|
|
*/
|
|
function _resolveRelativeUuid(uuid, relative) {
|
|
if ( !(relative instanceof foundry.abstract.Document) ) {
|
|
throw new Error("A relative Document instance must be provided to _resolveRelativeUuid");
|
|
}
|
|
uuid = uuid.substring(1);
|
|
const parts = uuid.split(".");
|
|
if ( !parts.length ) throw new Error("Invalid relative UUID");
|
|
let id;
|
|
let type;
|
|
let root;
|
|
let primaryType;
|
|
let primaryId;
|
|
let collection;
|
|
|
|
// Identify the root document and its collection
|
|
const getRoot = (doc) => {
|
|
if ( doc.parent ) parts.unshift(doc.documentName, doc.id);
|
|
return doc.parent ? getRoot(doc.parent) : doc;
|
|
}
|
|
|
|
// Even-numbered parts include an explicit child document type
|
|
if ( (parts.length % 2) === 0 ) {
|
|
root = getRoot(relative);
|
|
id = parts.at(-1);
|
|
type = parts.at(-2);
|
|
primaryType = root.documentName;
|
|
primaryId = root.id;
|
|
uuid = [primaryType, primaryId, ...parts].join(".");
|
|
}
|
|
|
|
// Relative Embedded Document
|
|
else if ( relative.parent ) {
|
|
root = getRoot(relative.parent);
|
|
id = parts.at(-1);
|
|
type = relative.documentName;
|
|
parts.unshift(type);
|
|
primaryType = root.documentName;
|
|
primaryId = root.id;
|
|
uuid = [primaryType, primaryId, ...parts].join(".");
|
|
}
|
|
|
|
// Relative Document
|
|
else {
|
|
root = relative;
|
|
id = parts.pop();
|
|
type = relative.documentName;
|
|
uuid = [type, id].join(".");
|
|
}
|
|
|
|
// Recreate fully-qualified UUID and return the resolved result
|
|
collection = root.pack ? root.compendium : root.collection;
|
|
if ( root.pack ) uuid = `Compendium.${root.pack}.${uuid}`;
|
|
return {uuid, type, id, collection, primaryType, primaryId, embedded: parts,
|
|
documentType: primaryType ?? type, documentId: primaryId ?? id};
|
|
}
|