mirror of
https://github.com/node-red/node-red.git
synced 2025-03-01 10:36:34 +00:00
parent
918943816f
commit
ff8eb0ec2b
@ -17,7 +17,20 @@
|
||||
</select>
|
||||
<input style="width:40px;" type="text" id="node-input-sep" pattern=".">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label><i class="fa fa-code"></i> <span data-i18n="csv.label.spec"></span></label>
|
||||
<div style="display: inline-grid;width: 70%;">
|
||||
<select style="width:100%" id="csv-option-spec">
|
||||
<option value="rfc" data-i18n="csv.spec.rfc"></option>
|
||||
<option value="" data-i18n="csv.spec.legacy"></option>
|
||||
</select>
|
||||
<div>
|
||||
<div class="form-tips csv-lecacy-warning" data-i18n="node-red:csv.spec.legacy_warning"
|
||||
style="width: calc(100% - 18px); margin-top: 4px; max-width: unset;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> <span data-i18n="common.label.name"></span></label>
|
||||
<input type="text" id="node-input-name" data-i18n="[placeholder]common.label.name">
|
||||
@ -60,10 +73,10 @@
|
||||
<div class="form-row" style="padding-left:20px;">
|
||||
<label></label>
|
||||
<label style="width:auto; margin-right:10px;" for="node-input-ret"><span data-i18n="csv.label.newline"></span></label>
|
||||
<select style="width:150px;" id="node-input-ret">
|
||||
<select style="width:calc(70% - 108px);" id="node-input-ret">
|
||||
<option value='\r\n' data-i18n="csv.newline.windows"></option>
|
||||
<option value='\n' data-i18n="csv.newline.linux"></option>
|
||||
<option value='\r' data-i18n="csv.newline.mac"></option>
|
||||
<option value='\r\n' data-i18n="csv.newline.windows"></option>
|
||||
</select>
|
||||
</div>
|
||||
</script>
|
||||
@ -75,6 +88,7 @@
|
||||
color:"#DEBD5C",
|
||||
defaults: {
|
||||
name: {value:""},
|
||||
spec: {value:"rfc"},
|
||||
sep: {
|
||||
value:',', required:true,
|
||||
label:RED._("node-red:csv.label.separator"),
|
||||
@ -83,7 +97,7 @@
|
||||
hdrin: {value:""},
|
||||
hdrout: {value:"none"},
|
||||
multi: {value:"one",required:true},
|
||||
ret: {value:'\\n'},
|
||||
ret: {value:'\\r\\n'}, // default to CRLF (RFC4180 Sec 2.1: "Each record is located on a separate line, delimited by a line break (CRLF)")
|
||||
temp: {value:""},
|
||||
skip: {value:"0"},
|
||||
strings: {value:true},
|
||||
@ -123,6 +137,27 @@
|
||||
$("#node-input-sep").hide();
|
||||
}
|
||||
});
|
||||
|
||||
$("#csv-option-spec").on("change", function() {
|
||||
if ($("#csv-option-spec").val() == "rfc") {
|
||||
$(".form-tips.csv-lecacy-warning").hide();
|
||||
} else {
|
||||
$(".form-tips.csv-lecacy-warning").show();
|
||||
}
|
||||
});
|
||||
// new nodes will have `spec` set to "rfc" (default), but existing nodes will either not have
|
||||
// a spec value or it will be empty - we need to maintain the legacy behaviour for existing
|
||||
// flows but default to rfc for new nodes
|
||||
let spec = !this.spec ? "" : "rfc"
|
||||
$("#csv-option-spec").val(spec).trigger("change")
|
||||
},
|
||||
oneditsave: function() {
|
||||
const specFormVal = $("#csv-option-spec").val() || '' // empty === legacy
|
||||
const spectNodeVal = this.spec || '' // empty === legacy, null/undefined means in-place node upgrade (keep as is)
|
||||
if (specFormVal !== spectNodeVal) {
|
||||
// only update the flow value if changed (avoid marking the node dirty unnecessarily)
|
||||
this.spec = specFormVal
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
@ -15,9 +15,18 @@
|
||||
**/
|
||||
|
||||
module.exports = function(RED) {
|
||||
const csv = require('./lib/csv')
|
||||
|
||||
"use strict";
|
||||
function CSVNode(n) {
|
||||
RED.nodes.createNode(this,n);
|
||||
RED.nodes.createNode(this,n)
|
||||
const node = this
|
||||
const RFC4180Mode = n.spec === 'rfc'
|
||||
const legacyMode = !RFC4180Mode
|
||||
|
||||
node.status({}) // clear status
|
||||
|
||||
if (legacyMode) {
|
||||
this.template = (n.temp || "");
|
||||
this.sep = (n.sep || ',').replace(/\\t/g,"\t").replace(/\\n/g,"\n").replace(/\\r/g,"\r");
|
||||
this.quo = '"';
|
||||
@ -37,7 +46,7 @@ module.exports = function(RED) {
|
||||
if (this.hdrout === false) { this.hdrout = "none"; }
|
||||
if (this.hdrout === true) { this.hdrout = "all"; }
|
||||
var tmpwarn = true;
|
||||
var node = this;
|
||||
// var node = this;
|
||||
var re = new RegExp(node.sep.replace(/[-[\]{}()*+!<=:?.\/\\^$|#\s,]/g,'\\$&') + '(?=(?:(?:[^"]*"){2})*[^"]*$)','g');
|
||||
|
||||
// pass in an array of column names to be trimmed, de-quoted and retrimmed
|
||||
@ -163,7 +172,9 @@ module.exports = function(RED) {
|
||||
// join lines, don't forget to add the last new line
|
||||
msg.payload = ou.join(node.ret) + node.ret;
|
||||
msg.columns = template.map(v => v.indexOf(',')!==-1 ? '"'+v+'"' : v).join(',');
|
||||
if (msg.payload !== '') { send(msg); }
|
||||
if (msg.payload !== '') {
|
||||
send(msg);
|
||||
}
|
||||
done();
|
||||
}
|
||||
catch(e) { done(e); }
|
||||
@ -332,5 +343,346 @@ module.exports = function(RED) {
|
||||
}
|
||||
});
|
||||
}
|
||||
RED.nodes.registerType("csv",CSVNode);
|
||||
|
||||
if(RFC4180Mode) {
|
||||
node.template = (n.temp || "")
|
||||
node.sep = (n.sep || ',').replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r")
|
||||
node.quo = '"'
|
||||
// default to CRLF (RFC4180 Sec 2.1: "Each record is located on a separate line, delimited by a line break (CRLF)")
|
||||
node.ret = (n.ret || "\r\n").replace(/\\n/g, "\n").replace(/\\r/g, "\r")
|
||||
node.multi = n.multi || "one"
|
||||
node.hdrin = n.hdrin || false
|
||||
node.hdrout = n.hdrout || "none"
|
||||
node.goodtmpl = true
|
||||
node.skip = parseInt(n.skip || 0)
|
||||
node.store = []
|
||||
node.parsestrings = n.strings
|
||||
node.include_empty_strings = n.include_empty_strings || false
|
||||
node.include_null_values = n.include_null_values || false
|
||||
if (node.parsestrings === undefined) { node.parsestrings = true }
|
||||
if (node.hdrout === false) { node.hdrout = "none" }
|
||||
if (node.hdrout === true) { node.hdrout = "all" }
|
||||
const dontSendHeaders = node.hdrout === "none"
|
||||
const sendHeadersOnce = node.hdrout === "once"
|
||||
const sendHeadersAlways = node.hdrout === "all"
|
||||
const sendHeaders = !dontSendHeaders && (sendHeadersOnce || sendHeadersAlways)
|
||||
const quoteables = [node.sep, node.quo, "\n", "\r"]
|
||||
const templateQuoteables = [',', '"', "\n", "\r"]
|
||||
let badTemplateWarnOnce = true
|
||||
|
||||
const columnStringToTemplateArray = function (col, sep) {
|
||||
// NOTE: enforce strict column template parsing in RFC4180 mode
|
||||
const parsed = csv.parse(col, { separator: sep, quote: node.quo, outputStyle: 'array', strict: true })
|
||||
if (parsed.headers.length > 0) { node.goodtmpl = true } else { node.goodtmpl = false }
|
||||
return parsed.headers.length ? parsed.headers : null
|
||||
}
|
||||
const templateArrayToColumnString = function (template, keepEmptyColumns) {
|
||||
// NOTE: enforce strict column template parsing in RFC4180 mode
|
||||
const parsed = csv.parse('', {headers: template, headersOnly:true, separator: ',', quote: node.quo, outputStyle: 'array', strict: true })
|
||||
return keepEmptyColumns
|
||||
? parsed.headers.map(e => addQuotes(e || '', { separator: ',', quoteables: templateQuoteables}))
|
||||
: parsed.header // exclues empty columns
|
||||
// TODO: resolve inconsistency between CSV->JSON and JSON->CSV
|
||||
// CSV->JSON: empty columns are excluded
|
||||
// JSON->CSV: empty columns are kept in some cases
|
||||
}
|
||||
function addQuotes(cell, options) {
|
||||
options = options || {}
|
||||
return csv.quoteCell(cell, {
|
||||
quote: options.quote || node.quo || '"',
|
||||
separator: options.separator || node.sep || ',',
|
||||
quoteables: options.quoteables || quoteables
|
||||
})
|
||||
}
|
||||
const hasTemplate = (t) => t?.length > 0 && !(t.length === 1 && t[0] === '')
|
||||
let template
|
||||
try {
|
||||
template = columnStringToTemplateArray(node.template, ',') || ['']
|
||||
} catch (e) {
|
||||
node.warn(RED._("csv.errors.bad_template")) // is warning really necessary now we have status?
|
||||
node.status({ fill: "red", shape: "dot", text: RED._("csv.errors.bad_template") })
|
||||
return // dont hook up the node
|
||||
}
|
||||
const noTemplate = hasTemplate(template) === false
|
||||
node.hdrSent = false
|
||||
|
||||
node.on("input", function (msg, send, done) {
|
||||
node.status({}) // clear status
|
||||
if (msg.hasOwnProperty("reset")) {
|
||||
node.hdrSent = false
|
||||
}
|
||||
if (msg.hasOwnProperty("payload")) {
|
||||
let inputData = msg.payload
|
||||
if (typeof inputData == "object") { // convert object to CSV string
|
||||
try {
|
||||
// first determine the payload kind. Array or objects? Array of primitives? Array of arrays? Just an object?
|
||||
// then, if necessary, convert to an array of objects/arrays
|
||||
let isObject = !Array.isArray(inputData) && typeof inputData === 'object'
|
||||
let isArrayOfObjects = Array.isArray(inputData) && inputData.length > 0 && typeof inputData[0] === 'object'
|
||||
let isArrayOfArrays = Array.isArray(inputData) && inputData.length > 0 && Array.isArray(inputData[0])
|
||||
let isArrayOfPrimitives = Array.isArray(inputData) && inputData.length > 0 && typeof inputData[0] !== 'object'
|
||||
|
||||
if (isObject) {
|
||||
inputData = [inputData]
|
||||
isArrayOfObjects = true
|
||||
isObject = false
|
||||
} else if (isArrayOfPrimitives) {
|
||||
inputData = [inputData]
|
||||
isArrayOfArrays = true
|
||||
isArrayOfPrimitives = false
|
||||
}
|
||||
|
||||
const stringBuilder = []
|
||||
if (!(noTemplate && (msg.hasOwnProperty("parts") && msg.parts.hasOwnProperty("index") && msg.parts.index > 0))) {
|
||||
template = columnStringToTemplateArray(node.template) || ['']
|
||||
}
|
||||
|
||||
// build header line
|
||||
if (sendHeaders && node.hdrSent === false) {
|
||||
if (hasTemplate(template) === false) {
|
||||
if (msg.hasOwnProperty("columns")) {
|
||||
template = columnStringToTemplateArray(msg.columns || "", ",") || ['']
|
||||
}
|
||||
else {
|
||||
template = Object.keys(inputData[0]) || ['']
|
||||
}
|
||||
}
|
||||
stringBuilder.push(templateArrayToColumnString(template, true))
|
||||
if (sendHeadersOnce) { node.hdrSent = true }
|
||||
}
|
||||
|
||||
// build csv lines
|
||||
for (let s = 0; s < inputData.length; s++) {
|
||||
let row = inputData[s]
|
||||
if (isArrayOfArrays) {
|
||||
/*** row is an array of arrays ***/
|
||||
const _hasTemplate = hasTemplate(template)
|
||||
const len = _hasTemplate ? template.length : row.length
|
||||
const result = []
|
||||
for (let t = 0; t < len; t++) {
|
||||
let cell = row[t]
|
||||
if (cell === undefined) { cell = "" }
|
||||
if(_hasTemplate) {
|
||||
const header = template[t]
|
||||
if (header) {
|
||||
result[t] = addQuotes(RED.util.ensureString(cell))
|
||||
}
|
||||
} else {
|
||||
result[t] = addQuotes(RED.util.ensureString(cell))
|
||||
}
|
||||
}
|
||||
stringBuilder.push(result.join(node.sep))
|
||||
} else {
|
||||
/*** row is an object ***/
|
||||
if (hasTemplate(template) === false && (msg.hasOwnProperty("columns"))) {
|
||||
template = columnStringToTemplateArray(msg.columns || "", ",")
|
||||
}
|
||||
if (hasTemplate(template) === false) {
|
||||
/*** row is an object but we still don't have a template ***/
|
||||
if (badTemplateWarnOnce === true) {
|
||||
node.warn(RED._("csv.errors.obj_csv"))
|
||||
badTemplateWarnOnce = false
|
||||
}
|
||||
const rowData = []
|
||||
for (let header in inputData[0]) {
|
||||
if (row.hasOwnProperty(header)) {
|
||||
const cell = row[header]
|
||||
if (typeof cell !== "object") {
|
||||
let cellValue = ""
|
||||
if (cell !== undefined) {
|
||||
cellValue += cell
|
||||
}
|
||||
rowData.push(addQuotes(cellValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
stringBuilder.push(rowData.join(node.sep))
|
||||
} else {
|
||||
/*** row is an object and we have a template ***/
|
||||
const rowData = []
|
||||
for (let t = 0; t < template.length; t++) {
|
||||
if (!template[t]) {
|
||||
rowData.push('')
|
||||
}
|
||||
else {
|
||||
let cellValue = inputData[s][template[t]]
|
||||
if (cellValue === undefined) { cellValue = "" }
|
||||
cellValue = RED.util.ensureString(cellValue)
|
||||
rowData.push(addQuotes(cellValue))
|
||||
}
|
||||
}
|
||||
stringBuilder.push(rowData.join(node.sep)); // add separator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// join lines, don't forget to add the last new line
|
||||
msg.payload = stringBuilder.join(node.ret) + node.ret
|
||||
msg.columns = templateArrayToColumnString(template)
|
||||
if (msg.payload !== '') { send(msg) }
|
||||
done()
|
||||
}
|
||||
catch (e) {
|
||||
done(e)
|
||||
}
|
||||
}
|
||||
else if (typeof inputData == "string") { // convert CSV string to object
|
||||
try {
|
||||
let firstLine = true; // is this the first line
|
||||
let last = false
|
||||
let linecount = 0
|
||||
const has_parts = msg.hasOwnProperty("parts")
|
||||
|
||||
// determine if this is a multi part message and if so what part we are processing
|
||||
if (msg.hasOwnProperty("parts")) {
|
||||
linecount = msg.parts.index
|
||||
if (msg.parts.index > node.skip) { firstLine = false }
|
||||
if (msg.parts.hasOwnProperty("count") && (msg.parts.index + 1 >= msg.parts.count)) { last = true }
|
||||
}
|
||||
|
||||
// If skip is set, compute the cursor position to start parsing from
|
||||
let _cursor = 0
|
||||
if (node.skip > 0 && linecount < node.skip) {
|
||||
for (; _cursor < inputData.length; _cursor++) {
|
||||
if (firstLine && (linecount < node.skip)) {
|
||||
if (inputData[_cursor] === "\r" || inputData[_cursor] === "\n") {
|
||||
linecount += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if (_cursor >= inputData.length) {
|
||||
return // skip this line
|
||||
}
|
||||
}
|
||||
|
||||
// count the number of line breaks in the string
|
||||
const noofCR = ((_cursor ? inputData.slice(_cursor) : inputData).match(/[\r\n]/g) || []).length
|
||||
|
||||
// if we have `parts` and we are outputting multiple objects and we have more than one line
|
||||
// then we need to set firstLine to true so that we process the header line
|
||||
if (has_parts && node.multi === "mult" && noofCR > 1) {
|
||||
firstLine = true
|
||||
}
|
||||
|
||||
// if we are processing the first line and the node has been set to extract the header line
|
||||
// update the template with the header line
|
||||
if (firstLine && node.hdrin === true) {
|
||||
/** @type {import('./lib/csv/index.js').CSVParseOptions} */
|
||||
const csvOptionsForHeaderRow = {
|
||||
cursor: _cursor,
|
||||
separator: node.sep,
|
||||
quote: node.quo,
|
||||
dataHasHeaderRow: true,
|
||||
headersOnly: true,
|
||||
outputStyle: 'array',
|
||||
strict: true // enforce strict parsing of the header row
|
||||
}
|
||||
try {
|
||||
const csvHeader = csv.parse(inputData, csvOptionsForHeaderRow)
|
||||
template = csvHeader.headers
|
||||
_cursor = csvHeader.cursor
|
||||
} catch (e) {
|
||||
// node.warn(RED._("csv.errors.bad_template")) // add warning?
|
||||
node.status({ fill: "red", shape: "dot", text: RED._("csv.errors.bad_template") })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// now we process the data lines
|
||||
/** @type {import('./lib/csv/index.js').CSVParseOptions} */
|
||||
const csvOptions = {
|
||||
cursor: _cursor,
|
||||
separator: node.sep,
|
||||
quote: node.quo,
|
||||
dataHasHeaderRow: false,
|
||||
headers: hasTemplate(template) ? template : null,
|
||||
outputStyle: 'object',
|
||||
includeNullValues: node.include_null_values,
|
||||
includeEmptyStrings: node.include_empty_strings,
|
||||
parseNumeric: node.parsestrings,
|
||||
strict: false // relax the strictness of the parser for data rows
|
||||
}
|
||||
const csvParseResult = csv.parse(inputData, csvOptions)
|
||||
const data = csvParseResult.data
|
||||
|
||||
// output results
|
||||
if (node.multi !== "one") {
|
||||
if (has_parts && noofCR <= 1) {
|
||||
if (data.length > 0) {
|
||||
node.store.push(...data)
|
||||
}
|
||||
if (msg.parts.index + 1 === msg.parts.count) {
|
||||
msg.payload = node.store
|
||||
msg.columns = csvParseResult.header
|
||||
// msg._mode = 'RFC4180 mode'
|
||||
delete msg.parts
|
||||
send(msg)
|
||||
node.store = []
|
||||
}
|
||||
}
|
||||
else {
|
||||
msg.columns = csvParseResult.header
|
||||
// msg._mode = 'RFC4180 mode'
|
||||
msg.payload = data
|
||||
send(msg); // finally send the array
|
||||
}
|
||||
}
|
||||
else {
|
||||
const len = data.length
|
||||
for (let row = 0; row < len; row++) {
|
||||
const newMessage = RED.util.cloneMessage(msg)
|
||||
newMessage.columns = csvParseResult.header
|
||||
newMessage.payload = data[row]
|
||||
if (!has_parts) {
|
||||
newMessage.parts = {
|
||||
id: msg._msgid,
|
||||
index: row,
|
||||
count: len
|
||||
}
|
||||
}
|
||||
else {
|
||||
newMessage.parts.index -= node.skip
|
||||
newMessage.parts.count -= node.skip
|
||||
if (node.hdrin) { // if we removed the header line then shift the counts by 1
|
||||
newMessage.parts.index -= 1
|
||||
newMessage.parts.count -= 1
|
||||
}
|
||||
}
|
||||
if (last) { newMessage.complete = true }
|
||||
// newMessage._mode = 'RFC4180 mode'
|
||||
send(newMessage)
|
||||
}
|
||||
if (has_parts && last && len === 0) {
|
||||
// send({complete:true, _mode: 'RFC4180 mode'})
|
||||
send({ complete: true })
|
||||
}
|
||||
}
|
||||
|
||||
node.linecount = 0
|
||||
done()
|
||||
}
|
||||
catch (e) {
|
||||
done(e)
|
||||
}
|
||||
}
|
||||
else {
|
||||
// RFC-vs-legacy mode difference: In RFC mode, we throw catchable errors and provide a status message
|
||||
const err = new Error(RED._("csv.errors.csv_js"))
|
||||
node.status({ fill: "red", shape: "dot", text: err.message })
|
||||
done(err)
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!msg.hasOwnProperty("reset")) {
|
||||
node.send(msg); // If no payload and not reset - just pass it on.
|
||||
}
|
||||
done()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
RED.nodes.registerType("csv",CSVNode)
|
||||
}
|
||||
|
324
packages/node_modules/@node-red/nodes/core/parsers/lib/csv/index.js
vendored
Normal file
324
packages/node_modules/@node-red/nodes/core/parsers/lib/csv/index.js
vendored
Normal file
@ -0,0 +1,324 @@
|
||||
|
||||
/**
|
||||
* @typedef {Object} CSVParseOptions
|
||||
* @property {number} [cursor=0] - an index into the CSV to start parsing from
|
||||
* @property {string} [separator=','] - the separator character
|
||||
* @property {string} [quote='"'] - the quote character
|
||||
* @property {boolean} [headersOnly=false] - only parse the headers and return them
|
||||
* @property {string[]} [headers=[]] - an array of headers to use instead of the first row of the CSV data
|
||||
* @property {boolean} [dataHasHeaderRow=true] - whether the CSV data to parse has a header row
|
||||
* @property {boolean} [outputHeader=true] - whether the output data should include a header row (only applies to array output)
|
||||
* @property {boolean} [parseNumeric=false] - parse numeric values into numbers
|
||||
* @property {boolean} [includeNullValues=false] - include null values in the output
|
||||
* @property {boolean} [includeEmptyStrings=true] - include empty strings in the output
|
||||
* @property {string} [outputStyle='object'] - output an array of arrays or an array of objects
|
||||
* @property {boolean} [strict=false] - throw an error if the CSV is malformed
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parses a CSV string into an array of arrays or an array of objects.
|
||||
*
|
||||
* NOTES:
|
||||
* * Deviations from the RFC4180 spec (for the sake of user fiendliness, system implementations and flexibility), this parser will:
|
||||
* * accept any separator character, not just `,`
|
||||
* * accept any quote character, not just `"`
|
||||
* * parse `\r`, `\n` or `\r\n` as line endings (RRFC4180 2.1 states lines are separated by CRLF)
|
||||
* * Only single character `quote` is supported
|
||||
* * `quote` is `"` by default
|
||||
* * Any cell that contains a `quote` or `separator` will be quoted
|
||||
* * Any `quote` characters inside a cell will be escaped as per RFC 4180 2.6
|
||||
* * Only single character `separator` is supported
|
||||
* * Only `array` and `object` output styles are supported
|
||||
* * `array` output style is an array of arrays [[],[],[]]
|
||||
* * `object` output style is an array of objects [{},{},{}]
|
||||
* * Only `headers` or `dataHasHeaderRow` are supported, not both
|
||||
* @param {string} csvIn - the CSV string to parse
|
||||
* @param {CSVParseOptions} parseOptions - options
|
||||
* @throws {Error}
|
||||
*/
|
||||
function parse(csvIn, parseOptions) {
|
||||
/* Normalise options */
|
||||
parseOptions = parseOptions || {};
|
||||
const separator = parseOptions.separator ?? ',';
|
||||
const quote = parseOptions.quote ?? '"';
|
||||
const headersOnly = parseOptions.headersOnly ?? false;
|
||||
const headers = Array.isArray(parseOptions.headers) ? parseOptions.headers : []
|
||||
const dataHasHeaderRow = parseOptions.dataHasHeaderRow ?? true;
|
||||
const outputHeader = parseOptions.outputHeader ?? true;
|
||||
const parseNumeric = parseOptions.parseNumeric ?? false;
|
||||
const includeNullValues = parseOptions.includeNullValues ?? false;
|
||||
const includeEmptyStrings = parseOptions.includeEmptyStrings ?? true;
|
||||
const outputStyle = ['array', 'object'].includes(parseOptions.outputStyle) ? parseOptions.outputStyle : 'object'; // 'array [[],[],[]]' or 'object [{},{},{}]
|
||||
const strict = parseOptions.strict ?? false
|
||||
|
||||
/* Local variables */
|
||||
const cursorMax = csvIn.length;
|
||||
const ouputArrays = outputStyle === 'array';
|
||||
const headersSupplied = headers.length > 0
|
||||
// The original regex was an "is-a-number" positive logic test. /^ *[-]?(?!E)(?!0\d)\d*\.?\d*(E-?\+?)?\d+ *$/i;
|
||||
// Below, is less strict and inverted logic but coupled with +cast it is 13%+ faster than original regex+parsefloat
|
||||
// and has the benefit of understanding hexadecimals, binary and octal numbers.
|
||||
const skipNumberConversion = /^ *(\+|-0\d|0\d)/
|
||||
const cellBuilder = []
|
||||
let rowBuilder = []
|
||||
let cursor = typeof parseOptions.cursor === 'number' ? parseOptions.cursor : 0;
|
||||
let newCell = true, inQuote = false, closed = false, output = [];
|
||||
|
||||
/* inline helper functions */
|
||||
const finaliseCell = () => {
|
||||
let cell = cellBuilder.join('')
|
||||
cellBuilder.length = 0
|
||||
// push the cell:
|
||||
// NOTE: if cell is empty but newCell==true, then this cell had zero chars - push `null`
|
||||
// otherwise push empty string
|
||||
return rowBuilder.push(cell || (newCell ? null : ''))
|
||||
}
|
||||
const finaliseRow = () => {
|
||||
if (cellBuilder.length) {
|
||||
finaliseCell()
|
||||
}
|
||||
if (rowBuilder.length) {
|
||||
output.push(rowBuilder)
|
||||
rowBuilder = []
|
||||
}
|
||||
}
|
||||
|
||||
/* Main parsing loop */
|
||||
while (cursor < cursorMax) {
|
||||
const char = csvIn[cursor]
|
||||
if (inQuote) {
|
||||
if (char === quote && csvIn[cursor + 1] === quote) {
|
||||
cellBuilder.push(quote)
|
||||
cursor += 2;
|
||||
newCell = false;
|
||||
closed = false;
|
||||
} else if (char === quote) {
|
||||
inQuote = false;
|
||||
cursor += 1;
|
||||
newCell = false;
|
||||
closed = true;
|
||||
} else {
|
||||
cellBuilder.push(char)
|
||||
newCell = false;
|
||||
closed = false;
|
||||
cursor++;
|
||||
}
|
||||
} else {
|
||||
if (char === separator) {
|
||||
finaliseCell()
|
||||
cursor += 1;
|
||||
newCell = true;
|
||||
closed = false;
|
||||
} else if (char === quote) {
|
||||
if (newCell) {
|
||||
inQuote = true;
|
||||
cursor += 1;
|
||||
newCell = false;
|
||||
closed = false;
|
||||
}
|
||||
else if (strict) {
|
||||
throw new UnquotedQuoteError(cursor)
|
||||
} else {
|
||||
// not strict, keep 1 quote if the next char is not a cell/record separator
|
||||
cursor++
|
||||
if (csvIn[cursor] && csvIn[cursor] !== '\n' && csvIn[cursor] !== '\r' && csvIn[cursor] !== separator) {
|
||||
cellBuilder.push(char)
|
||||
if (csvIn[cursor] === quote) {
|
||||
cursor++ // skip the next quote
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (char === '\n' || char === '\r') {
|
||||
finaliseRow()
|
||||
if (csvIn[cursor + 1] === '\n') {
|
||||
cursor += 2;
|
||||
} else {
|
||||
cursor++
|
||||
}
|
||||
newCell = true;
|
||||
closed = false;
|
||||
if (headersOnly) {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (closed) {
|
||||
if (strict) {
|
||||
throw new DataAfterCloseError(cursor)
|
||||
} else {
|
||||
cursor--; // move back to grab the previously discarded char
|
||||
closed = false
|
||||
}
|
||||
} else {
|
||||
cellBuilder.push(char)
|
||||
newCell = false;
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (strict && inQuote) {
|
||||
throw new ParseError(`Missing quote, unclosed cell`, cursor)
|
||||
}
|
||||
// finalise the last cell/row
|
||||
finaliseRow()
|
||||
let firstRowIsHeader = false
|
||||
// if no headers supplied, generate them
|
||||
if (output.length >= 1) {
|
||||
if (headersSupplied) {
|
||||
// headers already supplied
|
||||
} else if (dataHasHeaderRow) {
|
||||
// take the first row as the headers
|
||||
headers.push(...output[0])
|
||||
firstRowIsHeader = true
|
||||
} else {
|
||||
// generate headers col1, col2, col3, etc
|
||||
for (let i = 0; i < output[0].length; i++) {
|
||||
headers.push("col" + (i + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalResult = {
|
||||
/** @type {String[]} headers as an array of string */
|
||||
headers: headers,
|
||||
/** @type {String} headers as a comma-separated string */
|
||||
header: null,
|
||||
/** @type {Any[]} Result Data (may include header row: check `firstRowIsHeader` flag) */
|
||||
data: [],
|
||||
/** @type {Boolean|undefined} flag to indicate if the first row is a header row (only applies when `outputStyle` is 'array') */
|
||||
firstRowIsHeader: undefined,
|
||||
/** @type {'array'|'object'} flag to indicate the output style */
|
||||
outputStyle: outputStyle,
|
||||
/** @type {Number} The current cursor position */
|
||||
cursor: cursor,
|
||||
}
|
||||
|
||||
const quotedHeaders = []
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
if (!headers[i]) {
|
||||
continue
|
||||
}
|
||||
quotedHeaders.push(quoteCell(headers[i], { quote, separator: ',' }))
|
||||
}
|
||||
finalResult.header = quotedHeaders.join(',') // always quote headers and join with comma
|
||||
|
||||
// output is an array of arrays [[],[],[]]
|
||||
if (ouputArrays || headersOnly) {
|
||||
if (!firstRowIsHeader && !headersOnly && outputHeader && headers.length > 0) {
|
||||
if (output.length > 0) {
|
||||
output.unshift(headers)
|
||||
} else {
|
||||
output = [headers]
|
||||
}
|
||||
firstRowIsHeader = true
|
||||
}
|
||||
if (headersOnly) {
|
||||
delete finalResult.firstRowIsHeader
|
||||
return finalResult
|
||||
}
|
||||
finalResult.firstRowIsHeader = firstRowIsHeader
|
||||
finalResult.data = (firstRowIsHeader && !outputHeader) ? output.slice(1) : output
|
||||
return finalResult
|
||||
}
|
||||
|
||||
// output is an array of objects [{},{},{}]
|
||||
const outputObjects = []
|
||||
let i = firstRowIsHeader ? 1 : 0
|
||||
for (; i < output.length; i++) {
|
||||
const rowObject = {}
|
||||
let isEmpty = true
|
||||
for (let j = 0; j < headers.length; j++) {
|
||||
if (!headers[j]) {
|
||||
continue
|
||||
}
|
||||
let v = output[i][j] === undefined ? null : output[i][j]
|
||||
if (v === null && !includeNullValues) {
|
||||
continue
|
||||
} else if (v === "" && !includeEmptyStrings) {
|
||||
continue
|
||||
} else if (parseNumeric === true && v && !skipNumberConversion.test(v)) {
|
||||
const vTemp = +v
|
||||
const isNumber = !isNaN(vTemp)
|
||||
if(isNumber) {
|
||||
v = vTemp
|
||||
}
|
||||
}
|
||||
rowObject[headers[j]] = v
|
||||
isEmpty = false
|
||||
}
|
||||
// determine if this row is empty
|
||||
if (!isEmpty) {
|
||||
outputObjects.push(rowObject)
|
||||
}
|
||||
}
|
||||
finalResult.data = outputObjects
|
||||
delete finalResult.firstRowIsHeader
|
||||
return finalResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Quotes a cell in a CSV string if necessary. Addiionally, any double quotes inside the cell will be escaped as per RFC 4180 2.6 (https://datatracker.ietf.org/doc/html/rfc4180#section-2).
|
||||
* @param {string} cell - the string to quote
|
||||
* @param {*} options - options
|
||||
* @param {string} [options.quote='"'] - the quote character
|
||||
* @param {string} [options.separator=','] - the separator character
|
||||
* @param {string[]} [options.quoteables] - an array of characters that, when encountered, will trigger the application of outer quotes
|
||||
* @returns
|
||||
*/
|
||||
function quoteCell(cell, { quote = '"', separator = ",", quoteables } = {
|
||||
quote: '"',
|
||||
separator: ",",
|
||||
quoteables: [quote, separator, '\r', '\n']
|
||||
}) {
|
||||
quoteables = quoteables || [quote, separator, '\r', '\n'];
|
||||
|
||||
let doubleUp = false;
|
||||
if (cell.indexOf(quote) !== -1) { // add double quotes if any quotes
|
||||
doubleUp = true;
|
||||
}
|
||||
const quoteChar = quoteables.some(q => cell.includes(q)) ? quote : '';
|
||||
return quoteChar + (doubleUp ? cell.replace(/"/g, '""') : cell) + quoteChar;
|
||||
}
|
||||
|
||||
// #region Custom Error Classes
|
||||
class ParseError extends Error {
|
||||
/**
|
||||
* @param {string} message - the error message
|
||||
* @param {number} cursor - the cursor index where the error occurred
|
||||
*/
|
||||
constructor(message, cursor) {
|
||||
super(message)
|
||||
this.name = 'ParseError'
|
||||
this.cursor = cursor
|
||||
}
|
||||
}
|
||||
|
||||
class UnquotedQuoteError extends ParseError {
|
||||
/**
|
||||
* @param {number} cursor - the cursor index where the error occurred
|
||||
*/
|
||||
constructor(cursor) {
|
||||
super('Quote found in the middle of an unquoted field', cursor)
|
||||
this.name = 'UnquotedQuoteError'
|
||||
}
|
||||
}
|
||||
|
||||
class DataAfterCloseError extends ParseError {
|
||||
/**
|
||||
* @param {number} cursor - the cursor index where the error occurred
|
||||
*/
|
||||
constructor(cursor) {
|
||||
super('Data found after closing quote', cursor)
|
||||
this.name = 'DataAfterCloseError'
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
exports.parse = parse
|
||||
exports.quoteCell = quoteCell
|
||||
exports.ParseError = ParseError
|
||||
exports.UnquotedQuoteError = UnquotedQuoteError
|
||||
exports.DataAfterCloseError = DataAfterCloseError
|
@ -849,7 +849,13 @@
|
||||
"newline": "Newline",
|
||||
"usestrings": "parse numerical values",
|
||||
"include_empty_strings": "include empty strings",
|
||||
"include_null_values": "include null values"
|
||||
"include_null_values": "include null values",
|
||||
"spec": "Specification"
|
||||
},
|
||||
"spec": {
|
||||
"rfc": "RFC4180",
|
||||
"legacy": "Legacy",
|
||||
"warning": "Legacy mode will be removed in a future release."
|
||||
},
|
||||
"placeholder": {
|
||||
"columns": "comma-separated column names"
|
||||
@ -878,6 +884,7 @@
|
||||
"once": "send headers once, until msg.reset"
|
||||
},
|
||||
"errors": {
|
||||
"bad_template": "Malformed columns template.",
|
||||
"csv_js": "This node only handles CSV strings or js objects.",
|
||||
"obj_csv": "No columns template specified for object -> CSV.",
|
||||
"bad_csv": "Malformed CSV data - output probably corrupt."
|
||||
|
@ -36,7 +36,9 @@
|
||||
</dl>
|
||||
<h3>Details</h3>
|
||||
<p>The column template can contain an ordered list of column names. When converting CSV to an object, the column names
|
||||
will be used as the property names. Alternatively, the column names can be taken from the first row of the CSV.</p>
|
||||
will be used as the property names. Alternatively, the column names can be taken from the first row of the CSV.
|
||||
<p>When the RFC specification is selected, the column template must be compliant with RFC4180.</p>
|
||||
</p>
|
||||
<p>When converting to CSV, the columns template is used to identify which properties to extract from the object and in what order.</p>
|
||||
<p>If the columns template is blank then you can use a simple comma separated list of properties supplied in <code>msg.columns</code> to
|
||||
determine what to extract and in what order. If neither are present then all the object properties are output in the order
|
||||
@ -49,4 +51,5 @@
|
||||
<p>If outputting multiple messages they will have their <code>parts</code> property set and form a complete message sequence.</p>
|
||||
<p>If the node is set to only send column headers once, then setting <code>msg.reset</code> to any value will cause the node to resend the headers.</p>
|
||||
<p><b>Note:</b> the column template must be comma separated - even if a different separator is chosen for the data.</p>
|
||||
<p><b>Note:</b> in RFC mode, catchable errors will be thrown for malformed CSV headers and invalid input payload data</p>
|
||||
</script>
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user