easy-cli-framework
Version:
A framework for building CLI applications that are robust and easy to maintain. Supports theming, configuration files, interactive prompts, and more.
280 lines (276 loc) • 10.9 kB
JavaScript
var fs = require('fs');
var promptChoice = require('../prompts/prompt-choice.js');
require('yargs-interactive');
require('strip-ansi');
var csv = require('csv-parser');
/*
* @class CSVMapper
* A class to map CSV files to objects.
*
* @template TObject An object representing the output object type after transformation.
* @template TFileObject An object representing the format of the CSV file.
*
* @example
* ```typescript
* const csvProcessor = new CSVMapper({
* mappings: {
* username: {
* aliases: ['Username'],
* required: true,
* transform: value => value,
* },
* id: {
* aliases: ['Identifier'],
* required: true,
* transform: value => parseInt(value),
* },
* lastName: {
* aliases: [],
* required: true,
* transform: value => value,
* },
* firstName: {
* aliases: ['First name', 'First Name'],
* required: true,
* transform: value => value,
* },
* firstInital: {
* aliases: ['First name', 'First Name'],
* required: true,
* transform: value => value[0],
* },
* },
* interactive: true,
* });
*
* const data = await csvProcessor.processFile('./username.csv');
* ```
*/
class CSVMapper {
/**
* Create a new CSV Mapper instance.
*
* @param options The options for the CSV Mapper.
* @returns {CSVMapper<TObject, TFileObject>} A new CSV Mapper instance.
*
* @example
* const csvProcessor = new CSVMapper({
* mappings: {
* username: {
* aliases: ['Username'],
* required: true,
* transform: value => value,
* },
* id: {
* aliases: ['Identifier'],
* required: true,
* transform: value => parseInt(value),
* },
* lastName: {
* aliases: [],
* required: true,
* transform: value => value,
* },
* firstName: {
* aliases: ['First name', 'First Name'],
* required: true,
* transform: value => value,
* },
* firstInital: {
* aliases: ['First name', 'First Name'],
* required: true,
* transform: value => value[0],
* },
* },
* interactive: true,
* });
*/
constructor(options) {
var _a, _b, _c, _d;
/**
* Read a CSV file and parse it into an array of objects.
*
* @param path The path to the CSV file to read.
* @throws {Error} If the file is not found or there is an error reading the file.
*
* @returns The parsed CSV file as an array of objects.
*/
this.readFile = async (path) => new Promise(resolve => {
let results = [];
return fs
.createReadStream(path)
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', () => {
resolve(results);
});
});
/**
* Prompt the user for missing fields in the mappings.
*
* @param missingFields What fields are missing from the mappings
* @param unmappedColumns What columns are not mapped
*
* @returns Additional mappings for the CSV columns
*/
this.promptMissingFields = async (missingFields, unmappedColumns) => {
const interactiveMappedFields = {};
// TODO: Add support for linking multiple fields to one column.
for (const field of missingFields) {
const skippable = !this.mappings[field].required;
const mappedField = await promptChoice.promptChoice(`Select a column for ${field}${skippable ? '' : ' (Required)'}`, skippable
? [`SKIP`, ...unmappedColumns]
: unmappedColumns, {
theme: this.theme,
defaultOption: skippable ? `SKIP` : unmappedColumns[0],
});
if (mappedField === `SKIP`)
continue;
// TODO: Remove field from unmappedColumns as it's now mappeds
interactiveMappedFields[mappedField] = [field];
}
return interactiveMappedFields;
};
/**
* Process a CSV file and return the data as an array of transformed objects.
*
* @param path The path to the CSV file to process
*
* @throws {Error} If there are validation errors in the CSV file and the validate option is set to true.
*
* @returns {Promise<TObject[]>} The data from the CSV file as an array of transformed objects
*
* @example
* ```typescript
* const csvProcessor = new CSVMapper({...});
* const data = await csvProcessor.processFile('./username.csv');
* ```
*/
this.processFile = async (path) => {
const csvData = await this.readFile(path);
const fields = Object.keys(csvData[0]);
const fieldMap = await this.buildFieldMap(fields);
if (this.validate)
this.validateData(csvData, fieldMap);
return Promise.all(csvData.map(row => this.transformRow(row, fieldMap)));
};
/**
* Transforn a row from the CSV file into the correct output.
*
* @param row The row data to transform
* @param fieldMapping How to map the fields from the CSV to the object
*
* @returns {Promise<TObject>} A normalized row object
*/
this.transformRow = async (row, fieldMapping) => {
// Get the default values for the fields
const defaultValues = Object.entries(this.mappings).reduce((acc, [key, { defaultValue }]) => {
if (defaultValue !== undefined)
acc[key] = defaultValue;
return acc;
}, this.discardOriginalFields ? {} : row);
const transformed = Object.entries(row).reduce((acc, [column, value]) => {
var _a, _b;
const fields = fieldMapping[column];
if (!fields)
return acc;
for (const field of fields) {
const transformedValue = ((_b = (_a = this === null || this === undefined ? undefined : this.mappings) === null || _a === undefined ? undefined : _a[field]) === null || _b === undefined ? undefined : _b.transform)
? this.mappings[field].transform(value)
: value;
acc[field] =
!transformedValue && acc[field] ? acc[field] : transformedValue;
}
return acc;
}, defaultValues);
await Promise.all(Object.values(transformed));
return transformed;
};
/**
* Build a field map from the columns found in the CSV file.
*
* @param {(keyof TFileObject)[]} columns The columns found in the CSV file
*
* @returns {ObjectDataMapper<TObject, TFileObject>} A field map that maps the CSV columns to the object fields
*/
this.buildFieldMap = async (columns) => {
const base = columns.reduce((acc, column) => {
const mappedFields = this.findMappedField(column);
if (!(mappedFields === null || mappedFields === undefined ? undefined : mappedFields.length))
return acc;
acc[column] = mappedFields;
return acc;
}, {});
if (!this.interactive)
return base;
const mappedFields = Object.values(base).reduce((acc, fields) => {
return [...acc, ...fields];
}, []);
const mappedColumns = Object.keys(base);
const missingFields = Object.keys(this.mappings).filter(field => !mappedFields.includes(field));
const unmappedColumns = columns.filter(column => !mappedColumns.includes(column));
const interactiveMappedFields = await this.promptMissingFields(missingFields, unmappedColumns);
return { ...base, ...interactiveMappedFields };
};
/**
* Find the mapped field for a CSV column.
*
* @param {keyof TFileObject} field The field to find the mapped field for
*
* @returns {(keyof TObject[])} The mapped fields for the CSV column
*/
this.findMappedField = (field) => {
return Object.entries(this.mappings)
.filter(([_key, { aliases }]) => {
return aliases.includes(field);
})
.map(([key]) => key);
};
this.mappings = options.mappings;
this.interactive = (_a = options.interactive) !== null && _a !== undefined ? _a : false;
this.discardOriginalFields = (_b = options.discardOriginalFields) !== null && _b !== undefined ? _b : true;
this.theme = (_c = options.theme) !== null && _c !== undefined ? _c : null;
this.validate = (_d = options.validate) !== null && _d !== undefined ? _d : true;
}
/**
* Validation for the CSV file, comparing the fields to the mappings.
*
* @param rows The rows to validate
* @param fieldMap The field map to use for validation
*
* @throws {Error} If there are validation errors in the CSV file
*/
validateData(rows, fieldMap) {
const mappedFields = Object.values(fieldMap).reduce((acc, fields) => {
return [...acc, ...fields];
}, []);
// Find Required Fields that are not mapped
const requiredFieldErrors = Object.entries(this.mappings)
.filter(([key, { required }]) => required && !mappedFields.includes(key))
.map(([key]) => `Required Field ${key} Not Mapped`);
// Find records with empty values that are not allowed
const emptyFieldErrors = rows
.map((row, idx) => {
const fieldErrors = Object.entries(row).reduce((acc, [column, value]) => {
const fields = fieldMap[column];
if (!fields)
return acc;
for (const field of fields) {
if (!this.mappings[field].allowEmpty && !value) {
acc.push(field);
}
}
return acc;
}, []);
return { idx, fieldErrors };
})
.filter(row => row.fieldErrors.length > 0)
.map(row => `Row ${row.idx} missing data: ${row.fieldErrors.join(', ')}`);
if (requiredFieldErrors.length || emptyFieldErrors.length) {
throw new Error(`Validation Errors:\n` +
[...requiredFieldErrors, ...emptyFieldErrors].join('\n'));
}
}
}
exports.CSVMapper = CSVMapper;
;