NodeBB/NodeBB

View on GitHub
public/src/utils.common.js

Summary

Maintainability
C
1 day
Test Coverage
'use strict';


// add default escape function for escaping HTML entities
const escapeCharMap = Object.freeze({
    '&': '&',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '`': '&#x60;',
    '=': '&#x3D;',
});
function replaceChar(c) {
    return escapeCharMap[c];
}
const escapeChars = /[&<>"'`=]/g;

const HTMLEntities = Object.freeze({
    amp: '&',
    gt: '>',
    lt: '<',
    quot: '"',
    apos: "'",
    AElig: 198,
    Aacute: 193,
    Acirc: 194,
    Agrave: 192,
    Aring: 197,
    Atilde: 195,
    Auml: 196,
    Ccedil: 199,
    ETH: 208,
    Eacute: 201,
    Ecirc: 202,
    Egrave: 200,
    Euml: 203,
    Iacute: 205,
    Icirc: 206,
    Igrave: 204,
    Iuml: 207,
    Ntilde: 209,
    Oacute: 211,
    Ocirc: 212,
    Ograve: 210,
    Oslash: 216,
    Otilde: 213,
    Ouml: 214,
    THORN: 222,
    Uacute: 218,
    Ucirc: 219,
    Ugrave: 217,
    Uuml: 220,
    Yacute: 221,
    aacute: 225,
    acirc: 226,
    aelig: 230,
    agrave: 224,
    aring: 229,
    atilde: 227,
    auml: 228,
    ccedil: 231,
    eacute: 233,
    ecirc: 234,
    egrave: 232,
    eth: 240,
    euml: 235,
    iacute: 237,
    icirc: 238,
    igrave: 236,
    iuml: 239,
    ntilde: 241,
    oacute: 243,
    ocirc: 244,
    ograve: 242,
    oslash: 248,
    otilde: 245,
    ouml: 246,
    szlig: 223,
    thorn: 254,
    uacute: 250,
    ucirc: 251,
    ugrave: 249,
    uuml: 252,
    yacute: 253,
    yuml: 255,
    copy: 169,
    reg: 174,
    nbsp: 160,
    iexcl: 161,
    cent: 162,
    pound: 163,
    curren: 164,
    yen: 165,
    brvbar: 166,
    sect: 167,
    uml: 168,
    ordf: 170,
    laquo: 171,
    not: 172,
    shy: 173,
    macr: 175,
    deg: 176,
    plusmn: 177,
    sup1: 185,
    sup2: 178,
    sup3: 179,
    acute: 180,
    micro: 181,
    para: 182,
    middot: 183,
    cedil: 184,
    ordm: 186,
    raquo: 187,
    frac14: 188,
    frac12: 189,
    frac34: 190,
    iquest: 191,
    times: 215,
    divide: 247,
    'OElig;': 338,
    'oelig;': 339,
    'Scaron;': 352,
    'scaron;': 353,
    'Yuml;': 376,
    'fnof;': 402,
    'circ;': 710,
    'tilde;': 732,
    'Alpha;': 913,
    'Beta;': 914,
    'Gamma;': 915,
    'Delta;': 916,
    'Epsilon;': 917,
    'Zeta;': 918,
    'Eta;': 919,
    'Theta;': 920,
    'Iota;': 921,
    'Kappa;': 922,
    'Lambda;': 923,
    'Mu;': 924,
    'Nu;': 925,
    'Xi;': 926,
    'Omicron;': 927,
    'Pi;': 928,
    'Rho;': 929,
    'Sigma;': 931,
    'Tau;': 932,
    'Upsilon;': 933,
    'Phi;': 934,
    'Chi;': 935,
    'Psi;': 936,
    'Omega;': 937,
    'alpha;': 945,
    'beta;': 946,
    'gamma;': 947,
    'delta;': 948,
    'epsilon;': 949,
    'zeta;': 950,
    'eta;': 951,
    'theta;': 952,
    'iota;': 953,
    'kappa;': 954,
    'lambda;': 955,
    'mu;': 956,
    'nu;': 957,
    'xi;': 958,
    'omicron;': 959,
    'pi;': 960,
    'rho;': 961,
    'sigmaf;': 962,
    'sigma;': 963,
    'tau;': 964,
    'upsilon;': 965,
    'phi;': 966,
    'chi;': 967,
    'psi;': 968,
    'omega;': 969,
    'thetasym;': 977,
    'upsih;': 978,
    'piv;': 982,
    'ensp;': 8194,
    'emsp;': 8195,
    'thinsp;': 8201,
    'zwnj;': 8204,
    'zwj;': 8205,
    'lrm;': 8206,
    'rlm;': 8207,
    'ndash;': 8211,
    'mdash;': 8212,
    'lsquo;': 8216,
    'rsquo;': 8217,
    'sbquo;': 8218,
    'ldquo;': 8220,
    'rdquo;': 8221,
    'bdquo;': 8222,
    'dagger;': 8224,
    'Dagger;': 8225,
    'bull;': 8226,
    'hellip;': 8230,
    'permil;': 8240,
    'prime;': 8242,
    'Prime;': 8243,
    'lsaquo;': 8249,
    'rsaquo;': 8250,
    'oline;': 8254,
    'frasl;': 8260,
    'euro;': 8364,
    'image;': 8465,
    'weierp;': 8472,
    'real;': 8476,
    'trade;': 8482,
    'alefsym;': 8501,
    'larr;': 8592,
    'uarr;': 8593,
    'rarr;': 8594,
    'darr;': 8595,
    'harr;': 8596,
    'crarr;': 8629,
    'lArr;': 8656,
    'uArr;': 8657,
    'rArr;': 8658,
    'dArr;': 8659,
    'hArr;': 8660,
    'forall;': 8704,
    'part;': 8706,
    'exist;': 8707,
    'empty;': 8709,
    'nabla;': 8711,
    'isin;': 8712,
    'notin;': 8713,
    'ni;': 8715,
    'prod;': 8719,
    'sum;': 8721,
    'minus;': 8722,
    'lowast;': 8727,
    'radic;': 8730,
    'prop;': 8733,
    'infin;': 8734,
    'ang;': 8736,
    'and;': 8743,
    'or;': 8744,
    'cap;': 8745,
    'cup;': 8746,
    'int;': 8747,
    'there4;': 8756,
    'sim;': 8764,
    'cong;': 8773,
    'asymp;': 8776,
    'ne;': 8800,
    'equiv;': 8801,
    'le;': 8804,
    'ge;': 8805,
    'sub;': 8834,
    'sup;': 8835,
    'nsub;': 8836,
    'sube;': 8838,
    'supe;': 8839,
    'oplus;': 8853,
    'otimes;': 8855,
    'perp;': 8869,
    'sdot;': 8901,
    'lceil;': 8968,
    'rceil;': 8969,
    'lfloor;': 8970,
    'rfloor;': 8971,
    'lang;': 9001,
    'rang;': 9002,
    'loz;': 9674,
    'spades;': 9824,
    'clubs;': 9827,
    'hearts;': 9829,
    'diams;': 9830,
});

/* eslint-disable no-redeclare */
const utils = {
    // https://github.com/substack/node-ent/blob/master/index.js
    decodeHTMLEntities: function (html) {
        return String(html)
            .replace(/&#(\d+);?/g, function (_, code) {
                return String.fromCharCode(code);
            })
            .replace(/&#[xX]([A-Fa-f0-9]+);?/g, function (_, hex) {
                return String.fromCharCode(parseInt(hex, 16));
            })
            .replace(/&([^;\W]+;?)/g, function (m, e) {
                const ee = e.replace(/;$/, '');
                const target = HTMLEntities[e] || (e.match(/;$/) && HTMLEntities[ee]);

                if (typeof target === 'number') {
                    return String.fromCharCode(target);
                } else if (typeof target === 'string') {
                    return target;
                }

                return m;
            });
    },
    // https://github.com/jprichardson/string.js/blob/master/lib/string.js
    stripHTMLTags: function (str, tags) {
        const pattern = (tags || ['']).join('|');
        return String(str).replace(new RegExp('<(\\/)?(' + (pattern || '[^\\s>]+') + ')(\\s+[^<>]*?)?\\s*(\\/)?>', 'gi'), '');
    },

    cleanUpTag: function (tag, maxLength) {
        if (typeof tag !== 'string' || !tag.length) {
            return '';
        }

        tag = tag.trim().toLowerCase();
        // see https://github.com/NodeBB/NodeBB/issues/4378
        tag = tag.replace(/\u202E/gi, '');
        tag = tag.replace(/[,/#!$^*;:{}=_`<>'"~()?|]/g, '');
        tag = tag.slice(0, maxLength || 15).trim();
        const matches = tag.match(/^[.-]*(.+?)[.-]*$/);
        if (matches && matches.length > 1) {
            tag = matches[1];
        }
        return tag;
    },

    removePunctuation: function (str) {
        return str.replace(/[.,-/#!$%^&*;:{}=\-_`<>'"~()?]/g, '');
    },

    isEmailValid: function (email) {
        return typeof email === 'string' && email.length && email.indexOf('@') !== -1 && email.indexOf(',') === -1 && email.indexOf(';') === -1;
    },

    isUserNameValid: function (name) {
        return (name && name !== '' && (/^['" \-+.*[\]0-9\u00BF-\u1FFF\u2C00-\uD7FF\w]+$/.test(name)));
    },

    isPasswordValid: function (password) {
        return typeof password === 'string' && password.length;
    },

    isNumber: function (n) {
        // `isFinite('') === true` so isNan parseFloat check is necessary
        return !isNaN(parseFloat(n)) && isFinite(n);
    },

    languageKeyRegex: /\[\[[\w]+:.+\]\]/,
    hasLanguageKey: function (input) {
        return utils.languageKeyRegex.test(input);
    },
    userLangToTimeagoCode: function (userLang) {
        const mapping = {
            'en-GB': 'en',
            'en-US': 'en',
            'fa-IR': 'fa',
            'pt-BR': 'pt-br',
            nb: 'no',
        };
        return mapping.hasOwnProperty(userLang) ? mapping[userLang] : userLang;
    },
    // shallow objects merge
    merge: function () {
        const result = {};
        let obj;
        let keys;
        for (let i = 0; i < arguments.length; i += 1) {
            obj = arguments[i] || {};
            keys = Object.keys(obj);
            for (let j = 0; j < keys.length; j += 1) {
                result[keys[j]] = obj[keys[j]];
            }
        }
        return result;
    },

    fileExtension: function (path) {
        return ('' + path).split('.').pop();
    },

    extensionMimeTypeMap: {
        bmp: 'image/bmp',
        cmx: 'image/x-cmx',
        cod: 'image/cis-cod',
        gif: 'image/gif',
        ico: 'image/x-icon',
        ief: 'image/ief',
        jfif: 'image/pipeg',
        jpe: 'image/jpeg',
        jpeg: 'image/jpeg',
        jpg: 'image/jpeg',
        png: 'image/png',
        pbm: 'image/x-portable-bitmap',
        pgm: 'image/x-portable-graymap',
        pnm: 'image/x-portable-anymap',
        ppm: 'image/x-portable-pixmap',
        ras: 'image/x-cmu-raster',
        rgb: 'image/x-rgb',
        svg: 'image/svg+xml',
        tif: 'image/tiff',
        tiff: 'image/tiff',
        xbm: 'image/x-xbitmap',
        xpm: 'image/x-xpixmap',
        xwd: 'image/x-xwindowdump',
    },

    fileMimeType: function (path) {
        return utils.extensionToMimeType(utils.fileExtension(path));
    },

    extensionToMimeType: function (extension) {
        return utils.extensionMimeTypeMap.hasOwnProperty(extension) ? utils.extensionMimeTypeMap[extension] : '*';
    },

    isPromise: function (object) {
        // https://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise#comment97339131_27746324
        return object && typeof object.then === 'function';
    },

    promiseParallel: function (obj) {
        const keys = Object.keys(obj);
        return Promise.all(
            keys.map(function (k) { return obj[k]; })
        ).then(function (results) {
            const data = {};
            keys.forEach(function (k, i) {
                data[k] = results[i];
            });
            return data;
        });
    },

    // https://github.com/sindresorhus/is-absolute-url
    isAbsoluteUrlRE: /^[a-zA-Z][a-zA-Z\d+\-.]*:/,
    isWinPathRE: /^[a-zA-Z]:\\/,
    isAbsoluteUrl: function (url) {
        if (utils.isWinPathRE.test(url)) {
            return false;
        }
        return utils.isAbsoluteUrlRE.test(url);
    },

    isRelativeUrl: function (url) {
        return !utils.isAbsoluteUrl(url);
    },

    makeNumberHumanReadable: function (num, toFixed = 1) {
        const n = parseInt(num, 10);
        if (!n) {
            return String(num);
        }
        if (n > 999999) {
            return (n / 1000000).toFixed(toFixed) + 'm';
        } else if (n > 999) {
            return (n / 1000).toFixed(toFixed) + 'k';
        }
        return n;
    },

    // takes a string like 1000 and returns 1,000
    addCommas: function (text) {
        return String(text).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,');
    },

    toISOString: function (timestamp) {
        if (!timestamp || !Date.prototype.toISOString) {
            return '';
        }

        // Prevent too-high values to be passed to Date object
        timestamp = Math.min(timestamp, 8640000000000000);

        try {
            return new Date(parseInt(timestamp, 10)).toISOString();
        } catch (e) {
            return timestamp;
        }
    },

    tags: ['a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'b', 'base', 'basefont',
        'bdi', 'bdo', 'big', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup',
        'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed',
        'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link',
        'map', 'mark', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option',
        'output', 'p', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select',
        'small', 'source', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot',
        'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'],

    stripTags: ['abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'base', 'basefont',
        'bdi', 'bdo', 'big', 'blink', 'body', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup',
        'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed',
        'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        'head', 'header', 'hr', 'html', 'iframe', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link',
        'map', 'mark', 'marquee', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option',
        'output', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select',
        'source', 'span', 'strike', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot',
        'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'],

    escapeRegexChars: function (text) {
        return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
    },

    escapeHTML: function (str) {
        if (str == null) {
            return '';
        }
        if (!str) {
            return String(str);
        }

        return str.toString().replace(escapeChars, replaceChar);
    },

    isAndroidBrowser: function () {
        // http://stackoverflow.com/questions/9286355/how-to-detect-only-the-native-android-browser
        const nua = navigator.userAgent;
        return ((nua.indexOf('Mozilla/5.0') > -1 && nua.indexOf('Android ') > -1 && nua.indexOf('AppleWebKit') > -1) && !(nua.indexOf('Chrome') > -1));
    },

    isTouchDevice: function () {
        return 'ontouchstart' in document.documentElement;
    },

    getHoursArray: function () {
        const currentHour = new Date().getHours();
        const labels = [];

        for (let i = currentHour, ii = currentHour - 24; i > ii; i -= 1) {
            const hour = i < 0 ? 24 + i : i;
            labels.push(hour + ':00');
        }

        return labels.reverse();
    },

    getDaysArray: function (from, amount) {
        const currentDay = new Date(parseInt(from, 10) || Date.now()).getTime();
        const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
        const labels = [];
        let tmpDate;

        for (let x = (amount || 30) - 1; x >= 0; x -= 1) {
            tmpDate = new Date(currentDay - (1000 * 60 * 60 * 24 * x));
            labels.push(months[tmpDate.getMonth()] + ' ' + tmpDate.getDate());
        }

        return labels;
    },

    /* Retrieved from http://stackoverflow.com/a/7557433 @ 27 Mar 2016 */
    isElementInViewport: function (el) {
        // special bonus for those using jQuery
        if (typeof jQuery === 'function' && el instanceof jQuery) {
            el = el[0];
        }

        const rect = el.getBoundingClientRect();

        return (
            rect.top >= 0 &&
            rect.left >= 0 &&
            rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /* or $(window).height() */
            rect.right <= (window.innerWidth || document.documentElement.clientWidth) /* or $(window).width() */
        );
    },

    // get all the url params in a single key/value hash
    params: function (options = {}) {
        let url;
        if (options.url && !options.url.startsWith('http')) {
            // relative path passed in
            options.url = options.url.replace(new RegExp(`/?${config.relative_path.slice(1)}/`, 'g'), '');
            url = new URL(document.location);
            url.pathname = options.url;
        } else {
            url = new URL(options.url || document.location);
        }
        let params = url.searchParams;

        if (options.full) { // return URLSearchParams object
            return params;
        }

        // Handle arrays passed in query string (Object.fromEntries does not)
        const arrays = {};
        params.forEach((value, key) => {
            if (!key.endsWith('[]')) {
                return;
            }

            key = key.slice(0, -2);
            arrays[key] = arrays[key] || [];
            arrays[key].push(utils.toType(value));
        });
        Object.keys(arrays).forEach((key) => {
            params.delete(`${key}[]`);
        });

        // Backwards compatibility with v1.x -- all values passed through utils.toType()
        params = Object.fromEntries(params);
        Object.keys(params).forEach((key) => {
            params[key] = utils.toType(params[key]);
        });

        return { ...params, ...arrays };
    },

    param: function (key) {
        return this.params()[key];
    },

    urlToLocation: function (url) {
        const a = document.createElement('a');
        a.href = url;
        return a;
    },

    // return boolean if string 'true' or string 'false', or if a parsable string which is a number
    // also supports JSON object and/or arrays parsing
    toType: function (str) {
        const type = typeof str;
        if (type !== 'string') {
            return str;
        }
        const nb = parseFloat(str);
        if (!isNaN(nb) && isFinite(str)) {
            return nb;
        }
        if (str === 'false') {
            return false;
        }
        if (str === 'true') {
            return true;
        }

        try {
            str = JSON.parse(str);
        } catch (e) {}

        return str;
    },

    // Safely get/set chained properties on an object
    // set example: utils.props(A, 'a.b.c.d', 10) // sets A to {a: {b: {c: {d: 10}}}}, and returns 10
    // get example: utils.props(A, 'a.b.c') // returns {d: 10}
    // get example: utils.props(A, 'a.b.c.foo.bar') // returns undefined without throwing a TypeError
    // credits to github.com/gkindel
    props: function (obj, props, value) {
        if (obj === undefined) {
            obj = window;
        }
        if (props == null) {
            return undefined;
        }
        const i = props.indexOf('.');
        if (i === -1) {
            if (value !== undefined) {
                obj[props] = value;
            }
            return obj[props];
        }
        const prop = props.slice(0, i);
        const newProps = props.slice(i + 1);

        if (props !== undefined && !(obj[prop] instanceof Object)) {
            obj[prop] = {};
        }

        return utils.props(obj[prop], newProps, value);
    },

    isInternalURI: function (targetLocation, referenceLocation, relative_path) {
        return targetLocation.host === '' || // Relative paths are always internal links
            (
                targetLocation.host === referenceLocation.host &&
                // Otherwise need to check if protocol and host match
                targetLocation.protocol === referenceLocation.protocol &&
                // Subfolder installs need this additional check
                (relative_path.length > 0 ? targetLocation.pathname.indexOf(relative_path) === 0 : true)
            );
    },

    rtrim: function (str) {
        return str.replace(/\s+$/g, '');
    },

    debounce: function (func, wait, immediate) {
        // modified from https://davidwalsh.name/javascript-debounce-function
        let timeout;
        return function () {
            const context = this;
            const args = arguments;
            const later = function () {
                timeout = null;
                if (!immediate) {
                    func.apply(context, args);
                }
            };
            const callNow = immediate && !timeout;
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
            if (callNow) {
                func.apply(context, args);
            }
        };
    },
    throttle: function (func, wait, immediate) {
        let timeout;
        return function () {
            const context = this;
            const args = arguments;
            const later = function () {
                timeout = null;
                func.apply(context, args);
            };
            const callNow = immediate && !timeout;
            if (!timeout) {
                timeout = setTimeout(later, wait);
            }
            if (callNow) {
                func.apply(context, args);
            }
        };
    },
    generateSaveId: function (uid) {
        return ['composer', uid, Date.now()].join(':');
    },
};

module.exports = utils;