helpers/jrh_misc.js

/**
 * @module helpers/jrh_misc
 * @author jesse reichler <mouser@donationcoder.com>
 * @copyright 5/7/19

 * @description
 * Collection of general purpose helper functions
*/

"use strict";


// modules
const util = require("util");
const path = require("path");

// 3rd party for durations
const humanizeDuration = require("humanize-duration");






//---------------------------------------------------------------------------
// helper function for merging unique arrays
// see https://stackoverflow.com/questions/1584370/how-to-merge-two-arrays-in-javascript-and-de-duplicate-items
/**
 * Combine two arrays and return the concatenation; duplicates are *not* removed
 *
 * @param {array} array1
 * @param {array} array2
 * @returns concatenated array
 */
function mergeArraysKeepDupes(array1, array2) {
	return array1.concat(array2);
}

/**
 * Combine two arrays and return the combined array, removing all duplicates
 *
 * @param {array} array1
 * @param {array} array2
 * @returns the combined de-duped array
 */
function mergeArraysDedupe(array1, array2) {
	return Array.from(new Set(array1.concat(array2)));
}

/**
 * Check if a value is present in any arrays
 *
 * @param {*} val
 * @param {array} arrays (1 or more arrays passed as variadic arguements)
 * @returns true if val is found in any of the arrays
 */
function isInAnyArray(val, ...arrays) {
	for (const ar of arrays) {
		if (ar && ar.indexOf(val) !== -1) {
			return true;
		}
	}
	return false;
}


/**
 * Copy over any values with keys not already in target; leave others identical
 * @example asyncAwaitForEachFunctionCall([1 2 3], (x) => {console.log(x)})
 * @see <a href="https://gist.github.com/Atinux/fd2bcce63e44a7d3addddc166ce93fb2">foreach async</a>
 *
 * @param {object} target
 * @param {object} source
 */
function copyMissingValues(target, source) {
	for (const key in source) {
		if (!(key in target)) {
			target[key] = source[key];
		}
	}
}
//---------------------------------------------------------------------------



//---------------------------------------------------------------------------
/**
 * Take an iteratble (array) of objects and a function to call on each, and do an await function on each.
 * @example asyncAwaitForEachFunctionCall([1 2 3], (x) => {console.log(x)})
 * @see <a href="https://gist.github.com/Atinux/fd2bcce63e44a7d3addddc166ce93fb2">foreach async</a>
 *
 * @param {array} array
 * @param {function} func
 */
async function asyncAwaitForEachFunctionCall(array, func) {
	for (let index = 0; index < array.length; index++) {
		await func(array[index], index, array);
	}
}



/**
 * Take an iteratble (array) of objects and a function to call on each, and do an await function on each.
 * @example asyncAwaitForEachFunctionCall({key: val}}, (key, val) => {console.log(val)})
 * @see <a href="https://gist.github.com/Atinux/fd2bcce63e44a7d3addddc166ce93fb2">foreach async</a>
 *
 * @param {array} array
 * @param {function} func
 */
async function asyncAwaitForEachObjectKeyFunctionCall(obj, func) {
	for (const key in obj) {
		await func(key, obj[key]);
	}
}
//---------------------------------------------------------------------------



//---------------------------------------------------------------------------
/**
 * If val evaluates to false (which will happen on values of null, undefined, false, and ""), return defaultVal, otherwise return val.
 * ##### Notes
 *  * The intention of this function is to be used to check if a value was provided for some option, and use a default if not.
 *  * This can be confusing if the false value is passed in and caller expects to get it back instead of the defaultVal
 *  * In previous version we explicitly tested val against null, undefined, and ""
 *  * The main reason to use a function for this instead of just using a line in code testing truthiness of val, is to help us locate these kind of tests in code via a search, since they are prone to issues.
 *
 * @param {*} val
 * @param {*} defaultVal
 * @returns val [if it evaluates to true] else defaultVal
 */
function getNonFalseValueOrDefault(val, defaultVal) {
	if (!val) {
		return defaultVal;
	}
	return val;
}


/**
 * If val is not undefined and not null, return it; otherwise return default val.
 * ##### Notes
 *  * The intention of this function is to be used to check if a value was provided for some option, and use a default if not.
 *  * This can be confusing if the false value is passed in and caller expects to get it back instead of the defaultVal
 *  * The main reason to use a function for this instead of just using a line in code testing truthiness of val, is to help us locate these kind of tests in code via a search, since they are prone to issues.
 *
 * @param {*} val
 * @param {*} defaultVal
 * @returns val [if it evaluates to true] else defaultVal
 */
function getNonNullValueOrDefault(val, defaultVal) {
	if (val === undefined || val === null) {
		return defaultVal;
	}
	return val;
}



/**
 * Accepts a variadic list of arguments and returns the first one that can be coerced to true.
 *
 * @param {*} args - variadic list of arguments
 * @returns the first arg that evaluates to true (e.g. not null, undefined, false, ""); if none found returns undefined
 */
function firstCoercedTrueValue(...args) {
	if (!args || args.length === 0) {
		return undefined;
	}
	for (const arg of args) {
		if (arg) {
			return arg;
		}
	}
	// none found
	return undefined;
}



/**
 * Checks if the passed argument is an empty object/array/list by checking its length
 *
 * @param {*} obj - an array, object, or iterable with length property, OR an undefined/null valued variable
 * @returns true if the passed obj evaluates to false (null, undefined, "", false) OR is an object/array with no properties.
 * @see <a href="https://stackoverflow.com/questions/679915/how-do-i-test-for-an-empty-javascript-object">stackoverflow question on testing for empty objects</a>
 */
function isObjectEmpty(obj) {
	if (!obj || (Object.entries(obj).length === 0 && obj.constructor === Object)) {
		return true;
	}
	if (!obj || Object.keys(obj).length === 0) {
		return true;
	}
	return false;
}
//---------------------------------------------------------------------------




//---------------------------------------------------------------------------
/**
 * return a new Date() whose date is a certain number of minutes in the future; useful for setting expiration times
 *
 * @param {int} expirationMinutes
 * @returns Date
 */
function DateNowPlusMinutes(expirationMinutes) {
	const expirationDate = new Date();
	expirationDate.setMinutes(expirationDate.getMinutes() + expirationMinutes);
	return expirationDate;
}
//---------------------------------------------------------------------------







//---------------------------------------------------------------------------
/**
 * Copies an objects properties, in a shallow fashion (nested objects/arrays reuse the references)
 * ##### Notes
 *  * This is not a deep copy, it will reuse any sub object/array properties
 *  * We use a function instead of one-line of code to make it easier to find places in code where this happens
 *  * See <a href="https://medium.com/better-programming/3-ways-to-clone-objects-in-javascript-f752d148054d">Medium.com post on different ways to copy objects</a>
 *
 * @param {object} source
 * @returns new object with cloned properties
 */
function shallowCopy(source) {
	// just a simple wrapper to make code easier to understand
	// let obj = Object.assign({}, source);
	const obj = { ...source };
	return obj;
}



/**
 * Performs a deep copy of an object, recursing into sub properties to clone them instead of reuse references
 * ##### Notes
 * See <a href="https://medium.com/better-programming/3-ways-to-clone-objects-in-javascript-f752d148054d">Medium.com post on different ways to copy objects</a>
 * @param {*} src
 * @returns new object deep copy of the source
 */
function deepIterationCopy(src) {
	const target = {};
	for (const prop in src) {
		if (Object.prototype.hasOwnProperty.call(src, prop)) {
			if (isSimpleObject(src[prop])) {
				// it's a nested property
				target[prop] = deepIterationCopy(src[prop]);
			} else {
				target[prop] = src[prop];
			}
		}
	}
	return target;
}
//---------------------------------------------------------------------------


//---------------------------------------------------------------------------
/**
 * Simple wrapper arround array.toString that returns blank string if passed value is null, undefined, or empty
 *
 * @param {array} arr - an array or null or undefined
 * @returns arr.toString() if there are items in the array, otherwise empty string ""
 */
function stringArrayToNiceString(arr) {
	if (!arr || arr.length === 0) {
		return "";
	}
	return arr.toString();
}


/**
 * Return current date as a string in a nice format, in local time zone
 * @returns current date as string
 */
function getNiceNowString() {
	return new Date(Date.now()).toLocaleString();
}


/**
 * Return the current date in a standardized string format with precise timing; suitable for timestampign to seconds accuracy
 * @returns current date and time as string with seconds precision
 */
function getPreciseNowString() {
	return new Date(Date.now()).toISOString();
}


/**
 * Return date as a string in a nice format, in local time zone
 * @returns current date as string
 */
function getNiceDateValString(val) {
	return new Date(val).toLocaleString();
}


/**
 * Nice string expressing duration at useful granularity
 *
 * @param {integer} elapsedMs
 * @returns human readable string describing the durations in milliseconds
 */
function getNiceDurationTimeMs(elapsedMs) {
	return humanizeDuration(elapsedMs);
}

/**
 * Return current date as a compact string suitable for filename
 * @returns current date as string
 */
function getCompactNowString() {
	const dt = new Date(Date.now());
	const str = dt.getFullYear() + jrZeroPadInt(dt.getMonth() + 1, 2) + jrZeroPadInt(dt.getDate(), 2) + "_" + jrZeroPadInt(dt.getHours(), 2) + jrZeroPadInt(dt.getMinutes(), 2) + jrZeroPadInt(dt.getSeconds(), 2);
	return str;
}

function jrZeroPadInt(intval, padding) {
	let str = intval.toString();
	while (str.length < padding) {
		str = "0" + str;
	}
	return str;
}
//---------------------------------------------------------------------------








//---------------------------------------------------------------------------
/**
 * Replace special characters in string so it can be used in regex.
 * This is useful when we want to use a user-provided string in a regular expression and so we need to validate/escape it first.
 * It is used in our admin crud area filters to convert a simple user substring into a wildcard search string
 * @todo Security: check this for any security vulnerabilities
 *
 * @param {string} str
 * @returns escaped version of string suitable for use inside a regular expression (i.e. no unescaped regex characters)
 */
function regexEscapeStr(str) {
	str = str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
	return str;
}

/**
 * Escape any characters or remove them, that would be illegal to have inside a form input value
 * ##### Notes
 *  * Replaces double quote characters with &quot;
 *
 * @param {string} str
 * @returns version of string with double quotes replaced, etc.
 */
function makeSafeForFormInput(str) {
	return str.replace(/"/g, "&quot;");
}
//---------------------------------------------------------------------------




//---------------------------------------------------------------------------
/**
 * Returns true if the passed value is derived from the Object class/constructor.
 * ##### Notes
 *  * This will return false for object that are more elaborate classes, and is only meant to be used for doing deep copies of simple {} objects
 *  * This will return false if the passed value is null or undefined
 *  * This will return false if the passed object is a function (unlike the code it was based on)
 *  * see <a href="https://stackoverflow.com/questions/8511281/check-if-a-value-is-an-object-in-javascript">stackoverflow on checking if a value is an object</a>
 *
 * @param {*} maybeObj
 * @returns true if the passed value is a (simple) object
 */
function isSimpleObject(maybeObj) {
	// return (maybeObj !== undefined && maybeObj !== null && maybeObj.constructor === Object);
	const type = typeof maybeObj;
	return type === "object" && !!maybeObj;
}



/**
 * Returns true if the passed value is derived from the Object class/constructor.
 * @param {*} maybeObj
 * @returns true if the passed value is a object
 */
function isObjectHashMappableType(maybeObj) {
	// return (maybeObj !== undefined && maybeObj !== null && maybeObj.constructor === Object);
	const type = typeof maybeObj;
	return type === "object" && !!maybeObj;
}




/**
 * Returns true if the object passed is a promise
 * @see <a href="https://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise">stackoverflow post</a>
 *
 * @param {*} value
 * @returns true if the passed value is a promise
 */
function isPromise(value) {
	return Boolean(value && typeof value.then === "function");
}
//---------------------------------------------------------------------------




//---------------------------------------------------------------------------
/**
 * Simple wrapper around JSON.parse, which parses a json string and creates an object.
 * Only thing extra we do is check for being passed in an empty string/null/undefined and return {} in that case
 *
 * @param {string} str
 * @param {*} defaultVal
 * @returns result of JSON.parse on string or {} if string is "" or undefined or null
 */
function createObjectFromJsonParse(str, defaultVal) {
	if (!str) {
		return defaultVal;
	}
	const parsedObj = JSON.parse(str);
	return parsedObj;
}
//---------------------------------------------------------------------------



//---------------------------------------------------------------------------
/**
 * Try to figure out the servers ip address, and return it as a string.
 * ##### Notes
 *  * see <a href="https://stackoverflow.com/questions/3653065/get-local-ip-address-in-node-js">stackoverflow post</a>
 *  * This function has barely been tested and should not be considered reliable.
 *  * We are currently using it only to get a string that we can use to look for a site-specific configuration file based on the current server ip, and just to display on screen and in logs.
 *  * So our goal is simply to return a string that is different on different servers, but always the same on a given server
 *  * You cannot assume that the returned value is of a particular format; we dont check for that.
 * @returns string representation of ip address
 */
function getServerIpAddress() {
	const os = require("os");

	const ifaces = os.networkInterfaces();
	const ifacesKeys = Object.keys(ifaces);
	let iface, ifname, ifaceset;
	let bestip;
	let alias = 0;

	// jrdebug.debugObj(ifaces, "ifaces");
	// jrdebug.debugObj(ifacesKeys, "ifacesKeys");

	for (let j = 0; j < ifacesKeys.length; ++j) {
		ifname = ifacesKeys[j];

		ifaceset = ifaces[ifname];
		// jrdebug.debugObj(ifaceset, "ifaceset");

		alias = 0;
		for (let i = 0; i < ifaceset.length; ++i) {
			iface = ifaceset[i];
			// jrdebug.debugObj(iface, "iface[" + ifname + "]");
			if (iface.family !== "IPv4" || iface.internal !== false) {
				// skip over internal (i.e. 127.0.0.1) and non-ipv4 addresses
				bestip = iface.address;
			} else if (alias >= 1) {
				// this single interface has multiple ipv4 addresses
				bestip = iface.address;
				break;
			} else {
				// this interface has only one ipv4 adress
				bestip = iface.address;
				break;
			}
			++alias;
		}
		if (bestip) {
			return bestip;
		}
	}
	// not found;
	return "";
}
//---------------------------------------------------------------------------



//---------------------------------------------------------------------------
/**
 * Find the longest string in haystack which matches a prefix of longstr (with separatorStr or empty)
 * Then return a tuple [longeststring, remainder] with separatorStr removed from longeststring and remainder
 *
 * @param {string} longstr
 * @param {array} haystack
 * @param {string} separatorStr
 */
function findLongestPrefixAndRemainder(longstr, haystack, separatorStr) {
	let longestLen = 0;
	let longestStr = "";
	const separatorStrLength = separatorStr.length;
	const longstrLength = longstr.length;
	let candidateLength;
	let candidate;
	// walk array of strings
	for (let i = 0; i < haystack.length; ++i) {
		candidate = haystack[i];
		candidateLength = candidate.length;
		if (candidateLength > longstrLength) {
			// cannot match, too long
			continue;
		}
		if (candidate === longstr) {
			// found exact match, we can stop now
			return [candidate, ""];
		}
		if (candidateLength + separatorStrLength < longstrLength && candidateLength > longestLen) {
			// check if its an initial substring
			if (longstr.substr(0, candidateLength + separatorStrLength) === candidate + separatorStr) {
				// ok we have a match with candidate + separate on left hand side
				// our new longest candidate
				longestStr = candidate;
				longestLen = candidateLength;
			}
		}
	}
	// did we find a good match?
	if (longestLen === 0) {
		// nothing found
		return ["", longstr];
	}
	// we found something
	const remainderStr = longstr.substr(candidateLength + separatorStrLength);
	return [longestStr, remainderStr];
}
//---------------------------------------------------------------------------





//---------------------------------------------------------------------------
/**
 * Stringify an object nicely for display on console
 * ##### Notes
 *  * See <a href="https://stackoverflow.com/questions/10729276/how-can-i-get-the-full-object-in-node-jss-console-log-rather-than-object">stackoverflow</a>
 *  * See <a href="https://nodejs.org/api/util.html#util_util_inspect_object_options">nodejs docs</a>
 *
 * @param {*} obj - the object to stringify
 * @param {*} flagCompact - if true then we use a compact single line output format
 * @returns string suitable for debug/diagnostic display
 */
function objToString(obj, flagCompact) {
	// return util.inspect(obj, false, null, true /* enable colors */);
	let options = {};
	if (flagCompact) {
		options = {
			showHidden: false,
			depth: 5,
			// showHidden: true,
			// depth: 10,
			colors: false,
			compact: true,
			breakLength: Infinity,
		};
	} else {
		options = {
			showHidden: false,
			depth: 5,
			// showHidden: true,
			// depth: 10,
			colors: false,
			compact: false,
			breakLength: 80,
		};
	}
	return util.inspect(obj, options);
}


/**
 * Stringify2 an object nicely for display on console
 * ##### Notes
 *  * See <a href="https://stackoverflow.com/questions/10729276/how-can-i-get-the-full-object-in-node-jss-console-log-rather-than-object">stackoverflow</a>
 *  * See <a href="https://nodejs.org/api/util.html#util_util_inspect_object_options">nodejs docs</a>
 *
 * @param {*} obj - the object to stringify
 * @param {*} flagCompact - if true then we use a compact single line output format
 * @returns string suitable for debug/diagnostic display
 */
function objToString2(obj, flagCompact) {
	return JSON.stringify(obj, null, "  ");
}
//---------------------------------------------------------------------------










//---------------------------------------------------------------------------
/**
 * Just a wrapper for process.nextTick for use when running an async await from a non async function
 * Having a dedicated function for this makes it easier to search for.
 * @example asyncNextTick(async () => {await anAsyncFunc(a,b,c);})
 *
 * @param {function} func
 */
function asyncNextTick(func) {
	process.nextTick(func);
}
//---------------------------------------------------------------------------




/**
 * For Error object instances, convert to nice simple object
 *
 * @param {Error} err
 * @returns a standard object with name, message, stack features
 */
function ErrorToHashableMapObject(err) {
	// mongo (and other cases) may have trouble getting properties of err objects (err instanceof Error), so we convert it to nice object
	const obj = {
		name: err.name,
		message: err.message,
		stack: err.stack,
	};
	return obj;
}
//---------------------------------------------------------------------------


/**
 * Simple wrapper that returns true if pop is a property of obj
 *
 * @param {object} obj
 * @param {string} prop
 * @returns returns true if pop is a property of obj
 */
function objectHasProperty(obj, prop) {
	if (!obj) {
		return false;
	}
	return (prop in obj);
}
//---------------------------------------------------------------------------


/**
 * Returns the value of a property of an object, or pushes a JrResult error and returns null if not found
 *
 * @param {*} obj
 * @param {string} key
 * @param {*} jrResult
 * @param {*} hintMessage
 * @returns value of property or null if not found (and pushes JrResult error)
 */
function getNonNullValueFromObject(jrContext, obj, key, hintMessage) {
	if (!obj || !obj[key]) {
		jrContext.pushFieldError(key, "Missing value for " + hintMessage + " (" + key + ")");
		return null;
	}
	return obj[key];
}
//---------------------------------------------------------------------------




//---------------------------------------------------------------------------
/**
 * If path starts with a . then resolve it relative to the passed basedir
 *
 * @param {*} dirpath
 * @param {*} basedir
 * @returns absolute path of dirpath
 */
function resolvePossiblyRelativeDirectory(dirpath, basedir) {
	if (dirpath.startsWith(".")) {
		// relative to our base dir
		return path.resolve(basedir, dirpath);
	}
	// absolute
	return dirpath;
}
//---------------------------------------------------------------------------



//---------------------------------------------------------------------------
/**
 * Awaiting on this will sleep the program for a specified number of milliseconds.
 * Used for testing.
 * see https://stackoverflow.com/questions/14249506/how-can-i-wait-in-node-js-javascript-l-need-to-pause-for-a-period-of-time
 *
 * @param {*} ms
 */
function usleep(ms) {
	return new Promise((resolve) => {
		setTimeout(resolve, ms);
	});
}
//---------------------------------------------------------------------------
















module.exports = {
	mergeArraysKeepDupes,
	mergeArraysDedupe,
	isInAnyArray,
	copyMissingValues,

	asyncAwaitForEachFunctionCall,
	asyncAwaitForEachObjectKeyFunctionCall,

	getNonFalseValueOrDefault,
	getNonNullValueOrDefault,
	firstCoercedTrueValue,
	isObjectEmpty,

	DateNowPlusMinutes,

	shallowCopy,
	deepIterationCopy,

	stringArrayToNiceString,
	getNiceNowString,
	getPreciseNowString,
	getNiceDateValString,
	getNiceDurationTimeMs,
	getCompactNowString,

	regexEscapeStr,
	makeSafeForFormInput,

	isSimpleObject, isObjectHashMappableType,
	isPromise,
	createObjectFromJsonParse,

	getServerIpAddress,

	findLongestPrefixAndRemainder,

	objToString,
	objToString2,

	asyncNextTick,

	ErrorToHashableMapObject,

	objectHasProperty,

	getNonNullValueFromObject,

	resolvePossiblyRelativeDirectory,

	usleep,
};