/** * Galaxy utilities comprises small functions, which at this point * do not require their own classes/files */ import axios, { type AxiosError, type AxiosResponse } from "axios"; import { NON_TERMINAL_STATES } from "@/api/jobs"; import _l from "@/utils/localization"; export function stateIsTerminal(result: Record) { return !NON_TERMINAL_STATES.includes(result.state); } /** Object with any internal structure. More specific key than built-in Object type */ export type AnyObject = Record; /** * Call callback on every object in an object recursively * * @param object object to traverse * @param callback ran on every nested child object */ export function deepEach( object: Readonly, callback: (object: V | AnyObject) => void, ): void { Object.values(object).forEach((value) => { if (Boolean(value) && typeof value === "object") { callback(value); deepEach(value, callback); } }); } /** * Identifies urls and replaces them with anchors * * @param inputText * @returns string with anchor tags, wrapping found URLs and e-mail addresses */ export function linkify(inputText: string): string { let replacedText = inputText; // URLs starting with http://, https://, or ftp:// const urlProtocolPattern = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gim; replacedText = replacedText.replace(urlProtocolPattern, '$1'); // URLs starting with "www." (without // before it, or it'd re-link the ones done above). const urlPattern = /(^|[^/])(www\.[\S]+(\b|$))/gim; replacedText = replacedText.replace(urlPattern, '$1$2'); // Change email addresses to mailto:: links. const emailPattern = /(([a-zA-Z0-9\-_.])+@[a-zA-Z_]+?(\.[a-zA-Z]{2,6})+)/gim; replacedText = replacedText.replace(emailPattern, '$1'); return replacedText; } /** * @deprecated in favor of built in `structuredClone` method * * This is a deep copy of the object input */ export function clone(obj: Readonly): T { return JSON.parse(JSON.stringify(obj)); } /** * Check if a string is a json string * * @param text Content to be validated */ export function isJSON(text: string): boolean { return /^[\],:{}\s]*$/.test( text .replace(/\\["\\/bfnrtu]/g, "@") .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/g, "]") .replace(/(?:^|:|,)(?:\s*\[)+/g, ""), ); } const emptyValues = ["__null__", "__undefined__", null, undefined] as const; /** * Checks if a value or list of values is "empty". * Usually used for selectable options * * Considers `null`, `undefined` and the string literals `__null__`, `__undefined__` as "empty" * * @param value Value or list of values to be validated */ export function isEmpty(value: any | Readonly) { if (!Array.isArray(value)) { return emptyValues.includes(value); } else { if (value.length === 0) { return true; } for (let index = 0; index < value.length; index++) { if (emptyValues.includes(value[index])) { return true; } } return false; } } /** * Convert list to pretty string. * * @example ``` * const list = [1, 2, 3]; * const pretty = textify(list); * console.log(pretty); * // outputs => 1, 2 or 3 * ``` * * @param list List of strings to be converted in human readable list sentence */ export function textify(list: Readonly, connectorWord = "or"): string { let string = list.toString().replace(/,/g, ", "); const pos = string.lastIndexOf(", "); if (pos !== -1) { string = `${string.substring(0, pos)} ${connectorWord} ${string.substring(pos + 2)}`; } return string; } /** * Request handler for GET. * * @property url Url request is made to * @property success Callback on success * @property error Callback on error * @property data parameters to be sent with the request */ interface getOptions { url: string; data?: any; success?: (data: AxiosResponse) => void; error?: (error: AxiosError) => void; } /** * @deprecated legacy layer for old $.ajax interface * @param options */ export function get(options: getOptions): void { axios .get(options.url, { params: options.data }) .then((response) => { if (options.success) { options.success(response.data); } }) .catch((error) => { // I don't believe any consumers actually use this, just drop it as // we remove this interface if (options.error) { options.error(error); } }); } /** * Safely merge two dictionaries * * @param options Target dictionary * @param optionsDefault Source dictionary * * @returns modified options dictionary or new object */ export function merge(options: AnyObject | null | undefined, optionsDefault: Readonly) { const object = options ?? {}; Object.entries(optionsDefault).forEach(([key, value]) => { if (!object[key]) { object[key] = value; } }); return object; } /** * Round floating point `number` to `numPlaces` number of decimal places. * * @param number a floating point number * @param numPlaces number of decimal places */ export function roundToDecimalPlaces(number: number, numPlaces: number) { return parseFloat(number.toFixed(numPlaces)); } const kb = 1024; const mb = kb * kb; const gb = mb * kb; const tb = gb * kb; /** * Format byte size to string with units * * @param size Size in bytes * @param normalFont Switches font between normal and bold * @param numPlaces decimalPlaces to round to * * @returns string representation of bytes, * or `strong` tag with size in bytes and unit (as string), * or `strong` tag with "-" (as string) */ export function bytesToString(size: number, normalFont = true, numPlaces = 1) { let unit = ""; if (size >= tb) { size = size / tb; unit = "TB"; } else if (size >= gb) { size = size / gb; unit = "GB"; } else if (size >= mb) { size = size / mb; unit = "MB"; } else if (size >= kb) { size = size / kb; unit = "KB"; } else if (size > 0) { unit = "b"; } else { return normalFont ? "0 b" : "-"; } const rounded = unit == "b" ? size : roundToDecimalPlaces(size, numPlaces); if (normalFont) { return `${rounded} ${unit}`; } else { return `${rounded} ${unit}`; } } let idCounter = 0; /** * @deprecated in favor of useUid composable * * Create a unique id */ export function uid(): string { idCounter += 1; return `uid-${idCounter}`; } /** Create a time stamp */ export function time(): string { const d = new Date(); const hours = (d.getHours() < 10 ? "0" : "") + d.getHours(); const minutes = (d.getMinutes() < 10 ? "0" : "") + d.getMinutes(); return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()}, ${hours}:${minutes}`; } /** * Append script and style tags to Galaxy main application * * @param data object containing script and style strings */ export function appendScriptStyle(data: Readonly<{ script?: string; styles?: string }>) { // create a script tag inside head tag if (data.script && data.script !== "") { const tag = document.createElement("script"); tag.type = "text/javascript"; tag.textContent = data.script; document.head.appendChild(tag); } // create a style tag inside head tag if (data.styles && data.styles !== "") { const tag = document.createElement("style"); tag.textContent = data.styles; document.head.appendChild(tag); } } /** * Calculate a 32 bit FNV-1a hash * Found here: https://gist.github.com/vaiorabbit/5657561 * Ref.: http://isthe.com/chongo/tech/comp/fnv/ * * @param str the input value * @returns integer number */ export function hashFnv32a(str: string): number { let hval = 0x811c9dc5; for (let i = 0, l = str.length; i < l; i++) { hval ^= str.charCodeAt(i); hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24); } return hval >>> 0; } /** * Return a promise, resolve it when element appears * * @param selector css selector * * @returns Element */ export async function waitForElementToBePresent(selector: string) { while (document.querySelector(selector) === null) { await new Promise((resolve) => requestAnimationFrame(resolve)); } return document.querySelector(selector) as Element; } /** * Async `setTimeout` utility. * Resolves promise after set time * * @param milliseconds amount of time to wait in milliseconds */ export function wait(milliseconds: number) { return new Promise((resolve) => { setTimeout(() => resolve(), milliseconds); }); } /** * Merges two arrays of objects with unique id values into a single array. * If an object with the same id exists in both arrays, the object from the * newList will overwrite the object from the oldList. The merged array will * be sorted by the sortKey in the sortDirection. * @param oldList The original array of objects. * @param newList The array of objects to merge into the original array. * @param sortKey If provided, the merged array will be sorted by this key. * @param sortDirection If sortKey is provided, the merged array will be sorted in this direction. * @returns An array of merged objects. */ export function mergeObjectListsById( oldList: T[], newList: T[], sortKey: string | null = null, sortDirection: "asc" | "desc" = "desc", ): T[] { const idToObjMap: { [key: string]: T } = oldList.reduce((acc, obj) => ({ ...acc, [obj.id]: obj }), {}); newList.forEach((obj) => { idToObjMap[obj.id] = obj; }); const mergedList = Object.values(idToObjMap); if (sortKey) { mergedList.sort((a, b) => (a[sortKey] < b[sortKey] ? -1 : 1) * (sortDirection === "asc" ? 1 : -1)); } return mergedList; } export function parseBool(value: string): boolean { return value.toLowerCase() === "true"; } type MatchObject = { [_Case in T]: () => R; }; /** * Alternative to `switch` statement. * Unlike `switch` it is exhaustive and allows for returning a value. * * @param key A key with the type of a Union of possible keys * @param matcher An object with a key for every possible match and a function as value, which will be ran if a match occurs * @returns The ran functions return value * * @example * ```ts * type literal = "a" | "b"; * const thing = "a" as literal; * * const result = match(thing, { * a: () => 1, * b: () => 2, * }); * * result === 1; * ``` */ export function match(key: T, matcher: MatchObject): R { return matcher[key](); } /** * Checks whether or not an object contains all supplied keys. * * @param object Object to check * @param keys Array of all keys to check for * @returns if all keys were found */ export function hasKeys(object: unknown, keys: string[]) { if (typeof object === "object" && object !== null) { let valid = true; keys.forEach((key) => (valid = valid && key in object)); return valid; } else { return false; } } export default { get, merge, bytesToString, uid, time, textify, isEmpty, deepEach, isJSON, clone, linkify, appendScriptStyle, waitForElementToBePresent, wait, mergeObjectListsById, };