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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- 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
|
+
}
|