From 4ca6f224c902e8359e6e0783c31b0998ef83992f Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 27 Feb 2026 02:59:53 +0000 Subject: [PATCH] Add double-quote aware CSV parsing and column copy button - CsvParser._parseRow() now handles quoted fields: commas inside double quotes are treated as literal, and "" is an escaped quote. - Empty lines are filtered out during parsing. - Each header cell gets a copy button (clipboard icon) that appears on hover, right-aligned. Clicking it copies the entire column (header + all data rows) joined by newlines to the clipboard. - Styling: button is absolutely positioned, fades in on header hover, uses Obsidian CSS variables for theming. Co-authored-by: Shelley --- main.js | 99 +++++++++++++++++++++++++++++++++++++++++++++++------- styles.css | 32 +++++++++++++++++- 2 files changed, 118 insertions(+), 13 deletions(-) diff --git a/main.js b/main.js index d2422a0..4190903 100644 --- a/main.js +++ b/main.js @@ -2,38 +2,113 @@ const { Plugin } = require('obsidian'); class CsvParser { constructor(lines) { - let rows = lines.split("\n"); - this._cells = rows.map((l) => l.split(",")); + let rows = lines.split("\n").filter((l) => l.length > 0); + this._cells = rows.map((l) => this._parseRow(l)); } - + + _parseRow(line) { + const cells = []; + let i = 0; + while (i <= line.length) { + if (i === line.length) { + // trailing comma produced an empty final field + break; + } + if (line[i] === '"') { + // quoted field + i++; // skip opening quote + let value = ''; + while (i < line.length) { + if (line[i] === '"') { + if (i + 1 < line.length && line[i + 1] === '"') { + // escaped double quote + value += '"'; + i += 2; + } else { + // closing quote + i++; // skip closing quote + break; + } + } else { + value += line[i]; + i++; + } + } + cells.push(value); + // skip comma after closing quote + if (i < line.length && line[i] === ',') { + i++; + } + } else { + // unquoted field + let end = line.indexOf(',', i); + if (end === -1) { + cells.push(line.substring(i)); + i = line.length; + } else { + cells.push(line.substring(i, end)); + i = end + 1; + } + } + } + return cells; + } + cells() { return this._cells; } } +const COPY_ICON_SVG = ''; + class CsvPlugin extends Plugin { async onload() { this.addStyle(); this.registerMarkdownCodeBlockProcessor('csv', (source, el, ctx) => { const wrapper = el.createEl('div'); wrapper.addClass('csv-table-wrapper'); - + const table = wrapper.createEl('table'); table.addClass('csv-table'); - + let cp = new CsvParser(source); + let allCells = cp.cells(); let twrap = null; - - for (let row of cp.cells()) { + + for (let rowIdx = 0; rowIdx < allCells.length; rowIdx++) { + let row = allCells[rowIdx]; if (twrap == null) { twrap = table.createEl('thead'); - } else if (twrap.nodeName == 'THEAD') { + } else if (twrap.nodeName == 'THEAD') { twrap = table.createEl('tbody'); } - + let rowElem = twrap.createEl('tr'); - for (let col of row) { - rowElem.createEl('td', { text: col }); + for (let colIdx = 0; colIdx < row.length; colIdx++) { + let col = row[colIdx]; + if (rowIdx === 0) { + // Header cell with copy button + let td = rowElem.createEl('td'); + td.addClass('csv-table-header'); + td.createEl('span', { text: col }); + + let btn = td.createEl('button'); + btn.addClass('csv-copy-btn'); + btn.setAttribute('aria-label', 'Copy column'); + btn.innerHTML = COPY_ICON_SVG; + + const ci = colIdx; + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + let columnText = allCells + .map((r) => (ci < r.length ? r[ci] : '')) + .join('\n'); + navigator.clipboard.writeText(columnText); + }); + } else { + rowElem.createEl('td', { text: col }); + } } } }); @@ -47,4 +122,4 @@ class CsvPlugin extends Plugin { } } -module.exports = CsvPlugin; \ No newline at end of file +module.exports = CsvPlugin; diff --git a/styles.css b/styles.css index a3e6662..e23bc63 100644 --- a/styles.css +++ b/styles.css @@ -14,4 +14,34 @@ div.csv-table-wrapper { .csv-table td { border: 1px solid var(--background-modifier-border); padding: 4px 8px; -} \ No newline at end of file +} + +.csv-table-header { + position: relative; + padding-right: 28px !important; +} + +.csv-copy-btn { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + padding: 2px; + border-radius: 4px; + line-height: 0; + color: var(--text-muted); + opacity: 0; + transition: opacity 0.15s ease; +} + +.csv-table-header:hover .csv-copy-btn { + opacity: 1; +} + +.csv-copy-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +}