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

87
main.js
View file

@ -2,8 +2,56 @@ 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() {
@ -11,6 +59,8 @@ class CsvParser {
}
}
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 {
async onload() {
this.addStyle();
@ -22,18 +72,43 @@ class CsvPlugin extends Plugin {
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 });
}
}
}
});

View file

@ -15,3 +15,33 @@ div.csv-table-wrapper {
border: 1px solid var(--background-modifier-border);
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);
}