From a07c34f9a98cd6ae90504886130c3eec52d68d33 Mon Sep 17 00:00:00 2001 From: Jeffrey Heer Date: Tue, 24 Sep 2024 15:50:11 -0700 Subject: [PATCH] feat: Add header option to toCSV method. (#370) * feat: Add toCSV header option, ensure trailing newline. * test: Update toCSV tests, test header option. * docs: Add toCSV header option docs. --- docs/api/index.md | 2 +- docs/api/table.md | 1 + src/format/to-csv.js | 14 +++++++++----- src/format/to-html.js | 10 +++++++--- src/format/to-markdown.js | 11 +++++++---- src/format/util.js | 7 +++++-- test/format/to-csv-test.js | 28 ++++++++++++++++++++++------ 7 files changed, 52 insertions(+), 21 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index e4f6803..f1b001f 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -132,7 +132,7 @@ This method performs parsing only. To both load and parse a CSV file, use [loadC * *options*: A CSV format options object: * *delimiter*: A single-character delimiter string between column values (default `','`). * *decimal*: A single-character numeric decimal separator (default `'.'`). - * *header*: Boolean flag (default `true`) to specify the presence of a header row. If `true`, indicates the CSV contains a header row with column names. If `false`, indicates the CSV does not contain a header row and the columns are given the names `'col1'`, `'col2'`, etc unless the *names* option is specified. + * *header*: Boolean flag (default `true`) to specify the presence of a header row. If `true`, indicates the CSV contains a header row with column names. If `false`, indicates the CSV does not contain a header row and the columns are given the names `'col1'`, `'col2'`, etc unless the *names* option is specified. * *names*: An array of column names to use for header-less CSV files. This option is ignored if the *header* option is `true`. * *skip*: The number of lines to skip (default `0`) before reading data. * *comment*: A string used to identify comment lines. Any lines that start with the comment pattern are skipped. diff --git a/docs/api/table.md b/docs/api/table.md index 79187cd..a355559 100644 --- a/docs/api/table.md +++ b/docs/api/table.md @@ -647,6 +647,7 @@ Format this table as a comma-separated values (CSV) string. Other delimiters, su * *options*: A formatting options object: * *delimiter*: The delimiter between values (default `","`). + * *header*: Boolean flag (default `true`) to specify the presence of a header row. If `true`, includes a header row with column names. If `false`, the header is omitted. * *limit*: The maximum number of rows to print (default `Infinity`). * *offset*: The row offset indicating how many initial rows to skip (default `0`). * *columns*: Ordered list of column names to include. If function-valued, the function should accept a table as input and return an array of column name strings. Otherwise, should be an array of name strings. diff --git a/src/format/to-csv.js b/src/format/to-csv.js index db7ff67..1ae6ede 100644 --- a/src/format/to-csv.js +++ b/src/format/to-csv.js @@ -6,6 +6,9 @@ import isDate from '../util/is-date.js'; * Options for CSV formatting. * @typedef {object} CSVFormatOptions * @property {string} [delimiter=','] The delimiter between values. + * @property {boolean} [header=true] Flag to specify presence of header row. + * If true, includes a header row with column names. + * If false, the header is omitted. * @property {number} [limit=Infinity] The maximum number of rows to print. * @property {number} [offset=0] The row offset indicating how many initial rows to skip. * @property {import('./util.js').ColumnSelectOptions} [columns] Ordered list @@ -29,6 +32,7 @@ export default function(table, options = {}) { const names = columns(table, options.columns); const format = options.format || {}; const delim = options.delimiter || ','; + const header = options.header ?? true; const reFormat = new RegExp(`["${delim}\n\r]`); const formatValue = value => value == null ? '' @@ -37,16 +41,16 @@ export default function(table, options = {}) { : value; const vals = names.map(formatValue); - let text = ''; + let text = header ? (vals.join(delim) + '\n') : ''; scan(table, names, options.limit || Infinity, options.offset, { - row() { - text += vals.join(delim) + '\n'; - }, cell(value, name, index) { vals[index] = formatValue(format[name] ? format[name](value) : value); + }, + end() { + text += vals.join(delim) + '\n'; } }); - return text + vals.join(delim); + return text; } diff --git a/src/format/to-html.js b/src/format/to-html.js index 54b7b26..1914d53 100644 --- a/src/format/to-html.js +++ b/src/format/to-html.js @@ -92,18 +92,22 @@ export default function(table, options = {}) { + tag('tbody'); scan(table, names, options.limit, options.offset, { - row(row) { + start(row) { r = row; - text += (++idx ? '' : '') + tag('tr'); + ++idx; + text += tag('tr'); }, cell(value, name) { text += tag('td', name, 1) + formatter(value, format[name]) + ''; + }, + end() { + text += ''; } }); - return text + ''; + return text + ''; } function styles(options) { diff --git a/src/format/to-markdown.js b/src/format/to-markdown.js index 70d5c25..b579942 100644 --- a/src/format/to-markdown.js +++ b/src/format/to-markdown.js @@ -40,16 +40,19 @@ export default function(table, options = {}) { + names.map(escape).join('|') + '|\n|' + names.map(name => alignValue(align[name])).join('|') - + '|'; + + '|\n'; scan(table, names, options.limit, options.offset, { - row() { - text += '\n|'; + start() { + text += '|'; }, cell(value, name) { text += escape(formatValue(value, format[name])) + '|'; + }, + end() { + text += '\n'; } }); - return text + '\n'; + return text; } diff --git a/src/format/util.js b/src/format/util.js index c1105b4..9cfbc1b 100644 --- a/src/format/util.js +++ b/src/format/util.js @@ -1,5 +1,6 @@ import inferFormat from './infer.js'; import isFunction from '../util/is-function.js'; +import identity from '../util/identity.js'; /** * Column selection function. @@ -59,13 +60,15 @@ function values(table, columnName) { } export function scan(table, names, limit = 100, offset, ctx) { + const { start = identity, cell, end = identity } = ctx; const data = table.data(); const n = names.length; table.scan(row => { - ctx.row(row); + start(row); for (let i = 0; i < n; ++i) { const name = names[i]; - ctx.cell(data[names[i]].at(row), name, i); + cell(data[name].at(row), name, i); } + end(row); }, true, limit, offset); } diff --git a/test/format/to-csv-test.js b/test/format/to-csv-test.js index 661ca0a..6bb5542 100644 --- a/test/format/to-csv-test.js +++ b/test/format/to-csv-test.js @@ -23,12 +23,12 @@ const tabText = text.map(t => t.split(',').join('\t')); describe('toCSV', () => { it('formats delimited text', () => { const dt = new ColumnTable(data()); - assert.equal(toCSV(dt), text.join('\n'), 'csv text'); + assert.equal(toCSV(dt), text.join('\n') + '\n', 'csv text'); assert.equal( toCSV(dt, { limit: 2, columns: ['str', 'int'] }), text.slice(0, 3) .map(s => s.split(',').slice(0, 2).join(',')) - .join('\n'), + .join('\n') + '\n', 'csv text with limit' ); }); @@ -37,24 +37,40 @@ describe('toCSV', () => { const dt = new ColumnTable(data()); assert.equal( toCSV(dt, { delimiter: '\t' }), - tabText.join('\n'), + tabText.join('\n') + '\n', 'csv text with delimiter' ); assert.equal( toCSV(dt, { limit: 2, delimiter: '\t', columns: ['str', 'int'] }), text.slice(0, 3) .map(s => s.split(',').slice(0, 2).join('\t')) - .join('\n'), + .join('\n') + '\n', 'csv text with delimiter and limit' ); }); + it('formats delimited text with header option', () => { + const dt = new ColumnTable(data()); + assert.equal( + toCSV(dt, { header: false }), + text.slice(1).join('\n') + '\n', + 'csv text without header' + ); + assert.equal( + toCSV(dt, { header: false, limit: 2, columns: ['str', 'int'] }), + text.slice(1, 3) + .map(s => s.split(',').slice(0, 2).join(',')) + .join('\n') + '\n', + 'csv text without header and with limit' + ); + }); + it('formats delimited text for filtered table', () => { const bs = new BitSet(3).not(); bs.clear(1); const dt = new ColumnTable(data(), null, bs); assert.equal( toCSV(dt), - [ ...text.slice(0, 2), ...text.slice(3) ].join('\n'), + [ ...text.slice(0, 2), ...text.slice(3) ].join('\n') + '\n', 'csv text with limit' ); }); @@ -63,7 +79,7 @@ describe('toCSV', () => { const dt = new ColumnTable(data()); assert.equal( toCSV(dt, { limit: 2, columns: ['str'], format: { str: d => d + '!' } }), - ['str', 'a!', 'b!'].join('\n'), + ['str', 'a!', 'b!'].join('\n') + '\n', 'csv text with custom format' ); });