UNPKG

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
'use strict'; 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;