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 <shelley@exe.dev>
This commit is contained in:
exe.dev user 2026-02-27 02:59:53 +00:00
parent 26de64d667
commit 4ca6f224c9
2 changed files with 118 additions and 13 deletions

99
main.js
View file

@ -2,38 +2,113 @@ const { Plugin } = require('obsidian');
class CsvParser { class CsvParser {
constructor(lines) { constructor(lines) {
let rows = lines.split("\n"); let rows = lines.split("\n").filter((l) => l.length > 0);
this._cells = rows.map((l) => l.split(",")); 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() { cells() {
return this._cells; return this._cells;
} }
} }
const COPY_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
class CsvPlugin extends Plugin { class CsvPlugin extends Plugin {
async onload() { async onload() {
this.addStyle(); this.addStyle();
this.registerMarkdownCodeBlockProcessor('csv', (source, el, ctx) => { this.registerMarkdownCodeBlockProcessor('csv', (source, el, ctx) => {
const wrapper = el.createEl('div'); const wrapper = el.createEl('div');
wrapper.addClass('csv-table-wrapper'); wrapper.addClass('csv-table-wrapper');
const table = wrapper.createEl('table'); const table = wrapper.createEl('table');
table.addClass('csv-table'); table.addClass('csv-table');
let cp = new CsvParser(source); let cp = new CsvParser(source);
let allCells = cp.cells();
let twrap = null; let twrap = null;
for (let row of cp.cells()) { for (let rowIdx = 0; rowIdx < allCells.length; rowIdx++) {
let row = allCells[rowIdx];
if (twrap == null) { if (twrap == null) {
twrap = table.createEl('thead'); twrap = table.createEl('thead');
} else if (twrap.nodeName == 'THEAD') { } else if (twrap.nodeName == 'THEAD') {
twrap = table.createEl('tbody'); twrap = table.createEl('tbody');
} }
let rowElem = twrap.createEl('tr'); let rowElem = twrap.createEl('tr');
for (let col of row) { for (let colIdx = 0; colIdx < row.length; colIdx++) {
rowElem.createEl('td', { text: col }); 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; module.exports = CsvPlugin;

View file

@ -14,4 +14,34 @@ div.csv-table-wrapper {
.csv-table td { .csv-table td {
border: 1px solid var(--background-modifier-border); border: 1px solid var(--background-modifier-border);
padding: 4px 8px; padding: 4px 8px;
} }
.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);
}