tablewalk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Export the result you are looking at, as CSV or JSON.
3
+ *
4
+ * The serialisers at the top are pure — columns and rows in, a string out —
5
+ * because CSV is the part of this feature that is easy to get subtly wrong
6
+ * and the only way to know it is right is to test it without a browser. The
7
+ * DOM half below is a thin wrapper over them.
8
+ *
9
+ * The rule the format has to hold up to: a spreadsheet reading the file back
10
+ * must see exactly the values the grid showed, including the distinction
11
+ * between a null and an empty string, which every naive `join(',')` loses.
12
+ */
13
+ import { api, disclosure, el, findTable, loadJson, pageSize, saveJson, state, toast } from './core.js';
14
+ import { maskQuoted } from './clauses.js';
15
+ import { copyPosition, positionOf } from './handoff.js';
16
+
17
+ /* Mirrors MAX_LIMIT in src/adapters/adapter.ts. The server clamps to it
18
+ regardless, so this is only here to keep the button honest about how many
19
+ rows "all matching" can actually mean. */
20
+ export const EXPORT_MAX_ROWS = 100_000;
21
+
22
+ /* RFC 4180 §2.6: a field containing a comma, a quote or a line break must be
23
+ quoted. CR and LF are listed separately because a lone CR inside a value is
24
+ just as capable of splitting a record as a full CRLF. */
25
+ const NEEDS_QUOTES = /[",\r\n]/;
26
+
27
+ /* Excel, Sheets and LibreOffice all treat a cell beginning with one of these
28
+ as a formula to evaluate rather than text to show — so a value like
29
+ `=HYPERLINK(...)` or `@SUM(A1)` out of a database column becomes code the
30
+ moment someone opens the file. Prefixing a single quote is the standard
31
+ mitigation: spreadsheets show the rest as literal text.
32
+ Tab and CR are included because they are skipped before the parse, which
33
+ makes `\t=cmd` a formula too. */
34
+ const FORMULA_LEAD = /^[=+\-@\t\r]/;
35
+
36
+ /* ...but `-5` and `+1.5e3` are numbers, not formulas, and prefixing them
37
+ would corrupt every negative amount in the export to buy nothing. Only a
38
+ leading sign that is *not* part of a plain number is dangerous. */
39
+ const PLAIN_NUMBER = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
40
+
41
+ const CRLF = '\r\n';
42
+
43
+ /**
44
+ * One CSV field.
45
+ *
46
+ * null and undefined emit nothing at all, while an empty string emits `""`.
47
+ * That asymmetry is the whole point: `a,,b` and `a,"",b` are different
48
+ * documents, and collapsing them means an export cannot tell you whether a
49
+ * column was never set or set to blank — often the exact question the export
50
+ * was taken to answer.
51
+ */
52
+ export function csvField(value) {
53
+ if (value === null || value === undefined) return '';
54
+ // A JSON/JSONB column arrives as a parsed object; `String(obj)` would write
55
+ // "[object Object]" into every row of it.
56
+ const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
57
+ const safe = FORMULA_LEAD.test(text) && !PLAIN_NUMBER.test(text) ? `'${text}` : text;
58
+ if (safe === '' || NEEDS_QUOTES.test(safe)) return `"${safe.replace(/"/g, '""')}"`;
59
+ return safe;
60
+ }
61
+
62
+ /** A CSV document: a header row of column names, then one row per record. */
63
+ export function toCsv(columns, rows) {
64
+ const lines = [columns.map(csvField).join(',')];
65
+ for (const row of rows ?? []) lines.push(columns.map((name) => csvField(row?.[name])).join(','));
66
+ // CRLF, per RFC 4180, and a trailing one so the file ends on a record
67
+ // boundary rather than mid-line.
68
+ return lines.join(CRLF) + CRLF;
69
+ }
70
+
71
+ /**
72
+ * A JSON document: an array of objects, in the column order on screen.
73
+ *
74
+ * Rebuilt from `columns` rather than emitted as-is so the shape matches the
75
+ * CSV exactly — including a key whose value is missing, which `JSON.stringify`
76
+ * would silently drop and leave the two formats disagreeing about the row.
77
+ */
78
+ export function toJson(columns, rows) {
79
+ const shaped = (rows ?? []).map((row) => {
80
+ const out = {};
81
+ for (const name of columns) out[name] = row?.[name] ?? null;
82
+ return out;
83
+ });
84
+ return `${JSON.stringify(shaped, null, 2)}\n`;
85
+ }
86
+
87
+ /**
88
+ * Put the selected columns into a query, replacing any `show` clause it has.
89
+ *
90
+ * The language is parsed on the server and nowhere else, so the only way for
91
+ * the client to ask for different columns is to edit the text — the same
92
+ * trick the sortable headers use. Replacing rather than appending is the part
93
+ * that matters: the parser concatenates repeated clauses with `and`, so a
94
+ * second `show` would arrive as one column named "b and c" and the export
95
+ * would fail with a column error instead of exporting anything.
96
+ *
97
+ * An empty list means "every column", which is what no `show` clause means.
98
+ */
99
+ export function withColumns(query, names) {
100
+ const marks = clauseMarks(query);
101
+ let out = '';
102
+ let cut = 0;
103
+ marks.forEach((mark, i) => {
104
+ if (mark.word !== 'show' && mark.word !== 'select') return;
105
+ out += query.slice(cut, mark.start);
106
+ cut = i + 1 < marks.length ? marks[i + 1].start : query.length;
107
+ });
108
+ const base = `${out}${query.slice(cut)}`.trim();
109
+ return names.length ? `${base} show ${names.join(', ')}` : base;
110
+ }
111
+
112
+ /* Mirrors CLAUSE_RE in src/shared/query.ts. Two copies of a rule is a cost,
113
+ but the alternative is a build step to share one — and the client is a
114
+ static file on purpose. */
115
+ const CLAUSE_RE = /\b(filter|where|show|select|sort|order\s+by|limit)\b/gi;
116
+
117
+ /* ...including the rule that makes a clause word a column name instead. A
118
+ table with a column called `show` is unusual but `show = 3` is a filter,
119
+ and cutting from there would silently drop the rest of the query. */
120
+ const FOLLOWED_BY_OPERATOR =
121
+ /^\s*(?:=|!=|>=|<=|>|<|\bis\b|\bcontains\b|\bstartswith\b|\bendswith\b|\bin\b|\bnot\s+in\b)/i;
122
+
123
+ function clauseMarks(text) {
124
+ /* Scanned with quoted values blanked out, and the offsets used against the
125
+ real text — the same masking the parser does, for the same reason. Without
126
+ it `name contains "show me"` was cut at the value's own word, and the
127
+ rewritten query came back with a filter holding `"sort name show id, name`:
128
+ a string assembled out of the query's punctuation, exported as though it
129
+ were what the user asked for. */
130
+ const scannable = maskQuoted(text);
131
+ const marks = [];
132
+ CLAUSE_RE.lastIndex = 0;
133
+ let m;
134
+ while ((m = CLAUSE_RE.exec(scannable))) {
135
+ if (FOLLOWED_BY_OPERATOR.test(scannable.slice(CLAUSE_RE.lastIndex))) continue;
136
+ marks.push({ word: m[1].toLowerCase().replace(/\s+/g, ' '), start: m.index });
137
+ }
138
+ return marks;
139
+ }
140
+
141
+ /**
142
+ * The chosen columns, in the order the table declares them.
143
+ *
144
+ * Not the order they were ticked in: two exports of the same table a week
145
+ * apart should differ only where the data differs, and a column list that
146
+ * depends on which checkbox someone clicked first makes every diff noise.
147
+ * A name the table does not declare keeps its relative position at the end,
148
+ * which is where a computed column from a view lands.
149
+ */
150
+ export function orderColumns(selected, declared) {
151
+ const rank = new Map(declared.map((name, i) => [name, i]));
152
+ return [...new Set(selected)].sort(
153
+ (a, b) => (rank.get(a) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b) ?? Number.MAX_SAFE_INTEGER),
154
+ );
155
+ }
156
+
157
+ const sameList = (a, b) => a.length === b.length && a.every((name, i) => name === b[i]);
158
+
159
+ /* ---------- which columns ---------- */
160
+
161
+ const COLUMN_KEY = 'tablewalk.export-columns.v1';
162
+
163
+ /* Per connection as well as per table: `orders` on staging and `orders` on
164
+ production are not the same table, and neither are their useful columns. */
165
+ const scope = () => state.activeConnection ?? 'default';
166
+
167
+ /* Survives a re-render within the session even when nothing can be persisted,
168
+ which is the case in private mode and with site data blocked. */
169
+ const chosen = new Map();
170
+
171
+ function rememberColumns(tableId, names) {
172
+ chosen.set(`${scope()}::${tableId}`, names);
173
+ // loadJson/saveJson swallow a throwing or full localStorage: a column
174
+ // preference that cannot be saved is not a reason to fail the export.
175
+ const all = loadJson(COLUMN_KEY, {}) ?? {};
176
+ all[scope()] = { ...(all[scope()] ?? {}), [tableId]: names };
177
+ saveJson(COLUMN_KEY, all);
178
+ }
179
+
180
+ /** Column names of the table as the catalog declares them, or [] if unknown. */
181
+ function declaredColumns(tableId) {
182
+ if (!state.schema) return [];
183
+ return (findTable(tableId)?.columns ?? []).map((c) => c.name);
184
+ }
185
+
186
+ /**
187
+ * What to export, before anyone opens the chooser: last time's selection if
188
+ * there is one, otherwise exactly what is on screen — which already respects
189
+ * a `show` clause in the query.
190
+ */
191
+ function selectionFor(tableId, displayed, declared) {
192
+ const key = `${scope()}::${tableId}`;
193
+ const remembered = chosen.get(key) ?? loadJson(COLUMN_KEY, {})?.[scope()]?.[tableId];
194
+ const known = new Set([...declared, ...displayed]);
195
+ // A remembered column that no longer exists is dropped rather than sent:
196
+ // the query would fail on it, and the export would fail with it.
197
+ const valid = Array.isArray(remembered) ? remembered.filter((n) => known.has(n)) : [];
198
+ return orderColumns(valid.length ? valid : displayed, declared);
199
+ }
200
+
201
+ /** `customer-2026-08-22.csv` — the table and the day, which is what you look for later. */
202
+ export function exportFilename(table, extension, now = new Date()) {
203
+ const stem = String(table ?? 'export').replace(/[^\w.-]+/g, '_');
204
+ return `${stem}-${now.toISOString().slice(0, 10)}.${extension}`;
205
+ }
206
+
207
+ export function download(filename, text, mime) {
208
+ /* A UTF-8 CSV with no BOM opens in Excel as the local codepage, which turns
209
+ every accented name into mojibake. The BOM is the one thing that makes it
210
+ read the file as UTF-8, and other tools skip it. */
211
+ const body = mime === 'text/csv' ? `\ufeff${text}` : text;
212
+ const url = URL.createObjectURL(new Blob([body], { type: `${mime};charset=utf-8` }));
213
+ const link = el('a', { href: url, download: filename });
214
+ document.body.append(link);
215
+ link.click();
216
+ link.remove();
217
+ /* Revoked, or the blob is held for the life of the page — but not until the
218
+ next tick: revoking in the same task can cancel the download that click
219
+ just started. */
220
+ setTimeout(() => URL.revokeObjectURL(url), 0);
221
+ }
222
+
223
+ function save(kind, table, columns, rows) {
224
+ if (kind === 'csv') download(exportFilename(table, 'csv'), toCsv(columns, rows), 'text/csv');
225
+ else download(exportFilename(table, 'json'), toJson(columns, rows), 'application/json');
226
+ toast(`Exported ${rows.length.toLocaleString()} row${rows.length === 1 ? '' : 's'}.`);
227
+ }
228
+
229
+ /**
230
+ * The Export control for the result header.
231
+ *
232
+ * Both scopes are offered with their row count spelled out, because the
233
+ * default page is 20 rows and a file called `invoice-2026-08-22.csv` gives no
234
+ * hint that the other 49,980 rows were left behind. Whoever opens it a week
235
+ * later cannot tell either.
236
+ */
237
+ export function exportControl(entry, data) {
238
+ const displayed = data.columns ?? [];
239
+ const declared = declaredColumns(data.table);
240
+ const pageRows = data.rows ?? [];
241
+ const total = data.total ?? pageRows.length;
242
+ const capped = Math.min(total, EXPORT_MAX_ROWS);
243
+ let selected = selectionFor(data.table, displayed, declared);
244
+
245
+ const menu = el('div', { class: 'export-menu' });
246
+ const wrap = disclosure(el('details', { class: 'export' }, [
247
+ el('summary', { class: 'export-summary', title: 'Download this result' }, 'Export'),
248
+ menu,
249
+ ]));
250
+
251
+ const start = (button, kind, all) =>
252
+ void deliver(button, wrap, kind, all, entry, data, selected, declared, capped);
253
+
254
+ function fill() {
255
+ const pageLabel = `this page (${pageRows.length.toLocaleString()} row${pageRows.length === 1 ? '' : 's'})`;
256
+ const allLabel = `all matching rows (${capped.toLocaleString()})`;
257
+ menu.replaceChildren();
258
+
259
+ menu.append(el('div', { class: 'export-group' }, el('button', {
260
+ type: 'button',
261
+ class: 'export-item export-columns-item',
262
+ onclick: () => chooseColumns(data.table, displayed, declared, selected, (picked) => {
263
+ selected = picked;
264
+ rememberColumns(data.table, picked);
265
+ fill();
266
+ }),
267
+ }, [
268
+ el('span', { text: 'Choose columns…' }),
269
+ el('span', {
270
+ class: 'export-count',
271
+ text: declared.length ? `${selected.length} of ${declared.length}` : `${selected.length}`,
272
+ }),
273
+ ])));
274
+
275
+ for (const [kind, name] of [['csv', 'CSV'], ['json', 'JSON']]) {
276
+ menu.append(el('div', { class: 'export-group' }, [
277
+ el('span', { class: 'export-kind', text: name }),
278
+ el('button', {
279
+ type: 'button',
280
+ class: 'export-item',
281
+ text: pageLabel,
282
+ onclick: (e) => start(e.currentTarget, kind, false),
283
+ }),
284
+ // Only worth offering when it would actually differ from the page.
285
+ total > pageRows.length
286
+ ? el('button', {
287
+ type: 'button',
288
+ class: 'export-item',
289
+ text: allLabel,
290
+ onclick: (e) => start(e.currentTarget, kind, true),
291
+ })
292
+ : null,
293
+ ]));
294
+ }
295
+
296
+ /* Not a file, but the same question: take this away with you. An agent
297
+ is the other place a result goes. */
298
+ menu.append(el('div', { class: 'export-group' }, [
299
+ el('span', { class: 'export-kind', text: 'Agent' }),
300
+ el('button', {
301
+ type: 'button',
302
+ class: 'export-item',
303
+ text: 'Copy this position',
304
+ title: 'The query, the shape and how to run it over MCP — not the rows',
305
+ onclick: () => {
306
+ wrap.open = false;
307
+ void copyPosition(
308
+ positionOf(state, entry, { columns: displayed, shown: pageRows.length, total }),
309
+ toast,
310
+ );
311
+ },
312
+ }),
313
+ ]));
314
+
315
+ if (total > EXPORT_MAX_ROWS) {
316
+ menu.append(el('p', {
317
+ class: 'export-note',
318
+ text: `Capped at ${EXPORT_MAX_ROWS.toLocaleString()} rows — the same ceiling every read in this tool has. Narrow the query to export the rest.`,
319
+ }));
320
+ }
321
+ }
322
+
323
+ fill();
324
+ return wrap;
325
+ }
326
+
327
+ /**
328
+ * Fetch if the file would differ from what is on screen, then write it.
329
+ *
330
+ * The re-fetch is the whole reason the chooser is safe to offer. Picking
331
+ * columns out of the rows already in hand would quietly export blanks for any
332
+ * column the query never selected — and "all matching rows" would export the
333
+ * columns that happened to be on screen, which is the failure this feature
334
+ * would otherwise introduce.
335
+ */
336
+ async function deliver(button, wrap, kind, all, entry, data, selected, declared, capped) {
337
+ const onScreen = sameList(selected, data.columns ?? []);
338
+ let rows = data.rows ?? [];
339
+
340
+ if (all || !onScreen) {
341
+ const label = button.textContent;
342
+ button.disabled = true;
343
+ button.textContent = 'Fetching…';
344
+ try {
345
+ /* "All" is one bounded re-run rather than pages stitched together:
346
+ pages walked separately can duplicate or skip rows across the seam if
347
+ anything writes to the table in between. A full selection asks with
348
+ no `show` clause at all, which is the same query the grid ran. */
349
+ const q = withColumns(entry.query, sameList(selected, declared) ? [] : selected);
350
+ const fresh = await api('/api/run', {
351
+ q,
352
+ limit: all ? capped : pageSize(),
353
+ offset: all ? 0 : state.page * pageSize(),
354
+ });
355
+ if (!fresh.columns) {
356
+ throw new Error(fresh.errors?.[0]?.message ?? 'That query no longer returns rows.');
357
+ }
358
+ rows = fresh.rows ?? [];
359
+ } catch (err) {
360
+ toast(err.message, 'error');
361
+ return;
362
+ } finally {
363
+ button.disabled = false;
364
+ button.textContent = label;
365
+ }
366
+ }
367
+
368
+ wrap.open = false;
369
+ // `selected` rather than what came back, so the file follows the declared
370
+ // order whatever order the database returned.
371
+ save(kind, data.table, selected, rows);
372
+ }
373
+
374
+ /**
375
+ * The column chooser.
376
+ *
377
+ * A checklist rather than a multi-select: every column is visible at once
378
+ * with its type and its keys, which is the information the choice actually
379
+ * turns on — a `sys_id`-shaped key column and a display name are told apart
380
+ * by their type far more often than by their name.
381
+ */
382
+ function chooseColumns(tableId, displayed, declared, selected, onDone) {
383
+ const table = state.schema ? findTable(tableId) : null;
384
+ // A view whose columns the catalog does not carry still has the ones on
385
+ // screen, so the chooser degrades to those rather than opening empty.
386
+ const columns = table?.columns?.length
387
+ ? table.columns
388
+ : declared.concat(displayed.filter((n) => !declared.includes(n))).map((name) => ({ name }));
389
+ const ticked = new Set(selected);
390
+
391
+ const count = el('span', { class: 'col-count' });
392
+ const list = el('div', { class: 'col-list' });
393
+ const boxes = new Map();
394
+
395
+ const refresh = () => {
396
+ const ticks = [...boxes.values()].filter((box) => box.checked).length;
397
+ count.textContent = `${ticks} of ${columns.length} selected`;
398
+ };
399
+
400
+ for (const column of columns) {
401
+ const box = el('input', { type: 'checkbox', checked: ticked.has(column.name), onchange: refresh });
402
+ boxes.set(column.name, box);
403
+ list.append(el('label', { class: 'col-row' }, [
404
+ box,
405
+ el('span', { class: 'col-name', text: column.name }),
406
+ // The same markers the result grid puts on its headers, so the two
407
+ // read as one vocabulary rather than two.
408
+ column.primaryKey ? el('span', { class: 'pk', text: ' key' }) : null,
409
+ column.references ? el('span', { class: 'fk', text: ' →' }) : null,
410
+ el('span', { class: 'col-type', text: column.type ?? '' }),
411
+ displayed.includes(column.name) ? null : el('span', { class: 'col-extra', text: 'not shown' }),
412
+ ]));
413
+ }
414
+ refresh();
415
+
416
+ const setAll = (names) => {
417
+ for (const [name, box] of boxes) box.checked = names.includes(name);
418
+ refresh();
419
+ };
420
+
421
+ const dialog = el('dialog', { class: 'confirm cols' }, [
422
+ el('h2', { text: 'Columns to export' }),
423
+ el('p', {
424
+ text: 'Ticked columns are written in the order the table declares them. Choosing a column that is not on screen re-runs the query to fetch it.',
425
+ }),
426
+ el('div', { class: 'col-actions' }, [
427
+ el('button', { type: 'button', class: 'ghost', text: 'All', onclick: () => setAll(columns.map((c) => c.name)) }),
428
+ el('button', { type: 'button', class: 'ghost', text: 'None', onclick: () => setAll([]) }),
429
+ el('button', { type: 'button', class: 'ghost', text: 'As displayed', onclick: () => setAll(displayed) }),
430
+ count,
431
+ ]),
432
+ list,
433
+ el('p', { class: 'confirm-params', text: 'Remembered for this table on this connection.' }),
434
+ el('div', { class: 'confirm-actions' }, [
435
+ el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => dialog.close() }),
436
+ el('button', {
437
+ type: 'button',
438
+ text: 'Use these columns',
439
+ onclick: () => {
440
+ const picked = [...boxes].filter(([, box]) => box.checked).map(([name]) => name);
441
+ // A file of nothing but row separators is not an export. Refusing
442
+ // here is kinder than writing it and letting them find out.
443
+ if (!picked.length) {
444
+ toast('Pick at least one column to export.', 'error');
445
+ return;
446
+ }
447
+ dialog.close();
448
+ onDone(orderColumns(picked, declared));
449
+ },
450
+ }),
451
+ ]),
452
+ ]);
453
+ dialog.addEventListener('close', () => dialog.remove());
454
+ document.body.append(dialog);
455
+ dialog.showModal();
456
+ }