Repository: node-red/node-red (js)

Issue:
CSV Node Separator Issue: Headers and Body Misalignment
### Current Behavior

The CSV node has an error in the column headers separator character. When using the `;` (semicolon) as a separator, the output has headers using `,` (comma) and the body using`;` (semicolon).


![image](https://github.com/user-attachments/assets/2219806e-984f-4c69-aeb8-eaed06c771bc)

**N.B. Legacy mode is not affected**

### Expected Behavior

The CSV node should use the selected separator (`;` in this case) for both the headers and the body of the CSV.

### Steps To Reproduce

Configure the CSV node to use `;` (semicolon) as the separator.

Use the RFC4180 mode in the CSV node.

Inject a message with a CSV payload.

![image](https://github.com/user-attachments/assets/c4ce93ae-6e63-42ce-9cef-bc8d006cb11b)



### Example flow

```[{"id":"8c44ba9e59b84a84","type":"inject","z":"a9796cb084e3b87e","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"foo\":12,\"bar\":\"Hello\"}","payloadType":"json","x":110,"y":80,"wires":[["506a7cf138283d0a","1b1e6a11d42b2c02"]]},{"id":"e9616867afc47686","type":"debug","z":"a9796cb084e3b87e","name":"output csv","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":630,"y":80,"wires":[]},{"id":"506a7cf138283d0a","type":"csv","z":"a9796cb084e3b87e","name":"","spec":"rfc","sep":";","hdrin":"","hdrout":"all","multi":"one","ret":"\\r\\n","temp":"","skip":"0","strings":true,"include_empty_strings":"","include_null_values":"","x":390,"y":80,"wires":[["e9616867afc47686"]]},{"id":"1b1e6a11d42b2c02","type":"debug","z":"a9796cb084e3b87e","name":"input json","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":280,"y":160,"wires":[]}]```

### Environment

- Node-RED version: 4.0.5
- Node.js version: both and 20 / 22
- npm version:
- Platform/OS:
- Browser: Chrome


Pull request:
Ensure node.sep is honoured when generating CSV
Fixes #4977

`node.sep` was not being honoured in lots of places when generating CSV. 

Given how many places had `,` hardcoded, I'm not 100% confident this won't have other side effects - so want to get more eyes on this (particularly from @Steve-Mcl) before merging.

Patch:
diff --git a/packages/node_modules/@node-red/nodes/core/parsers/70-CSV.js b/packages/node_modules/@node-red/nodes/core/parsers/70-CSV.js
index c6a91d61a..26fdb636f 100644
--- a/packages/node_modules/@node-red/nodes/core/parsers/70-CSV.js
+++ b/packages/node_modules/@node-red/nodes/core/parsers/70-CSV.js
@@ -367,20 +367,21 @@ module.exports = function(RED) {
             const sendHeadersAlways = node.hdrout === "all"
             const sendHeaders = !dontSendHeaders && (sendHeadersOnce || sendHeadersAlways)
             const quoteables = [node.sep, node.quo, "\n", "\r"]
-            const templateQuoteables = [',', '"', "\n", "\r"]
+            const templateQuoteables = [node.sep, node.quo, "\n", "\r"]
+            const templateQuoteablesStrict = [',', '"', "\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
+                if (parsed.data?.length === 1) { node.goodtmpl = true } else { node.goodtmpl = false }
+                return node.goodtmpl ? parsed.data[0] : 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 })
+            const templateArrayToColumnString = function (template, keepEmptyColumns, separator = ',', quotables = templateQuoteablesStrict) {
+                // NOTE: defaults to strict column template parsing (commas and double quotes)
+                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.headers.map(e => addQuotes(e || '', { separator, quoteables: quotables })).join(separator)
                     : parsed.header // exclues empty columns
                     // TODO: resolve inconsistency between CSV->JSON and JSON->CSV
                     // CSV->JSON: empty columns are excluded
@@ -447,7 +448,7 @@ module.exports = function(RED) {
                                         template = Object.keys(inputData[0]) || ['']
                                     }
                                 }
-                                stringBuilder.push(templateArrayToColumnString(template, true))
+                                stringBuilder.push(templateArrayToColumnString(template, true, node.sep, templateQuoteables)) // use user set separator for output data.
                                 if (sendHeadersOnce) { node.hdrSent = true }
                             }
 
@@ -483,6 +484,7 @@ module.exports = function(RED) {
                                             node.warn(RED._("csv.errors.obj_csv"))
                                             badTemplateWarnOnce = false
                                         }
+                                        template = Object.keys(row) || ['']
                                         const rowData = []
                                         for (let header in inputData[0]) {
                                             if (row.hasOwnProperty(header)) {
@@ -518,7 +520,7 @@ module.exports = function(RED) {
 
                             // join lines, don't forget to add the last new line
                             msg.payload = stringBuilder.join(node.ret) + node.ret
-                            msg.columns = templateArrayToColumnString(template)
+                            msg.columns = templateArrayToColumnString(template) // always strict commas + double quotes  for 
                             if (msg.payload !== '') { send(msg) }
                             done()
                         }
@@ -615,16 +617,15 @@ module.exports = function(RED) {
                                     }
                                     if (msg.parts.index + 1 === msg.parts.count) {
                                         msg.payload = node.store
-                                        msg.columns = csvParseResult.header
-                                        // msg._mode = 'RFC4180 mode'
+                                        // msg.columns = csvParseResult.header
+                                        msg.columns = templateArrayToColumnString(csvParseResult.headers) // always strict commas + double quotes  for msg.columns
                                         delete msg.parts
                                         send(msg)
                                         node.store = []
                                     }
                                 }
                                 else {
-                                    msg.columns = csvParseResult.header
-                                    // msg._mode = 'RFC4180 mode'
+                                    msg.columns = templateArrayToColumnString(csvParseResult.headers) // always strict commas + double quotes  for msg.columns
                                     msg.payload = data
                                     send(msg); // finally send the array
                                 }
@@ -633,7 +634,8 @@ module.exports = function(RED) {
                                 const len = data.length
                                 for (let row = 0; row < len; row++) {
                                     const newMessage = RED.util.cloneMessage(msg)
-                                    newMessage.columns = csvParseResult.header
+                                    // newMessage.columns = csvParseResult.header
+                                    newMessage.columns = templateArrayToColumnString(csvParseResult.headers) // always strict commas + double quotes  for msg.columns
                                     newMessage.payload = data[row]
                                     if (!has_parts) {
                                         newMessage.parts = {


Test patch:
diff --git a/test/nodes/core/parsers/70-CSV_spec.js b/test/nodes/core/parsers/70-CSV_spec.js
index f362ecdbf..9f6749b9f 100644
--- a/test/nodes/core/parsers/70-CSV_spec.js
+++ b/test/nodes/core/parsers/70-CSV_spec.js
@@ -2067,6 +2067,27 @@ describe('CSV node (RFC Mode)', function () {
                 n2.on("input", function (msg) {
                     try {
                         msg.should.have.property('payload', '1\tfoo\t"ba""r"\tdi,ng\n');
+                        msg.should.have.property('columns', 'd,b,c,a'); // Strict RFC columns
+                        done();
+                    } catch (e) {
+                        done(e);
+                    }
+                });
+                const testJson = { d: 1, b: "foo", c: "ba\"r", a: "di,ng" };
+                n1.emit("input", { payload: testJson });
+            });
+        });
+
+        it('should convert a simple object back to a tsv with headers using a tab as a separator', function (done) {
+            const flow = [{ id: "n1", type: "csv", spec: "rfc", temp: "", sep: "\t", ret: '\n', hdrout: "all", wires: [["n2"]] }, // RFC-vs-Legacy difference - use line separator \n to satisfy original test
+            { id: "n2", type: "helper" }];
+            helper.load(csvNode, flow, function () {
+                const n1 = helper.getNode("n1");
+                const n2 = helper.getNode("n2");
+                n2.on("input", function (msg) {
+                    try {
+                        msg.should.have.property('payload', 'd\tb\tc\ta\n1\tfoo\t"ba""r"\tdi,ng\n');
+                        msg.should.have.property('columns', 'd,b,c,a'); // Strict RFC columns
                         done();
                     } catch (e) {
                         done(e);
@@ -2086,6 +2107,7 @@ describe('CSV node (RFC Mode)', function () {
                 n2.on("input", function (msg) {
                     try {
                         msg.should.have.property('payload', '4,foo,true,,0\n');
+                        msg.should.have.property('columns', 'a,b o,c p,e'); // Strict RFC columns
                         done();
                     } catch (e) {
                         done(e);
@@ -2106,6 +2128,7 @@ describe('CSV node (RFC Mode)', function () {
                     try {
                         //                       'payload', 'a"a,b\'b\nA1,B1\nA2,B2\n'); // Legacy
                         msg.should.have.property('payload', '"a""a",b\'b\nA1,B1\nA2,B2\n'); // RFC-vs-Legacy difference - RFC4180 Section 2.6, 2.7 quote handling
+                        msg.should.have.property('columns', '"a""a",b\'b'); // RCF compliant column names
                         done();
                     } catch (e) {
                         done(e);
@@ -2171,6 +2194,7 @@ describe('CSV node (RFC Mode)', function () {
                 n2.on("input", function (msg) {
                     try {
                         msg.should.have.property('payload', '1,3,2,4\n4,2,3,1\n');
+                        msg.should.have.property('columns', 'd,b,c,a'); // Strict RFC columns
                         done();
                     }
                     catch (e) { done(e); }
@@ -2189,6 +2213,7 @@ describe('CSV node (RFC Mode)', function () {
                 n2.on("input", function (msg) {
                     try {
                         msg.should.have.property('payload', 'd,b,c,a\n1,3,2,4\n4,"f\ng",3,1\n');
+                        msg.should.have.property('columns', 'd,b,c,a'); // Strict RFC columns
                         done();
                     }
                     catch (e) { done(e); }
@@ -2208,6 +2233,7 @@ describe('CSV node (RFC Mode)', function () {
                     try {
                         //                       'payload', ',0,1,foo,"ba""r","di,ng","fa\nba"\n');
                         msg.should.have.property('payload', ',0,1,foo\n'); // RFC-vs-Legacy difference - respect that user has specified a template with 4 columns
+                        msg.should.have.property('columns', 'a,b,c,d');
                         done();
                     }
                     catch (e) { done(e); }
@@ -2327,6 +2353,7 @@ describe('CSV node (RFC Mode)', function () {
                 n2.on("input", function (msg) {
                     try {
                         msg.should.have.property('payload', '{},"text,with,commas","This ""is"" a banana","{""sub"":""object""}"\n');
+                        msg.should.have.property('columns', 'a,b,c,d');
                         done();
                     }
                     catch (e) { done(e); }