UNPKG

flowxo-utils

Version:
791 lines (694 loc) 23.6 kB
'use strict'; var _ = require('lodash'), moment = require('moment-timezone'), SugarDate = require('sugar-date').Date; require('sugar-date/locales'); var Utils = {}; var DEFAULT_FLATTENED_DELIMITER = '__'; var DEFAULT_FLATTENED_ARRAY_DELIMITER = '_+_'; var TIMEZONE_TO_LOCALE = { 'Europe/London': 'en-GB' }; var humanize = function(str) { /* istanbul ignore if */ if(!_.isString(str)) { return str; } return _.capitalize(str.trim().replace(/_/g, ' ')); }; var formatParsedObject = function(type, input, valid, parsed) { return { type: type, input: input, valid: valid, parsed: parsed }; }; /* istanbul ignore next */ Utils._getCurrentDate = function(options) { // Makes unit testing possible, by allowing this // function to be mocked. return SugarDate.create('now', options); }; /* istanbul ignore next */ Utils._getFutureDate = function(str, options) { // Work around bug in SugarDate // if yesterday or today is in str `future:false` else true var future = !/yesterday|today/.test(str); // Makes unit testing possible, by allowing this // function to be mocked. return SugarDate.create(str, _.assign({}, options, {future: future})); }; Utils.activateDateParser = function() { // Add support for the 'enhanced' date object. // This augments the Date prototype with extra methods, // and (importantly) allows use to use the Sugar.js // date parsing algorithms. // v2 of SugarDate supports optionally extending. // This is still here to support legacy dependencies SugarDate.extend(); }; /** * Converts an array into a hashtable, with each value set * to `true`. Useful for lookups, avoiding Array.indexOf calls. * * Example: * Utils.toHash([ 'one', 'two', 'three' ]); * // -> { one: true, two: true, three: true } * // * // Now you can do `if (items.three)` instead of * // `if (items.indexOf('three') !== 1)`. * * @param {Array} items array of items to turn into hashtable * @return {Object} hashtable */ Utils.toHash = function(items) { return _.reduce(items, function(result, item) { result[item] = true; return result; }, {}); }; /** * Converts a hashtable into an array of key-value pairs. * * Example: * Utils.hashToKeyValPairs({ * one: 'two', * buckle: 'my shoe' * }); * // -> [{ * key: 'one', * value: 'two' * }, { * key: 'buckle', * value: 'my shoe' * }] * @param {Object} hash hashtable to convert * @return {Array} Array of key-value pairs. */ Utils.hashToKeyValPairs = function(hash) { return _.reduce(hash, function(arr, value, key) { arr.push({ key: key, value: value }); return arr; }, []); }; /** * Gets a value from a flattened object or array using 'flattened' property notation. * * A flattened field is one that looks like: * `some__flattened__key` * This would correspond to the following object: * { * some: { * flattened: { * key: 'value' * } * } * } * * Double underscores are used to delimit the keys. * * Array syntax: * `some__0__key` * Corresponds to: * { * some: [{ * key: 'value' * }] * } * or * { * some: { * 0: { * key: 'value' * } * } * } * * You can also get collections using the plus-underscore notation. * * Collection syntax: * `some_+_key` * Corresponds to: * { * some: [{ * key1: 'value 1' * }, { * key2: 'value 2' * }] * } * * Example: * Utils.getFlattenedValue({ flattened: { data: 'panda' } }, 'flattened__data'); * // -> 'panda' * * Utils.getFlattenedValue({ flattened: ['happy', 'panda' ] }, 'flattened__1'); * // -> 'panda' * * Utils.getFlattenedValue({ flattened: { data: 'panda' } }, 'flattened.data', { delimiter: '.' }); * // -> 'panda' * * Utils.getFlattenedValue({ flattened: [{ data: 'panda' }] }, 'flattened_+_data', * { arrayFormatter(keys, data) { return data[0][keys[0]]; } }); * // -> 'panda' * * Utils.getFlattenedValue({ flattened: { data: 'panda' } }, 'wrong__key'); * // -> { flattened: { data: 'panda' } } * * @param {Object|Array} data [object to get value from] * @param {String} path [string describing flattened property to get] * @param {Object} [options] [optional options object] * @return {Any} [the flattened property, or the original `data` if it was null.] */ Utils.getFlattenedValue = function(data, path, options) { // Nested logic borrowed from // https://github.com/mickhansen/dottie.js if(data == null) { return data; } // Backwards compatibility if(_.isString(options)) { options = { delimiter: options }; } options = options || {}; options.delimiter = options.delimiter || DEFAULT_FLATTENED_DELIMITER; options.arrayDelimiter = options.arrayDelimiter || DEFAULT_FLATTENED_ARRAY_DELIMITER; var pieces = path.split(options.delimiter).reverse(), keyPieces, key; while(pieces.length && data != null) { key = pieces.pop(); keyPieces = key.split(options.arrayDelimiter); if(keyPieces.length > 1 && options.arrayFormatter) { data = options.arrayFormatter(keyPieces.slice(1), data[keyPieces[0]], pieces); continue; } data = data[key]; } return data; }; /** * Sets a value into the passed object using `flattened` property notation. * * Note that numbers in the flattened key are used to create an object with that key, not an index at an array. * * Example: * Utils.setFlattenedValue({}, 'some__flattened', 'data'); * // -> { some: { flattened: 'data' } }; * Utils.setFlattenedValue({}, 'some.flattened', 'data', '.'); * // -> { some: { flattened: 'data' } }; * Utils.setFlattenedValue({}, 'some__1', 'data'); * // -> { some: { 1: 'data' } }; * // Note that this will create an object with * // a key of `1`, not an array. * * @param {Object} data [target object to set value in] * @param {String} path [flattened property key] * @param {Any} value [value to set] * @param {String} delimiter [optional nesting delimiter] * @return {Any} [the `data` that was passed in.] */ Utils.setFlattenedValue = function(data, path, value, delimiter) { if(data != null) { delimiter = delimiter || DEFAULT_FLATTENED_DELIMITER; var pieces = path.split(delimiter), current = data, piece, i, length = pieces.length; for(i = 0; i < length; i++) { piece = pieces[i]; if(i === length-1) { current[piece] = value; } else if(!current[piece]) { current[piece] = {}; } current = current[piece]; } } return data; }; /** * Returns an array of objects, each representing the flattened version of a value from the passed object. Each returned object includes the flattened version of the property key, the humanized label of the flattened property key and the value that was found. * * Example: * * * @param {Object|Array} data the object or array to parse for flattened fields * @param {String} delimiter optional flattened field delimiter * @param {Object} [options] * @return {Array} an array of objects, each representing the flattened property. */ Utils.getFlattenedFields = function(data, options) { options = options || {}; options.delimiter = options.delimiter || DEFAULT_FLATTENED_DELIMITER; options.arrayDelimiter = options.arrayDelimiter || DEFAULT_FLATTENED_ARRAY_DELIMITER; options.limit = options.limit || Infinity; var output, addOutput, outputCount = 0; if(options.idx) { // Return a hashmap. output = {}; addOutput = function(field) { output[field.key] = field; }; } else { // Return an array of fields. output = []; addOutput = function(field) { output.push(field); }; } var flatten = function(o, prevKey, prevLabel, isCollection) { var itr = _.isArray(o) ? _.forEach : _.forOwn; itr(o, function(val, key) { if (outputCount >= options.limit) { return false; } // Nest the key var delimiter = isCollection ? options.arrayDelimiter : options.delimiter; var newKey = prevKey ? prevKey + delimiter + key : key; var newLabel = prevLabel ? prevLabel + ' ' + key : key; // If the value is an object or an array, recurse if(_.isArray(val) || _.isPlainObject(val)) { // If it's the first element, recurse to create the collection if(_.endsWith(newKey, options.delimiter + '0')) { var collectionKey = newKey.substr(0, newKey.length - 3); var collectionLabel = newLabel.substr(0, newLabel.length - 2); flatten(val, collectionKey, collectionLabel, true); } return flatten(val, newKey, newLabel); } // Get the flattened value for collections if(newKey.indexOf(options.arrayDelimiter) !== -1) { val = Utils.getFlattenedValue(data, newKey, options); } // Otherwise output the flattened field addOutput({ key: newKey, label: humanize(newLabel), value: val }); ++outputCount; }); }; if(_.isPlainObject(data)) { flatten(data); } return output; }; /** * Returns a hashmap, each representing the flattened version of a value from the passed object. Each returned object includes the flattened version of the property key, the humanized label of the flattened property key and the value that was found. * * Example: * * * @param {Object|Array} data the object or array to parse for flattened fields * @param {Object} [options] optional options object * @return {Object} a hashmap of objects, each representing the flattened property. */ Utils.getFlattenedFieldsIdx = function(data, options) { // Backwards compatibility if(_.isString(options)) { options = { delimiter: options }; } var opts = _.assign({}, options); opts.idx = true; return Utils.getFlattenedFields(data, opts); }; /** * Clones the passed object or array, removing any undefined or nulls. If this results in an empty object or array, this is also removed. * * Example: * Util.cloneTerse({ some: 'data', other: null, nested: { and: 'data', again: undefined } }); // -> { some: 'data', nested: { and: 'data' } } * @param {Object|Array} input [object or array to tersify.] * @return {Object|Array|null} [tersified object/array, or null if the tersified object/array is empty.] */ Utils.cloneTerse = function(input) { var isArray = _.isArray(input); var rtn = isArray ? [] : {}; var piece; for(var k in input) { if(_.isArray(input[k]) || _.isPlainObject(input[k])) { // Recurse. piece = Utils.cloneTerse(input[k]); } else { piece = input[k]; } if(piece != null && input.hasOwnProperty(k)) { if(isArray) { rtn.push(piece); } else { rtn[k] = piece; } } } return rtn == null || _.isEmpty(rtn) ? null : rtn; }; /** * Parses a date string into a date object. * * Supports a wide range of formats. For example: * - Utils.parseDateTimeField('today') * - Utils.parseDateTimeField('tomorrow') * - Utils.parseDateTimeField('next week') * - Utils.parseDateTimeField('11pm') * - Utils.parseDateTimeField('23:30') * - Utils.parseDateTimeField('Friday, January 9th 2015') * - Utils.parseDateTimeField('this friday at 10am') * - Utils.parseDateTimeField('may 25th of next year') * - Utils.parseDateTimeField('2014-01-18 09:30:00') * - Utils.parseDateTimeField('2014-01-18 09:30:00 -0400') * - Utils.parseDateTimeField('2014-01-18 09:30:00', {locale: 'en-GB'}) * - Utils.parseDateTimeField('2014-01-18 09:30:00', {timezone: 'America/Chicago'}) * - Utils.parseDateTimeField('2014-01-18 09:30:00', {fromUTC: true, setUTC: true}) * - Utils.parseDateTimeField('in 2 days') * - Utils.parseDateTimeField('5 minutes from now') * - Utils.parseDateTimeField('2014-01-18 09:30:00 -0400 +2d +30m') * - Utils.parseDateTimeField('this friday at 10am -2h -15m') * * The parsed date can be adjusted using offset modifiers, * which take the form (+-)num(dhms). For example: * - +2d -15m will advance the parsed date by 2 days, * then reduce it by 15 mins * - -4d -6h will reduce the parsed date by 4 days and 6 hours * * @param {String} field the date string to parse. * @param {Object} options passed through to SugarDate.create * @return {Object} an object containing the parsed date, or the passed field if it was not a String. */ Utils.parseDateTimeField = function(field, options) { var getRtnObject = function(parsed, isValid) { return formatParsedObject('date', field, isValid, parsed); }; Utils.Logger.debug('Utils.parseDateTimeField: params:', field, options); // Copy options options = _.assign({}, options); // Default timezone var timezone = options.timezone || moment.tz.guess(); delete options.timezone; // If invalid if (moment.tz.zone(timezone) === null) { var tz = moment.tz.guess() || 'UTC'; Utils.Logger.warn('Utils.parseDateTimeField - Timezone "' + timezone + '" is invalid. Assuming ' + tz); timezone = tz; } // Lookup Locale. Check options, then look up based on timezone. Default to 'en' options.locale = options.locale || TIMEZONE_TO_LOCALE[timezone] || 'en'; function parseDateTime(field, options, timezone) { Utils.Logger.debug('Utils.parseDateTimeField: parseDateTime:', field, options, timezone); // Help SugarDate be aware of timezones var previousNewDateInternal; if (timezone) { Utils.Logger.debug('Utils.parseDateTimeField: parseDateTime: Setting up newDateInternal'); previousNewDateInternal = SugarDate.getOption('newDateInternal'); SugarDate.setOption('newDateInternal', function() { var date = new Date(); var offsetMinutes = date.getTimezoneOffset() - moment.tz.zone(timezone).offset(date); SugarDate.addMinutes(date, offsetMinutes); Utils.Logger.debug('Utils.parseDateTimeField: newDateInternal:', offsetMinutes, date); return date; }); } var parsedDate; if(_.isDate(field)) { parsedDate = field; } else if(!_.isString(field)) { // Just create a date from the passed value. parsedDate = SugarDate.create(field, options); } else { // Regex for parsing a offset modifier. var offsetRegex = /(?:\s*)([+-])(?:\s*)(\d+)([dhms])/gi; // Multipliers, for converting the type of offset // modifier into seconds. var offsetMultipliers = { s: 1, m: 60, h: 3600, d: 86400 }; // Run the regex on the string to strip out any // offset modifiers, adding/subtracting // them from the overall offsetSecs. var offsetSecs = 0; var hasOffsetModifier = false; var withoutOffsetModifiers = field .replace( offsetRegex, function(match, p1, p2, p3) { if(p1 === '+') { // We should increment the offsetSecs // by the appropriate amount. offsetSecs = offsetSecs + (offsetMultipliers[p3] * p2); } else { // We should decrement the offsetSecs // by the appropriate amount. offsetSecs = offsetSecs - (offsetMultipliers[p3] * p2); } // Mark the fact we have at least one offset modifier. hasOffsetModifier = true; // Return a blank string, to remove the // offset modifier from the original string. // This is so that the offset modifier does // not interfere with the datetime parsing. return ''; }) .trim(); // If we only have offset modifiers, initialise the date as now. // Otherwise, parse the string to create a date in the future. parsedDate = withoutOffsetModifiers === '' && hasOffsetModifier ? Utils._getCurrentDate(options) : Utils._getFutureDate(withoutOffsetModifiers, options); if(SugarDate.isValid(parsedDate) && hasOffsetModifier && offsetSecs) { // Apply the offset modifier. // If it is negative, it will subtract. SugarDate.addSeconds(parsedDate, offsetSecs); } } if (previousNewDateInternal) { Utils.Logger.debug('Utils.parseDateTimeField: parseDateTime: Tearing down newDateInternal'); SugarDate.setOption('newDateInternal', previousNewDateInternal); } return parsedDate; } var parsedDate = parseDateTime(field, options); var rtnObject = getRtnObject(parsedDate, SugarDate.isValid(parsedDate)); var parsedDateWithTimezone = parseDateTime(field, options, timezone); if (SugarDate.isValid(parsedDateWithTimezone)) { var hasTZOffset = Utils.hasTZOffset(field); var dateMoment = Utils.dateToTimezone(parsedDateWithTimezone, timezone, hasTZOffset); // Add the dateMoment to the return object rtnObject.moment = dateMoment; } return rtnObject; }; /** * Utils.dateToTimezone - create a Moment.js date in a timezone * * @param {Date} date The date to create in the timezone * @param {String} timezone Timezone to create the date into ex, 'America/New_York' * @param {Boolean} hasTZOffset Specify if the incoming date had a offset already applied. * @return {Moment} A moment.js object */ Utils.dateToTimezone = function(date, timezone, hasTZOffset) { Utils.Logger.debug('Utils.dateToTimezone: date:', date.toISOString()); // Convert to string. Important to remove offset/tz info (if none was provided originally) // or moment will ignore the passed in tz in later step. var tzFormatStr = hasTZOffset ? '{Z}' : ''; var sugarDateString = SugarDate.format(date, '{yyyy}-{MM}-{dd}T{HH}:{mm}:{ss}.{SSS}' + tzFormatStr); Utils.Logger.debug('Utils.applyTzOffset: sugarDateString:', sugarDateString); // This parses the dateString in the timezone specified. var dateMoment = moment.tz(sugarDateString, timezone); Utils.Logger.debug('Utils.applyTzOffset: moment:', dateMoment); return dateMoment; }; /** * Utils.applyTzOffset - Applies TZ Offset * * @param {Date} date The date to offset * @param {String} timezone Timezone to offset the date for ex, 'America/New_York' * @return {Date} A date with the offset applied */ Utils.applyTzOffset = function(date, timezone) { Utils.Logger.debug('Utils.applyTzOffset: before:', date); SugarDate.addMinutes(date, moment.tz.zone(timezone).offset(date)); Utils.Logger.debug('Utils.applyTzOffset: after:', date); return date; }; /** * Utils.hasTZOffset - Checks to see if the string has a timezone offset * Examples * - `-06:00` * - `+0600` * - `Z` * * @param {String} str The date string to be checked * @return {Boolean} true or false */ Utils.hasTZOffset = function(str) { return /[zZ]|[+-][01]\d:?[0-5]\d/.test(str); }; /** * Parses a boolean-ish input into a boolean object. * * Casts valid inputs as follows: * - Boolean `true`: returns `true` * - Number `1`: returns `true` * - Strings `'true'`, `'yes'`, `'y'`, `'1'`: returns `true` * - Boolean `false`: returns `false` * - Number `0`: returns `false` * - Strings `'false'`, `'no'`, `'n'`, `'0'`: returns `false` * * Any other data is considered invalid and is not parsed to a boolean. * * @param {Any} field the boolean-ish argument to parse. * @return {Object} an object containing the parsed boolean, or `null` if the data could not be parsed. */ Utils.parseBooleanField = function(field) { var getRtnObject = function(parsed, isValid) { return formatParsedObject('boolean', field, isValid, parsed); }; if(_.isBoolean(field)) { return getRtnObject(field, true); } if(field === 1 || /^\s*(?:true|yes|y|1)\s*$/i.test(field)) { return getRtnObject(true, true); } if(field === 0 || /^\s*(?:false|no|n|0)\s*$/i.test(field)) { return getRtnObject(false, true); } return getRtnObject(null, false); }; Utils._annotateData = function(data, fields, normaliseData, options) { if(!fields || !fields.length) { // Return an empty array. return []; } options = options || {}; var shouldIncludeData = options.includeEmptyFields ? function() { // Include all method fields. return true; } : function(data) { // Only include script data if it is // not null, undefined or the empty string // != null covers null and undefined return data != null && data !== ''; }; // We only care about data that corresponds // to a field in the method. // so, loop through the method fields, // searching for data to return. return fields.reduce(function(result, field) { // First of all, grab the data. var value = normaliseData(data[field.key], field); // Then check if we should add it or not. if(shouldIncludeData(value)) { result.push({ key: field.key, label: field.label, value: value }); } return result; }, []); }; var processOptions = function(field, val) { // If the field has input options, then try to // lookup the label from the matching option. var rtn = val; if(field && field.input_options && field.input_options.length > 0) { var chosenOption = _.find(field.input_options, function(option) { return option.value === val; }); if(chosenOption && chosenOption.hasOwnProperty('label')) { rtn = chosenOption.label; } } return rtn; }; /** * Turns the passed raw data into a 'pretty' * version, using the inputFields to * label the data. * * If a boolean or datetime field is used, * the raw input value is returned. * * If input data is not found in the field, * the data will **not** be returned. * * @param {Object} inputData * @param {Array} inputFields * @return {Object} annotated script data */ Utils.annotateInputData = function(inputData, inputFields, options) { // Input data should not be flattened. // We should account for datetime and boolean fields. var normaliseData = function(item, field) { var rtn; switch(field.type) { case 'datetime': case 'boolean': rtn = item && item.hasOwnProperty('input') ? item.input : item; break; case 'select': rtn = processOptions(field, item); break; case 'dictionary': rtn = JSON.stringify(item, null, 2); break; default: rtn = item; break; } return rtn; }; return Utils._annotateData(inputData, inputFields, normaliseData, options); }; /** * Turns the passed raw data into a 'pretty' * version, using the outputFields to * label the data. * * If output data is not found in the field, * the data will **not** be returned. * * @param {Object} outputData * @param {Array} outputFields * @param {Object} [options] * @return {Object} annotated output data */ Utils.annotateOutputData = function(outputData, outputFields, options) { // Output data should be flattened. outputData = Utils.getFlattenedFieldsIdx(outputData, options); // Output data items will be an object with // key-label-value properties. var normaliseOutputData = function(item) { return item && item.value; }; return Utils._annotateData(outputData, outputFields, normaliseOutputData, options); }; Utils.Backoff = require('./backoff'); Utils.Logger = require('./logger'); module.exports = Utils;