Source

util.js

const fs = require('fs');
const crypto = require('crypto');
const common = require('./common');

/**
 * A module containing helpful utilities to ease the use of xmtoolbox.<br><br>
 *
 * @module util
 */

/**
 * @typedef module:util.CSVTOJSON_OPTIONS
 * @type {Object}
 * @property {number}  headerRow Default: 0
 *
 * The line index where the header starts.
 * @property  {number}  dataStartRow Default: 1
 *
 * The line index where the data starts.
 *
 * @property {string[]}  headers default: undefined
 *
 * If no header row is included, include an array of string names for the columns.
 * Included a name for all columns from left  most column to right most needed column.
 * If headers are not included it may be desired to set the dataStartRow option to start at index 0.
 */

/**
 * Takes a path to a CSV file and returns a JSON object representing the file.
 * Included to prevent the need for additional packages in most cases.
 * If this doesn't work for you implement your own or use some of the other packages available.
 * @param {string} path path to to CSV file.
 * @param {module:util.CSVTOJSON_OPTIONS} options options object to allow some flexibility when converting CSV files to JSON.
 * @returns {Object} JSON Object representation of the CSV data
 */
async function CsvToJsonFromFile(path, options) {
  const data = await fs.promises.readFile(path, 'utf8');
  return CsvToJson(data, options);
}

/**
 * Takes a string of data in CSV format and returns a JSON object representing the data.
 * Included to prevent the need for additional packages in most cases.
 * If this doesn't work for you implement your own or use some of the other packages available.
 * @param {string} data the CSV data.
 * @param {module:util.CSVTOJSON_OPTIONS} options options object to allow some flexibility when converting CSV files to JSON.
 * @returns {Object} JSON Object representation of the CSV data
 */
function CsvToJson(text, options = {}) {
  let p = '';
  let row = [''];
  let lines = [row];
  let i = 0;
  let r = 0;
  let toggle = true;
  const delimiter = options.delimiter || ',';

  //parse text and look for encoded commas.
  for (let character of text) {
    if (character === '"') {
      if (toggle && character === p) row[i] += character;
      toggle = !toggle;
    } else if (character === delimiter && toggle) character = row[++i] = '';
    else if (character === '\n' && toggle) {
      if (p === '\r') row[i] = row[i].slice(0, -1);
      row = lines[++r] = [(character = '')];
      i = 0;
    } else row[i] += character;
    p = character;
  }

  const headerRow = options.headerRow || 0;
  const dataStartRow = options.dataStartRow || 1;
  //trim header names.
  let headers = options.headers || lines[headerRow];

  headers = headers.map(colName => colName.trim());

  headers.filter(function (header) {
    return header != null && header !== '';
  });

  return lines.slice(dataStartRow).map(line => {
    const row = {};
    for (let i = 0; i < headers.length; i++) {
      row[headers[i]] = line[i];
    }
    return row;
  });
}

function JsonToCsv(data, options = {}) {
  // https://tools.ietf.org/html/rfc4180
  // https://www.loc.gov/preservation/digital/formats/fdd/fdd000323.shtml
  let columns;
  if (options.columns) {
    columns = options.columns;
  } else {
    columns = new Set();

    data.map(row => {
      Object.keys(row).forEach(column => columns.add(column));
    });
  }

  columns = Array.from(columns);

  const delimiter = options.delimiter || ',';

  //add rows
  let rows = data.map(d => {
    return columns.map(column => (d[column] ? ParseCSV(d[column]) : '')).join(delimiter);
  });

  //add headers
  const headers = columns.map(column => ParseCSV(column));
  rows.unshift(headers);

  return rows.join('\r\n');
}

//turn a string to a csv safe string
function ParseCSV(value) {
  let text = value.toString();

  text = text.replace(/"/g, '""');

  if (text.indexOf('"') > -1 || text.indexOf(',') > -1) return '"' + text + '"';

  return text;
}

/**
 * POST request to xMatters Instance. Useful for sending data to a Workflow Flow.<br><br>
 *
 * Abides by the concurrency limit for the instance.<br><br>
 *
 * WARNING: The environment's readOnly option is not enforced when using this function.
 * @param {module:environments.xMattersEnvironment} env The xmtoolbox representation of an xMatters instance.
 * @param {string} api The api endpoint past the base URL. Example: '/api/xm/1/groups' or 'https://SUBDOMAIN.xmatters.com'
 * @param {Object} json The object to include in the request body as json
 * @param {Object} query A json object representing the query string parameters for this request.
 */
async function post(env, api, json, query) {
  return common.request(env, api, query, { json, method: 'POST' }, 'Custom Request');
}

const EncryptToFile = (path, text, key, options) => {
  const encrypted = Encrypt(text, key, options);
  fs.writeFileSync(path, JSON.stringify(encrypted));
};

const DecryptFromFile = (path, key, options) => {
  const encrypted = JSON.parse(fs.readFileSync(path, 'utf8'));
  return Decrypt(encrypted, key, options);
};

const Encrypt = (text, key, options = {}) => {
  if (key.length < 32) throw Error('The key must be at least 32 characters.');

  const algorithm = options.algorithm || 'aes-256-gcm';
  const bytes = options.bytes || 16;
  const iv = crypto.randomBytes(bytes);
  const cipher = crypto.createCipheriv(algorithm, key.substring(0, 32), iv);
  const data = Buffer.concat([cipher.update(text), cipher.final()]);
  const tag = cipher.getAuthTag();

  const encrypted = {
    iv: iv.toString('hex'),
    data: data.toString('hex'),
    tag: tag.toString('hex'),
  };

  return encrypted;
};

const Decrypt = (encrypted, key, options = {}) => {
  if (key.length < 32) throw Error('The key must be at least 32 characters.');
  const algorithm = options.algorithm || 'aes-256-gcm';
  const { iv, data, tag } = encrypted;

  const decipher = crypto.createDecipheriv(algorithm, key.substring(0, 32), Buffer.from(iv, 'hex'));
  decipher.setAuthTag(Buffer.from(tag, 'hex'));

  let decrypted = Buffer.concat([decipher.update(data, 'hex'), decipher.final()]);
  return decrypted.toString();
};

const pick = (object, keys) => {
  const _object = {};

  if (!Array.isArray(keys)) keys = [keys];

  keys.forEach(key => {
    _object[key] = object[key];
  });
  return _object;
};

const find = (items, object) => {
  const keys = Object.keys(object);

  itemLoop: for (let i = 0; i < items.length; i++) {
    const item = items[i];

    keyLoop: for (let ki = 0; ki < keys.length; ki++) {
      if (item[keys[ki]] !== object[keys[ki]]) continue itemLoop;
    }

    return item;
  }
};

// supports string, number, date, functions, regex
// doesn't support buffers, array buffers, error objects, maps, sets, typed Arrays, symbols,
const isEqual = (object, source) => {
  if (object === null || object === undefined || source === null || source === undefined) {
    return object === source;
    /*   } else if (
      typeof source !== typeof source || //not same type
      Array.isArray(source) !== Array.isArray(object) //not both arrays
    ) {
      console.log('isEqual 1');
      return false; */
  }
  if (object.constructor !== source.constructor) {
    return false;
  }
  if (object instanceof Function || object instanceof RegExp) {
    //function or Regex
    return object === source;
  }

  if (object === source || object.valueOf() === source.valueOf()) {
    return true;
  }

  if (Array.isArray(object) && object.length !== source.length) {
    return false;
  }

  if (object instanceof Date) {
    //matches on valueof above. if here, they didn't match.
    return false;
  }

  //if here, need to be object
  if (!(object instanceof Object)) {
    return false;
  }
  if (!(source instanceof Object)) {
    return false;
  }

  //Object, check that they have matching key values for all and validate value for every key
  const keys = Object.keys(object);
  return (
    Object.keys(source).every(function (ki) {
      return keys.indexOf(ki) !== -1;
    }) &&
    keys.every(function (ki) {
      return isEqual(object[ki], source[ki]);
    })
  );
};

/**
 * Compares Objects for match, object can contain more than source
 * @param {*} object
 * @param {*} source
 */
const isMatch = (object, source) => {
  if (!source) return true;
  if (Array.isArray(object) && Array.isArray(source)) {
    return source.every(sItem => object.some(oItem => isEqual(sItem, oItem)));
  } else if (object instanceof Object && source instanceof Object) {
    const keys = Object.keys(source);
    return keys.every(key => isMatch(object[key], source[key]));
  } else {
    return isEqual(object, source);
  }
};

/**
 * if keys from source are undefined in object, they are set on the object and object is returned.
 * @param {*} object
 * @param {*} source
 */
const defaults = (object, source) => {
  if (!object) return source;
  if (!source) return object;

  const keys = Object.keys(source);

  keys.forEach(key => {
    if (object[key] === undefined) object[key] = source[key];
  });
  return object;
};

const defaultsDeep = (object, source) => {
  if (!object) return source;
  if (!source) return object;

  const keys = Object.keys(source);

  keys.forEach(key => {
    if (object[key] === undefined) {
      object[key] = source[key];
    } else if (
      typeof source[key] === 'object' &&
      source[key] !== null &&
      typeof object[key] === 'object' &&
      object[key] !== null
    ) {
      object[key] = defaultsDeep(object[key], source[key]);
    }
  });
  return object;
};

module.exports = {
  CsvToJsonFromFile,
  CsvToJsonFromData: CsvToJson,
  CsvToJson,
  JsonToCsv,
  post,
  Encrypt,
  Decrypt,
  EncryptToFile,
  DecryptFromFile,
  pick,
  find,
  isEqual,
  isMatch,
  defaults,
  defaultsDeep,
};