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:
2026-09-14 09:01:07 +05:30
parent bd86748c47
commit 1b85bc47a4
3 changed files with 381 additions and 10 deletions
+171
View File
@@ -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);
});
});