From 847e082e6efde97cf77177abf602b7fa807f65d3 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 19 May 2026 07:00:23 -0500 Subject: [PATCH] feat(tables): Export CSV button in the table toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side download of the current view — filter + sort + column order match what's on screen, values pass through util.formatCell so dates / numbers / booleans render the same way they do in cells. RFC 4180 quoting; UTF-8 BOM so Excel detects encoding without an import wizard. Sits next to "+ Add row" and shows for every table that loaded with columns (no HTTP gate — the data is already in the client), so MDL, RSK, SSR, and both project-level rollups all get the affordance. Co-Authored-By: Claude Opus 4.7 (1M context) --- tables/build.sh | 1 + tables/js/export.js | 79 +++++++++++++++++++++++ tables/js/main.js | 18 ++++++ tables/template.html | 1 + zddc/internal/handler/tables.html | 101 +++++++++++++++++++++++++++++- 5 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 tables/js/export.js diff --git a/tables/build.sh b/tables/build.sh index d733d81..2b2a667 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -55,6 +55,7 @@ concat_files \ "js/save.js" \ "js/row-ops.js" \ "js/clipboard.js" \ + "js/export.js" \ "js/render.js" \ "js/main.js" \ "../form/js/app.js" \ diff --git a/tables/js/export.js b/tables/js/export.js new file mode 100644 index 0000000..71091a0 --- /dev/null +++ b/tables/js/export.js @@ -0,0 +1,79 @@ +// export.js — CSV download of the current table view. +// +// Exports what the user sees: filter + sort applied, columns in the +// order declared by the spec. Values pass through util.formatCell so +// date / number / boolean cells match their on-screen rendering. +// RFC 4180 quoting (double-quote any cell with a comma, newline, or +// quote; escape inner quotes by doubling). UTF-8 BOM prepended so +// Excel detects the encoding without a manual import-wizard step. + +(function (app) { + 'use strict'; + + function csvEscape(value) { + if (value == null) return ''; + const str = String(value); + if (/[",\r\n]/.test(str)) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; + } + + function buildCsv(rows, columns, util) { + const lines = []; + lines.push(columns.map(function (c) { + return csvEscape(c.title || c.field || ''); + }).join(',')); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const cells = columns.map(function (c) { + const raw = util.resolveField(row.data, c.field); + return csvEscape(util.formatCell(raw, c.format)); + }); + lines.push(cells.join(',')); + } + return lines.join('\r\n') + '\r\n'; + } + + function suggestFilename() { + const titleEl = document.getElementById('table-title'); + const raw = (titleEl && titleEl.textContent) ? titleEl.textContent : 'table'; + const base = raw.toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'table'; + const stamp = new Date().toISOString().slice(0, 10); + return base + '-' + stamp + '.csv'; + } + + function download(csv, filename) { + const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { URL.revokeObjectURL(url); }, 1000); + } + + function invoke() { + const ctx = app.context || {}; + const columns = Array.isArray(ctx.columns) ? ctx.columns : []; + if (columns.length === 0) { + return; + } + const state = app.state; + const util = app.modules.util; + const filtered = app.modules.filters.apply(state.rows, columns, state.filter, util.resolveField); + const sorted = app.modules.sort.apply(filtered, state.sort, columns, util); + const csv = buildCsv(sorted, columns, util); + download(csv, suggestFilename()); + } + + app.modules.exportCsv = { + invoke: invoke, + buildCsv: buildCsv, + csvEscape: csvEscape + }; +})(window.tablesApp); diff --git a/tables/js/main.js b/tables/js/main.js index bf73833..2b0e5bc 100644 --- a/tables/js/main.js +++ b/tables/js/main.js @@ -30,6 +30,7 @@ const countEl = document.getElementById('table-rowcount'); const clearBtn = document.getElementById('table-clear-filters'); const addRowBtn = document.getElementById('table-add-row'); + const exportBtn = document.getElementById('table-export-csv'); // Add-row button: appends a draft row inline. Save fires on // row-blur, which POSTs to /form.html and swaps the @@ -38,6 +39,23 @@ // context loaded with columns) — the test-fixture inline-context // harness opens tables.html directly with no URL shape, so we // gate on having a column list AND running over http(s). + // Export CSV: client-side build of the current view (filtered + + // sorted columns + values). No server round-trip, no auth gate + // — the user already has the data on screen. Shown on every + // table that loaded with columns, regardless of HTTP/file://. + if (exportBtn) { + const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0; + if (hasCols) { + exportBtn.hidden = false; + exportBtn.addEventListener('click', function () { + const exp = app.modules.exportCsv; + if (exp && typeof exp.invoke === 'function') { + exp.invoke(); + } + }); + } + } + if (addRowBtn) { const onHttp = location.protocol === 'http:' || location.protocol === 'https:'; const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0; diff --git a/tables/template.html b/tables/template.html index 486d908..09eda85 100644 --- a/tables/template.html +++ b/tables/template.html @@ -47,6 +47,7 @@
+
diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index dbb59ec..e131876 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1511,7 +1511,7 @@ body.is-elevated::after {
ZDDC Table - v0.0.17-alpha · 2026-05-19 02:25:26 · da4754b-dirty + v0.0.17-alpha · 2026-05-19 11:59:55 · 73e34be-dirty
@@ -1536,6 +1536,7 @@ body.is-elevated::after {
+
@@ -6095,6 +6096,86 @@ body.is-elevated::after { }; })(window.tablesApp); +// export.js — CSV download of the current table view. +// +// Exports what the user sees: filter + sort applied, columns in the +// order declared by the spec. Values pass through util.formatCell so +// date / number / boolean cells match their on-screen rendering. +// RFC 4180 quoting (double-quote any cell with a comma, newline, or +// quote; escape inner quotes by doubling). UTF-8 BOM prepended so +// Excel detects the encoding without a manual import-wizard step. + +(function (app) { + 'use strict'; + + function csvEscape(value) { + if (value == null) return ''; + const str = String(value); + if (/[",\r\n]/.test(str)) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; + } + + function buildCsv(rows, columns, util) { + const lines = []; + lines.push(columns.map(function (c) { + return csvEscape(c.title || c.field || ''); + }).join(',')); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const cells = columns.map(function (c) { + const raw = util.resolveField(row.data, c.field); + return csvEscape(util.formatCell(raw, c.format)); + }); + lines.push(cells.join(',')); + } + return lines.join('\r\n') + '\r\n'; + } + + function suggestFilename() { + const titleEl = document.getElementById('table-title'); + const raw = (titleEl && titleEl.textContent) ? titleEl.textContent : 'table'; + const base = raw.toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'table'; + const stamp = new Date().toISOString().slice(0, 10); + return base + '-' + stamp + '.csv'; + } + + function download(csv, filename) { + const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { URL.revokeObjectURL(url); }, 1000); + } + + function invoke() { + const ctx = app.context || {}; + const columns = Array.isArray(ctx.columns) ? ctx.columns : []; + if (columns.length === 0) { + return; + } + const state = app.state; + const util = app.modules.util; + const filtered = app.modules.filters.apply(state.rows, columns, state.filter, util.resolveField); + const sorted = app.modules.sort.apply(filtered, state.sort, columns, util); + const csv = buildCsv(sorted, columns, util); + download(csv, suggestFilename()); + } + + app.modules.exportCsv = { + invoke: invoke, + buildCsv: buildCsv, + csvEscape: csvEscape + }; +})(window.tablesApp); + (function (app) { 'use strict'; @@ -6225,6 +6306,7 @@ body.is-elevated::after { const countEl = document.getElementById('table-rowcount'); const clearBtn = document.getElementById('table-clear-filters'); const addRowBtn = document.getElementById('table-add-row'); + const exportBtn = document.getElementById('table-export-csv'); // Add-row button: appends a draft row inline. Save fires on // row-blur, which POSTs to /form.html and swaps the @@ -6233,6 +6315,23 @@ body.is-elevated::after { // context loaded with columns) — the test-fixture inline-context // harness opens tables.html directly with no URL shape, so we // gate on having a column list AND running over http(s). + // Export CSV: client-side build of the current view (filtered + + // sorted columns + values). No server round-trip, no auth gate + // — the user already has the data on screen. Shown on every + // table that loaded with columns, regardless of HTTP/file://. + if (exportBtn) { + const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0; + if (hasCols) { + exportBtn.hidden = false; + exportBtn.addEventListener('click', function () { + const exp = app.modules.exportCsv; + if (exp && typeof exp.invoke === 'function') { + exp.invoke(); + } + }); + } + } + if (addRowBtn) { const onHttp = location.protocol === 'http:' || location.protocol === 'https:'; const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;