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:
parent
26de64d667
commit
4ca6f224c9
99
main.js
99
main.js
|
|
@ -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;
|
||||||
|
|
|
||||||
32
styles.css
32
styles.css
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue