mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(paste): CSV/TSV → markdown table on tabular paste
- src/utils/csv-to-table.js — pure module. Auto-detects the delimiter
(tab wins over comma; comma wins over semicolon). RFC 4180-style
parsing handles quoted fields, escaped quotes (""), and CRLF. Cells
are escaped for markdown tables (\\ for backslashes, \| for pipes,
newlines stripped). Alignment: a column is right-aligned when every
non-empty cell matches a numeric pattern AND there are ≥2 rows or
≥1 multi-character cell (single-char numbers like "1" alone are
too ambiguous to call as numeric — could be labels). Single-row input
produces a header-only table (no fabricated "Column N" labels, no
body).
- src/editor/smart-paste.js — paste handler now tries CSV first (it
needs no async), falls back to the existing URL → title flow.
Tests (30 new, tests/csv-to-table.test.js):
- detectDelimiter: tab > comma > semicolon, empty/non-string safe
- parseRows: simple, RFC 4180 quoted, escaped quotes, CRLF, multi-line
- escapeCell: pipes, backslash-first escaping, newline strip, null/number
- csvToTable: simple CSV, single-row (header-only), TSV, empty input
- alignment: numeric detection (≥2 rows OR ≥1 multi-char cell), currency,
percentages, text columns stay left
- escaping inside cells (pipes, newlines)
- ragged-row padding
- looksLikeCsv: true for tab/comma multi-row, false for single row,
column-count mismatch, prose, very short input
Full suite: 72 suites, 861 tests, lint+format clean.
Amit Haridas
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* csv-to-table tests — pure module.
|
||||
*/
|
||||
const { detectDelimiter, csvToTable, looksLikeCsv, parseRows, escapeCell } = require('../src/utils/csv-to-table');
|
||||
|
||||
describe('detectDelimiter', () => {
|
||||
test('prefers tabs over commas when both are present', () => {
|
||||
expect(detectDelimiter('a\tb\tc\n1\t2\t3')).toBe('\t');
|
||||
});
|
||||
|
||||
test('falls back to commas', () => {
|
||||
expect(detectDelimiter('a,b,c\n1,2,3')).toBe(',');
|
||||
});
|
||||
|
||||
test('uses semicolons when commas are absent', () => {
|
||||
expect(detectDelimiter('a;b;c\n1;2;3')).toBe(';');
|
||||
});
|
||||
|
||||
test('returns comma for empty / non-string input', () => {
|
||||
expect(detectDelimiter('')).toBe(',');
|
||||
expect(detectDelimiter(null)).toBe(',');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRows (RFC 4180 quoting)', () => {
|
||||
test('parses simple comma-separated rows', () => {
|
||||
expect(parseRows('a,b,c\n1,2,3', ',')).toEqual([
|
||||
['a', 'b', 'c'],
|
||||
['1', '2', '3'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles quoted fields with embedded delimiter', () => {
|
||||
expect(parseRows('"a, b",c', ',')).toEqual([['a, b', 'c']]);
|
||||
});
|
||||
|
||||
test('handles escaped double-quotes inside quoted fields', () => {
|
||||
expect(parseRows('"he said ""hi""",ok', ',')).toEqual([['he said "hi"', 'ok']]);
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () => {
|
||||
expect(parseRows('a,b\r\n1,2', ',')).toEqual([
|
||||
['a', 'b'],
|
||||
['1', '2'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles multi-line quoted fields', () => {
|
||||
expect(parseRows('"line1\nline2",x', ',')).toEqual([['line1\nline2', 'x']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeCell', () => {
|
||||
test('escapes pipes (table syntax)', () => {
|
||||
expect(escapeCell('a | b')).toBe('a \\| b');
|
||||
});
|
||||
|
||||
test('escapes backslashes first', () => {
|
||||
expect(escapeCell('a\\b')).toBe('a\\\\b');
|
||||
expect(escapeCell('a\\|b')).toBe('a\\\\\\|b');
|
||||
});
|
||||
|
||||
test('strips newlines (cells must be one logical line)', () => {
|
||||
expect(escapeCell('a\nb')).toBe('ab');
|
||||
expect(escapeCell('a\r\nb')).toBe('ab');
|
||||
// Tabs survive — they don't break markdown tables and may carry intent.
|
||||
expect(escapeCell('a\tb')).toBe('a\tb');
|
||||
});
|
||||
|
||||
test('handles null / undefined / numbers', () => {
|
||||
expect(escapeCell(null)).toBe('');
|
||||
expect(escapeCell(undefined)).toBe('');
|
||||
expect(escapeCell(42)).toBe('42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvToTable — basic', () => {
|
||||
test('converts simple CSV with first row as header', () => {
|
||||
const md = csvToTable('name,age\nAlice,30\nBob,25');
|
||||
// age column has 2 numeric values (30, 25) → right-aligned
|
||||
expect(md).toBe('| name | age |\n| --- | ---: |\n| Alice | 30 |\n| Bob | 25 |');
|
||||
});
|
||||
|
||||
test('handles a single row (no header)', () => {
|
||||
const md = csvToTable('just,one,row');
|
||||
expect(md).toBe('| just | one | row |\n| --- | --- | --- |');
|
||||
});
|
||||
|
||||
test('TSV (Excel/Sheets paste)', () => {
|
||||
const md = csvToTable('a\tb\tc\n1\t2\t3');
|
||||
expect(md).toBe('| a | b | c |\n| --- | --- | --- |\n| 1 | 2 | 3 |');
|
||||
});
|
||||
|
||||
test('returns empty string for empty / whitespace-only input', () => {
|
||||
expect(csvToTable('')).toBe('');
|
||||
expect(csvToTable(' \n ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvToTable — alignment', () => {
|
||||
test('right-aligns numeric columns', () => {
|
||||
const md = csvToTable('name,count\nalpha,5\nbeta,12');
|
||||
expect(md).toContain('| name | count |');
|
||||
expect(md).toContain('| --- | ---: |');
|
||||
});
|
||||
|
||||
test('keeps left alignment for text columns', () => {
|
||||
const md = csvToTable('name,city\nAlice,Paris');
|
||||
expect(md).toContain('| --- | --- |');
|
||||
});
|
||||
|
||||
test('recognizes currency-formatted numbers as numeric', () => {
|
||||
const md = csvToTable('item,price\napple,$1.50\npear,2.30');
|
||||
expect(md).toContain('| --- | ---: |');
|
||||
});
|
||||
|
||||
test('recognizes percentages', () => {
|
||||
const md = csvToTable('metric,rate\nuptime,99.5%');
|
||||
expect(md).toContain('| --- | ---: |');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvToTable — escaping', () => {
|
||||
test('escapes pipes inside cells', () => {
|
||||
const md = csvToTable('a,b\nfoo|bar,baz');
|
||||
expect(md).toContain('| foo\\|bar | baz |');
|
||||
});
|
||||
|
||||
test('escapes newlines inside cells', () => {
|
||||
const md = csvToTable('a,b\n"line1\nline2",x');
|
||||
expect(md).toContain('| line1line2 | x |');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvToTable — ragged rows', () => {
|
||||
test('pads short rows with empty cells so the table stays rectangular', () => {
|
||||
const md = csvToTable('a,b,c\n1,2');
|
||||
const dataLines = md.split('\n').slice(2); // skip header + sep
|
||||
expect(dataLines[0]).toBe('| 1 | 2 | |');
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeCsv', () => {
|
||||
test('returns true for a tab-separated multi-row paste', () => {
|
||||
expect(looksLikeCsv('a\tb\tc\n1\t2\t3')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for a comma-separated multi-row paste', () => {
|
||||
expect(looksLikeCsv('a,b,c\n1,2,3')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for a single row', () => {
|
||||
expect(looksLikeCsv('a,b,c')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when the column counts don\'t line up at all', () => {
|
||||
expect(looksLikeCsv('a,b,c\n1\n2,3,4,5')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for very short / empty input', () => {
|
||||
expect(looksLikeCsv('')).toBe(false);
|
||||
expect(looksLikeCsv('ab')).toBe(false);
|
||||
expect(looksLikeCsv(null)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for prose (no delimiter)', () => {
|
||||
expect(looksLikeCsv('The quick brown fox jumps over the lazy dog.\nThe cat sat on the mat.')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user