@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.6
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/package.json +4 -4
- package/src/access.ts +188 -0
- package/src/client/api.ts +261 -0
- package/src/client/bulk.ts +361 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +201 -0
- package/src/client/csv-commit.ts +185 -0
- package/src/client/csv-map.ts +274 -0
- package/src/client/csv-model.ts +420 -0
- package/src/client/csv-pick.ts +54 -0
- package/src/client/csv-preview.ts +91 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +144 -0
- package/src/client/edit-session.ts +219 -0
- package/src/client/editors.ts +283 -0
- package/src/client/filter-builder.ts +198 -0
- package/src/client/fk.ts +242 -0
- package/src/client/grid-body.ts +103 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +466 -0
- package/src/client/meta.ts +188 -0
- package/src/client/page.ts +332 -0
- package/src/client/panel.ts +296 -0
- package/src/client/relations.ts +205 -0
- package/src/client/save.ts +209 -0
- package/src/client/sidebar.ts +110 -0
- package/src/client/state.ts +218 -0
- package/src/client/statusbar.ts +130 -0
- package/src/client/structure.ts +231 -0
- package/src/client/tabs.ts +224 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +374 -160
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +0 -0
- package/src/endpoints/import.ts +89 -0
- package/src/endpoints/read.ts +173 -0
- package/src/endpoints/rows.ts +435 -0
- package/src/identity.ts +391 -0
- package/src/index.ts +40 -45
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +64 -81
- package/src/shared/coerce.ts +399 -0
- package/src/shared/csv.ts +235 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +164 -0
- package/src/shell.ts +187 -0
- package/src/validate.ts +295 -0
- package/src/credential.ts +0 -26
- package/src/endpoints.ts +0 -48
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The import wizard's state, with no DOM anywhere in it.
|
|
3
|
+
*
|
|
4
|
+
* The wizard has five steps and the interesting behaviour is all in the
|
|
5
|
+
* transitions between them — a re-pick *moves* a database column rather than
|
|
6
|
+
* duplicating it, an unmapped NOT NULL column blocks, `empty → NULL` defaults
|
|
7
|
+
* differently per kind, and the preview recomputes from the mapping every
|
|
8
|
+
* time. None of that is testable through a rendered `<select>`, so none of it
|
|
9
|
+
* lives there. `csv.ts` is the view; this is the machine.
|
|
10
|
+
*
|
|
11
|
+
* Parsing, coercion and mapping are **not reimplemented** — `shared/csv.ts`,
|
|
12
|
+
* `shared/coerce.ts` and `shared/plan.ts` are the same modules the server runs,
|
|
13
|
+
* which is the only way the preview and the outcome agree.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { coerceValue } from '../shared/coerce'
|
|
17
|
+
import { parseCSVRows, sniffDelimiter } from '../shared/csv'
|
|
18
|
+
import {
|
|
19
|
+
autoMap,
|
|
20
|
+
blockingIssues,
|
|
21
|
+
type ColumnMapping,
|
|
22
|
+
type PlanIssue,
|
|
23
|
+
} from '../shared/plan'
|
|
24
|
+
import { columnMeta, type SchemaColumn } from './meta'
|
|
25
|
+
|
|
26
|
+
/** What one CSV column feeds. */
|
|
27
|
+
export type Assignment =
|
|
28
|
+
| { kind: 'skip' }
|
|
29
|
+
| { kind: 'column'; column: string }
|
|
30
|
+
/** Ignore this CSV column; write a literal into `column` on every row. */
|
|
31
|
+
| { kind: 'constant'; column: string | null; text: string }
|
|
32
|
+
|
|
33
|
+
export type BadRowPolicy = 'skip' | 'stop' | 'all'
|
|
34
|
+
|
|
35
|
+
export interface ImportModel {
|
|
36
|
+
headers: string[]
|
|
37
|
+
rows: string[][]
|
|
38
|
+
delimiter: string
|
|
39
|
+
hasHeader: boolean
|
|
40
|
+
/** By CSV header. */
|
|
41
|
+
assign: Record<string, Assignment>
|
|
42
|
+
/** By database column. Defaults on for every kind except text. */
|
|
43
|
+
emptyToNull: Record<string, boolean>
|
|
44
|
+
onBadRow: BadRowPolicy
|
|
45
|
+
/** Rows whose field count differed from the header count. */
|
|
46
|
+
ragged: { row: number; fields: number }[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SKIP: Assignment = { kind: 'skip' }
|
|
50
|
+
|
|
51
|
+
/** The database column an assignment claims, or `null`. */
|
|
52
|
+
export function targetOf(assignment: Assignment): string | null {
|
|
53
|
+
if (assignment.kind === 'column') return assignment.column
|
|
54
|
+
if (assignment.kind === 'constant') return assignment.column
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Does row 0 name the columns?
|
|
60
|
+
*
|
|
61
|
+
* Non-empty, unique, and not a number — the three properties a header row has
|
|
62
|
+
* and a data row almost never does. Deliberately conservative in the direction
|
|
63
|
+
* of *yes*: guessing "header" for a data file loses one row in a preview the
|
|
64
|
+
* user is looking at, while guessing "data" for a header file maps nothing and
|
|
65
|
+
* looks like the file is broken.
|
|
66
|
+
*/
|
|
67
|
+
export function looksLikeHeader(rows: readonly string[][]): boolean {
|
|
68
|
+
const first = rows[0]
|
|
69
|
+
if (!first?.length) return false
|
|
70
|
+
const seen = new Set<string>()
|
|
71
|
+
for (const field of first) {
|
|
72
|
+
const value = field.trim()
|
|
73
|
+
if (!value) return false
|
|
74
|
+
if (seen.has(value)) return false
|
|
75
|
+
seen.add(value)
|
|
76
|
+
if (Number.isFinite(Number(value))) return false
|
|
77
|
+
}
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function synthHeaders(width: number): string[] {
|
|
82
|
+
return Array.from({ length: width }, (_v, i) => `Column ${i + 1}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function square(fields: string[], width: number): string[] {
|
|
86
|
+
const out = fields.slice(0, width)
|
|
87
|
+
while (out.length < width) out.push('')
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Empty means NULL by default for everything except text.
|
|
93
|
+
*
|
|
94
|
+
* `''` in a numeric or date column is never a value — `coerceValue` refuses it
|
|
95
|
+
* outright with `empty_string` — so defaulting the toggle off there guarantees
|
|
96
|
+
* a failure the user then has to diagnose. In a text column `''` *is* a value,
|
|
97
|
+
* and silently turning every blank cell into NULL would be the dashboard's bug
|
|
98
|
+
* with a checkbox on it.
|
|
99
|
+
*/
|
|
100
|
+
function defaultEmptyToNull(
|
|
101
|
+
columns: readonly SchemaColumn[],
|
|
102
|
+
): Record<string, boolean> {
|
|
103
|
+
const out: Record<string, boolean> = {}
|
|
104
|
+
for (const column of columns) out[column.name] = column.kind !== 'string'
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface BuildOptions {
|
|
109
|
+
delimiter?: string
|
|
110
|
+
hasHeader?: boolean
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Parse a file into a model with the mapping already guessed. */
|
|
114
|
+
export function buildModel(
|
|
115
|
+
text: string,
|
|
116
|
+
columns: readonly SchemaColumn[],
|
|
117
|
+
options: BuildOptions = {},
|
|
118
|
+
): ImportModel {
|
|
119
|
+
const delimiter = options.delimiter ?? sniffDelimiter(text)
|
|
120
|
+
const all = parseCSVRows(text, delimiter)
|
|
121
|
+
const hasHeader = options.hasHeader ?? looksLikeHeader(all)
|
|
122
|
+
|
|
123
|
+
const width = all.reduce((max, row) => Math.max(max, row.length), 0)
|
|
124
|
+
const headers = hasHeader
|
|
125
|
+
? square(all[0] ?? [], width).map(h => h.trim())
|
|
126
|
+
: synthHeaders(width)
|
|
127
|
+
const body = hasHeader ? all.slice(1) : all
|
|
128
|
+
|
|
129
|
+
const ragged: { row: number; fields: number }[] = []
|
|
130
|
+
const rows = body.map((fields, index) => {
|
|
131
|
+
if (fields.length !== headers.length) {
|
|
132
|
+
ragged.push({ row: index + 1, fields: fields.length })
|
|
133
|
+
}
|
|
134
|
+
return square(fields, headers.length)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const guessed = autoMap(
|
|
138
|
+
headers,
|
|
139
|
+
columns.map(c => c.name),
|
|
140
|
+
)
|
|
141
|
+
const assign: Record<string, Assignment> = {}
|
|
142
|
+
for (const header of headers) {
|
|
143
|
+
const match = guessed[header]
|
|
144
|
+
assign[header] = match ? { kind: 'column', column: match } : SKIP
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
headers,
|
|
149
|
+
rows,
|
|
150
|
+
delimiter,
|
|
151
|
+
hasHeader,
|
|
152
|
+
assign,
|
|
153
|
+
emptyToNull: defaultEmptyToNull(columns),
|
|
154
|
+
onBadRow: 'skip',
|
|
155
|
+
ragged,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Re-assign one CSV column, **moving** the target rather than sharing it.
|
|
161
|
+
*
|
|
162
|
+
* Any other header holding the same database column falls back to skip. That
|
|
163
|
+
* makes a duplicate mapping impossible to express, which is a stronger promise
|
|
164
|
+
* than `blockingIssues`' `duplicate_target` — that check stays as the
|
|
165
|
+
* server-side guarantee, because the server accepts a mapping this UI did not
|
|
166
|
+
* build.
|
|
167
|
+
*/
|
|
168
|
+
export function reassign(
|
|
169
|
+
model: ImportModel,
|
|
170
|
+
header: string,
|
|
171
|
+
next: Assignment,
|
|
172
|
+
): ImportModel {
|
|
173
|
+
const target = targetOf(next)
|
|
174
|
+
const assign: Record<string, Assignment> = {}
|
|
175
|
+
for (const [key, current] of Object.entries(model.assign)) {
|
|
176
|
+
if (key === header) assign[key] = next
|
|
177
|
+
else if (target && targetOf(current) === target) assign[key] = SKIP
|
|
178
|
+
else assign[key] = current
|
|
179
|
+
}
|
|
180
|
+
return { ...model, assign }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function setEmptyToNull(
|
|
184
|
+
model: ImportModel,
|
|
185
|
+
column: string,
|
|
186
|
+
value: boolean,
|
|
187
|
+
): ImportModel {
|
|
188
|
+
return { ...model, emptyToNull: { ...model.emptyToNull, [column]: value } }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function setBadRowPolicy(
|
|
192
|
+
model: ImportModel,
|
|
193
|
+
onBadRow: BadRowPolicy,
|
|
194
|
+
): ImportModel {
|
|
195
|
+
return { ...model, onBadRow }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The mapping in `shared/plan.ts`'s vocabulary, constants included. */
|
|
199
|
+
export function mappingOf(model: ImportModel): ColumnMapping {
|
|
200
|
+
const mapping: ColumnMapping = {}
|
|
201
|
+
for (const [header, assignment] of Object.entries(model.assign)) {
|
|
202
|
+
mapping[header] = targetOf(assignment)
|
|
203
|
+
}
|
|
204
|
+
return mapping
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface ModelIssues {
|
|
208
|
+
/** What stops the import. `blockingIssues` decides, not this module. */
|
|
209
|
+
blocking: PlanIssue[]
|
|
210
|
+
/** CSV columns going nowhere. Fine, but counted and named. */
|
|
211
|
+
unmatched: string[]
|
|
212
|
+
/** Database columns nobody feeds, with why that is or is not a problem. */
|
|
213
|
+
unmapped: { column: string; status: 'required' | 'default' | 'nullable' }[]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function issuesOf(
|
|
217
|
+
model: ImportModel,
|
|
218
|
+
columns: readonly SchemaColumn[],
|
|
219
|
+
): ModelIssues {
|
|
220
|
+
const mapping = mappingOf(model)
|
|
221
|
+
const blocking = blockingIssues(
|
|
222
|
+
mapping,
|
|
223
|
+
columns.map(c => ({ name: c.name, meta: columnMeta(c) })),
|
|
224
|
+
)
|
|
225
|
+
const claimed = new Set(Object.values(mapping).filter(Boolean) as string[])
|
|
226
|
+
const unmatched = model.headers.filter(header => !mapping[header])
|
|
227
|
+
const unmapped = columns
|
|
228
|
+
.filter(column => !claimed.has(column.name))
|
|
229
|
+
.map(column => ({ column: column.name, status: statusOf(column) }))
|
|
230
|
+
return { blocking, unmatched, unmapped }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function statusOf(column: SchemaColumn): 'required' | 'default' | 'nullable' {
|
|
234
|
+
if (column.autoIncrement || column.hasDefault) return 'default'
|
|
235
|
+
if (column.nullable) return 'nullable'
|
|
236
|
+
return 'required'
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface CellPreview {
|
|
240
|
+
column: string
|
|
241
|
+
/** Exactly what the file said, or the constant. */
|
|
242
|
+
raw: string
|
|
243
|
+
ok: boolean
|
|
244
|
+
value?: unknown
|
|
245
|
+
message?: string
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface RowPreview {
|
|
249
|
+
/** 1-based over the data rows, so it matches what a spreadsheet shows. */
|
|
250
|
+
index: number
|
|
251
|
+
cells: CellPreview[]
|
|
252
|
+
ok: boolean
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* One CSV field, through the same coercer the server will run.
|
|
257
|
+
*
|
|
258
|
+
* The `empty → NULL` toggle happens *before* coercion, which is the only
|
|
259
|
+
* ordering that works: `coerceValue` refuses `''` on every non-text kind, so a
|
|
260
|
+
* toggle applied afterwards would never be reached.
|
|
261
|
+
*/
|
|
262
|
+
function coerceField(
|
|
263
|
+
raw: string,
|
|
264
|
+
column: SchemaColumn,
|
|
265
|
+
emptyToNull: boolean,
|
|
266
|
+
): CellPreview {
|
|
267
|
+
const input = raw === '' && emptyToNull ? null : raw
|
|
268
|
+
const result = coerceValue(input, columnMeta(column))
|
|
269
|
+
if (result.ok)
|
|
270
|
+
return { column: column.name, raw, ok: true, value: result.value }
|
|
271
|
+
return { column: column.name, raw, ok: false, message: result.message }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface Feed {
|
|
275
|
+
column: SchemaColumn
|
|
276
|
+
/** Where the value comes from: a CSV field index, or a literal. */
|
|
277
|
+
index: number | null
|
|
278
|
+
constant: string
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The assignments resolved to actual sources, once per pass rather than per row. */
|
|
282
|
+
function feedsOf(model: ImportModel, columns: readonly SchemaColumn[]): Feed[] {
|
|
283
|
+
const byName = new Map(columns.map(column => [column.name, column]))
|
|
284
|
+
const feeds: Feed[] = []
|
|
285
|
+
model.headers.forEach((header, index) => {
|
|
286
|
+
const assignment = model.assign[header] ?? SKIP
|
|
287
|
+
const target = targetOf(assignment)
|
|
288
|
+
const column = target ? byName.get(target) : undefined
|
|
289
|
+
if (!column) return
|
|
290
|
+
if (assignment.kind === 'constant') {
|
|
291
|
+
feeds.push({ column, index: null, constant: assignment.text })
|
|
292
|
+
} else {
|
|
293
|
+
feeds.push({ column, index, constant: '' })
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
return feeds
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function previewRow(
|
|
300
|
+
model: ImportModel,
|
|
301
|
+
feeds: readonly Feed[],
|
|
302
|
+
fields: readonly string[],
|
|
303
|
+
index: number,
|
|
304
|
+
): RowPreview {
|
|
305
|
+
const cells = feeds.map(feed => {
|
|
306
|
+
const raw = feed.index === null ? feed.constant : (fields[feed.index] ?? '')
|
|
307
|
+
return coerceField(
|
|
308
|
+
raw,
|
|
309
|
+
feed.column,
|
|
310
|
+
model.emptyToNull[feed.column.name] ?? false,
|
|
311
|
+
)
|
|
312
|
+
})
|
|
313
|
+
return { index: index + 1, cells, ok: cells.every(cell => cell.ok) }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The first `limit` rows, coerced. Recomputed on every mapping change. */
|
|
317
|
+
export function previewRows(
|
|
318
|
+
model: ImportModel,
|
|
319
|
+
columns: readonly SchemaColumn[],
|
|
320
|
+
limit = 10,
|
|
321
|
+
): RowPreview[] {
|
|
322
|
+
const feeds = feedsOf(model, columns)
|
|
323
|
+
return model.rows
|
|
324
|
+
.slice(0, limit)
|
|
325
|
+
.map((fields, index) => previewRow(model, feeds, fields, index))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface RowFailure {
|
|
329
|
+
/** 1-based over the data rows. */
|
|
330
|
+
index: number
|
|
331
|
+
fields: string[]
|
|
332
|
+
message: string
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface BuildResult {
|
|
336
|
+
records: Record<string, unknown>[]
|
|
337
|
+
failures: RowFailure[]
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Every row, as records to send and rows that could not be built.
|
|
342
|
+
*
|
|
343
|
+
* One pass over the file. The wizard's live bad-row count is
|
|
344
|
+
* `failures.length` from this same call rather than a second walk, because two
|
|
345
|
+
* walks over fifty thousand rows is the difference between a responsive dialog
|
|
346
|
+
* and a frozen tab.
|
|
347
|
+
*/
|
|
348
|
+
export function buildRecords(
|
|
349
|
+
model: ImportModel,
|
|
350
|
+
columns: readonly SchemaColumn[],
|
|
351
|
+
): BuildResult {
|
|
352
|
+
const feeds = feedsOf(model, columns)
|
|
353
|
+
const records: Record<string, unknown>[] = []
|
|
354
|
+
const failures: RowFailure[] = []
|
|
355
|
+
|
|
356
|
+
model.rows.forEach((fields, index) => {
|
|
357
|
+
const preview = previewRow(model, feeds, fields, index)
|
|
358
|
+
if (!preview.ok) {
|
|
359
|
+
const bad = preview.cells.find(cell => !cell.ok)!
|
|
360
|
+
failures.push({
|
|
361
|
+
index: index + 1,
|
|
362
|
+
fields: [...fields],
|
|
363
|
+
message: `${bad.column}: ${bad.message}`,
|
|
364
|
+
})
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
const record: Record<string, unknown> = {}
|
|
368
|
+
for (const cell of preview.cells) record[cell.column] = cell.value
|
|
369
|
+
records.push(record)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
return { records, failures }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** RFC 4180 quoting: only when the field needs it, and `"` doubles. */
|
|
376
|
+
function quote(field: string, delimiter: string): string {
|
|
377
|
+
const needs =
|
|
378
|
+
field.includes(delimiter) ||
|
|
379
|
+
field.includes('"') ||
|
|
380
|
+
field.includes('\n') ||
|
|
381
|
+
field.includes('\r')
|
|
382
|
+
return needs ? `"${field.replace(/"/g, '""')}"` : field
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function writeCSV(
|
|
386
|
+
headers: readonly string[],
|
|
387
|
+
rows: readonly (readonly string[])[],
|
|
388
|
+
delimiter = ',',
|
|
389
|
+
): string {
|
|
390
|
+
const line = (fields: readonly string[]) =>
|
|
391
|
+
fields.map(field => quote(field, delimiter)).join(delimiter)
|
|
392
|
+
return [line(headers), ...rows.map(line)].join('\r\n')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The rows that did not import, as a file the user can fix and re-drop.
|
|
397
|
+
*
|
|
398
|
+
* The original fields, unmodified, plus a trailing `_error` column. Unmodified
|
|
399
|
+
* matters: a rejected-rows export that had been through the coercer would have
|
|
400
|
+
* lost the very text that explains why it was rejected.
|
|
401
|
+
*/
|
|
402
|
+
export function rejectedCSV(
|
|
403
|
+
model: ImportModel,
|
|
404
|
+
failures: readonly RowFailure[],
|
|
405
|
+
): string {
|
|
406
|
+
return writeCSV(
|
|
407
|
+
[...model.headers, '_error'],
|
|
408
|
+
failures.map(failure => [...failure.fields, failure.message]),
|
|
409
|
+
model.delimiter,
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Send in slices so a fifty-thousand-row file is not one request. */
|
|
414
|
+
export function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
415
|
+
const out: T[][] = []
|
|
416
|
+
for (let i = 0; i < items.length; i += size) {
|
|
417
|
+
out.push(items.slice(i, i + size) as T[])
|
|
418
|
+
}
|
|
419
|
+
return out
|
|
420
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step one of the import wizard: get the text.
|
|
3
|
+
*
|
|
4
|
+
* A file input and a drop zone, and both end in the same `onText` callback —
|
|
5
|
+
* which is the whole reason this is a module rather than two handlers. Nothing
|
|
6
|
+
* downstream knows or cares which way the file arrived.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { append, box, button, el, on } from './dom'
|
|
10
|
+
|
|
11
|
+
export function renderPick(
|
|
12
|
+
stage: HTMLElement,
|
|
13
|
+
onText: (text: string) => void,
|
|
14
|
+
onCancel: () => void,
|
|
15
|
+
): void {
|
|
16
|
+
stage.replaceChildren()
|
|
17
|
+
const zone = box('drop')
|
|
18
|
+
zone.appendChild(el('p', { text: 'Drop a CSV here, or choose a file.' }))
|
|
19
|
+
zone.appendChild(fileInput(onText))
|
|
20
|
+
wireDrop(zone, onText)
|
|
21
|
+
|
|
22
|
+
append(stage, [
|
|
23
|
+
zone,
|
|
24
|
+
box('row-bar', button('Cancel', onCancel, { class: 'btn' })),
|
|
25
|
+
])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fileInput(onText: (text: string) => void): HTMLInputElement {
|
|
29
|
+
const input = el('input')
|
|
30
|
+
input.type = 'file'
|
|
31
|
+
input.accept = '.csv,.tsv,.txt,text/csv'
|
|
32
|
+
on(input, 'change', () => {
|
|
33
|
+
const file = input.files?.[0]
|
|
34
|
+
if (file) void file.text().then(onText)
|
|
35
|
+
})
|
|
36
|
+
return input
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Named so the three listeners are not an inline block inside `renderPick`. */
|
|
40
|
+
function wireDrop(zone: HTMLElement, onText: (text: string) => void): void {
|
|
41
|
+
on(zone, 'dragover', event => {
|
|
42
|
+
// Without `preventDefault` the browser navigates to the file instead,
|
|
43
|
+
// which loses the dialog and everything in it.
|
|
44
|
+
event.preventDefault()
|
|
45
|
+
zone.classList.add('over')
|
|
46
|
+
})
|
|
47
|
+
on(zone, 'dragleave', () => zone.classList.remove('over'))
|
|
48
|
+
on(zone, 'drop', event => {
|
|
49
|
+
event.preventDefault()
|
|
50
|
+
zone.classList.remove('over')
|
|
51
|
+
const file = (event as DragEvent).dataTransfer?.files?.[0]
|
|
52
|
+
if (file) void file.text().then(onText)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step four: the first ten rows exactly as they will be written.
|
|
3
|
+
*
|
|
4
|
+
* The preview recomputes on **every** mapping change, which is the point: a
|
|
5
|
+
* user changing one `<select>` sees immediately that a column now coerces, or
|
|
6
|
+
* now does not. Ten rows keeps that instant on a fifty-thousand-row file.
|
|
7
|
+
*
|
|
8
|
+
* A coerced value that differs from the text carries the text as its `title`,
|
|
9
|
+
* so `"007"` landing in an integer column is visibly `7` and traceably `007`.
|
|
10
|
+
* A failure is ringed and shows the reason `coerceValue` gave — the same
|
|
11
|
+
* sentence the server would have returned.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type ImportModel,
|
|
16
|
+
previewRows,
|
|
17
|
+
type RowPreview,
|
|
18
|
+
setEmptyToNull,
|
|
19
|
+
} from './csv-model'
|
|
20
|
+
import { box, each, el, on } from './dom'
|
|
21
|
+
import type { SchemaColumn } from './meta'
|
|
22
|
+
|
|
23
|
+
export function paintPreview(
|
|
24
|
+
node: HTMLElement,
|
|
25
|
+
columns: SchemaColumn[],
|
|
26
|
+
model: ImportModel,
|
|
27
|
+
update: (next: ImportModel) => void,
|
|
28
|
+
): void {
|
|
29
|
+
node.replaceChildren()
|
|
30
|
+
const rows = previewRows(model, columns, 10)
|
|
31
|
+
node.appendChild(el('h4', { text: 'Preview' }))
|
|
32
|
+
if (!rows.length) {
|
|
33
|
+
node.appendChild(el('p', { class: 'note', text: 'nothing to import yet' }))
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const table = el('table', { class: 'grid' })
|
|
38
|
+
const head = el('tr')
|
|
39
|
+
const names = rows[0]!.cells.map(cell => cell.column)
|
|
40
|
+
each(head, names, column => el('th', { text: column }))
|
|
41
|
+
table.append(head, nullToggleRow(model, names, update))
|
|
42
|
+
each(table, rows, row => previewRow(row))
|
|
43
|
+
node.appendChild(box('scroll', table))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A per-column `empty → NULL` switch, sitting under the header where the
|
|
48
|
+
* column it governs is.
|
|
49
|
+
*
|
|
50
|
+
* On by default for every kind except text, because `''` is a value in a text
|
|
51
|
+
* column and an error in every other one — see `defaultEmptyToNull`.
|
|
52
|
+
*/
|
|
53
|
+
function nullToggleRow(
|
|
54
|
+
model: ImportModel,
|
|
55
|
+
columns: readonly string[],
|
|
56
|
+
update: (next: ImportModel) => void,
|
|
57
|
+
): HTMLElement {
|
|
58
|
+
const tr = el('tr')
|
|
59
|
+
each(tr, columns, column => {
|
|
60
|
+
const td = el('td')
|
|
61
|
+
const check = el('input', {
|
|
62
|
+
attrs: { 'aria-label': `${column}: empty becomes NULL` },
|
|
63
|
+
})
|
|
64
|
+
check.type = 'checkbox'
|
|
65
|
+
check.checked = model.emptyToNull[column] ?? false
|
|
66
|
+
on(check, 'change', () =>
|
|
67
|
+
update(setEmptyToNull(model, column, check.checked)),
|
|
68
|
+
)
|
|
69
|
+
const label = el('label', {
|
|
70
|
+
class: 'note',
|
|
71
|
+
text: ' ∅',
|
|
72
|
+
title: 'empty → NULL',
|
|
73
|
+
})
|
|
74
|
+
label.prepend(check)
|
|
75
|
+
td.appendChild(label)
|
|
76
|
+
return td
|
|
77
|
+
})
|
|
78
|
+
return tr
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function previewRow(row: RowPreview): HTMLElement {
|
|
82
|
+
const tr = el('tr')
|
|
83
|
+
each(tr, row.cells, cell => {
|
|
84
|
+
const text = cell.ok ? String(cell.value ?? 'NULL') : cell.raw
|
|
85
|
+
const td = el('td', { class: cell.ok ? 'cell' : 'cell bad', text })
|
|
86
|
+
if (!cell.ok) td.title = cell.message ?? ''
|
|
87
|
+
else if (text !== cell.raw) td.title = cell.raw
|
|
88
|
+
return td
|
|
89
|
+
})
|
|
90
|
+
return tr
|
|
91
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CSV import wizard: pick → sniff → map → preview → commit.
|
|
3
|
+
*
|
|
4
|
+
* This file is the composition and nothing else. Each step is its own module —
|
|
5
|
+
* `csv-pick.ts`, `csv-map.ts`, `csv-preview.ts`, `csv-commit.ts` — and
|
|
6
|
+
* everything that *decides* anything is in `csv-model.ts` and is pure. What is
|
|
7
|
+
* left here is one mutable `model` reference and the repaint that follows it.
|
|
8
|
+
*
|
|
9
|
+
* Nothing inspects a `<select>` to work out what the mapping is; the mapping is
|
|
10
|
+
* the model, and every control replaces it wholesale.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { paintFooter } from './csv-commit'
|
|
14
|
+
import {
|
|
15
|
+
paintHead,
|
|
16
|
+
paintMapping,
|
|
17
|
+
paintUnmapped,
|
|
18
|
+
type Reparse,
|
|
19
|
+
reparse,
|
|
20
|
+
} from './csv-map'
|
|
21
|
+
import { buildModel, type ImportModel } from './csv-model'
|
|
22
|
+
import { renderPick } from './csv-pick'
|
|
23
|
+
import { paintPreview } from './csv-preview'
|
|
24
|
+
import { append, box, el } from './dom'
|
|
25
|
+
import type { SchemaColumn, SchemaTable } from './meta'
|
|
26
|
+
|
|
27
|
+
export interface ImportContext {
|
|
28
|
+
table: SchemaTable
|
|
29
|
+
columns: SchemaColumn[]
|
|
30
|
+
reload: () => Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function openImport(ctx: ImportContext): void {
|
|
34
|
+
const dialog = el('dialog', { class: 'danger wide import' })
|
|
35
|
+
dialog.appendChild(el('h3', { text: `Import CSV into ${ctx.table.name}` }))
|
|
36
|
+
|
|
37
|
+
const stage = box('import-stage')
|
|
38
|
+
dialog.appendChild(stage)
|
|
39
|
+
dialog.addEventListener('close', () => dialog.remove())
|
|
40
|
+
document.body.appendChild(dialog)
|
|
41
|
+
dialog.showModal()
|
|
42
|
+
|
|
43
|
+
const finish = () => dialog.close()
|
|
44
|
+
const onText = (text: string) =>
|
|
45
|
+
renderMapping(stage, ctx, buildModel(text, ctx.columns), finish)
|
|
46
|
+
renderPick(stage, onText, finish)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface Sections {
|
|
50
|
+
head: HTMLElement
|
|
51
|
+
mapping: HTMLElement
|
|
52
|
+
unmapped: HTMLElement
|
|
53
|
+
preview: HTMLElement
|
|
54
|
+
footer: HTMLElement
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mapping, preview and footer, repainted together.
|
|
59
|
+
*
|
|
60
|
+
* Together rather than selectively, because they are all functions of the one
|
|
61
|
+
* model: the footer's row count, the preview's coercions and the unmapped
|
|
62
|
+
* list's blocking issues all change when a single `<select>` does, and a
|
|
63
|
+
* partial repaint is how two of them end up disagreeing.
|
|
64
|
+
*/
|
|
65
|
+
function renderMapping(
|
|
66
|
+
stage: HTMLElement,
|
|
67
|
+
ctx: ImportContext,
|
|
68
|
+
initial: ImportModel,
|
|
69
|
+
onClose: () => void,
|
|
70
|
+
): void {
|
|
71
|
+
let model = initial
|
|
72
|
+
stage.replaceChildren()
|
|
73
|
+
|
|
74
|
+
const sections: Sections = {
|
|
75
|
+
head: box('import-head'),
|
|
76
|
+
mapping: box('import-map'),
|
|
77
|
+
unmapped: box('import-unmapped'),
|
|
78
|
+
preview: box('import-preview'),
|
|
79
|
+
footer: box('row-bar'),
|
|
80
|
+
}
|
|
81
|
+
append(stage, [
|
|
82
|
+
sections.head,
|
|
83
|
+
sections.mapping,
|
|
84
|
+
sections.unmapped,
|
|
85
|
+
sections.preview,
|
|
86
|
+
sections.footer,
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
const update = (next: ImportModel) => {
|
|
90
|
+
model = next
|
|
91
|
+
paint()
|
|
92
|
+
}
|
|
93
|
+
const onReparse = (next: Reparse) => update(reparse(next, ctx.columns))
|
|
94
|
+
|
|
95
|
+
const paint = () => {
|
|
96
|
+
paintHead(sections.head, model, onReparse)
|
|
97
|
+
paintMapping(sections.mapping, ctx, model, update)
|
|
98
|
+
paintUnmapped(sections.unmapped, ctx, model)
|
|
99
|
+
paintPreview(sections.preview, ctx.columns, model, update)
|
|
100
|
+
paintFooter(sections.footer, ctx, model, update, onClose, stage)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
paint()
|
|
104
|
+
}
|