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
+190
View File
@@ -0,0 +1,190 @@
/**
* CSV → markdown table converter.
*
* Smart-paste flavor: when the user pastes tab-separated text (Excel,
* Sheets, Numbers copies) or CSV, convert to a markdown pipe-table.
* Pure module — no IO, no DOM — so the renderer can require it directly
* and main never has to touch the clipboard.
*
* Grammar (deliberately minimal):
* - Rows split on newlines (LF or CRLF)
* - Cells split on the configured delimiter (tab, comma, or semicolon)
* - Quoted fields per RFC 4180: "…" may contain the delimiter, "" is an
* escaped quote, multi-line quoted fields are supported
* - First row becomes the header (only when there are ≥2 rows; otherwise
* the single row is treated as data with no header)
*
* Alignment column is added per the longest cell in each column.
*
* @module csv-to-table
*/
/**
* Detect the delimiter by sampling the first non-empty line.
* Tabs win over commas when both are present (Excel/Sheets default).
*/
function detectDelimiter(text) {
if (typeof text !== 'string' || text.length === 0) return ',';
const firstLine = text.split(/\r?\n/, 1)[0] || '';
const counts = {
'\t': (firstLine.match(/\t/g) || []).length,
',': (firstLine.match(/,/g) || []).length,
';': (firstLine.match(/;/g) || []).length,
};
let best = ',';
let bestCount = counts[','];
for (const d of ['\t', ',', ';']) {
if (counts[d] > bestCount) {
best = d;
bestCount = counts[d];
}
}
return bestCount === 0 ? ',' : best;
}
/** Parse a CSV string into rows of cells. Handles RFC 4180 quoting. */
function parseRows(text, delimiter) {
const rows = [];
let row = [];
let cell = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') {
cell += '"';
i++;
} else {
inQuotes = false;
}
} else {
cell += ch;
}
continue;
}
if (ch === '"') {
inQuotes = true;
} else if (ch === delimiter) {
row.push(cell);
cell = '';
} else if (ch === '\n' || ch === '\r') {
row.push(cell);
if (row.length > 1 || row[0] !== '') rows.push(row);
row = [];
cell = '';
// Swallow the \n after \r
if (ch === '\r' && text[i + 1] === '\n') i++;
} else {
cell += ch;
}
}
if (cell !== '' || row.length > 0) {
row.push(cell);
rows.push(row);
}
return rows;
}
/** Escape a cell so it can sit inside a markdown table cell. */
function escapeCell(value) {
if (value === undefined || value === null) return '';
return String(value)
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/[\r\n]+/g, '');
}
/** Detect alignment per column from the data. */
function alignmentFor(rows, colIdx) {
const nonEmpty = rows.filter((r) => (r[colIdx] || '').trim() !== '');
if (nonEmpty.length === 0) return '---';
const numericish = nonEmpty.every((r) => {
const v = (r[colIdx] || '').trim().replace(/^[-$€£¥+]/, '');
return /^-?[\d,.]+%?$/.test(v);
});
// Numeric when:
// - every cell matches the numeric pattern AND
// - we have ≥ 2 non-empty rows OR ≥ 1 multi-character cell (a single
// "1" or "5" is too ambiguous — the column might really be labels)
if (!numericish) return '---';
const hasMultiChar = nonEmpty.some((r) => (r[colIdx] || '').length >= 2);
if (nonEmpty.length >= 2 || hasMultiChar) return '---:';
return '---';
}
/**
* Convert CSV / TSV text to a markdown table.
*
* @param {string} text
* @param {object} [opts]
* @param {string} [opts.delimiter] auto-detected when omitted
* @param {boolean} [opts.firstRowIsHeader=true]
* @returns {string} markdown table, or empty string when input has no rows
*/
function csvToTable(text, opts = {}) {
if (typeof text !== 'string' || text.trim() === '') return '';
const delimiter = opts.delimiter || detectDelimiter(text);
const rows = parseRows(text, delimiter).filter((r) => !(r.length === 1 && r[0] === ''));
if (rows.length === 0) return '';
// Normalize column count
const colCount = rows.reduce((m, r) => Math.max(m, r.length), 0);
for (const r of rows) {
while (r.length < colCount) r.push('');
}
const firstRowIsHeader = opts.firstRowIsHeader !== false && rows.length >= 2;
let header, body;
if (firstRowIsHeader) {
header = rows[0];
body = rows.slice(1);
} else if (rows.length === 1) {
// Single-row input: treat the row as the header and emit no body — a
// table with one row of data and fabricated "Column N" labels would be
// useless noise.
header = rows[0];
body = [];
} else {
header = Array.from({ length: colCount }, (_, i) => `Column ${i + 1}`);
body = rows;
}
// Alignment comes from the DATA rows only; headers are text by convention.
const dataForAlignment = firstRowIsHeader ? rows.slice(1) : rows;
const alignments = [];
for (let i = 0; i < colCount; i++) alignments.push(alignmentFor(dataForAlignment, i));
const lines = [];
lines.push('| ' + header.map(escapeCell).join(' | ') + ' |');
lines.push('| ' + alignments.join(' | ') + ' |');
for (let i = 0; i < body.length; i++) {
lines.push('| ' + body[i].map(escapeCell).join(' | ') + ' |');
}
return lines.join('\n');
}
/** Heuristic: is this paste candidate a CSV/TSV? */
function looksLikeCsv(text) {
if (typeof text !== 'string' || text.length < 4) return false;
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
if (lines.length < 2) return false;
// Every non-empty line must have the same number of delimiters as the
// first, allowing a ±1 slack for trailing empties.
const delim = detectDelimiter(text);
const counts = lines.slice(0, 5).map((l) => (l.match(new RegExp(escapeRegex(delim), 'g')) || []).length);
const first = counts[0];
if (first < 1) return false;
return counts.every((c) => Math.abs(c - first) <= 1);
}
function escapeRegex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
module.exports = {
detectDelimiter,
parseRows,
escapeCell,
csvToTable,
looksLikeCsv,
};