/* CSV Stringify Please look at the [project documentation](https://csv.js.org/stringify/) for additional information. */ const { Transform } = require('stream') const bom_utf8 = Buffer.from([239, 187, 191]) class Stringifier extends Transform { constructor(opts = {}){ super({...{writableObjectMode: true}, ...opts}) const options = {} let err // Merge with user options for(let opt in opts){ options[underscore(opt)] = opts[opt] } if(err = this.normalize(options)) throw err switch(options.record_delimiter){ case 'auto': options.record_delimiter = null break case 'unix': options.record_delimiter = "\n" break case 'mac': options.record_delimiter = "\r" break case 'windows': options.record_delimiter = "\r\n" break case 'ascii': options.record_delimiter = "\u001e" break case 'unicode': options.record_delimiter = "\u2028" break } // Expose options this.options = options // Internal state this.state = { stop: false } // Information this.info = { records: 0 } } normalize(options){ // Normalize option `bom` if(options.bom === undefined || options.bom === null || options.bom === false){ options.bom = false }else if(options.bom !== true){ return new CsvError('CSV_OPTION_BOOLEAN_INVALID_TYPE', [ 'option `bom` is optional and must be a boolean value,', `got ${JSON.stringify(options.bom)}` ]) } // Normalize option `delimiter` if(options.delimiter === undefined || options.delimiter === null){ options.delimiter = ',' }else if(Buffer.isBuffer(options.delimiter)){ options.delimiter = options.delimiter.toString() }else if(typeof options.delimiter !== 'string'){ return new CsvError('CSV_OPTION_DELIMITER_INVALID_TYPE', [ 'option `delimiter` must be a buffer or a string,', `got ${JSON.stringify(options.delimiter)}` ]) } // Normalize option `quote` if(options.quote === undefined || options.quote === null){ options.quote = '"' }else if(options.quote === true){ options.quote = '"' }else if(options.quote === false){ options.quote = '' }else if (Buffer.isBuffer(options.quote)){ options.quote = options.quote.toString() }else if(typeof options.quote !== 'string'){ return new CsvError('CSV_OPTION_QUOTE_INVALID_TYPE', [ 'option `quote` must be a boolean, a buffer or a string,', `got ${JSON.stringify(options.quote)}` ]) } // Normalize option `quoted` if(options.quoted === undefined || options.quoted === null){ options.quoted = false }else{ // todo } // Normalize option `quoted_empty` if(options.quoted_empty === undefined || options.quoted_empty === null){ options.quoted_empty = undefined }else{ // todo } // Normalize option `quoted_match` if(options.quoted_match === undefined || options.quoted_match === null || options.quoted_match === false){ options.quoted_match = null }else if(!Array.isArray(options.quoted_match)){ options.quoted_match = [options.quoted_match] } if(options.quoted_match){ for(let quoted_match of options.quoted_match){ const isString = typeof quoted_match === 'string' const isRegExp = quoted_match instanceof RegExp if(!isString && !isRegExp){ return Error(`Invalid Option: quoted_match must be a string or a regex, got ${JSON.stringify(quoted_match)}`) } } } // Normalize option `quoted_string` if(options.quoted_string === undefined || options.quoted_string === null){ options.quoted_string = false }else{ // todo } // Normalize option `eof` if(options.eof === undefined || options.eof === null){ options.eof = true }else{ // todo } // Normalize option `escape` if(options.escape === undefined || options.escape === null){ options.escape = '"' }else if(Buffer.isBuffer(options.escape)){ options.escape = options.escape.toString() }else if(typeof options.escape !== 'string'){ return Error(`Invalid Option: escape must be a buffer or a string, got ${JSON.stringify(options.escape)}`) } if (options.escape.length > 1){ return Error(`Invalid Option: escape must be one character, got ${options.escape.length} characters`) } // Normalize option `header` if(options.header === undefined || options.header === null){ options.header = false }else{ // todo } // Normalize option `columns` options.columns = this.normalize_columns(options.columns) // Normalize option `quoted` if(options.quoted === undefined || options.quoted === null){ options.quoted = false }else{ // todo } // Normalize option `cast` if(options.cast === undefined || options.cast === null){ options.cast = {} }else{ // todo } // Normalize option cast.bigint if(options.cast.bigint === undefined || options.cast.bigint === null){ // Cast boolean to string by default options.cast.bigint = value => '' + value } // Normalize option cast.boolean if(options.cast.boolean === undefined || options.cast.boolean === null){ // Cast boolean to string by default options.cast.boolean = value => value ? '1' : '' } // Normalize option cast.date if(options.cast.date === undefined || options.cast.date === null){ // Cast date to timestamp string by default options.cast.date = value => '' + value.getTime() } // Normalize option cast.number if(options.cast.number === undefined || options.cast.number === null){ // Cast number to string using native casting by default options.cast.number = value => '' + value } // Normalize option cast.object if(options.cast.object === undefined || options.cast.object === null){ // Stringify object as JSON by default options.cast.object = value => JSON.stringify(value) } // Normalize option cast.string if(options.cast.string === undefined || options.cast.string === null){ // Leave string untouched options.cast.string = function(value){return value} } // Normalize option `record_delimiter` if(options.record_delimiter === undefined || options.record_delimiter === null){ options.record_delimiter = '\n' }else if(Buffer.isBuffer(options.record_delimiter)){ options.record_delimiter = options.record_delimiter.toString() }else if(typeof options.record_delimiter !== 'string'){ return Error(`Invalid Option: record_delimiter must be a buffer or a string, got ${JSON.stringify(options.record_delimiter)}`) } } _transform(chunk, encoding, callback){ if(this.state.stop === true){ return } // Chunk validation if(!Array.isArray(chunk) && typeof chunk !== 'object'){ this.state.stop = true return callback(Error(`Invalid Record: expect an array or an object, got ${JSON.stringify(chunk)}`)) } // Detect columns from the first record if(this.info.records === 0){ if(Array.isArray(chunk)){ if(this.options.header === true && !this.options.columns){ this.state.stop = true return callback(Error('Undiscoverable Columns: header option requires column option or object records')) } }else if(this.options.columns === undefined || this.options.columns === null){ this.options.columns = this.normalize_columns(Object.keys(chunk)) } } // Emit the header if(this.info.records === 0){ this.bom() this.headers() } // Emit and stringify the record if an object or an array try{ this.emit('record', chunk, this.info.records) }catch(err){ this.state.stop = true return this.emit('error', err) } // Convert the record into a string let chunk_string if(this.options.eof){ chunk_string = this.stringify(chunk) if(chunk_string === undefined){ return }else{ chunk_string = chunk_string + this.options.record_delimiter } }else{ chunk_string = this.stringify(chunk) if(chunk_string === undefined){ return }else{ if(this.options.header || this.info.records){ chunk_string = this.options.record_delimiter + chunk_string } } } // Emit the csv this.info.records++ this.push(chunk_string) callback() } _flush(callback){ if(this.info.records === 0){ this.bom() this.headers() } callback() } stringify(chunk, chunkIsHeader=false){ if(typeof chunk !== 'object'){ return chunk } const {columns, header} = this.options const record = [] // Record is an array if(Array.isArray(chunk)){ // We are getting an array but the user has specified output columns. In // this case, we respect the columns indexes if(columns){ chunk.splice(columns.length) } // Cast record elements for(let i=0; i= 0 const containsQuote = (quote !== '') && value.indexOf(quote) >= 0 const containsEscape = value.indexOf(escape) >= 0 && (escape !== quote) const containsRecordDelimiter = value.indexOf(record_delimiter) >= 0 const quotedString = quoted_string && typeof field === 'string' let quotedMatch = quoted_match && quoted_match.filter( quoted_match => { if(typeof quoted_match === 'string'){ return value.indexOf(quoted_match) !== -1 }else{ return quoted_match.test(value) } }) quotedMatch = quotedMatch && quotedMatch.length > 0 const shouldQuote = containsQuote === true || containsdelimiter || containsRecordDelimiter || quoted || quotedString || quotedMatch if(shouldQuote === true && containsEscape === true){ const regexp = escape === '\\' ? new RegExp(escape + escape, 'g') : new RegExp(escape, 'g') value = value.replace(regexp, escape + escape) } if(containsQuote === true){ const regexp = new RegExp(quote,'g') value = value.replace(regexp, escape + quote) } if(shouldQuote === true){ value = quote + value + quote } csvrecord += value }else if(quoted_empty === true || (field === '' && quoted_string === true && quoted_empty !== false)){ csvrecord += quote + quote } if(i !== record.length - 1){ csvrecord += delimiter } } return csvrecord } bom(){ if(this.options.bom !== true){ return } this.push(bom_utf8) } headers(){ if(this.options.header === false){ return } if(this.options.columns === undefined){ return } let headers = this.options.columns.map(column => column.header) if(this.options.eof){ headers = this.stringify(headers, true) + this.options.record_delimiter }else{ headers = this.stringify(headers) } this.push(headers) } __cast(value, context){ const type = typeof value try{ if(type === 'string'){ // Fine for 99% of the cases return [undefined, this.options.cast.string(value, context)] }else if(type === 'bigint'){ return [undefined, this.options.cast.bigint(value, context)] }else if(type === 'number'){ return [undefined, this.options.cast.number(value, context)] }else if(type === 'boolean'){ return [undefined, this.options.cast.boolean(value, context)] }else if(value instanceof Date){ return [undefined, this.options.cast.date(value, context)] }else if(type === 'object' && value !== null){ return [undefined, this.options.cast.object(value, context)] }else{ return [undefined, value, value] } }catch(err){ return [err] } } normalize_columns(columns){ if(columns === undefined || columns === null){ return undefined } if(typeof columns !== 'object'){ throw Error('Invalid option "columns": expect an array or an object') } if(!Array.isArray(columns)){ const newcolumns = [] for(let k in columns){ newcolumns.push({ key: k, header: columns[k] }) } columns = newcolumns }else{ const newcolumns = [] for(let column of columns){ if(typeof column === 'string'){ newcolumns.push({ key: column, header: column }) }else if(typeof column === 'object' && column !== undefined && !Array.isArray(column)){ if(!column.key){ throw Error('Invalid column definition: property "key" is required') } if(column.header === undefined){ column.header = column.key } newcolumns.push(column) }else{ throw Error('Invalid column definition: expect a string or an object') } } columns = newcolumns } return columns } } const stringify = function(){ let data, options, callback for(let i in arguments){ const argument = arguments[i] const type = typeof argument if(data === undefined && (Array.isArray(argument))){ data = argument }else if(options === undefined && isObject(argument)){ options = argument }else if(callback === undefined && type === 'function'){ callback = argument }else{ throw new CsvError('CSV_INVALID_ARGUMENT', [ 'Invalid argument:', `got ${JSON.stringify(argument)} at index ${i}` ]) } } const stringifier = new Stringifier(options) if(callback){ const chunks = [] stringifier.on('readable', function(){ let chunk while((chunk = this.read()) !== null){ chunks.push(chunk) } }) stringifier.on('error', function(err){ callback(err) }) stringifier.on('end', function(){ callback(undefined, chunks.join('')) }) } if(data !== undefined){ // Give a chance for events to be registered later if(typeof setImmediate === 'function'){ setImmediate(function(){ for(let record of data){ stringifier.write(record) } stringifier.end() }) }else{ for(let record of data){ stringifier.write(record) } stringifier.end() } } return stringifier } class CsvError extends Error { constructor(code, message, ...contexts) { if(Array.isArray(message)) message = message.join(' ') super(message) if(Error.captureStackTrace !== undefined){ Error.captureStackTrace(this, CsvError) } this.code = code for(const context of contexts){ for(const key in context){ const value = context[key] this[key] = Buffer.isBuffer(value) ? value.toString() : value == null ? value : JSON.parse(JSON.stringify(value)) } } } } stringify.Stringifier = Stringifier stringify.CsvError = CsvError module.exports = stringify const isObject = function(obj){ return typeof obj === 'object' && obj !== null && ! Array.isArray(obj) } const underscore = function(str){ return str.replace(/([A-Z])/g, function(_, match){ return '_' + match.toLowerCase() }) } // Lodash implementation of `get` const charCodeOfDot = '.'.charCodeAt(0) const reEscapeChar = /\\(\\)?/g const rePropName = RegExp( // Match anything that isn't a dot or bracket. '[^.[\\]]+' + '|' + // Or match property names within brackets. '\\[(?:' + // Match a non-string expression. '([^"\'][^[]*)' + '|' + // Or match strings (supports escaping characters). '(["\'])((?:(?!\\2)[^\\\\]|\\\\.)*?)\\2' + ')\\]'+ '|' + // Or match "" as the space between consecutive dots or empty brackets. '(?=(?:\\.|\\[\\])(?:\\.|\\[\\]|$))' , 'g') const reIsDeepProp = /\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/ const reIsPlainProp = /^\w*$/ const getTag = function(value){ if(!value) value === undefined ? '[object Undefined]' : '[object Null]' return Object.prototype.toString.call(value) } const isKey = function(value, object){ if(Array.isArray(value)){ return false } const type = typeof value if(type === 'number' || type === 'symbol' || type === 'boolean' || !value || isSymbol(value)){ return true } return reIsPlainProp.test(value) || !reIsDeepProp.test(value) || (object != null && value in Object(object)) } const isSymbol = function(value){ const type = typeof value return type === 'symbol' || (type === 'object' && value && getTag(value) === '[object Symbol]') } const stringToPath = function(string){ const result = [] if(string.charCodeAt(0) === charCodeOfDot){ result.push('') } string.replace(rePropName, function(match, expression, quote, subString){ let key = match if(quote){ key = subString.replace(reEscapeChar, '$1') }else if(expression){ key = expression.trim() } result.push(key) }) return result } const castPath = function(value, object){ if(Array.isArray(value)){ return value } else { return isKey(value, object) ? [value] : stringToPath(value) } } const toKey = function(value){ if(typeof value === 'string' || isSymbol(value)) return value const result = `${value}` return (result == '0' && (1 / value) == -INFINITY) ? '-0' : result } const get = function(object, path){ path = castPath(path, object) let index = 0 const length = path.length while(object != null && index < length){ object = object[toKey(path[index++])] } return (index && index === length) ? object : undefined }