From 1b85bc47a418d4d8c377cbd43ee54862d8e89978 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Mon, 14 Sep 2026 09:01:07 +0530 Subject: [PATCH] =?UTF-8?q?feat(paste):=20CSV/TSV=20=E2=86=92=20markdown?= =?UTF-8?q?=20table=20on=20tabular=20paste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/editor/smart-paste.js | 30 ++++-- src/utils/csv-to-table.js | 190 +++++++++++++++++++++++++++++++++++++ tests/csv-to-table.test.js | 171 +++++++++++++++++++++++++++++++++ 3 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 src/utils/csv-to-table.js create mode 100644 tests/csv-to-table.test.js diff --git a/src/editor/smart-paste.js b/src/editor/smart-paste.js index 228d608..4e9bedb 100644 --- a/src/editor/smart-paste.js +++ b/src/editor/smart-paste.js @@ -15,6 +15,7 @@ * @returns {import('@codemirror/view').Extension} */ const { EditorView } = require('@codemirror/view'); +const { csvToTable, looksLikeCsv } = require('../utils/csv-to-table'); const URL_ONLY_RE = /^\s*(https?:\/\/[^\s]+)\s*$/i; const TIMEOUT_MS = 4000; @@ -28,14 +29,27 @@ function smartPaste(deps) { return EditorView.domEventHandlers({ paste(event, view) { const text = event.clipboardData && event.clipboardData.getData('text/plain'); - const match = text && URL_ONLY_RE.exec(text); - if (!match) return; // not a URL-only paste — let the default handler run + if (!text) return; - const url = match[1]; + // CSV/TSV flavor first — it's instant and self-contained (no async). + if (looksLikeCsv(text)) { + 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; + } + + // URL flavor — paste the URL immediately, then rewrite with the title. + const urlMatch = URL_ONLY_RE.exec(text); + if (!urlMatch) return; // not our department — let the default handler run + + const url = urlMatch[1]; event.preventDefault(); - - // Insert the URL immediately so the paste isn't lost on slow networks, - // then async-fetch the title and rewrite the just-pasted range. const head = view.state.selection.main.head; const from = head; @@ -44,16 +58,12 @@ function smartPaste(deps) { selection: { anchor: from + url.length }, }); - // Best-effort fetch; if it fails, leave the URL as-is. Promise.race([ fetchTitle({ url, timeoutMs: TIMEOUT_MS }), new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)), ]) .then((result) => { 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 rewriteTo = Math.min(from + url.length, currentLen); if (rewriteTo <= from) return; diff --git a/src/utils/csv-to-table.js b/src/utils/csv-to-table.js new file mode 100644 index 0000000..547cd63 --- /dev/null +++ b/src/utils/csv-to-table.js @@ -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, +}; \ No newline at end of file diff --git a/tests/csv-to-table.test.js b/tests/csv-to-table.test.js new file mode 100644 index 0000000..63c0b9d --- /dev/null +++ b/tests/csv-to-table.test.js @@ -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); + }); +}); \ No newline at end of file