mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-11-05 16:07:52 +08:00
480 lines
13 KiB
TypeScript
480 lines
13 KiB
TypeScript
/**
|
|
* 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 { getGalaxyInstance } from "@/app";
|
|
import { NON_TERMINAL_STATES } from "@/components/WorkflowInvocationState/util";
|
|
import { getAppRoot } from "@/onload/loadConfig";
|
|
import _l from "@/utils/localization";
|
|
|
|
export function stateIsTerminal(result: Record<string, any>) {
|
|
return !NON_TERMINAL_STATES.includes(result.state);
|
|
}
|
|
|
|
/** Object with any internal structure. More specific key than built-in Object type */
|
|
export type AnyObject = Record<string | number | symbol, any>;
|
|
|
|
/**
|
|
* 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<O extends AnyObject, V extends O[keyof O] extends AnyObject ? O[keyof O] : never>(
|
|
object: Readonly<O>,
|
|
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 <a> 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, '<a href="$1" target="_blank">$1</a>');
|
|
|
|
// 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<a href="http://$2" target="_blank">$2</a>');
|
|
|
|
// 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, '<a href="mailto:$1">$1</a>');
|
|
|
|
return replacedText;
|
|
}
|
|
|
|
/**
|
|
* @deprecated in favor of built in `structuredClone` method
|
|
*
|
|
* This is a deep copy of the object input
|
|
*/
|
|
export function clone<T>(obj: Readonly<T>): 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<any[]>) {
|
|
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<string[]>): string {
|
|
let string = list.toString().replace(/,/g, ", ");
|
|
const pos = string.lastIndexOf(", ");
|
|
|
|
if (pos !== -1) {
|
|
string = `${string.substring(0, pos)} or ${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);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load a CSS file
|
|
*
|
|
* @param url Url of CSS file
|
|
*/
|
|
export function cssLoadFile(url: string): void {
|
|
const fullUrl = getAppRoot() + url;
|
|
const links = document.head.getElementsByTagName("link");
|
|
|
|
if (Array.from(links).find((link) => link.href === fullUrl)) {
|
|
return;
|
|
}
|
|
|
|
const link = document.createElement("link");
|
|
link.type = "text/css";
|
|
link.rel = "stylesheet";
|
|
link.href = fullUrl;
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
/**
|
|
* 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<AnyObject>) {
|
|
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" : "<strong>-</strong>";
|
|
}
|
|
|
|
const rounded = unit == "b" ? size : roundToDecimalPlaces(size, numPlaces);
|
|
|
|
if (normalFont) {
|
|
return `${rounded} ${unit}`;
|
|
} else {
|
|
return `<strong>${rounded}</strong> ${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);
|
|
}
|
|
}
|
|
|
|
export function setWindowTitle(title: string): void {
|
|
const Galaxy = getGalaxyInstance();
|
|
if (title) {
|
|
window.document.title = `Galaxy ${Galaxy.config.brand ? ` | ${Galaxy.config.brand}` : ""} | ${_l(title)}`;
|
|
} else {
|
|
window.document.title = `Galaxy ${Galaxy.config.brand ? ` | ${Galaxy.config.brand}` : ""}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<void>((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<T extends { id: string; [key: string]: any }>(
|
|
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<T extends string | number | symbol, R> = {
|
|
[_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<T extends string | number | symbol, R>(key: T, matcher: MatchObject<T, R>): 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the full URL path of the app
|
|
*
|
|
* @param path Path to append to the URL path
|
|
* @returns Full URL path of the app
|
|
*/
|
|
export function getFullAppUrl(path: string = ""): string {
|
|
const protocol = window.location.protocol;
|
|
const hostname = window.location.hostname;
|
|
const port = window.location.port ? `:${window.location.port}` : "";
|
|
const appRoot = getAppRoot();
|
|
|
|
return `${protocol}//${hostname}${port}${appRoot}${path}`;
|
|
}
|
|
|
|
export default {
|
|
cssLoadFile,
|
|
get,
|
|
merge,
|
|
bytesToString,
|
|
uid,
|
|
time,
|
|
textify,
|
|
isEmpty,
|
|
deepEach,
|
|
isJSON,
|
|
clone,
|
|
linkify,
|
|
appendScriptStyle,
|
|
setWindowTitle,
|
|
waitForElementToBePresent,
|
|
wait,
|
|
mergeObjectListsById,
|
|
getFullAppUrl,
|
|
};
|