core/util.js

const { Context } = require('./context');
const { fork } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');


/**
 * Util contains a number of static utility functions used throughout
 * the DART application. It has no constructor. Simply call Util.<method>(<args>)
 * to use any of its methods.
 *
 */

class Util {

    /**
      * Returns a version 4 uuid string.
      *
      * Thanks https://gist.github.com/kaizhu256/4482069
      * @returns {string }
      */
    static uuid4() {
        var uuid = '', ii;
        for (ii = 0; ii < 32; ii += 1) {
            switch (ii) {
            case 8:
            case 20:
                uuid += '-';
                uuid += (Math.random() * 16 | 0).toString(16);
                break;
            case 12:
                uuid += '-';
                uuid += '4';
                break;
            case 16:
                uuid += '-';
                uuid += (Math.random() * 4 | 8).toString(16);
                break;
            default:
                uuid += (Math.random() * 16 | 0).toString(16);
            }
        }
        return uuid;
    }

    /**
      * Returns the byte length of an utf8 string
      *
      * @param {string} str - The string to measure.
      * @returns {number}
      */
    static unicodeByteLength(str) {
        var s = str.length;
        for (var i=str.length-1; i>=0; i--) {
            var code = str.charCodeAt(i);
            if (code > 0x7f && code <= 0x7ff) s++;
            else if (code > 0x7ff && code <= 0xffff) s+=2;
            if (code >= 0xDC00 && code <= 0xDFFF) i--; //trail surrogate
        }
        return s;
    }

    /**
      * Returns true if str matches the regex for hex-formatted uuid.
      *
      * @param {string} str - The string to test.
      * @returns {boolean}
      */
    static looksLikeUUID(str) {
        var regex = /^([a-f\d]{8}(-[a-f\d]{4}){3}-[a-f\d]{12}?)$/i;
        return Util.stringMatchesRegex(str, regex);
    }

    /**
      * Returns true if str matches the regex for a URL beginning
      * with http or https and looks like a valid URL.
      *
      * @param {string} str - The string to test.
      * @returns {boolean}
      */
    static looksLikeHypertextURL(str) {
        let regex = /(^http:\/\/localhost)|(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*))/;
        return Util.stringMatchesRegex(str, regex);
    }

    static stringMatchesRegex(str, pattern) {
        var match = null
        try {
            match = str.match(pattern);
        } catch (ex) {
            // null string or non-string
        }
        return match != null;
    }

    /**
      * Returns true if str is null or empty.
      *
      * @param {string} str - The string to check.
      * @returns {boolean}
      */
    static isEmpty(str) {
        return (str == null || ((typeof str) == "string" && str.trim() == ""));
    }

    /**
      * Converts a camel case variable name to title-cased text.
      * For example, myVarName is converted to My Var Name.
      * This is useful for converting var names to labels in the UI.
      *
      * @param {string} str - The string to convert
      * @returns {string}
      */
    static camelToTitle(str) {
        var spaced = str.replace( /([A-Z])/g, " $1" );
        return spaced.charAt(0).toUpperCase() + spaced.slice(1);
    }

    /**
      * Returns true if array of strings is empty, or if all elements
      * of the array are null or empty.
      *
      * @param {string[]} arr - The list of strings to check.
      * @returns {boolean}
      */
    static isEmptyStringArray(arr) {
        if(arr == null || arr.length == 0) {
            return true;
        }
        for(let str of arr) {
            if (!Util.isEmpty(str)) {
                return false;
            }
        }
        return true;
    }

    /**
      * Returns an array of strings, with all null and empty strings
      * removed.
      *
      * @param {string[]} arr - The list of strings to filter.
      * @returns {string[]} - The list of strings, minus null and empty entries.
      */
    static filterEmptyStrings(arr) {
        if(arr == null || !Array.isArray(arr)) {
            return [];
        }
        return arr.map(item => item? item.trim() : "").filter(item => item != "");
    }

    /**
      * Returns true if list contains item. The list should contain simple
      * scalar values that can be compared with a simple equality test.
      * This is similar to the builtin array.includes(), but includes some
      * special handling for boolean values.
      *
      * @param {string[]|number[]|boolean[]} list - The list items to search.
      * @param {string|number|boolean} item - The item to look for.
      * @returns {boolean} True if the item is in the list.
      */
    static listContains(list, item) {
        for (var i of list) {
            if (i == item || Util.boolEqual(i, item)) {
                return true;
            }
        }
        return false;
    }

    /**
      * Returns a function suitable for sorting a list of objects
      * in ascending or desdending order.
      *
      * @param {string} property - The name of the object property to sort on.
      * @param {string} direction - 'desc' or 'asc' for descending or ascending.
      * @returns {function} A function with params (a, b) where a and b are items to be compared.
      */
    static getSortFunction(property, direction) {
        if (direction == 'desc') {
            return function(a, b) {
                // descending sort
                if (a[property] < b[property]) { return 1; }
                if (a[property] > b[property]) { return -1; }
                return 0;
            };
        } else {
            return function(a, b) {
                // ascending sort
                if (a[property] < b[property]) { return -1; }
                if (a[property] > b[property]) { return 1; }
                return 0;
            }
        }
    }

    /**
      * Returns true if a and b are both non-null and both indicate
      * the same truth value. E.g. true = "true" = "yes" and
      * false = "false" = "no".
      *
      * @param {boolean|string} a - First value to compare.
      * @param {boolean|string} b - Second value to compare.
      * @returns {boolean} True if the two values indicate the same boolean value.
      */
    static boolEqual(a, b) {
        var aValue = Util.boolValue(a);
        var bValue = Util.boolValue(b);
        return (aValue != null && aValue == bValue);
    }

    /**
      * Returns the boolean value of a string. "true" and "yes"
      * are true. "false" and "no" are false. String is case-insensitive.
      *
      * @param {boolean|string} val - The value to examine.
      *
      * @returns {boolean} The boolean value of the string, or undefined
      * if it can't be determined.
      */
    static boolValue(val) {
        if (typeof val === 'boolean') {
            return val;
        }
        var lcString = String(val).toLowerCase();
        var trueValues = ['true', 'yes', '1'];
        var falseValues = ['false', 'no', '0'];
        var retValue;
        if (trueValues.includes(lcString)) {
            retValue = true;
        } else if (falseValues.includes(lcString)) {
            retValue = false;
        }
        return retValue;
    }

    /**
     * toHumanSize returns a human-readable size for the given
     * number of bytes. The return value is trucated at two
     * decimal places. E.g. 2621440 converts to "2.50 MB"
     *
     * @param {number} bytes - The number of bytes to convert to human size.
     * @returns {string} - A human-readable string, like "2.5 MB"
     */
    static toHumanSize(bytes) {
        if (isNaN(bytes)) {
            return 'Not a Number';
        }
        var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
        var hs = bytes;
        for(var i=0; i < sizes.length; i++) {
            hs = bytes / (1024 ** i);
            if (hs < 1024) {
                break;
            }
        }
        return `${hs.toFixed(2)} ${sizes[i]}`;
    }

    /**
      * Truncates a string at len characters, and appends '..." to the end.
      *
      * @param {string} str - The string to truncate.
      * @param {number} len - Truncate the string at len characters.
      * @returns {string} - The truncated string, with three trailing dots.
      */
    static truncateString(str, len) {
        if (Util.isEmpty(str) || str.length <= len) {
            return str;
        }
        return str.slice(0, len - 1) + '...';
    }

    /**
     * Returns a copy of string str with the first letter set to
     * lowercase. Returns null if param str is null.
     *
     * @param {string}
     *
     * @returns {string}
     */
    static lcFirst(str) {
        if (!str) {
            return str;
        }
        return str.charAt(0).toLowerCase() + str.substr(1);
    }

    /**
     * Replaces backslashes in Windows paths with double backslashes.
     *
     * @param {string} winPath - A Windows path.
     *
     * @returns {string}
     */
    static escapeBackslashes(winPath) {
        return winPath.replace(/\\/g, '\\\\')
    }

    /**
	  * Converts an absolute Windows path to a path with forward slashes suitable
	  * for a BagIt file or tar file. Also strips off drive letters and share names.
      *
      * @param {string} winPath - An absolute Windows path.
      * @returns {string} - Path with drive and share removed, and slashes leaning
      * the right way.
      *
      */
	static normalizeWindowsPath(winPath) {
        winPath = Util.removeWindowsDrivePrefix(winPath);
		// Backslash to forward slash
		return winPath.replace(/\\/g, '/');
	}

    /**
	  * Strips off drive letters and share names from Windows paths.
      * E.g. "C:\some\path" becomes "\some\path" and "\\share\some\path"
      * becomes "\some\path"
      *
      * @param {string} winPath - An absolute Windows path.
      * @returns {string} - Path with drive and share removed.
      *
      */
    static removeWindowsDrivePrefix(winPath) {
		// Remove C:
		winPath = winPath.replace(/^[A-Z]:/i, '');
		// Remove \\share
		return winPath.replace(/^\\\\[^\\]+/, '');
    }

    /**
     * walkSync recursively lists all files in a directory and its
     * subdirectories and returns them in filelist. If you want to
     * filter the list, include a callback filterFunction, which takes
     * the filepath as its sole param (a string) and returns true or
     * false to indicate whether to include the file in the list.
     *
     * The returned list is a list of hashes, and each hash has keys
     * absPath: the absolute path to the file
     * stats: a Node fs.Stats object with info about the file
     * The returned list will not include links, only files.
     *
     * @param {string} dir - Path to directory.
     * @param {filterFunction} filterFunction - A function to filter out items that should not go into filelist.
     * @returns {string[]} A list of file paths under dir that pass the filter.
    */
    static walkSync(dir, filterFunction) {
        var files = fs.readdirSync(dir);
        var filelist = [];
        filterFunction = filterFunction || function(file) { return true };
        files.forEach(function(file) {
            var absPath = path.join(dir, file);
            if (!fs.existsSync(absPath)) {
                return;  // Symlinks give ENOENT error
            }
            var stats = fs.statSync(absPath);
            if (stats.isDirectory()) {
                filelist = Util.walkSync(absPath, filelist, filterFunction);
            }
            else if (stats.isFile() && filterFunction(absPath)) {
                filelist.push({
                    absPath: absPath,
                    stats: stats
                });
            }
        });
        return filelist;
    };

    /**
     * Recursively and synchronously deletes a directory and all its contents.
     * This will throw an exception if you try to delete any path that's
     * too close to the root of the file system (fewer than 8 characters
     * or fewer than 3 slashes/backslashes).
     *
     * @param {string} dir - Path to the directory you want to delete.
    */
    static deleteRecursive(filepath) {
        if (filepath.length < 8 || filepath.split(path.sep).length < 3) {
            throw `${filepath} does not look safe to delete`;
        }
        if (fs.existsSync(filepath)) {
            fs.readdirSync(filepath).forEach(function(file, index){
                var curPath = path.join(filepath, file);
                if (fs.lstatSync(curPath).isDirectory()) {
                    Util.deleteRecursive(curPath);
                } else {
                    fs.unlinkSync(curPath);
                }
            });
            // Delete the directory that the code above just emptied out,
            // and ignore errors on Windows that come from synchronous
            // delete calls not actually deleting files when they say they do.
            // This problem is described in this bug report:
            //
            // https://github.com/nodejs/node-v0.x-archive/issues/3051
            //
            // On Windows, Node's unlinkSync function merely marks a file
            // to be deleted and then returns without deleting it, if the
            // file has an open read handle. This causes the rmdirSync call
            // below to throw an exception saying the directory isn't empty.
            //
            // Well screw that. When a synchronous function says it's done,
            // it should be done. The worst side-effect of ignoring Window's
            // stupidity in the catch block is that the user will end up
            // with some unneeded empty directories.
			try {
				fs.rmdirSync(filepath);
			} catch (err) {
				if (os.platform == 'win32') {
				    // Windows == Loserville
				} else {
					console.log(err);
				}
			}
        }
    };

    /**
     * This deletes up to one item from an array, modifying the
     * array in place. For example, Util.deleteFromArray(list, "butter")
     * will delete the first instance of the word "butter" from the
     * array, or will delete nothing if the array does not contain
     * that word.
     *
     * @param {Array} array - A list of items.
     *
     * @param {string|number|boolean|Date} item - The item you want to
     * delete.
     */
    static deleteFromArray(array, item) {
        let index = array.indexOf(item);
        if (index > -1) {
            array.splice(index, 1);
        }
    }

    /**
     * This function returns the basename of the given file path with
     * with the following extensions stripped off:
     *
     * * .7z, .s7z
     * * .bz, .bz2
     * * .gz
     * * .par, par2
     * * .rar
     * * .tar, .tar.gz, .tgz, .tar.Z
     * * .zip, .zipx
     *
     * The point is to return the expected name of the bag, based
     * on the file path. If the file's basename has an unrecognized
     * extension or no extension, this will return the basename unaltered.
     *
     * @param {string} filepath - Path to the bag.
    */
    static bagNameFromPath(filepath) {
        var bagName = path.basename(filepath);
        return bagName.replace(/\.tar$|\.tar\.gz$|\.t?gz$|\.tar\.Z$|\.tar\.bz$|\.tar\.bz2$|\.bz$|\.bz2$|\.zip$|\.zipx$|\.rar$|\.7z$|\.s7z$|\.par$|\.par2$/, '');
    }

    /**
     * This casts the string value str to type toType and returns
     * the cast value. This is used primarily for converting values
     * from HTML forms to correctly-typed JavaScript values.
     *
     * @example
     * Util.cast('false', 'boolean') // returns false
     * Util.cast('yes', 'boolean')   // returns true
     * Util.cast('1', 'boolean')     // returns true
     * Util.cast('3', 'number')      // returns 3
     * Util.cast('3.14', 'number')   // returns 3.14
     *
     * @param {string} str - The string value to be cast.
     *
     * @param {string} toType - The type to which the string value
     * should be cast. Currently supports only 'number' and 'boolean'.
    */
    static cast(str, toType) {
        let castValue = str;
        if (toType === 'boolean') {
            castValue = Util.boolValue(str);
        } else if (toType === 'number') {
            if (str.indexOf('.') > -1) {
                castValue = parseFloat(str);
            } else {
                castValue = parseInt(str);
            }
        }
        return castValue;
    }

    /**
     * This returns true if settingValue begins with "env:".
     *
     * @param {string} settingValue - The value of the setting.
     *
     * @returns {boolean}
     */
    static looksLikeEnvSetting(settingValue) {
        return settingValue.startsWith('env:');
    }

    /**
     * This returns the value of an environment variable, if
     * the variable is available. Certain variables, such as
     * the login credentials used in the {@link StorageService}
     * class may be stored more safely as environment variables
     * outside of the DART database. Those variables follow the
     * pattern env:VAR_NAME.
     *
     * If you pass env:VAR_NAME to this function, it will return
     * the value of the environment variable VAR_NAME.
     *
     * @param {string} str - A string in the format env:VAR_NAME.
     *
     * @returns {string}
     */
    static getEnvSetting(str) {
        let [prefix, varname] = str.split(':');
        return process.env[varname];
    }

    /**
     * Returns a path to a temp file, without actually creating
     * the file.
     *
     * @returns {string}
     *
     */
    static tmpFilePath() {
        let dartTmpDir = path.join(os.tmpdir(), 'DART');
        if (!fs.existsSync(dartTmpDir)) {
            fs.mkdirSync(dartTmpDir);
        }
        let prefix = Util.uuid4().split('-')[0];
        let suffix = Date.now().toString();
        let filename = `${prefix}_${suffix}`;
        return path.join(dartTmpDir, filename);
    }

    /**
     * Returns true if arrays a and b contain the same elements, regardless
     * of order. When specifying orderMatters = false, be careful using this
     * with large arrays, since it copies and sorts each array. This is
     * intended for arrays of scalar values like strings, numbers, and dates,
     * whose values can be compared for simple equality.
     *
     * @param {Array} a - The first array.
     * @param {Array} b - The second array.
     * @param {boolean} orderMatters - Set this to true if the arrays must
     * contain the same elements in the same order. Set to false to check if
     * they contain the same elements in any order.
     *
     * @returns {boolean}
     *
     */
    static arrayContentsMatch(a, b, orderMatters) {
        if (!Array.isArray(a) || !Array.isArray(b)) {
            return false;
        }
        if (a === b) {
            return true;
        }
        if (a.length != b.length) {
            return false;
        }
        let aCopy = orderMatters ? a : [...a].sort();
        let bCopy = orderMatters ? b : [...b].sort();
        for (var i = 0; i < aCopy.length; ++i) {
            if (aCopy[i] !== bCopy[i]) {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns true if the file at filepath is readable by
     * the current user/application.
     *
     * @param {string} filepath - The path the the file.
     * @returns {boolean}
     */
    static canRead(filepath) {
        let canRead = true;
        try {
            fs.accessSync(filepath, fs.constants.R_OK);
        } catch (err) {
            canRead = false;
        }
        return canRead;
    }

    /**
     * Returns true if the file at filepath is writable by
     * the current user/application.
     *
     * @params {string} filepath - The path the the file.
     * @returns {boolean}
     */
    static canWrite(filepath) {
        let canWrite = true;
        try {
            fs.accessSync(filepath, fs.constants.W_OK);
        } catch (err) {
            canWrite = false;
        }
        return canWrite;
    }

    /**
     * Returns true if dirpath is a directory.
     *
     * @path {string} dirpath - The path to check
     *
     * @returns {boolean}
     */
    static isDirectory(dirpath) {
        return fs.existsSync(dirpath) && fs.lstatSync(dirpath).isDirectory()
    }

    /**
     * Returns true if dirpath is a non-empty directory.
     *
     * @path {string} dirpath - The path to check
     *
     * @returns {boolean}
     */
    static isNonEmptyDirectory(dirpath) {
        let files = []
        if (fs.existsSync(dirpath) && fs.lstatSync(dirpath).isDirectory()) {
            files = fs.readdirSync(dirpath)
        }
        return files.length > 0
    }

    /**
     * Given a list of file paths, this returns the prefix common
     * to all paths. This function probably has worse than 0n^2
     * efficiency, so it's OK if paths.length < 10, but it probably
     * gets pretty bad from there.
     *
     * If paths have nothing in common, this returns an empty string.
     *
     * @example
     * let posixPaths = [
     *     "/path/to/some/file.txt",
     *     "/path/to/some/other/document.pdf",
     *     "/path/to/some/different/photo.jpg"
     * ]
     *
     * Util.findCommonPathPrefix(posixPaths); // returns "/path/to/some/"
     *
     * let windowPaths = [
     *     "c:\\path\\to\\some\\file.txt",
     *     "c:\\path\\to\\some\\other\\file.txt",
     *     "c:\\path\\to\\some\\different\\file.txt",
     * ]
     *
     * Util.findCommonPathPrefix(windowsPaths); // returns "c:\\path\\to\\some\\"
     *
     * @param {Array<string>} paths - List of paths to compare.
     *
     * @param {string} pathSep - Optional param to specify the path
     * separator. This defaults to the operating system's path.sep, so you
     * don't need to specify this.
     *
     * @returns {string}
     */
    static findCommonPathPrefix(paths, pathSep = path.sep) {
        let i = 0;
        let lastPrefix = '';
        let prefix = '';
        let match = true;
        while(match) {
            i = paths[0].indexOf(pathSep, i + 1);
            if (i < 0) {
                break;
            }
            prefix = paths[0].slice(0, i + 1);
            for(let p of paths) {
                if (!p.startsWith(prefix)){
                    match = false;
                    prefix = lastPrefix; // this is the last one that did match
                    break;
                }
            }
            lastPrefix = prefix;
        }
        return prefix;
    }

    /**
     * Returns str trimmed to the specified length, with trimmed characters
     * replaced by '...'. If str is already less than or equal to len
     * characters, this returns the string unchanged. If len is too short,
     * the return value will likely be '...'.
     *
     * @param {string} str - The string to trim.
     * @param {number} len - The length to trim to.
     * @param {string} trimFrom - Where to trim the excess characters from.
     * Valid values are 'end' and 'middle'. Any other value is interpreted
     * as 'middle'.
     * @returns {string}
     */
    static trimToLength(str, len, trimFrom) {
        let trimmedStr = '';
        if (str.length <= len) {
            return str
        }
        if (trimFrom == 'end') {
            trimmedStr = str.substring(0, len-1) + '...';
        } else {
            let endOfStart = Math.max(((len / 2) - 4), 4);
            let start = str.substring(0, endOfStart);
            let endIndex = Math.max(0, str.length - (len / 2));
            let end = str.substring(endIndex, str.length)
            trimmedStr = start + '...' + end;
        }
        return trimmedStr;
    }

    /**
     * Launches a Job in a separate process. Returns an object with
     * keys childProcess and dartProcess.
     *
     * @param {Job} job - The job to run in the child process.
     *
     * @returns {Object}
     */
    static forkJobProcess(job) {
        const { DartProcess } = require('./dart_process');
        let tmpFile = Util.tmpFilePath();
        fs.writeFileSync(tmpFile, JSON.stringify(job));

        // Need to change npm command outside of dev env.
        let modulePath = path.join(__dirname, '..', 'main.js');
        let childProcess = fork(
                modulePath,
                ['--job', tmpFile]
        );

        let dartProcess = new DartProcess(
            job.title,
            job.id,
            childProcess
        );
        return {
            childProcess: childProcess,
            dartProcess: dartProcess,
        }
    }

    /**
     * Returns a human-readable version of an error. This works for
     * native JavaScript Error object and Node.js's syscall errors.
     * If err is a string, it returns err unchanged. If it's none of
     * those types, this returns a JSON string of the error.
     *
     * @param {Object} err - An Error, Node SystemError, or other object.
     *
     * @returns {string}
     */
    static formatError(err) {
        if (typeof err === 'string') {
            return err;
        }
        if (err.message) {
            return err.message;
        }
        if (err.code && err.syscall && err.path) {
            return Context.y18n.__("%s: Cannot %s %s", err.code, err.syscall, err.path)
        }
        return JSON.stringify(err);
    }

}

module.exports.Util = Util;