982 lines
30 KiB
JavaScript
982 lines
30 KiB
JavaScript
'use strict';
|
||
|
||
const crypto = require('crypto');
|
||
|
||
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, RateUnits } = require('./constants');
|
||
|
||
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 RateUnits.OZ_PER_ACRE:
|
||
result = isShort ? 'oz/ac' : 'ounces/acre';
|
||
break;
|
||
case RateUnits.GAL_PER_ACRE:
|
||
result = isShort ? 'gal/ac' : 'gallons/acre';
|
||
break;
|
||
case RateUnits.LBS_PER_ACRE:
|
||
result = isShort ? 'lbs/ac' : 'pounds/arce';
|
||
break;
|
||
case RateUnits.LIT_PER_HA:
|
||
result = isShort ? 'lit/ha' : 'liters/hectare';
|
||
break;
|
||
case RateUnits.KG_PER_HA:
|
||
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 RateUnits.OZ_PER_ACRE; // 0
|
||
if (strLC === "gals/acr" || strLC.startsWith("gallons/ac") || str === "GPA")
|
||
return RateUnits.GAL_PER_ACRE; // 1
|
||
if (strLC === "lbs/acr" || str === "LBPA")
|
||
return RateUnits.LBS_PER_ACRE; // 2
|
||
if (strLC === "lit/ha" || strLC.startsWith("liters/hectare") || str === "LPH")
|
||
return RateUnits.LIT_PER_HA; // 3
|
||
if (strLC === "kg/ha" || str === "KGPH")
|
||
return RateUnits.KG_PER_HA; // 4
|
||
}
|
||
else {
|
||
return isUS ? RateUnits.GAL_PER_ACRE : RateUnits.LIT_PER_HA;
|
||
}
|
||
}
|
||
|
||
function isUSRateUnit(rateUnit) {
|
||
return (rateUnit >= RateUnits.OZ_PER_ACRE && rateUnit <= RateUnits.LBS_PER_ACRE);
|
||
}
|
||
|
||
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 (if any), matType (if fcTType), useFC }
|
||
*/
|
||
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 = {
|
||
[RateUnits.OZ_PER_ACRE]: { value: value * 0.0730778, unit: RateUnits.LIT_PER_HA }, // oz(fluid)/ac => lit/ha
|
||
[RateUnits.GAL_PER_ACRE]: { value: value * 9.35396, unit: RateUnits.LIT_PER_HA }, // gal/ac => lit/ha
|
||
[RateUnits.LBS_PER_ACRE]: { value: value * 1.12085, unit: RateUnits.KG_PER_HA } // lbs/ac => kg/ha
|
||
}
|
||
return toMap[rateUnit] ? toMap[rateUnit] : { value: value, unit: rateUnit };
|
||
}
|
||
|
||
function matTypeFromFCType(fcType) {
|
||
if (fcType && typeof fcType === 'string') {
|
||
const strLC = fcType?.trim()?.toLowerCase();
|
||
if (strLC && strLC.length && !strLC.match(/none/i)) {
|
||
if (strLC.includes('gate') || strLC.includes('granular') || strLC.includes('release')) {
|
||
return 'dry';
|
||
}
|
||
return 'wet';
|
||
}
|
||
}
|
||
return 'none';
|
||
}
|
||
|
||
/*!
|
||
* 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 false;
|
||
}
|
||
}
|
||
|
||
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) {
|
||
// was /2.471 — mismatched toArea()'s *2.47105 ha=>acre factor above, so an area value
|
||
// round-tripped acre->ha (here) ->acre (toArea) drifted by ~0.002% every cycle, which in
|
||
// turn defeated advanced_report.js's report-generation cache (rptOp.areaSize/coverage
|
||
// never hashed the same twice). Matching the constant makes the round trip idempotent.
|
||
return acre ? acre / 2.47105 : 0;
|
||
}
|
||
|
||
function haToAcre(ha) {
|
||
return ha ? ha * 2.47105 : 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;
|
||
}
|
||
|
||
/**
|
||
* Parse an AgNav filename datetime code into a date string and UTC time-of-day string.
|
||
*
|
||
* AgNav devices write a compact 9-digit datetime code into every data filename:
|
||
* Position 0 (1 digit) – last digit of the 4-digit year (e.g. "5" for 2025)
|
||
* Position 1–2 (2 digits) – month (MM, 01–12)
|
||
* Position 3–4 (2 digits) – day (DD, 01–31)
|
||
* Position 5–6 (2 digits) – hour (HH, 00–23, GPS UTC time-of-day)
|
||
* Position 7–8 (2 digits) – minute (mm, 00–59, GPS UTC time-of-day)
|
||
*
|
||
* Example: "505221002" → year digit 5, month 05, day 22, hour 10, minute 02
|
||
*
|
||
* AgNav filename encoding (datasaveworker.cpp):
|
||
* - Date (YMMDD): device's LOCAL calendar date (QDate::currentDate())
|
||
* - Time (HHmm): GPS UTC time-of-day when the file was created
|
||
* (AppInfo::gpsTime ← gpsd._UTC from NMEA $GPGGA)
|
||
*
|
||
* Year inference: the full 4-digit year is reconstructed by combining the current year's
|
||
* century+decade with the single-digit year and sliding back one decade when necessary
|
||
* (to handle files that pre-date the current decade, e.g. a "9" suffix in 2026 → 2019).
|
||
*
|
||
* No timezone conversion is performed here — the date and time are returned as-is from
|
||
* the filename. Callers combine the returned date with GPS record seconds via toUTCDateTime().
|
||
*
|
||
* @param {string} agn AgNav filename datetime code (minimum 9 leading digits)
|
||
* @param {number} [format=1]
|
||
* 1 (default) – ISO-like strings: { date: "YYYY-MM-DD", time: "HH:MM:00" }
|
||
* other – compact strings: { date: "YYYYMMDD", time: "HHmmss" } (seconds set to "00")
|
||
* @returns {{ date: string, time: string } | null} null if agn is falsy or lacks 9 leading digits
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Build an intermediate moment by combining a local date string with the UTC time-of-day
|
||
* derived from GPS seconds.
|
||
*
|
||
* This is a helper step inside computeStartEndDate(). It combines two heterogeneous sources:
|
||
* - dateStr comes from the AgNav filename → LOCAL calendar date (QDate::currentDate())
|
||
* - seconds are GPS record time → UTC time-of-day (gpsd._UTC)
|
||
*
|
||
* The result is therefore a hybrid: local date + UTC time. moment.utc() mode is used so that
|
||
* the subsequent add(offsetHrs) call in computeStartEndDate() operates without interference
|
||
* from the server's system timezone.
|
||
*
|
||
* @param {number} seconds GPS seconds since midnight UTC (0–86399)
|
||
* @param {string} dateStr Local calendar date in YYYYMMDD compact format (from dateTimePartsFromAgNav format=2)
|
||
* @returns {moment.Moment} Moment in UTC mode representing: local_date + GPS_UTC_HHmmss.
|
||
*/
|
||
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 whether a value is a number or a string that can be converted to a number. Return false for null, undefined, boolean, array and empty string.
|
||
* @param {*} value the value to test
|
||
* @returns true if the value is a number or a string that can be converted to a number, otherwise false.
|
||
*/
|
||
function isNumber(value) {
|
||
if (value === null || value === undefined || typeof value === 'boolean' || Array.isArray(value)) return false;
|
||
if (typeof value === 'string') return value.trim() !== '' && !Number.isNaN(Number(value.trim()));
|
||
return typeof value === 'number' && !Number.isNaN(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)
|
||
}
|
||
|
||
/** Generates a secure random password.
|
||
* @param {string} seedChars - A string containing the characters to use for generating the password. Defaults to a predefined set if not provided.
|
||
* @param {number} length - The desired length of the password. Defaults to 10 if not provided.
|
||
* @returns {string} A randomly generated secure password.
|
||
*/
|
||
function generateRandomPassword(seedChars, length) {
|
||
const _length = length || 10;
|
||
const _seedChars = seedChars && seedChars?.length > 9 ? seedChars : 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+[]{}|;:,.<>?';
|
||
let password = '';
|
||
let randomBytes;
|
||
try {
|
||
randomBytes = crypto.randomBytes(_length);
|
||
} catch (error) {
|
||
throw new Error('Failed to generate random bytes: ' + error.message);
|
||
}
|
||
const seedLength = _seedChars.length;
|
||
for (let i = 0; i < _length; i++) {
|
||
password += _seedChars[randomBytes[i] % seedLength];
|
||
}
|
||
return password;
|
||
}
|
||
|
||
/**
|
||
* Extract creation timestamp from MongoDB ObjectId
|
||
* @param {ObjectId|string} objectId - MongoDB ObjectId
|
||
* @returns {Date} Creation timestamp of the document
|
||
*/
|
||
function getDateTSFromObjectId(objectId) {
|
||
if (!objectId) return null;
|
||
|
||
try {
|
||
const id = typeof objectId === 'string' ? new ObjectId(objectId) : objectId;
|
||
return id.getTimestamp();
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ensure the req object is valid for downstream mailer functions
|
||
* @param {*} req - The request object to validate/sanitize
|
||
* @returns {object} - A valid request object with proper structure
|
||
*/
|
||
function ensureValidReqObject(req) {
|
||
if (!req || typeof req !== 'object') {
|
||
return {}; // Create a minimal request object
|
||
}
|
||
return req;
|
||
}
|
||
|
||
/**
|
||
* Format a promo object's discount as a human-readable string.
|
||
* Returns e.g. "50% off", "$5.00 off", "Free", or null if unknown.
|
||
* @param {Object} promo - Promo object with discountType/discountValue fields
|
||
* @returns {string|null}
|
||
*/
|
||
function formatPromoDiscount(promo) {
|
||
if (!promo) return null;
|
||
if (promo.discountType === 'percent' && promo.discountValue) {
|
||
return `${promo.discountValue}% off`;
|
||
}
|
||
if (promo.discountType === 'fixed' && promo.discountValue) {
|
||
return `$${promo.discountValue.toFixed(2)} off`;
|
||
}
|
||
if (promo.discountType === 'free') {
|
||
return 'Free';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
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,
|
||
generateRandomPassword, getDateTSFromObjectId, ensureValidReqObject, matTypeFromFCType, formatPromoDiscount,
|
||
// FOR DEBUG ONLY
|
||
printByProp, readMemUsage,
|
||
}
|