Source: utils/utils.js

import "./utils.css";

/**
 * A collection of useful tools.
 * 
 * @author rkoppe <roland.koppe@awi.de>
 * @author rhess <robin.hess@awi.de>
 */


/**
 * Method to check if a value is null or undefined
 * @memberof vef.utils
 * 
 * @param {*} value 
 * @returns {boolean}
 */
export function isNullorUndefined(value) {
    return (value === null) || (value === undefined);
}

/**
 * replaces URLs starting with http or https and replaces them
 * with a link tag leading to their location
 * 
 * @memberof vef.utils
 * 
 * @param {string} text
 * @returns {string} text with added link tags
 */
export function createLinks(text) {
    // return original text if it contains link tags
    if (text.includes('<a') && text.includes('a>') && text.includes("href=")) return text;

    const URL_PATTERN = /(https?:\/\/[a-z0-9-]{2,}(\.[a-z0-9-]{2,})+(\/[a-z0-9-_\.]+)*\/?[a-z0-9-_\\?&@\.=:~\/]+)/gi;
    return text.replace(URL_PATTERN, `<a href="$1" target="_blank">$1</a>`);
}

/**
 * Creates the elements defined by the given HTML string and returns
 * these elements as array or the element, if it is only one.
 * 
 * @memberof vef.utils
 * 
 * @param {string} html 
 */
export function createElement(html) {
    const template = document.createElement('template');
    template.innerHTML = html;
    const fragment = template.content;

    if (fragment.children.length > 1) {
        return fragment.children;
    } else {
        return fragment.children[0];
    }
}


/**
 * Returns true if fn is a function.
 * 
 * @memberof vef.utils
 * 
 * @param {object} fn 
 */
export function isFunction(fn) {
    return fn && Object.prototype.toString.call(fn) === '[object Function]';
}


/**
 * Get query params from current document.location or optional URI
 *
 * @memberof vef.utils
 * 
 * @param {string} uri (optional)
 * @returns {object} URI params
 */
export function getQueryParams(uri) {
    const regex = /[(\?|\&|\#)]([^=]+)\=([^\&#]+)/g;
    const obj = {};
    let match;

    if (uri === undefined) uri = document.location.href;
    uri = uri.replace(/&&/g, '&');

    while (!!(match = regex.exec(uri))) {
        if (!!obj[match[1]]) {
            obj[match[1]].push(decodeURIComponent(match[2]));
        } else {
            obj[match[1]] = [decodeURIComponent(match[2])];
        }
    }
    return obj;
}

/**
 * Create a query params string from a given object
 *
 * @memberof vef.utils
 * 
 * @param {object} params
 * @returns {string} query string
 */
export function setQueryParams(params) {
    let query = "";

    const add = (k, v) => {
        let segment = encodeURIComponent(k) + "=" + encodeURIComponent(v);
        query += (query.length) ? ("&" + segment) : segment;
    }

    for (let k in params) {
        if (Array.isArray(params[k])) {
            for (let i = 0; i < params[k].length; ++i) {
                add(k, params[k][i]);
            }
        } else {
            add(k, params[k]);
        }
    }

    return (query.length) ? ("?" + query) : "";
}


/**
 * Download a Blob as a file
 * Triggers the browsers "save file" dialouge
 * 
 * @memberof vef.utils
 * 
 * @param {Blob} blob 
 * @param {string} fileName 
 */
export function saveAsFile(blob, fileName) {
    const a = document.createElement('a');
    a.download = fileName;
    a.rel = 'noopener';

    a.href = URL.createObjectURL(blob);
    a.dispatchEvent(new MouseEvent("click"));

    // Invalidate Object URL after 60 Seconds
    setTimeout(() => URL.revokeObjectURL(a.href), 60000);
}

/**
 * iterates through the object and sets
 * the mathing CSS properties.
 * 
 * The prefix "--" will automaticalle be added
 * to the property names 
 * 
 * @memberof vef.utils
 * 
 * @param {object} properties 
 */
export function setCSSProperties(properties) {
    const root = document.documentElement;
    for (let name in properties) {
        if (typeof properties[name] == "string") {
            root.style.setProperty('--' + name, properties[name]);
        }
    }
}

/**
 * Method for capitalizing a string
 *
 * @memberof vef.utils
 * 
 * @param {string} str
 */
export function capitalizeString(str) {
    str = str.replaceAll("_", " ");
    return str.replace(/\w\S*/g, function (txt) {
        return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
    });
}


/**
 * round a number to the given decimals
 * 
 * @memberof vef.utils
 * 
 * @param {number} num 
 * @param {number} decimals 
 */
export function roundNumber(num, decimals, trailingZeros) {
    // Used approach from https://www.htmlgoodies.com/javascript/round-in-javascript/ (2022-09-26)
    decimals = decimals || 0;
    const p = Math.pow(10, decimals);
    const n = (num * p) * (1 + Number.EPSILON);

    num = Math.round(n) / p;
    return (trailingZeros) ? num.toFixed(decimals) : num;
}

/**
 * Helper method to only execute a callback after the event has not
 * been dispatched for the given amount of milliseconds
 * 
 * @memberof vef.utils
 * 
 * @param {function} callback 
 * @param {number} milliseconds 
 * 
 * @returns {function} add this as event listener
 */
export function debounceCallback(callback, milliseconds) {
    let timeout = null;
    return function (event) {
        if (timeout) clearTimeout(timeout);
        timeout = setTimeout(() => {
            timeout = null;
            callback(event);
        }, milliseconds);
    }
}

/**
 * Display a temporary message banner on top of the page
 * @memberof vef.utils
 */
export function displayMessage(message, duration, type = "message") {
    duration = (Number.isFinite(duration)) ? duration : 5000;

    let bannerContainer = document.getElementsByClassName("vef-message-banner")[0];
    if (!bannerContainer) {
        bannerContainer = document.createElement("div");
        bannerContainer.classList.add("vef-message-banner", "vef-ui", `level-${type}`);
    }

    const bannerMessage = document.createElement("div");
    bannerMessage.classList.add("message-banner-inner");
    bannerMessage.innerHTML = `
            <div class="message-content">${message}</div>
            <i class="fas fa-times btn-close"></i>
    `;

    const hide = () => {
        if (bannerMessage.classList.contains("hiding"))
            return;
        bannerMessage.classList.add("hiding");
        setTimeout(() => {
            bannerMessage.remove();
            if (bannerContainer.childElementCount == 0)
                bannerContainer.remove()
        }, 500);
    }

    bannerMessage.querySelector(".btn-close").addEventListener("click", hide);
    setTimeout(hide, duration);

    bannerContainer.appendChild(bannerMessage);
    document.body.appendChild(bannerContainer);
}

/**
 * Method to remove script tags to prevent some forms of XSS attacks.
 * This method might note be safe to cover all possible XSS attacks.
 * 
 * Inspired by: https://gomakethings.com/how-to-sanitize-html-strings-with-vanilla-js-to-reduce-your-risk-of-xss-attacks/ (2022-10-29)
 * 
 * @memberof vef.utils
 * @param {string} html
 * @param {boolean} returnAsString
 * @returns {HTMLElement} parsed element
 */
export function sanitizeHTML(html, returnAsString) {
    // parse string to DOM
    const parser = new DOMParser();
    const element = parser.parseFromString(html, 'text/html').body || document.createElement('body');

    // remove scripts
    const scripts = element.querySelectorAll('script');
    for (let i = 0; i < scripts.length; ++i) {
        scripts[i].remove();
    }

    // recursive cleanup of malicious attributes in every node
    const cleanNode = node => {
        // remove dangerous attributes
        const attributes = node.attributes;
        for (let i = 0; i < attributes.length; ++i) {
            const name = attributes[i].name;
            if (name.startsWith("on")) {
                node.removeAttribute(name);
            } else if (['src', 'href', 'xlink:href'].includes(name)) {
                const value = attributes[i].value.replace(/\s+/g, '').toLowerCase();
                if (value.includes('javascript:') || value.includes('data:text/html')) {
                    node.removeAttribute(name);
                }
            }
        }
        // recursively iterate through child nodes
        const children = node.children;
        for (let i = 0; i < children.length; ++i) {
            cleanNode(children[i]);
        }
    }
    cleanNode(element);

    return (returnAsString) ? element.innerHTML : element.childNodes;
}

/**
 * Function to get a hash for a list of strings.
 * If the list argument contains only one string, the hash calculation is applied directly to the string without using the separator.
 * For multiple strings in the list, the strings are merged into one string via the separator, and the hash is calculated.
 * 
 * Inspired by: https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js (2023-02-01, public domain license)
 * 
 * @memberof vef.utils
 * @param {Array.<String>} list list containing one or multiple strings
 * @param {string} separator delimiter to join strings in list 
 * @param {Number} seed parameter to randomize the hash function
 * @returns {string} hash
 */
export function getHash(list, separator = '|', seed = 0) {

    const str = list.join(separator)
    const cyrb53 = (str, seed) => {
        let h1 = 0xdeadbeef ^ seed,
            h2 = 0x41c6ce57 ^ seed;
        for (let i = 0, ch; i < str.length; i++) {
            ch = str.charCodeAt(i);
            h1 = Math.imul(h1 ^ ch, 2654435761);
            h2 = Math.imul(h2 ^ ch, 1597334677);
        }

        h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
        h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);

        return 4294967296 * (2097151 & h2) + (h1 >>> 0);
    };
    return cyrb53(str)
}

/**
 * Generates an identifier based on time and a random number
 * 
 * @memberof vef.utils
 * @returns {string}
 */
export function generateUniqueId() {
    return new Date().getTime().toString() + Math.floor(Math.random() * 1000000).toString();
}