mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-02 01:29:32 +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:
@@ -15,6 +15,7 @@
|
|||||||
* @returns {import('@codemirror/view').Extension}
|
* @returns {import('@codemirror/view').Extension}
|
||||||
*/
|
*/
|
||||||
const { EditorView } = require('@codemirror/view');
|
const { EditorView } = require('@codemirror/view');
|
||||||
|
const { csvToTable, looksLikeCsv } = require('../utils/csv-to-table');
|
||||||
|
|
||||||
const URL_ONLY_RE = /^\s*(https?:\/\/[^\s]+)\s*$/i;
|
const URL_ONLY_RE = /^\s*(https?:\/\/[^\s]+)\s*$/i;
|
||||||
const TIMEOUT_MS = 4000;
|
const TIMEOUT_MS = 4000;
|
||||||
@@ -28,14 +29,27 @@ function smartPaste(deps) {
|
|||||||
return EditorView.domEventHandlers({
|
return EditorView.domEventHandlers({
|
||||||
paste(event, view) {
|
paste(event, view) {
|
||||||
const text = event.clipboardData && event.clipboardData.getData('text/plain');
|
const text = event.clipboardData && event.clipboardData.getData('text/plain');
|
||||||
const match = text && URL_ONLY_RE.exec(text);
|
if (!text) return;
|
||||||
if (!match) return; // not a URL-only paste — let the default handler run
|
|
||||||
|
|
||||||
const url = match[1];
|
// CSV/TSV flavor first — it's instant and self-contained (no async).
|
||||||
|
if (looksLikeCsv(text)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const table = csvToTable(text);
|
||||||
|
if (!table) return;
|
||||||
|
const head = view.state.selection.main.head;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: head, insert: table },
|
||||||
|
selection: { anchor: head + table.length },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Insert the URL immediately so the paste isn't lost on slow networks,
|
// URL flavor — paste the URL immediately, then rewrite with the title.
|
||||||
// then async-fetch the title and rewrite the just-pasted range.
|
const urlMatch = URL_ONLY_RE.exec(text);
|
||||||
|
if (!urlMatch) return; // not our department — let the default handler run
|
||||||
|
|
||||||
|
const url = urlMatch[1];
|
||||||
|
event.preventDefault();
|
||||||
const head = view.state.selection.main.head;
|
const head = view.state.selection.main.head;
|
||||||
const from = head;
|
const from = head;
|
||||||
|
|
||||||
@@ -44,16 +58,12 @@ function smartPaste(deps) {
|
|||||||
selection: { anchor: from + url.length },
|
selection: { anchor: from + url.length },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Best-effort fetch; if it fails, leave the URL as-is.
|
|
||||||
Promise.race([
|
Promise.race([
|
||||||
fetchTitle({ url, timeoutMs: TIMEOUT_MS }),
|
fetchTitle({ url, timeoutMs: TIMEOUT_MS }),
|
||||||
new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)),
|
new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)),
|
||||||
])
|
])
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!result || !result.title) return;
|
if (!result || !result.title) return;
|
||||||
// The user may have continued typing in the meantime. Cap the
|
|
||||||
// rewrite at the original insertion length so we don't clobber
|
|
||||||
// anything else.
|
|
||||||
const currentLen = view.state.doc.length;
|
const currentLen = view.state.doc.length;
|
||||||
const rewriteTo = Math.min(from + url.length, currentLen);
|
const rewriteTo = Math.min(from + url.length, currentLen);
|
||||||
if (rewriteTo <= from) return;
|
if (rewriteTo <= from) return;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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