@svgrid/enterprise 1.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/src/import.ts ADDED
@@ -0,0 +1,648 @@
1
+ /**
2
+ * Pro Import feature
3
+ * ------------------
4
+ *
5
+ * Read Excel (.xlsx), CSV, TSV, or JSON in the browser and produce a typed
6
+ * preview of the parsed rows, including per-cell validation errors. The
7
+ * caller decides whether to commit the parsed rows into the grid via
8
+ * `api.addRows(...)`, or to render an editing modal so the user can fix
9
+ * issues first.
10
+ *
11
+ * Two flavours of usage:
12
+ *
13
+ * 1. Read + preview, then commit explicitly:
14
+ *
15
+ * const result = await api.importData({ file, format: 'auto' })
16
+ * // result.headers, result.rows, result.errors
17
+ * if (result.errors.length === 0) api.addRows(result.rows, 'bottom')
18
+ *
19
+ * 2. Read and commit in one go (no preview UX):
20
+ *
21
+ * await api.importData({ file, format: 'auto', commit: true })
22
+ *
23
+ * Both go through the same polite license soft-gate as `exportData` and
24
+ * `print`, so unlicensed evaluation works but emits a one-time nudge.
25
+ */
26
+
27
+ import type { RowData, SvGridApi, TableFeatures } from '@svgrid/grid'
28
+ import { assertProLicensed } from './license'
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Public types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export type ImportFormat = 'xlsx' | 'csv' | 'tsv' | 'json' | 'auto'
35
+
36
+ export type ImportColumnMap = Record<string, string>
37
+
38
+ /**
39
+ * Declared data type per target field. When `columnTypes` is set on
40
+ * `ImportOptions`, the parser tries to coerce each non-empty cell into
41
+ * the declared shape and emits an `ImportRowError` when it can't -
42
+ * stricter than the ad-hoc "looks like a number" fallback.
43
+ */
44
+ export type ImportFieldType =
45
+ | 'string'
46
+ | 'number'
47
+ | 'integer'
48
+ | 'boolean'
49
+ | 'date' // returns ISO yyyy-mm-dd
50
+ | 'datetime' // returns ISO yyyy-mm-ddThh:mm:ss
51
+ | 'json' // parses the cell as JSON (object / array / primitive)
52
+
53
+ export type ImportColumnTypes = Record<string, ImportFieldType>
54
+
55
+ export type ImportRowError = {
56
+ /** 0-based row index in the SOURCE file (excluding the header). */
57
+ rowIndex: number
58
+ /** Target field name (the grid's column field), or '*' for whole-row errors. */
59
+ field: string
60
+ message: string
61
+ }
62
+
63
+ export type ImportValidator<TData> = (
64
+ row: TData,
65
+ rowIndex: number,
66
+ ) => Array<{ field: string; message: string }>
67
+
68
+ export type ImportOptions<TData> = {
69
+ /** The file to read. A `File`/`Blob` works for xlsx; a string is treated as
70
+ * inline CSV/TSV/JSON text. */
71
+ file: File | Blob | string
72
+ /** When 'auto', the format is sniffed from `file.name`'s extension (Files
73
+ * only), otherwise the function inspects the first characters of the
74
+ * text payload. */
75
+ format?: ImportFormat
76
+ /**
77
+ * Map source-header -> target-field. Missing entries fall back to the
78
+ * source header verbatim (lowercased + trimmed). Pass `null` for a
79
+ * source header you want to drop on the floor.
80
+ */
81
+ columnMap?: ImportColumnMap
82
+ /**
83
+ * Declared data types per target field. When set, the importer uses
84
+ * strict per-type coercion (`'2024-03-15'` -> ISO date; `'$1,234'`
85
+ * -> 1234 for `number` fields) and emits an `ImportRowError` whenever
86
+ * a value can't be coerced. Fields not listed fall back to the
87
+ * built-in best-effort coercion (currency / number / date sniffing
88
+ * by value shape).
89
+ */
90
+ columnTypes?: ImportColumnTypes
91
+ /** Per-row validator. Returned errors land in `result.errors`. */
92
+ validator?: ImportValidator<TData>
93
+ /** When true, the parsed rows are appended to the grid via
94
+ * `api.addRows(...)` automatically. Defaults to false (preview mode). */
95
+ commit?: boolean
96
+ /** Where to insert when `commit` is true. Defaults to 'bottom'. */
97
+ commitAt?: 'top' | 'bottom' | number
98
+ }
99
+
100
+ export type ImportResult<TData> = {
101
+ /** Source headers as found in the file, in their original order. */
102
+ headers: string[]
103
+ /** Parsed + mapped rows. Order matches the source. */
104
+ rows: TData[]
105
+ /** All validation errors (zero-length if the file passed clean). */
106
+ errors: ImportRowError[]
107
+ /** Rows skipped because they were entirely blank. */
108
+ skipped: number
109
+ /** Total source rows the parser saw (including blanks and bad rows). */
110
+ total: number
111
+ /** Detected format (resolved from 'auto'). */
112
+ format: Exclude<ImportFormat, 'auto'>
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Lazy peer dependency: jszip (xlsx only)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ let jszipPromise: Promise<unknown> | null = null
120
+ async function getJSZip(): Promise<any> {
121
+ if (typeof window === 'undefined') {
122
+ throw new Error('@svgrid/enterprise: importData requires a browser environment')
123
+ }
124
+ const g = globalThis as unknown as { JSZip?: unknown }
125
+ if (g.JSZip) return g.JSZip
126
+ if (!jszipPromise) {
127
+ jszipPromise = (async () => {
128
+ let mod: unknown
129
+ try {
130
+ mod = await import('jszip')
131
+ } catch {
132
+ throw new Error(
133
+ '@svgrid/enterprise: xlsx import requires the "jszip" peer dependency. ' +
134
+ 'Install it with: pnpm add jszip',
135
+ )
136
+ }
137
+ const J = (mod as { default?: unknown }).default ?? mod
138
+ g.JSZip = J
139
+ return J
140
+ })()
141
+ }
142
+ return jszipPromise
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Format sniffing
147
+ // ---------------------------------------------------------------------------
148
+
149
+ function sniffFormat(
150
+ file: File | Blob | string,
151
+ declared: ImportFormat,
152
+ ): Exclude<ImportFormat, 'auto'> {
153
+ if (declared !== 'auto') return declared
154
+ // File: trust the extension.
155
+ if (file instanceof File && file.name) {
156
+ const ext = file.name.toLowerCase().split('.').pop() ?? ''
157
+ if (ext === 'xlsx') return 'xlsx'
158
+ if (ext === 'csv') return 'csv'
159
+ if (ext === 'tsv' || ext === 'tab') return 'tsv'
160
+ if (ext === 'json') return 'json'
161
+ }
162
+ // String: sniff the first non-whitespace char.
163
+ if (typeof file === 'string') {
164
+ const trimmed = file.trimStart()
165
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) return 'json'
166
+ // Tab-rich heads are tsv; otherwise default to csv.
167
+ const firstLine = trimmed.slice(0, 200).split(/\r?\n/)[0] ?? ''
168
+ if ((firstLine.match(/\t/g)?.length ?? 0) >= 1) return 'tsv'
169
+ return 'csv'
170
+ }
171
+ // Blob without a filename / extension: assume xlsx (the most common case
172
+ // the importer is asked to handle).
173
+ return 'xlsx'
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // CSV / TSV parser
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * RFC 4180-flavour CSV / TSV parser. Handles:
182
+ * - quoted fields with embedded commas / tabs / newlines
183
+ * - escaped quotes ("") inside quoted fields
184
+ * - both \r\n and \n line endings
185
+ *
186
+ * Doesn't aim to be the world's fastest parser - it walks the string
187
+ * character-by-character. For files in the 10-100k row range this is
188
+ * fine; for million-row imports, do the import server-side.
189
+ */
190
+ function parseDelimited(text: string, sep: ',' | '\t'): string[][] {
191
+ const rows: string[][] = []
192
+ let row: string[] = []
193
+ let field = ''
194
+ let i = 0
195
+ let inQuotes = false
196
+ // Strip a UTF-8 BOM if present so the first header isn't named "ID".
197
+ if (text.charCodeAt(0) === 0xfeff) i = 1
198
+ while (i < text.length) {
199
+ const c = text[i]!
200
+ if (inQuotes) {
201
+ if (c === '"') {
202
+ if (text[i + 1] === '"') { field += '"'; i += 2; continue }
203
+ inQuotes = false
204
+ i += 1
205
+ continue
206
+ }
207
+ field += c
208
+ i += 1
209
+ continue
210
+ }
211
+ if (c === '"') { inQuotes = true; i += 1; continue }
212
+ if (c === sep) { row.push(field); field = ''; i += 1; continue }
213
+ if (c === '\r') {
214
+ if (text[i + 1] === '\n') i += 1
215
+ row.push(field); rows.push(row); row = []; field = ''; i += 1; continue
216
+ }
217
+ if (c === '\n') {
218
+ row.push(field); rows.push(row); row = []; field = ''; i += 1; continue
219
+ }
220
+ field += c
221
+ i += 1
222
+ }
223
+ // Flush the trailing field / row (no trailing newline).
224
+ if (field.length > 0 || row.length > 0) {
225
+ row.push(field)
226
+ rows.push(row)
227
+ }
228
+ return rows
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // XLSX parser (minimal)
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * Minimal SpreadsheetML reader. Supports the .xlsx shape Excel and Google
237
+ * Sheets produce by default: one default sheet, optional shared-strings
238
+ * table, inline strings, numbers, and dates expressed as serial numbers.
239
+ *
240
+ * The grid does NOT try to be a fully-featured Excel reader. Formulas
241
+ * are read as their cached value when present. Multi-sheet imports
242
+ * require the caller to pre-extract the desired sheet (or for a future
243
+ * release).
244
+ */
245
+ async function parseXlsx(file: File | Blob): Promise<string[][]> {
246
+ const J = await getJSZip()
247
+ const buf = await (file as Blob).arrayBuffer()
248
+ const zip = await J.loadAsync(buf)
249
+
250
+ // Read sharedStrings.xml if it exists.
251
+ let sharedStrings: string[] = []
252
+ const ssEntry = zip.file('xl/sharedStrings.xml')
253
+ if (ssEntry) {
254
+ const ssText = await ssEntry.async('string')
255
+ sharedStrings = parseSharedStrings(ssText)
256
+ }
257
+
258
+ // Read the FIRST sheet. Workbook.xml -> rels mapping is involved; for the
259
+ // 99% case the first sheet lives at xl/worksheets/sheet1.xml.
260
+ const sheetEntry =
261
+ zip.file('xl/worksheets/sheet1.xml') ??
262
+ zip.file('xl/worksheets/sheet 1.xml')
263
+ if (!sheetEntry) {
264
+ throw new Error(
265
+ '@svgrid/enterprise: could not locate sheet1.xml in the .xlsx archive',
266
+ )
267
+ }
268
+ const sheetText = await sheetEntry.async('string')
269
+ return parseSheetXml(sheetText, sharedStrings)
270
+ }
271
+
272
+ /** Pull every `<t>...</t>` out of sharedStrings.xml in document order. */
273
+ function parseSharedStrings(xml: string): string[] {
274
+ const out: string[] = []
275
+ // A shared-string item may be a plain <si><t>X</t></si> or a rich-text
276
+ // <si><r><t>X</t></r><r><t>Y</t></r></si>. Concatenate every <t> inside
277
+ // each <si>.
278
+ const itemRe = /<si\b[^>]*>([\s\S]*?)<\/si>/g
279
+ const tRe = /<t\b[^>]*>([\s\S]*?)<\/t>/g
280
+ let m: RegExpExecArray | null
281
+ while ((m = itemRe.exec(xml)) !== null) {
282
+ let combined = ''
283
+ let n: RegExpExecArray | null
284
+ tRe.lastIndex = 0
285
+ while ((n = tRe.exec(m[1] ?? '')) !== null) {
286
+ combined += decodeXmlEntities(n[1] ?? '')
287
+ }
288
+ out.push(combined)
289
+ }
290
+ return out
291
+ }
292
+
293
+ function decodeXmlEntities(s: string): string {
294
+ return s
295
+ .replace(/&amp;/g, '&')
296
+ .replace(/&lt;/g, '<')
297
+ .replace(/&gt;/g, '>')
298
+ .replace(/&quot;/g, '"')
299
+ .replace(/&apos;/g, "'")
300
+ .replace(/&#10;/g, '\n')
301
+ .replace(/&#9;/g, '\t')
302
+ }
303
+
304
+ /**
305
+ * Convert a sheet's XML body into a dense `string[][]`. Holes in the
306
+ * cell stream (e.g. row jumps from col B to col E) are filled with
307
+ * empty strings so the result is rectangular.
308
+ */
309
+ function parseSheetXml(xml: string, sharedStrings: string[]): string[][] {
310
+ const rows: string[][] = []
311
+ const rowRe = /<row\b[^>]*>([\s\S]*?)<\/row>/g
312
+ const cellRe = /<c\b([^>]*)>([\s\S]*?)<\/c>/g
313
+ const inlineStrRe = /<is>\s*([\s\S]*?)\s*<\/is>/
314
+ const tRe = /<t\b[^>]*>([\s\S]*?)<\/t>/g
315
+ const vRe = /<v>([\s\S]*?)<\/v>/
316
+ let rowMatch: RegExpExecArray | null
317
+ while ((rowMatch = rowRe.exec(xml)) !== null) {
318
+ const rowBody = rowMatch[1] ?? ''
319
+ const cells: string[] = []
320
+ let cellMatch: RegExpExecArray | null
321
+ cellRe.lastIndex = 0
322
+ while ((cellMatch = cellRe.exec(rowBody)) !== null) {
323
+ const attrs = cellMatch[1] ?? ''
324
+ const body = cellMatch[2] ?? ''
325
+ const ref = /r="([A-Z]+)\d+"/.exec(attrs)?.[1] ?? ''
326
+ const colIndex = colRefToIndex(ref)
327
+ const type = /t="([^"]+)"/.exec(attrs)?.[1] ?? 'n'
328
+ // Pad holes with empty strings so column alignment survives.
329
+ while (cells.length < colIndex) cells.push('')
330
+ let value = ''
331
+ if (type === 's') {
332
+ const idx = Number(vRe.exec(body)?.[1] ?? '-1')
333
+ value = sharedStrings[idx] ?? ''
334
+ } else if (type === 'inlineStr' || type === 'str') {
335
+ const inlineBody = inlineStrRe.exec(body)?.[1] ?? body
336
+ let parts = ''
337
+ let n: RegExpExecArray | null
338
+ tRe.lastIndex = 0
339
+ while ((n = tRe.exec(inlineBody)) !== null) parts += decodeXmlEntities(n[1] ?? '')
340
+ value = parts || decodeXmlEntities(vRe.exec(body)?.[1] ?? '')
341
+ } else if (type === 'b') {
342
+ value = vRe.exec(body)?.[1] === '1' ? 'true' : 'false'
343
+ } else {
344
+ // Numeric or date - we surface as the raw number string. The
345
+ // caller's column type then handles parsing.
346
+ value = decodeXmlEntities(vRe.exec(body)?.[1] ?? '')
347
+ }
348
+ cells.push(value)
349
+ }
350
+ rows.push(cells)
351
+ }
352
+ return rows
353
+ }
354
+
355
+ /** Excel column reference ("A", "B", ..., "AA", ...) to a zero-based index. */
356
+ function colRefToIndex(ref: string): number {
357
+ if (!ref) return 0
358
+ let n = 0
359
+ for (let i = 0; i < ref.length; i += 1) {
360
+ n = n * 26 + (ref.charCodeAt(i) - 64)
361
+ }
362
+ return Math.max(0, n - 1)
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Coerce raw string values into typed JS values based on a sample of other
367
+ // values in the same column. We don't ask the consumer for a schema - it
368
+ // would defeat the "drop a file in and go" UX - but we DO want
369
+ // "$1,234.56" to become 1234.56 and "true" to become true.
370
+ // ---------------------------------------------------------------------------
371
+
372
+ function inferAndCoerce(value: string): unknown {
373
+ if (value === '') return ''
374
+ const trimmed = value.trim()
375
+ if (trimmed === '') return ''
376
+ // Boolean
377
+ if (trimmed === 'true' || trimmed === 'TRUE') return true
378
+ if (trimmed === 'false' || trimmed === 'FALSE') return false
379
+ // Currency / number with grouping. Strip $, commas, whitespace.
380
+ if (/^[-+]?\$?\s?[\d,]+(?:\.\d+)?$/.test(trimmed)) {
381
+ const n = parseFloat(trimmed.replace(/[$,\s]/g, ''))
382
+ if (!Number.isNaN(n)) return n
383
+ }
384
+ // Bare integer / float
385
+ if (/^[-+]?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(trimmed)) {
386
+ const n = parseFloat(trimmed)
387
+ if (!Number.isNaN(n)) return n
388
+ }
389
+ // ISO date (yyyy-mm-dd, optionally with time) -> keep as ISO string. We
390
+ // don't return a Date because most grid columns prefer strings on the
391
+ // wire (sort + filter + format paths are already date-aware).
392
+ if (/^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:?\d{2})?)?$/.test(trimmed)) {
393
+ return trimmed
394
+ }
395
+ return trimmed
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Map source rows -> typed records.
400
+ // ---------------------------------------------------------------------------
401
+
402
+ function buildRecords<TData>(
403
+ matrix: string[][],
404
+ columnMap: ImportColumnMap | undefined,
405
+ columnTypes: ImportColumnTypes | undefined,
406
+ ): { headers: string[]; rows: TData[]; skipped: number; errors: ImportRowError[] } {
407
+ if (matrix.length === 0) return { headers: [], rows: [], skipped: 0, errors: [] }
408
+ const headers = (matrix[0] ?? []).map((h) => h.trim())
409
+ const dataRows = matrix.slice(1)
410
+ const out: TData[] = []
411
+ const errors: ImportRowError[] = []
412
+ let skipped = 0
413
+
414
+ // Pre-compute target-field per source header, honouring the columnMap.
415
+ const fields = headers.map((h) => {
416
+ if (columnMap && Object.prototype.hasOwnProperty.call(columnMap, h)) {
417
+ return columnMap[h] ?? null
418
+ }
419
+ // Default: lowercase + collapse whitespace to camelCase-ish.
420
+ const k = h.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
421
+ return k
422
+ })
423
+
424
+ // Skipped (blank) rows don't count toward the output row index used
425
+ // in errors, so the indices match what the consumer's preview UI
426
+ // shows.
427
+ let outRowIndex = 0
428
+ for (const cells of dataRows) {
429
+ const allBlank = cells.every((c) => c === undefined || c.trim() === '')
430
+ if (allBlank) { skipped += 1; continue }
431
+ const rec: Record<string, unknown> = {}
432
+ for (let i = 0; i < headers.length; i += 1) {
433
+ const field = fields[i]
434
+ if (field == null) continue
435
+ const raw = cells[i] ?? ''
436
+ const declaredType = columnTypes?.[field]
437
+ if (declaredType) {
438
+ const result = coerceTyped(raw, declaredType)
439
+ if (result.ok) {
440
+ rec[field] = result.value
441
+ } else {
442
+ errors.push({ rowIndex: outRowIndex, field, message: result.message })
443
+ // Park the raw string so downstream code still sees SOMETHING.
444
+ rec[field] = raw
445
+ }
446
+ } else {
447
+ rec[field] = inferAndCoerce(raw)
448
+ }
449
+ }
450
+ out.push(rec as unknown as TData)
451
+ outRowIndex += 1
452
+ }
453
+ return { headers, rows: out, skipped, errors }
454
+ }
455
+
456
+ /**
457
+ * Strict per-type coercion used when the consumer declares
458
+ * `columnTypes`. Returns either `{ ok: true, value }` or
459
+ * `{ ok: false, message }` describing why the value couldn't be
460
+ * shaped into the declared type.
461
+ *
462
+ * Empty values for required types (number, date) error rather than
463
+ * silently coerce to 0 / null - the consumer can drop the row in
464
+ * their validator if blanks should be tolerated.
465
+ */
466
+ function coerceTyped(raw: string, type: ImportFieldType):
467
+ | { ok: true; value: unknown }
468
+ | { ok: false; message: string }
469
+ {
470
+ const trimmed = raw.trim()
471
+ // Empty cells map to `null` for most types and the empty string for
472
+ // `string` - this is the only case where typed import returns a
473
+ // null. Validator step is the right place to insist on "required".
474
+ if (trimmed === '') {
475
+ if (type === 'string') return { ok: true, value: '' }
476
+ return { ok: true, value: null }
477
+ }
478
+
479
+ switch (type) {
480
+ case 'string':
481
+ return { ok: true, value: trimmed }
482
+
483
+ case 'boolean': {
484
+ const low = trimmed.toLowerCase()
485
+ if (low === 'true' || low === '1' || low === 'yes' || low === 'y') return { ok: true, value: true }
486
+ if (low === 'false' || low === '0' || low === 'no' || low === 'n') return { ok: true, value: false }
487
+ return { ok: false, message: `not a boolean: ${trimmed}` }
488
+ }
489
+
490
+ case 'number': {
491
+ const stripped = trimmed.replace(/[$,\s]/g, '')
492
+ const n = Number(stripped)
493
+ if (!Number.isFinite(n)) return { ok: false, message: `not a number: ${trimmed}` }
494
+ return { ok: true, value: n }
495
+ }
496
+
497
+ case 'integer': {
498
+ const stripped = trimmed.replace(/[$,\s]/g, '')
499
+ const n = Number(stripped)
500
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
501
+ return { ok: false, message: `not an integer: ${trimmed}` }
502
+ }
503
+ return { ok: true, value: n }
504
+ }
505
+
506
+ case 'date': {
507
+ // Accept ISO dates and a handful of common day-first / month-first
508
+ // shapes. Returns ISO yyyy-mm-dd.
509
+ const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/)
510
+ if (iso) return { ok: true, value: `${iso[1]}-${iso[2]}-${iso[3]}` }
511
+ // mm/dd/yyyy or dd/mm/yyyy - we don't try to disambiguate; the
512
+ // demo can declare the locale via its own validator if it cares.
513
+ const slash = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/)
514
+ if (slash) {
515
+ const [, a, b, y] = slash
516
+ // Default to mm/dd/yyyy because it's the dominant locale where
517
+ // Excel exports without an explicit ISO format. Consumers
518
+ // wanting EU-style can pre-normalise the file.
519
+ return { ok: true, value: `${y}-${a!.padStart(2, '0')}-${b!.padStart(2, '0')}` }
520
+ }
521
+ const parsed = Date.parse(trimmed)
522
+ if (!Number.isNaN(parsed)) {
523
+ return { ok: true, value: new Date(parsed).toISOString().slice(0, 10) }
524
+ }
525
+ return { ok: false, message: `not a date: ${trimmed}` }
526
+ }
527
+
528
+ case 'datetime': {
529
+ // Accept anything Date.parse handles.
530
+ const parsed = Date.parse(trimmed)
531
+ if (Number.isNaN(parsed)) return { ok: false, message: `not a datetime: ${trimmed}` }
532
+ return { ok: true, value: new Date(parsed).toISOString().slice(0, 19) }
533
+ }
534
+
535
+ case 'json': {
536
+ try {
537
+ return { ok: true, value: JSON.parse(trimmed) }
538
+ } catch {
539
+ return { ok: false, message: `not valid JSON: ${trimmed.slice(0, 30)}...` }
540
+ }
541
+ }
542
+ }
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Public entry point
547
+ // ---------------------------------------------------------------------------
548
+
549
+ /**
550
+ * Read a file (or inline text), parse, optionally validate + commit. The
551
+ * sister function of `exportGrid`.
552
+ */
553
+ export async function importData<
554
+ TFeatures extends TableFeatures,
555
+ TData extends RowData,
556
+ >(
557
+ api: SvGridApi<TFeatures, TData>,
558
+ opts: ImportOptions<TData>,
559
+ ): Promise<ImportResult<TData>> {
560
+ assertProLicensed('Import')
561
+ const format = sniffFormat(opts.file, opts.format ?? 'auto')
562
+
563
+ let matrix: string[][]
564
+ if (format === 'xlsx') {
565
+ if (typeof opts.file === 'string') {
566
+ throw new Error(
567
+ '@svgrid/enterprise: xlsx import expects a File or Blob, not a string. ' +
568
+ 'Use format: "csv" or "tsv" for inline text.',
569
+ )
570
+ }
571
+ matrix = await parseXlsx(opts.file)
572
+ } else if (format === 'json') {
573
+ const text = typeof opts.file === 'string' ? opts.file : await readText(opts.file)
574
+ matrix = jsonToMatrix(text)
575
+ } else {
576
+ const sep = format === 'tsv' ? '\t' : ','
577
+ const text = typeof opts.file === 'string' ? opts.file : await readText(opts.file)
578
+ matrix = parseDelimited(text, sep)
579
+ }
580
+
581
+ const { headers, rows, skipped, errors: typeErrors } =
582
+ buildRecords<TData>(matrix, opts.columnMap, opts.columnTypes)
583
+
584
+ // Type-coercion errors come first; the validator runs on the rows
585
+ // we managed to build, so its errors have the same `rowIndex` basis.
586
+ const errors: ImportRowError[] = [...typeErrors]
587
+ if (opts.validator) {
588
+ for (let i = 0; i < rows.length; i += 1) {
589
+ const errs = opts.validator(rows[i]!, i)
590
+ for (const e of errs) errors.push({ rowIndex: i, field: e.field, message: e.message })
591
+ }
592
+ }
593
+
594
+ if (opts.commit && errors.length === 0 && rows.length > 0) {
595
+ api.addRows(rows, opts.commitAt ?? 'bottom')
596
+ }
597
+
598
+ return {
599
+ headers,
600
+ rows,
601
+ errors,
602
+ skipped,
603
+ total: matrix.length > 0 ? matrix.length - 1 : 0,
604
+ format,
605
+ }
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Helpers
610
+ // ---------------------------------------------------------------------------
611
+
612
+ async function readText(blob: Blob): Promise<string> {
613
+ return blob.text()
614
+ }
615
+
616
+ /** Convert JSON text containing an array of objects into a `string[][]`
617
+ * matrix shaped like CSV: header row, then one row per record. */
618
+ function jsonToMatrix(text: string): string[][] {
619
+ const parsed = JSON.parse(text)
620
+ if (!Array.isArray(parsed)) {
621
+ throw new Error('@svgrid/enterprise: JSON import expects a top-level array')
622
+ }
623
+ if (parsed.length === 0) return []
624
+ // Collect the union of keys across the first ~50 records so we don't
625
+ // miss a column present only on later rows.
626
+ const headers: string[] = []
627
+ const seen = new Set<string>()
628
+ const sample = parsed.slice(0, 50) as Record<string, unknown>[]
629
+ for (const r of sample) {
630
+ if (r && typeof r === 'object') {
631
+ for (const k of Object.keys(r)) {
632
+ if (!seen.has(k)) { seen.add(k); headers.push(k) }
633
+ }
634
+ }
635
+ }
636
+ const rows: string[][] = [headers.slice()]
637
+ for (const r of parsed as Array<Record<string, unknown>>) {
638
+ const row: string[] = []
639
+ for (const h of headers) {
640
+ const v = r?.[h]
641
+ if (v == null) row.push('')
642
+ else if (typeof v === 'object') row.push(JSON.stringify(v))
643
+ else row.push(String(v))
644
+ }
645
+ rows.push(row)
646
+ }
647
+ return rows
648
+ }