Changeset 0.11.0 (#62)
This commit is contained in:
12
extensions/builtin/csv-table/manifest.json
Normal file
12
extensions/builtin/csv-table/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "csv-table",
|
||||
"name": "CSV Table Viewer",
|
||||
"version": "1.0.0",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Renders ```csv code blocks as sortable, interactive HTML tables",
|
||||
"permissions": [],
|
||||
"tools": [],
|
||||
"surfaces": [],
|
||||
"settings": {}
|
||||
}
|
||||
254
extensions/builtin/csv-table/script.js
Normal file
254
extensions/builtin/csv-table/script.js
Normal file
@@ -0,0 +1,254 @@
|
||||
// ==========================================
|
||||
// CSV Table Viewer — Browser Extension
|
||||
// ==========================================
|
||||
// Renders ```csv and ```tsv code blocks as sortable HTML tables.
|
||||
// No external dependencies.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'csv-table',
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
|
||||
ctx.renderers.register('csv-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'csv' || l === 'tsv';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
|
||||
const rows = self._parseCSV(code.trim(), delimiter);
|
||||
|
||||
if (rows.length === 0) {
|
||||
container.innerHTML = '<div class="csv-empty">No data</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = rows[0];
|
||||
const data = rows.slice(1);
|
||||
const tableId = 'csv-' + Math.random().toString(36).slice(2, 9);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="csv-table-block ext-rendered" data-csv-id="${tableId}">
|
||||
<div class="csv-toolbar">
|
||||
<span class="csv-info">${data.length} row${data.length !== 1 ? 's' : ''} × ${headers.length} col${headers.length !== 1 ? 's' : ''}</span>
|
||||
<button class="csv-copy-btn" title="Copy as CSV">📋 Copy</button>
|
||||
</div>
|
||||
<div class="csv-table-scroll">
|
||||
<table class="csv-table">
|
||||
<thead>
|
||||
<tr>${headers.map((h, i) => `<th data-col="${i}" title="Click to sort">${self._escapeHtml(h)}<span class="csv-sort-icon"></span></th>`).join('')}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.map(row => `<tr>${row.map(cell => `<td>${self._escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="csv-source">
|
||||
<summary>📄 View raw</summary>
|
||||
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach sort handlers + copy
|
||||
self._attachHandlers(container, tableId, code.trim());
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_attachHandlers(container, tableId, rawCSV) {
|
||||
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
|
||||
if (!block) return;
|
||||
|
||||
// ── Column sorting ──
|
||||
const ths = block.querySelectorAll('th[data-col]');
|
||||
let sortCol = -1;
|
||||
let sortAsc = true;
|
||||
|
||||
ths.forEach(th => {
|
||||
th.style.cursor = 'pointer';
|
||||
th.addEventListener('click', () => {
|
||||
const col = parseInt(th.dataset.col, 10);
|
||||
if (sortCol === col) {
|
||||
sortAsc = !sortAsc;
|
||||
} else {
|
||||
sortCol = col;
|
||||
sortAsc = true;
|
||||
}
|
||||
|
||||
const tbody = block.querySelector('tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const cellA = (a.children[col]?.textContent || '').trim();
|
||||
const cellB = (b.children[col]?.textContent || '').trim();
|
||||
const numA = parseFloat(cellA);
|
||||
const numB = parseFloat(cellB);
|
||||
|
||||
// Numeric sort if both are numbers
|
||||
if (!isNaN(numA) && !isNaN(numB)) {
|
||||
return sortAsc ? numA - numB : numB - numA;
|
||||
}
|
||||
// String sort
|
||||
const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
|
||||
// Update sort icons
|
||||
ths.forEach(h => {
|
||||
const icon = h.querySelector('.csv-sort-icon');
|
||||
if (icon) {
|
||||
const hCol = parseInt(h.dataset.col, 10);
|
||||
icon.textContent = hCol === sortCol ? (sortAsc ? ' ▲' : ' ▼') : '';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Copy button ──
|
||||
const copyBtn = block.querySelector('.csv-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(rawCSV).then(() => {
|
||||
copyBtn.textContent = '✓ Copied';
|
||||
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
|
||||
}).catch(() => {
|
||||
copyBtn.textContent = '✗ Failed';
|
||||
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Simple CSV parser that handles quoted fields.
|
||||
*/
|
||||
_parseCSV(text, delimiter) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
// Peek ahead: escaped quote or end of quoted field
|
||||
if (i + 1 < text.length && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
field += ch;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
if (ch === '"' && field === '') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
} else if (ch === delimiter) {
|
||||
row.push(field.trim());
|
||||
field = '';
|
||||
i++;
|
||||
} else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) {
|
||||
row.push(field.trim());
|
||||
if (row.some(cell => cell !== '')) rows.push(row);
|
||||
row = [];
|
||||
field = '';
|
||||
i += (ch === '\r') ? 2 : 1;
|
||||
} else {
|
||||
field += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last field/row
|
||||
row.push(field.trim());
|
||||
if (row.some(cell => cell !== '')) rows.push(row);
|
||||
|
||||
// Normalize: pad short rows to header length
|
||||
if (rows.length > 0) {
|
||||
const maxCols = Math.max(...rows.map(r => r.length));
|
||||
rows.forEach(r => {
|
||||
while (r.length < maxCols) r.push('');
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
_injectStyles() {
|
||||
if (document.getElementById('ext-style-csv-table')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-csv-table';
|
||||
style.textContent = `
|
||||
.csv-table-block {
|
||||
background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; overflow: hidden; margin: 12px 0;
|
||||
}
|
||||
.csv-toolbar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--text-3);
|
||||
}
|
||||
.csv-info { flex: 1; }
|
||||
.csv-copy-btn {
|
||||
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 2px 8px; font-size: 12px; cursor: pointer; color: var(--text-3);
|
||||
}
|
||||
.csv-copy-btn:hover { color: var(--text-2); border-color: var(--text-3); }
|
||||
.csv-table-scroll { overflow-x: auto; }
|
||||
.csv-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.csv-table th {
|
||||
background: var(--bg-3); color: var(--text-2); font-weight: 600;
|
||||
padding: 6px 12px; text-align: left; white-space: nowrap;
|
||||
border-bottom: 2px solid var(--border); user-select: none;
|
||||
}
|
||||
.csv-table th:hover { color: var(--text-1); }
|
||||
.csv-sort-icon { font-size: 11px; opacity: 0.7; }
|
||||
.csv-table td {
|
||||
padding: 4px 12px; border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap; max-width: 300px; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.csv-table tr:hover td { background: var(--bg-3); }
|
||||
.csv-source, .csv-empty { border-top: 1px solid var(--border); font-size: 12px; }
|
||||
.csv-source summary {
|
||||
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
|
||||
}
|
||||
.csv-source summary:hover { color: var(--text-2); }
|
||||
.csv-source pre {
|
||||
margin: 0; border-radius: 0; border: none;
|
||||
max-height: 200px; overflow: auto;
|
||||
}
|
||||
.csv-empty { padding: 16px; text-align: center; color: var(--text-3); }`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-csv-table')?.remove();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user