'use strict'; const exec = require('child_process').exec, debug = require('debug')('agm:utils'), moment = require('moment'), ms = require('ms'), path = require('path'), fs = require('fs'), URL = require('url').URL, ObjectId = require('mongodb').ObjectId, CVCST = require('./convert_constants'), Bignumber = require('bignumber.js'), RecTypes = require('./constants').RecTypes; function waitUntil(asyncTest, timeout, interval) { const endTime = Number(new Date()) + (timeout || 2000); interval = interval || 100; const checkCondition = (function (resolve, reject) { asyncTest() .then(function (value) { if (value === true) { resolve(true); } else if (Number(new Date()) < endTime) { setTimeout(checkCondition, interval, resolve, reject); } else { reject(new Error('timed out for ' + asyncTest + ': ' + arguments)); } }) .catch(() => reject()) }); return new Promise(checkCondition); } /** * Delay Promise function * @param {*} t time in milliseconds * @param {*} v resolve function */ function delay(t, v) { return new Promise(function (resolve) { setTimeout(resolve.bind(null, v), t); }); } function execAsync(cmd) { return new Promise( function (resolve, reject) { exec(cmd, (error, stdout /*,stderr*/) => { if (error) { reject(error); } else { resolve(stdout); } }); }); } function download(url, dest, cb) { const wUrl = new URL(url); const file = fs.createWriteStream(dest); const requestor = require(wUrl.protocol.replace(':', '')); requestor.get(url, function (response) { response.pipe(file); file.on('finish', function () { file.close(cb); // close() is async, call cb after close completes. }); }).on('error', function (err) { // Handle errors fs.unlink(dest); // Delete the file async. (But we don't check the result) if (cb) cb(err.message); }); }; /** * Get the rate unit string by the rateUnit type * @param {*} rateUnit the rateUnit type * @param {*} inShort true: in short string or else long rate string * @param {*} part 1: first part or second part */ function rateUnitString(rateUnit, isShort, part) { let result = ''; switch (rateUnit) { case 0: result = isShort ? 'oz/ac' : 'ounces/acre'; break; case 1: result = isShort ? 'gal/ac' : 'gallons/acre'; break; case 2: result = isShort ? 'lbs/ac' : 'pounds/arce'; break; case 3: result = isShort ? 'lit/ha' : 'liters/hectare'; break; case 4: result = isShort ? 'kg/ha' : 'kilograms/hectare'; break; } if (part) { result = part === 1 ? result.split('/')[0] : result.split('/')[1]; } return result; } /** * Infer to number Application Rate Unit from text * Default: isUS == true ? 1 (gals/ac) : 3 (lit/ha) * @param {*} rateStr rate string from APP RATE of a q file * @param {*} isUS true: is US measurement * */ function rateStringToCode(rateStr, isUS) { if (rateStr && typeof rateStr === 'string') { const str = rateStr.trim(); const strLC = str.toLowerCase(); if (strLC.startsWith("oz/ac") || str === "OZPA") return 0; if (strLC === "gals/acr" || strLC.startsWith("gallons/ac") || str === "GPA") return 1; if (strLC === "lbs/acr" || str === "LBPA") return 2; if (strLC === "lit/ha" || strLC.startsWith("liters/hectare") || str === "LPH") return 3; if (strLC === "kg/ha" || str === "KGPH") return 4; } else { return isUS ? 1 : 3; } } function isUSRateUnit(rateUnit) { return (rateUnit >= 0 && rateUnit <= 2); } function areaUnitString(isUS, isShort) { if (isShort) return isUS ? 'ac' : 'ha'; else return isUS ? 'acres' : 'hectares'; } /** * Get the application info from file meta data (read from Qfile) * @param {*} fileMeta meta data JSON object read from from QFile * @param {*} recType initial record type. For AGNAV data: Binary (1: AGN_BIN_LQD), Shape: (4: AGN_SHP). Initially assumed as liquid type. * @returns application rate info object { appRate: , rateUnit: , recType: } */ function rateInfoFromFileMeta(fileMeta, recType) { const rateInfo = { appRate: -1, rateUnit: -1, recType: recType, useFC: true }; if (fileMeta.hasQfile) { const _fcType = fileMeta && fileMeta.fcType && fileMeta.fcType.trim().toLowerCase(); if (_fcType && _fcType.length && !_fcType.match(/none/i)) { if (_fcType.includes('gate') || _fcType.includes('granular') || _fcType.includes('release')) { rateInfo.recType = recType === RecTypes.AGN_SHP ? RecTypes.AGN_SHP_DRY : RecTypes.AGN_BIN_DRY; } } else { rateInfo.useFC = false; } rateInfo.rateUnit = rateStringToCode(fileMeta.appRateUnitStr); } else { rateInfo.useFC = false; rateInfo.rateUnit = fileMeta.rateUnit; } rateInfo.appRate = fileMeta.appRate; return rateInfo; } /** * Calculate applied application rate from flow rate, swath and speed * @param flowRate flowrate in lit/min * @param swath swath width in meters * @param speed speed in m/s * @returns applied rate in lit/ha */ function appRateFromFlowRate(flowRate, swath, speed) { if (speed > 0 && swath > 0) return (flowRate * CVCST.HA2SM) / (speed * swath * 60.0); else return 0.0; } function toMetricRate(value, rateUnit) { const toMap = { 0: { value: value * 0.0730778, unit: 3 }, // oz(fluid)/ac => lit/ha 1: { value: value * 9.35396, unit: 3 }, // gal/ac => lit/ha 2: { value: value * 1.12085, unit: 4 } // lbs/ac => kg/ha } return toMap[rateUnit] ? toMap[rateUnit] : { value: value, unit: rateUnit }; } /*! * Convert degree [0..359] cycle to wind compass rose text * @param {*} deg degree * @return one of the text among the list {"N","NNE","NE","ENE","E","ESE", "SE", "SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"} */ function deg2Compass(deg) { /* from: http://stackoverflow.com/questions/7490660/converting-wind-direction-in-angles-to-text-words * Steps: 1. Divide the angle by 22.5 because 360deg/16 directions = 22.5deg/direction change. 2. Add .5 so that when you truncate the value you can break the 'tie' between the change threshold. 3. Truncate the value using integer division (so there is no rounding). 4. Directly index into the array and print the value (mod 16). */ const compass = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]; const val = Math.trunc((deg / 22.5) + .5); return compass[val % 16]; } /** * Format the Celcius degree to F String depends on the isUS. If NaN return '' * @param {*} degC * @param {*} isUS * @param {*} withUnitString */ function inCorF(degC, isUS, withUnitString) { if (!isNumber(degC)) return ''; if (!withUnitString) return (isUS ? ((1.8 * degC) + 32) : degC).toFixed(); else { const unitString = isUS ? "°F" : "°C"; return (isUS ? ((1.8 * degC) + 32) : degC).toFixed() + unitString; } } /** * Append items from array 2 to array 1. If array1 is empty return array 2. * @param {*} arr1 * @param {*} arr2 */ function appendArray(arr1, arr2) { if (isEmptyArray(arr2)) return arr1; if (!Array.isArray(arr1)) return arr2; return arr1.concat(arr2); } function isEmptyArray(theArray) { return (!theArray || !Array.isArray(theArray) || !theArray.length); } function isEmptyStr(str) { return (!str || !str.length); } /** * Check whether an object is null or empty * @param {*} obj the Object to test. * @returns true or false on whether the object is null/undefined or empty object {}. */ function isEmptyObj(obj) { if (!obj) return true; for (const prop in obj) { if (obj.hasOwnProperty(prop)) return false; } return JSON.stringify(obj) === JSON.stringify({}); } function getField(theObject, value, defaultVal) { if (!theObject) return defaultVal || ''; return theObject[value] ? theObject[value] : defaultVal || ''; } function isBlank(str) { return (!str || /^\s*$/.test(str)); } function isObjectId(val) { return val && ObjectId.isValid(val); } function stringToBoolean(string) { if (typeof string === "boolean") return string; if (typeof string === "number") return string > 0; if (string === undefined || string === null) return false; switch (string.toLowerCase().trim()) { case "true": case "yes": case "y": case "1": return true; case "false": case "no": case "n": case "0": case null: return false; default: return Boolean(string); } } function ensureString(str) { return str ? str.toString() : ''; } function noLinebreaks(str) { return !str ? '' : ensureString(str).replace(/\r?\n|\r/g, ''); } function noSpecialChars(str) { return !str ? '' : ensureString(str).replace(/[^a-zA-Z0-9]/g, '_').replace(/_{2,}/g, '_').replace(/_$/, ''); } function trimStrTo(str, maxLength) { if (!maxLength) return str; let _str = ensureString(str) || ''; if (!_str) return ''; _str = _str.trim(); const diff = _str.length - maxLength; _str = diff > 0 ? _str.substring(0, _str.length - diff).trim() : _str; return _str; } function removeEndChars(str, char) { let _str = ensureString(str) || ''; if (!_str) return ''; if (!char) return str; while (_str.endsWith(char)) { _str = _str.substring(0, _str.length - 1); } return _str; } function normalizeName(str, maxLength) { const _maxLength = maxLength ? maxLength : 20; return removeEndChars(trimStrTo(noSpecialChars(str), _maxLength), "_") } /** * Split a string by first pattern then values by 2nd pattern * @param {*} str the line string value * @param {*} first the first delimeters or regex string * @param {*} second the second delimeters or regex string */ function split2nd(str, first = ':', second = /[\t|\s]+/) { if (!str || !first) return [str]; const _str = str.substring(str.indexOf(first) + 1).trim(); return second ? _str.split(second) : [_str]; } function line2ndFields(line, splitVal = false, delimeter = ':') { return splitVal ? split2nd(line, delimeter) : split2nd(line, delimeter, null); } /** * Convert length value, feet, to meters if isUS is true */ function toMeter(value, isUS) { return isUS ? value * 0.3048 : value; } function mpSecToKnot(mpsec) { if (!isNumber(Number(mpsec))) return 0; return Number(mpsec) * 1.94384; } function toArea(value, isUS = true, isValueHa = false) { let result = 0.0; if (!value) return result; if (isValueHa) result = isUS ? value * 2.47105 : value; // if US convert ha => acre else result = isUS ? value / 4046.86 : value / 10000; // square meters to ha or acre return result; } function toVolume(value, isLiquid, isUS = true) { if (!value) return 0.0; if (isLiquid) return isUS ? (value * 0.264172) : value; // If US convert liters to gallons else return isUS ? (value * 2.20462) : value; // If US convert kilograms to pounds } function toMetricVolume(value, isLiquid, isUS = true) { if (!value) return 0.0; if (isLiquid) return isUS ? (value / 0.264172) : value; // If US convert gallons to litters else return isUS ? (value / 2.20462) : value; // If US convert pounds to kilograms } function acreToHa(acre) { return acre ? acre / 2.471 : 0; } function haToAcre(ha) { return ha ? ha * 2.471 : 0; } function ozToGal(ozs) { return ozs * 0.0078125; // ozs to gals } function getFirstProp(obj, defaultVal) { if (!obj || !Object.keys(obj).length) return defaultVal; for (let prop in obj) { if (obj[prop]); return obj[prop] || defaultVal; } } function hasProperty(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); } function getProp(obj, prop, defaultVal) { if (obj === undefined || prop === undefined) return null; prop = (prop + "").toLowerCase(); for (const p in obj) { if (hasProperty(obj, p) && prop == (p + "").toLowerCase()) { return obj[p] || defaultVal; } } return defaultVal || null; } function getPropNum(obj, prop, defaultVal) { let _defaultVal = +defaultVal; if (!isNumber(_defaultVal)) _defaultVal = 0; let val = +getProp(obj, prop, _defaultVal); return isNumber(val) ? val : _defaultVal; } function dateTimePartsFromAgNav(agn, format = 1) { if (agn && /^\d{9}/i.test(agn)) { let day = agn.substring(3, 5); let month = agn.substring(1, 3); let agnYear = agn.substring(0, 1); const fullYear = (new Date()).getFullYear().toString(); let thirdNumYear = fullYear.substring(2, 3); if (Number(thirdNumYear + agnYear) > Number(fullYear.substring(2))) thirdNumYear = thirdNumYear - 1; let year = `${fullYear.substring(0, 2)}${thirdNumYear}${agnYear}`; let hour = agn.substring(5, 7); let minute = agn.substring(7, 9); let date, time; switch (format) { case 1: date = `${year}-${month}-${day}`; time = `${hour}:${minute}:00`; break; default: date = `${year}${month}${day}`; time = `${hour}${minute}00`; break; } return { date: date, time: time }; } else return null; } function toUTCDateTime(seconds, dateStr) { return new moment.utc(`${dateStr}T${new Date(1000 * seconds).toISOString().substring(11, 19).replace(/:/g, '')}`); } function isValidDate(dateVal) { if (!dateVal) return false; if (dateVal instanceof Date) { return isNumber(dateVal.valueOf()); } else { let date = new Date(dateVal); return date instanceof Date && isNumber(date.valueOf()); } } function datafileNameToAgnString(filename) { if (isEmptyStr(filename)) return null; const baseName = path.basename(filename); if (baseName.length < 10) return null; if (/t/i.test(baseName)) return baseName.substring(1, 8) + path.extname(baseName).substring(2, 4); else return path.basename(baseName).substring(1, 10); } /** * Get a locale specific formatted numeric text from a number * @param {*} value the number * @param {*} fixedTo number of decimal * @param {*} lang language code in short i.e.: es, pt,.. * @note: Ref: https://github.com/unicode-org/full-icu-npm */ function toLocaleStr(value, fixedTo, lang = 'en') { let cValue; if (isNumber(value)) { cValue = Number(value).toLocaleString(lang, { minimumFractionDigits: 0, maximumFractionDigits: fixedTo }); return cValue; } else return ''; } // FILES function ArrayBuffertoBuffer(ab) { return Buffer.from(new Uint8Array(ab)); } // /** * Dynamicall sort an array of object by a fieldname (case-sensitive) * From: https://stackoverflow.com/questions/1129216/sort-array-of-objects-by-string-property-value-in-javascript * @param {any} property * @returns sorted condition for each call from array.sort(fn) */ function dynamicSort(property, isNumber = false) { let sortOrder = 1; if (property[0] === "-") { sortOrder = -1; property = property.substring(1); } return function (a, b) { let result; if (isNumber) result = (Number(a[property]) < Number(b[property])) ? -1 : (Number(a[property]) > Number(b[property])); else result = (a[property] < b[property]) ? -1 : (a[property] > b[property]) ? 1 : 0; return result * sortOrder; } } function bareFilename(filename, keepPath = false) { if (!filename) return filename; return !keepPath ? path.basename(filename).replace(/\.[^/\\.]+$/, "") : filename.replace(/\.[^/\\.]+$/, ""); } /** * Print all objects properties's value. Just used while debugging * @param {*} arr * @param {*} prop */ function printByProp(arr, prop) { if (!arr || !Array.isArray(arr)) return; let items = []; for (const item of arr) { if (prop in item) items.push(item[prop]); } console.log(items); } /** * Returns an array with arrays of the given size. * * @param myArray {Array} Array to split * @param chunkSize {Integer} Size of every group, default = 1000 items */ function chunkArray(myArray, chunk_size = 1000) { let chunks = [], i = 0, n = myArray.length; while (i < n) { chunks.push(myArray.slice(i, i += chunk_size)); } return chunks; } /** * Check wether a value is exactly a number * Ref: https://byby.dev/js-check-number * @param {*} value * @returns {boolean} true or false */ function isNumber(value) { return typeof value === "number" && !Number.isNaN(value); // return !isNaN(value) && value !== null && !Array.isArray(value); } /** * Truncate decimal to number of decimal in precision. Truncate the numbers of decimal digits only thus does not do rounding backward. * @param {*} value * @param {*} digits * @returns */ function fixedTo(value, digits) { if (undefined === value) return 0; let re = new RegExp("(-?\\d+\\.\\d{" + digits + "})(\\d)"), m = value.toString().match(re); return m ? parseFloat(m[1]) : value; }; /** * Round the number to a number of decimal in precision. * @param {*} value the number value * @param {*} decimals the number of decimal digits * @returns Works just like Number.toFixed(decimals) but return a number instead. */ function roundTo(value, decimals = 2) { if (undefined === value) return 0; let _decs = decimals || 0; return Number(Math.round(Number(value + 'e' + _decs)) + 'e-' + _decs); } function arrayToObject(array, keyField, makeKeyLowerCase = false) { return !isEmptyArray(array) ? array.reduce((obj, item) => { obj[makeKeyLowerCase ? item[keyField].toLowerCase() : item[keyField]] = item; return obj; }, {}) : null; } /** * Capitalize first characters on every words * @param {*} str input string */ function capitalize(str) { return (str && str.length) ? str.trim().toLowerCase().replace(/\w\S*/g, (w) => (w.replace(/^\w/, (c) => c.toUpperCase()))) : str; } function objToStrArray(obj, fields, ops = { delimeter: ':', endEach: '', capitalize: true }) { const strs = []; const regex = fields && fields.length ? new RegExp(fields.join("|")) : null; let theKey; for (const [key, val] of Object.entries(obj)) { theKey = ops.capitalize ? capitalize(key) : key; if (val && typeof val != 'object') { if (!regex || regex.test(key)) strs.push(`${theKey}${ops.delimeter} ${val}${ops.endEach}`); } } return strs; } /** * Determine two arrays equal values or not * WARNING: arrays must not contain {objects} or behavior may be undefined */ function arraysEqual(ar1, ar2, sorted = false) { if (sorted) return (Array.isArray(ar1) && Array.isArray(ar2) && JSON.stringify(ar1.sort()) == JSON.stringify(ar2.sort())); return JSON.stringify(ar1) == JSON.stringify(ar2); } function readMemUsage() { const used = process.memoryUsage(); for (let key in used) { debug(`${key} ${Math.round(used[key] / 1024 / 1024 * 100) / 100} MB`); } } function getProdUnit(unit) { let res = ''; switch (Number(unit)) { case 0: res = 'oz'; break; case 1: res = 'gal'; break; case 2: res = 'lb'; break; case 3: res = 'lit'; break; case 4: res = 'kg'; break; case 5: res = 'gr'; break; case 6: res = 'cc'; break; case 7: res = 'pt'; break; } // if (value > 1) res += "s"; return res; } function padZero(num, size) { let s = num + ''; while (s.length < size) { s = '0' + s; } return s; } function toNumber(val, defaultVal) { const _defaultVal = defaultVal || 0; const numVal = Number(val); return isNumber(numVal) ? numVal : _defaultVal; } function truncR(val, digits) { let _val = Number(val) || 0; return _val.toFixed(digits); }; function timeToSeconds(hms) { const a = hms.split(':'); return (+a[0]) * 60 * 60 + (+a[1]) * 60 + (+a[2]); } /** * Get the Julian Date string (YYYYddd) or Julian days from the input Date String * @param {*} dateStr standard date string * @param {*} isUTC Whether the date string is UTC. Default true */ function julianDate(dateStr, isUTC = true) { const dayCount = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; //convert passed string to date object let dte = isUTC ? new moment.utc(dateStr) : new moment(dateStr); if (!dte.isValid()) dte = isUTC ? new moment.utc() : new moment(); // Initialize date letiable let julianDays = 0; // Add days for previous months for (let i = 0; i < dte.month(); i++) { julianDays += dayCount[i]; } // Add days of the current month julianDays += dte.date(); if (dte.isLeapYear()) { julianDays++; } return { dateStr: '' + dte.format('YYYY') + padZero(julianDays, 3), days: julianDays }; } /** * Convert seconds to string format * @param {*} value * @param {*} format 1: HH:MM:SS, others: H[h] MM[m] [SS]s */ function secondsToHMS(value, format = 1) { const sec = parseInt(value, 10); // convert value to number if it's string if (value <= 0) return ''; let hours = Math.floor(sec / 3600); // get hours let minutes = Math.floor((sec - (hours * 3600)) / 60); // get minutes let seconds = sec - (hours * 3600) - (minutes * 60); // get seconds // add 0 if value < 10 if (format == 1 && hours < 10) { hours = "0" + hours; } if (minutes < 10) { minutes = "0" + minutes; } if (seconds < 10) { seconds = "0" + seconds; } return format == 1 ? hours + ':' + minutes + ':' + seconds // Return is HH : MM : SS : `${hours}h ${minutes}m ${seconds}s`; } function timestampToDate(ts, format = "YYYY/MM/DD z") { if (!ts) return ''; return moment.utc(ts * 1e3).format(format); } /** * Count the total different in months between two dates * @param {*} date1 * @param {*} date2 * @returns number of months */ function monthsDiff(date1, date2) { return (date2.getFullYear() - date1.getFullYear()) * 12 + (date2.getMonth() - date1.getMonth()); } function setConnTimeout(time) { let delay = typeof time === 'string' ? ms(time) : Number(time || 5000); return function (req, res, next) { req.socket.setKeepAlive(true); res.socket.setTimeout(delay); next(); } } /** * Offset the DateTime string with the number of hours (+/-) * * @param {any} dateTimeStr in YYYYMMDDTHHMMSS * @param {any} offSetHrs number of hours (+/-) * @returns offseted DateTime string in YYYYMMDDTHHMMSS format */ function offsetDateTimeString(dateTimeStr, offSetHrs) { let dt = dateTimePartsFromAgNav(dateTimeStr, 2); let utcDT = new moment.utc(dt.date + 'T' + dt.time); utcDT.subtract(offSetHrs); return utcDT; } function waitFor(timeoutms, testFn) { let ok = false; return new Promise((resolve, reject) => { let interval = setInterval(function () { ok = testFn(); console.log('checking'); if (ok) { resolve(true); clearInterval(interval); } else if ((timeoutms -= 100) < 0) { reject('timeout'); } }, 1000); }); } function objectIdIn(ois, oid) { if (isEmptyArray(ois) || !oid) return false; return ois.some(id => id.equals(oid)); } function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } function toFixedNumberString(numString, digits = 2, base = 10) { const pow = Math.pow(base, digits); return (Math.round(Number(numString) * pow) / pow).toString(); } function toFixedNumber(num, decimal = 2) { return Bignumber(Bignumber(num).toFixed(decimal)).toNumber() } function getPercentValue(val) { return Bignumber(val).dividedBy(100) } module.exports = { waitUntil, execAsync, rateUnitString, rateStringToCode, isUSRateUnit, areaUnitString, toArea, toVolume, toMetricVolume, deg2Compass, inCorF, isEmptyArray, isEmpty: isEmptyStr, isEmptyObj, isBlank, isObjectId, stringToBoolean, noLinebreaks, noSpecialChars, trimStrTo, removeEndChars, normalizeName, toMeter, getFirstProp, getProp, getPropNum, hasProperty, dateTimePartsFromAgNav, toUTCDateTime, isValidDate, datafileNameToAgnString, ArrayBuffertoBuffer, dynamicSort, bareFilename, delay, julianDate, appendArray, getField, mpSecToKnot, chunkArray, isNumber, fixedTo, roundTo, arrayToObject, objToStrArray, arraysEqual, objectIdIn, acreToHa, haToAcre, toLocaleStr, getProdUnit, ozToGal, padZero, download, toNumber, truncR, timeToSeconds, secondsToHMS, setConnTimeout, offsetDateTimeString, waitFor, monthsDiff, capitalize, split2nd, line2ndFields, rateInfoFromFileMeta, appRateFromFlowRate, toMetricRate, escapeRegExp, ensureString, timestampToDate, toFixedNumberString, toFixedNumber, getPercentValue, // FOR DEBUG ONLY printByProp, readMemUsage, }