mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-24 07:20:16 +05:30
feat(editor): add CSV-to-markdown-table toolbar converter
Amit Haridas
This commit is contained in:
+22
-1
@@ -151,7 +151,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar-separator"></div>
|
<div class="toolbar-separator"></div>
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<!-- Insert: Link, Code, Code Block, Table, HR -->
|
<!-- Insert: Link, Code, Code Block, Table, CSV Table, HR -->
|
||||||
<button id="btn-link" title="Link" aria-label="Insert link">
|
<button id="btn-link" title="Link" aria-label="Insert link">
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -212,6 +212,27 @@
|
|||||||
<line x1="15" y1="3" x2="15" y2="21"></line>
|
<line x1="15" y1="3" x2="15" y2="21"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="btn-csv-table"
|
||||||
|
title="CSV → Table"
|
||||||
|
aria-label="Convert selected CSV text to a markdown table"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="21"></line>
|
||||||
|
<path d="M7.5 12.5c0 1-.8 1.8-1.8 1.8"></path>
|
||||||
|
<path d="M9 18.5c0-.8-.6-1.4-1.4-1.4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
id="btn-horizontal-rule"
|
id="btn-horizontal-rule"
|
||||||
title="Horizontal Rule"
|
title="Horizontal Rule"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const hljs = require('highlight.js');
|
|||||||
const { createEditor } = require('./editor/codemirror-setup');
|
const { createEditor } = require('./editor/codemirror-setup');
|
||||||
const { undo, redo } = require('@codemirror/commands');
|
const { undo, redo } = require('@codemirror/commands');
|
||||||
const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog');
|
const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog');
|
||||||
|
const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle body classes that drive the monospace font + ligatures CSS tokens.
|
* Toggle body classes that drive the monospace font + ligatures CSS tokens.
|
||||||
@@ -1212,6 +1213,11 @@ class TabManager {
|
|||||||
this.insertTable();
|
this.insertTable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CSV to Table (converts the selected CSV text in place)
|
||||||
|
document.getElementById('btn-csv-table').addEventListener('click', () => {
|
||||||
|
this.convertSelectionToTable();
|
||||||
|
});
|
||||||
|
|
||||||
// Strikethrough
|
// Strikethrough
|
||||||
document.getElementById('btn-strikethrough').addEventListener('click', () => {
|
document.getElementById('btn-strikethrough').addEventListener('click', () => {
|
||||||
this.wrapSelection('~~', '~~');
|
this.wrapSelection('~~', '~~');
|
||||||
@@ -1284,6 +1290,26 @@ class TabManager {
|
|||||||
this.insertAtCursor(table);
|
this.insertAtCursor(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert the selected CSV text into a markdown table in place
|
||||||
|
convertSelectionToTable() {
|
||||||
|
const tab = this.tabs.get(this.activeTabId);
|
||||||
|
if (!tab?.editorView) return;
|
||||||
|
const view = tab.editorView;
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
const selectedText = view.state.sliceDoc(from, to);
|
||||||
|
if (!selectedText.trim()) return;
|
||||||
|
const table = csvToMarkdownTable(selectedText);
|
||||||
|
if (!table) return;
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
insert: table,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
// Insert a code block
|
// Insert a code block
|
||||||
insertCodeBlock() {
|
insertCodeBlock() {
|
||||||
const tab = this.tabs.get(this.activeTabId);
|
const tab = this.tabs.get(this.activeTabId);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* CSV-to-markdown-table converter
|
||||||
|
* Pure client-side conversion for the editor toolbar action — no Pandoc round-trip.
|
||||||
|
* Parses basic CSV: comma-separated fields, optional double-quote wrapping that may
|
||||||
|
* contain commas (with "" as an escaped quote), ragged rows padded with empty cells.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single CSV line into field values.
|
||||||
|
* @param {string} line - One CSV line (no line breaks).
|
||||||
|
* @returns {string[]} Field values for the line.
|
||||||
|
*/
|
||||||
|
function parseCsvLine(line) {
|
||||||
|
const cells = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (char === '"') {
|
||||||
|
if (line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
} else if (char === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (char === ',') {
|
||||||
|
cells.push(current);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(current);
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert CSV text into a GitHub-flavored markdown table.
|
||||||
|
* The first non-empty line becomes the header row; ragged rows are padded with
|
||||||
|
* empty cells; pipes inside cells are escaped so the table stays valid.
|
||||||
|
* @param {string} csvText - Raw CSV text.
|
||||||
|
* @returns {string} Markdown table, or '' when there is nothing to convert.
|
||||||
|
*/
|
||||||
|
function csvToMarkdownTable(csvText) {
|
||||||
|
if (typeof csvText !== 'string' || csvText.trim().length === 0) return '';
|
||||||
|
|
||||||
|
const rows = csvText
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.trim().length > 0)
|
||||||
|
.map((line) => parseCsvLine(line));
|
||||||
|
if (rows.length === 0) return '';
|
||||||
|
|
||||||
|
const columnCount = Math.max(...rows.map((row) => row.length));
|
||||||
|
const paddedRows = rows.map((row) => {
|
||||||
|
const cells = row.map((cell) => cell.trim().replace(/\|/g, '\\|'));
|
||||||
|
while (cells.length < columnCount) cells.push('');
|
||||||
|
return cells;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatRow = (cells) => `| ${cells.join(' | ')} |`;
|
||||||
|
const separator = `|${'---|'.repeat(columnCount)}`;
|
||||||
|
|
||||||
|
const [header, ...dataRows] = paddedRows;
|
||||||
|
return [formatRow(header), separator, ...dataRows.map(formatRow)].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { csvToMarkdownTable, parseCsvLine };
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the CSV-to-markdown-table converter
|
||||||
|
* Covers: simple CSV, quoted fields containing commas, ragged rows, empty input
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { csvToMarkdownTable } = require('../src/utils/csv-to-markdown-table');
|
||||||
|
|
||||||
|
describe('csvToMarkdownTable', () => {
|
||||||
|
it('converts simple CSV into a header, separator, and data rows', () => {
|
||||||
|
const csv = 'Name,Role,Team\nAlice,Engineer,Platform\nBob,Designer,Brand';
|
||||||
|
expect(csvToMarkdownTable(csv)).toBe(
|
||||||
|
'| Name | Role | Team |\n' +
|
||||||
|
'|---|---|---|\n' +
|
||||||
|
'| Alice | Engineer | Platform |\n' +
|
||||||
|
'| Bob | Designer | Brand |'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps quoted fields containing commas as single cells', () => {
|
||||||
|
const csv = 'Person,Role\n"Smith, John",Engineer\n"Alice ""AJ"" Jones","Dev, Ops"';
|
||||||
|
expect(csvToMarkdownTable(csv)).toBe(
|
||||||
|
'| Person | Role |\n' +
|
||||||
|
'|---|---|\n' +
|
||||||
|
'| Smith, John | Engineer |\n' +
|
||||||
|
'| Alice "AJ" Jones | Dev, Ops |'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pads ragged rows with empty cells up to the widest row', () => {
|
||||||
|
const csv = 'Name,Age,City\nAlice,30\nBob,25,NYC';
|
||||||
|
expect(csvToMarkdownTable(csv)).toBe(
|
||||||
|
'| Name | Age | City |\n' + '|---|---|---|\n' + '| Alice | 30 | |\n' + '| Bob | 25 | NYC |'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pads the header too when a data row is wider', () => {
|
||||||
|
const csv = 'Name\nAlice,30';
|
||||||
|
expect(csvToMarkdownTable(csv)).toBe('| Name | |\n|---|---|\n| Alice | 30 |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string for empty or whitespace-only input', () => {
|
||||||
|
expect(csvToMarkdownTable('')).toBe('');
|
||||||
|
expect(csvToMarkdownTable(' \n \n\t')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string for non-string input', () => {
|
||||||
|
expect(csvToMarkdownTable(null)).toBe('');
|
||||||
|
expect(csvToMarkdownTable(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips blank lines between rows and handles CRLF line endings', () => {
|
||||||
|
const csv = 'Name,Age\r\n\r\nAlice,30\r\n';
|
||||||
|
expect(csvToMarkdownTable(csv)).toBe('| Name | Age |\n|---|---|\n| Alice | 30 |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes pipes inside cells so the table stays valid', () => {
|
||||||
|
expect(csvToMarkdownTable('a|b,c')).toBe('| a\\|b | c |\n|---|---|');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a header-only table when the CSV has a single row', () => {
|
||||||
|
expect(csvToMarkdownTable('a,b,c')).toBe('| a | b | c |\n|---|---|---|');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user