- 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>
126 lines
3.2 KiB
JavaScript
126 lines
3.2 KiB
JavaScript
const { Plugin } = require('obsidian');
|
|
|
|
class CsvParser {
|
|
constructor(lines) {
|
|
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 = '<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();
|
|
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 rowIdx = 0; rowIdx < allCells.length; rowIdx++) {
|
|
let row = allCells[rowIdx];
|
|
if (twrap == null) {
|
|
twrap = table.createEl('thead');
|
|
} else if (twrap.nodeName == 'THEAD') {
|
|
twrap = table.createEl('tbody');
|
|
}
|
|
|
|
let rowElem = twrap.createEl('tr');
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
addStyle() {
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = this.app.vault.adapter.getResourcePath('styles.css');
|
|
document.head.appendChild(link);
|
|
}
|
|
}
|
|
|
|
module.exports = CsvPlugin;
|