@svgrid/enterprise 1.2.0 → 2.0.0

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 (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,1020 @@
1
+ <script lang="ts" generics="TFeatures extends TableFeatures, TData extends RowData">
2
+ /**
3
+ * SvImportDialog - a drop-in, production-hardened "bring your data in"
4
+ * dialog for a licensed grid. The mirror image of SvExportMenu: where that
5
+ * sends the grid OUT, this pulls a file IN and lines it up with the grid's
6
+ * own columns.
7
+ *
8
+ * - drag-drop / browse / paste (Excel .xlsx, CSV, TSV, JSON),
9
+ * - grid-aware auto-mapping: source headers matched to your columns,
10
+ * - per-column mapping UI (retarget or skip any source column),
11
+ * - typed coercion driven by each column's `format` (currency/date/...),
12
+ * - a live, NON-BLOCKING preview with per-cell validation errors,
13
+ * - size / row guard-rails, cancel, and a full focus trap.
14
+ *
15
+ * The parse happens ONCE (readImportMatrix); re-mapping runs on a chunked,
16
+ * cancelable worker loop (mapImportMatrixAsync) so even a 100k-row file
17
+ * never freezes the tab. Styling uses the same overridable CSS vars as the
18
+ * rest of the suite.
19
+ */
20
+ import type { RowData, TableFeatures } from '@svgrid/grid'
21
+ import type { EnterpriseGridApi } from './install'
22
+ import {
23
+ readImportMatrix,
24
+ mapImportMatrixAsync,
25
+ autoMapColumns,
26
+ inferImportColumnTypes,
27
+ type ImportColumnMap,
28
+ type ImportColumnTypes,
29
+ type ImportFieldType,
30
+ type ImportGridColumn,
31
+ type ImportResult,
32
+ type ImportRowError,
33
+ type ImportValidator,
34
+ } from './import'
35
+
36
+ type Props = {
37
+ api: EnterpriseGridApi<TFeatures, TData> | null | undefined
38
+ /** Trigger-button label. Default 'Import'. */
39
+ label?: string
40
+ /** Dialog heading. Default 'Import data'. */
41
+ title?: string
42
+ /** Where committed rows land (append mode). Default 'bottom'. */
43
+ commitAt?: 'top' | 'bottom' | number
44
+ /** Create a grid column for any imported field the grid doesn't already
45
+ * have, so "Import as new field" actually shows up. Default true. */
46
+ createColumns?: boolean
47
+ /** Offer an Append / Replace-all mode toggle. Replace clears the grid's
48
+ * existing rows first (and still creates any missing columns). Default true. */
49
+ allowReplace?: boolean
50
+ /** Initial commit mode. Default 'append'. */
51
+ defaultMode?: 'append' | 'replace'
52
+ /** In Replace mode, offer a "Replace columns" checkbox that also REMOVES
53
+ * grid columns absent from the file, so the grid matches it exactly.
54
+ * Default true (the checkbox itself defaults to off). */
55
+ allowColumnPrune?: boolean
56
+ /** Accept filter for the file picker. */
57
+ accept?: string
58
+ /** Rows to show in the preview table. Default 8. */
59
+ previewRows?: number
60
+ /** Block the import while any cell failed to coerce. Default false. */
61
+ strict?: boolean
62
+ /** Optional per-row validator, surfaced in the preview like type errors. */
63
+ validator?: ImportValidator<TData>
64
+ /** Reject a file larger than this many bytes. Default 25 MB. */
65
+ maxBytes?: number
66
+ /** Cap on data rows. See `overLimit` for what happens past it. */
67
+ maxRows?: number
68
+ /** Over `maxRows`: 'reject' (default) refuses the file; 'truncate' keeps
69
+ * the first `maxRows` and warns. */
70
+ overLimit?: 'reject' | 'truncate'
71
+ /** Cap the errors collected (memory guard). Default 500. */
72
+ maxErrors?: number
73
+ /** Drop duplicate rows by this target field (keeps the last). */
74
+ dedupeBy?: keyof TData | (string & {})
75
+ /** Text encoding for CSV/TSV/JSON files. Default 'utf-8'. */
76
+ encoding?: string
77
+ /** Called after rows are committed into the grid. */
78
+ onImported?: (result: ImportResult<TData>) => void
79
+ }
80
+
81
+ let {
82
+ api,
83
+ label = 'Import',
84
+ title = 'Import data',
85
+ commitAt = 'bottom',
86
+ createColumns = true,
87
+ allowReplace = true,
88
+ defaultMode = 'append',
89
+ allowColumnPrune = true,
90
+ accept = '.xlsx,.csv,.tsv,.tab,.json,text/csv,application/json',
91
+ previewRows = 8,
92
+ strict = false,
93
+ validator,
94
+ maxBytes = 25 * 1024 * 1024,
95
+ maxRows,
96
+ overLimit = 'reject',
97
+ maxErrors = 500,
98
+ dedupeBy,
99
+ encoding,
100
+ onImported,
101
+ }: Props = $props()
102
+
103
+ const SKIP = ' skip'
104
+ const DEFAULT = ' default'
105
+ const KNOWN_EXT = ['xlsx', 'csv', 'tsv', 'tab', 'json']
106
+
107
+ let open = $state(false)
108
+ // svelte-ignore state_referenced_locally
109
+ let mode = $state<'append' | 'replace'>(defaultMode)
110
+ let pruneColumns = $state(false) // Replace mode: also drop columns absent from the file
111
+ let busy = $state(false) // reading a file OR committing
112
+ let remapping = $state(false) // re-running the map after a mapping change
113
+ let progress = $state(0)
114
+ let error = $state<string | null>(null)
115
+ let dragging = $state(false)
116
+ let fileName = $state('')
117
+ let format = $state('')
118
+ let pasteMode = $state(false)
119
+ let pasteText = $state('')
120
+ let truncated = $state(false)
121
+ let sourceTotal = $state(0)
122
+
123
+ // The parsed source (read once). `mapping` is source header -> target
124
+ // field (or the SKIP / DEFAULT sentinels); the preview derives from it.
125
+ let matrix = $state<string[][] | null>(null)
126
+ let sourceHeaders = $state<string[]>([])
127
+ let mapping = $state<Record<string, string>>({})
128
+ let columnTypes = $state<ImportColumnTypes>({})
129
+ let gridFields = $state<Array<{ field: string; header: string; type?: ImportFieldType }>>([])
130
+ // Per-source-header config for headers mapped to a NEW field: display name,
131
+ // kind (drives coercion + the created column's format/editor), dropdown
132
+ // options, and visibility. Keyed by source header.
133
+ type ColKind =
134
+ | 'string' | 'number' | 'integer' | 'currency' | 'percent'
135
+ | 'boolean' | 'date' | 'datetime' | 'select' | 'json'
136
+ type NewColConfig = { header: string; kind: ColKind; options: string; visible: boolean }
137
+ let newCols = $state<Record<string, NewColConfig>>({})
138
+
139
+ // Mapped output (async). Held in state, not derived, because the map runs
140
+ // on a chunked loop that can be cancelled mid-flight.
141
+ let built = $state<{
142
+ rows: TData[]
143
+ errors: ImportRowError[]
144
+ skipped: number
145
+ total: number
146
+ errorsTruncated: boolean
147
+ deduped: number
148
+ } | null>(null)
149
+
150
+ let loadController: AbortController | null = null
151
+ let remapController: AbortController | null = null
152
+ let remapTimer: ReturnType<typeof setTimeout> | null = null
153
+
154
+ let fileInput: HTMLInputElement | undefined = $state()
155
+ let dialogEl: HTMLDivElement | undefined = $state()
156
+ let triggerEl: HTMLButtonElement | undefined = $state()
157
+ let lastFocused: HTMLElement | null = null
158
+
159
+ function reset() {
160
+ loadController?.abort()
161
+ remapController?.abort()
162
+ if (remapTimer) clearTimeout(remapTimer)
163
+ matrix = null
164
+ sourceHeaders = []
165
+ mapping = {}
166
+ columnTypes = {}
167
+ newCols = {}
168
+ built = null
169
+ fileName = ''
170
+ format = ''
171
+ error = null
172
+ progress = 0
173
+ busy = false
174
+ remapping = false
175
+ pasteMode = false
176
+ pasteText = ''
177
+ truncated = false
178
+ sourceTotal = 0
179
+ }
180
+
181
+ function openDialog() {
182
+ reset()
183
+ lastFocused = (document.activeElement as HTMLElement) ?? null
184
+ open = true
185
+ requestAnimationFrame(() => dialogEl?.focus())
186
+ }
187
+
188
+ function closeDialog() {
189
+ reset()
190
+ open = false
191
+ // Restore focus to wherever it was before the dialog opened.
192
+ ;(lastFocused ?? triggerEl)?.focus()
193
+ }
194
+
195
+ function gridColumns(): ImportGridColumn[] {
196
+ if (!api) return []
197
+ return api.getColumns().filter((c) => c.field) as ImportGridColumn[]
198
+ }
199
+
200
+ function extOf(name: string): string {
201
+ return name.toLowerCase().split('.').pop() ?? ''
202
+ }
203
+
204
+ /** Validate a picked/dropped file before we read a single byte. */
205
+ function validateFile(file: File): string | null {
206
+ const ext = extOf(file.name)
207
+ if (file.name.includes('.') && !KNOWN_EXT.includes(ext)) {
208
+ return `Unsupported file type ".${ext}". Use Excel (.xlsx), CSV, TSV, or JSON.`
209
+ }
210
+ if (file.size === 0) return 'That file is empty.'
211
+ if (file.size > maxBytes) {
212
+ return `File is ${fmtBytes(file.size)}, over the ${fmtBytes(maxBytes)} limit.`
213
+ }
214
+ return null
215
+ }
216
+
217
+ function fmtBytes(n: number): string {
218
+ if (n < 1024) return `${n} B`
219
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
220
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`
221
+ }
222
+
223
+ async function load(source: File | Blob | string, name: string) {
224
+ if (!api) return
225
+ loadController?.abort()
226
+ loadController = new AbortController()
227
+ const signal = loadController.signal
228
+ busy = true
229
+ error = null
230
+ progress = 0
231
+ try {
232
+ const read = await readImportMatrix(source, 'auto', { maxBytes, encoding, signal })
233
+ const dataCount = read.matrix.length > 0 ? read.matrix.length - 1 : 0
234
+ if (dataCount === 0) throw new Error('No data rows found in that file.')
235
+ // Enforce the row cap: reject outright, or truncate + warn.
236
+ let workMatrix = read.matrix
237
+ if (maxRows != null && dataCount > maxRows) {
238
+ if (overLimit === 'truncate') {
239
+ workMatrix = read.matrix.slice(0, maxRows + 1)
240
+ truncated = true
241
+ sourceTotal = dataCount
242
+ } else {
243
+ throw new Error(`File has ${dataCount.toLocaleString()} rows, over the ${maxRows.toLocaleString()} limit.`)
244
+ }
245
+ }
246
+ const cols = gridColumns()
247
+ const headers = (workMatrix[0] ?? []).map((h) => h.trim())
248
+ const auto = autoMapColumns(headers, cols)
249
+ const types = inferImportColumnTypes(cols)
250
+ matrix = workMatrix
251
+ sourceHeaders = headers
252
+ format = read.format
253
+ fileName = name
254
+ columnTypes = types
255
+ gridFields = cols.map((c) => ({ field: c.field!, header: c.header ?? c.field!, type: types[c.field!] }))
256
+ const seed: Record<string, string> = {}
257
+ const cfg: Record<string, NewColConfig> = {}
258
+ for (const h of headers) {
259
+ seed[h] = auto[h] ?? DEFAULT
260
+ // Default a new column: name = source header, kind = Text, visible; the
261
+ // user can rename / retype / hide / add dropdown options in the UI.
262
+ cfg[h] = { header: h || deriveField(h), kind: 'string', options: '', visible: true }
263
+ }
264
+ mapping = seed
265
+ newCols = cfg
266
+ await remap()
267
+ } catch (err) {
268
+ if ((err as { name?: string })?.name !== 'AbortError') {
269
+ matrix = null
270
+ error = err instanceof Error ? err.message : String(err)
271
+ }
272
+ } finally {
273
+ busy = false
274
+ }
275
+ }
276
+
277
+ function onPick(e: Event) {
278
+ const input = e.currentTarget as HTMLInputElement
279
+ const file = input.files?.[0]
280
+ input.value = '' // allow re-picking the same file
281
+ if (!file) return
282
+ const invalid = validateFile(file)
283
+ if (invalid) { error = invalid; return }
284
+ void load(file, file.name)
285
+ }
286
+
287
+ function onDrop(e: DragEvent) {
288
+ e.preventDefault()
289
+ dragging = false
290
+ const files = e.dataTransfer?.files
291
+ if (!files || files.length === 0) return
292
+ if (files.length > 1) { error = 'Drop one file at a time.'; return }
293
+ const file = files[0]!
294
+ const invalid = validateFile(file)
295
+ if (invalid) { error = invalid; return }
296
+ void load(file, file.name)
297
+ }
298
+
299
+ function loadPasted() {
300
+ const text = pasteText.trim()
301
+ if (!text) return
302
+ if (text.length > maxBytes) { error = `Pasted text is over the ${fmtBytes(maxBytes)} limit.`; return }
303
+ void load(text, 'pasted text')
304
+ }
305
+
306
+ // The snake_case field the engine derives for an unmapped ("new field")
307
+ // header - kept in step with the engine's own computeFields() so the keys line up.
308
+ function deriveField(h: string): string {
309
+ return h.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
310
+ }
311
+
312
+ // Translate the sentinel-bearing `mapping` into an ImportColumnMap:
313
+ // SKIP -> null (drop), DEFAULT -> omitted (engine's snake_case fallback),
314
+ // anything else -> that field.
315
+ function toColumnMap(): ImportColumnMap {
316
+ const out: ImportColumnMap = {}
317
+ for (const h of sourceHeaders) {
318
+ const v = mapping[h]
319
+ if (v === SKIP) out[h] = null as unknown as string
320
+ else if (v && v !== DEFAULT) out[h] = v
321
+ }
322
+ return out
323
+ }
324
+
325
+ // A column KIND (UI) maps to the engine's coercion type: currency/percent are
326
+ // numbers, a dropdown holds a string, the rest map through directly.
327
+ function coerceKind(kind: ColKind): ImportFieldType {
328
+ if (kind === 'currency' || kind === 'percent') return 'number'
329
+ if (kind === 'select') return 'string'
330
+ return kind
331
+ }
332
+
333
+ // Types the engine coerces with: those inferred from existing grid columns,
334
+ // plus the user's chosen kind for each header mapped to a NEW field.
335
+ function effectiveColumnTypes(): ImportColumnTypes {
336
+ const out: ImportColumnTypes = { ...columnTypes }
337
+ for (const h of sourceHeaders) {
338
+ const v = mapping[h]
339
+ if (!v || v === DEFAULT) {
340
+ const field = deriveField(h)
341
+ const kind = newCols[h]?.kind
342
+ if (field && kind) out[field] = coerceKind(kind)
343
+ }
344
+ }
345
+ return out
346
+ }
347
+
348
+ /** Re-run the (chunked, cancelable) map. Called on load + on mapping edits. */
349
+ async function remap() {
350
+ if (!matrix) return
351
+ remapController?.abort()
352
+ remapController = new AbortController()
353
+ const signal = remapController.signal
354
+ remapping = true
355
+ progress = 0
356
+ try {
357
+ const r = await mapImportMatrixAsync<TData>(matrix, {
358
+ columnMap: toColumnMap(),
359
+ columnTypes: effectiveColumnTypes(),
360
+ validator,
361
+ maxErrors,
362
+ dedupeBy,
363
+ signal,
364
+ onProgress: (p) => { if (!signal.aborted) progress = p.ratio },
365
+ })
366
+ if (!signal.aborted) built = r
367
+ } catch (err) {
368
+ if ((err as { name?: string })?.name !== 'AbortError') {
369
+ error = err instanceof Error ? err.message : String(err)
370
+ }
371
+ } finally {
372
+ if (!signal.aborted) remapping = false
373
+ }
374
+ }
375
+
376
+ // Debounced re-map whenever the mapping changes (reading mapping + matrix
377
+ // registers the dependency).
378
+ $effect(() => {
379
+ void JSON.stringify(mapping)
380
+ void JSON.stringify(newCols) // retype a new column -> re-coerce the preview
381
+ const m = matrix
382
+ if (!m) return
383
+ if (remapTimer) clearTimeout(remapTimer)
384
+ remapTimer = setTimeout(() => void remap(), 140)
385
+ })
386
+
387
+ const errorKeys = $derived(
388
+ new Set((built?.errors ?? []).map((e) => `${e.rowIndex}:${e.field}`)),
389
+ )
390
+ const errorCount = $derived(built?.errors.length ?? 0)
391
+ const rowCount = $derived(built?.rows.length ?? 0)
392
+
393
+ // Which target fields end up in the output (mapped or defaulted), in source
394
+ // order - the preview table's columns. Carries whether the field is NEW (to
395
+ // be created) + its type, so the preview header + commit both use it.
396
+ const previewCols = $derived.by(() => {
397
+ const cols: Array<{ field: string; header: string; isNew: boolean; cfg?: NewColConfig }> = []
398
+ const seen = new Set<string>()
399
+ for (const h of sourceHeaders) {
400
+ const v = mapping[h]
401
+ if (v === SKIP) continue
402
+ const isNew = !v || v === DEFAULT
403
+ const field = isNew ? deriveField(h) : v
404
+ if (!field || seen.has(field)) continue
405
+ seen.add(field)
406
+ if (isNew) {
407
+ const c = newCols[h]
408
+ cols.push({ field, header: c?.header || h, isNew: true, cfg: c })
409
+ } else {
410
+ const gf = gridFields.find((g) => g.field === field)
411
+ cols.push({ field, header: gf?.header ?? h, isNew: false })
412
+ }
413
+ }
414
+ return cols
415
+ })
416
+
417
+ // Two source headers pointing at the same grid field silently overwrite.
418
+ // Surface it so the user notices before importing.
419
+ const conflicts = $derived.by(() => {
420
+ const counts = new Map<string, number>()
421
+ for (const h of sourceHeaders) {
422
+ const v = mapping[h]
423
+ if (v && v !== DEFAULT && v !== SKIP) counts.set(v, (counts.get(v) ?? 0) + 1)
424
+ }
425
+ return [...counts.entries()].filter(([, n]) => n > 1).map(([f]) => f)
426
+ })
427
+
428
+ function cellText(v: unknown): string {
429
+ if (v == null || v === '') return ''
430
+ if (typeof v === 'boolean') return v ? 'true' : 'false'
431
+ if (typeof v === 'object') return JSON.stringify(v)
432
+ return String(v)
433
+ }
434
+
435
+ const canImport = $derived(
436
+ !!api && rowCount > 0 && !busy && !remapping && (!strict || errorCount === 0),
437
+ )
438
+
439
+ /** When a header is switched to the Dropdown kind, pre-fill its options box
440
+ * from the file's distinct values (visible + editable), so the auto-fill is
441
+ * obvious rather than silent. */
442
+ function onKindChange(h: string) {
443
+ const cfg = newCols[h]
444
+ if (cfg && cfg.kind === 'select' && !cfg.options.trim()) {
445
+ cfg.options = dropdownOptions(deriveField(h), '').join(', ')
446
+ }
447
+ }
448
+
449
+ /** Parse a comma-separated dropdown option string; empty -> derive distinct
450
+ * values from the imported column so a dropdown works out of the box. */
451
+ function dropdownOptions(field: string, raw: string): string[] {
452
+ const listed = raw.split(',').map((s) => s.trim()).filter(Boolean)
453
+ if (listed.length) return listed
454
+ const seen = new Set<string>()
455
+ for (const row of built?.rows ?? []) {
456
+ const val = (row as Record<string, unknown>)[field]
457
+ if (val == null || val === '') continue
458
+ seen.add(String(val))
459
+ if (seen.size >= 100) break
460
+ }
461
+ return [...seen]
462
+ }
463
+
464
+ /** Build a ColumnDef for a new field from its config - so the created column
465
+ * formats, edits, and shows/hides exactly as configured. */
466
+ function columnDefForConfig(field: string, cfg: NewColConfig) {
467
+ const base: Record<string, unknown> = { field, header: cfg.header, visible: cfg.visible }
468
+ switch (cfg.kind) {
469
+ case 'number':
470
+ case 'integer':
471
+ return { ...base, align: 'right', editorType: 'number', format: { type: 'number' } }
472
+ case 'currency':
473
+ return { ...base, align: 'right', editorType: 'number', format: { type: 'currency', currency: 'USD' } }
474
+ case 'percent':
475
+ return { ...base, align: 'right', editorType: 'number', format: { type: 'percent' } }
476
+ case 'boolean':
477
+ return { ...base, editorType: 'checkbox' }
478
+ case 'date':
479
+ return { ...base, editorType: 'date', format: { type: 'date' } }
480
+ case 'datetime':
481
+ return { ...base, editorType: 'date', format: { type: 'datetime' } }
482
+ case 'select':
483
+ return { ...base, editorType: 'select', editorOptions: dropdownOptions(field, cfg.options) }
484
+ default: // string / json
485
+ return { ...base, editorType: 'text' }
486
+ }
487
+ }
488
+
489
+ /** Add a grid column for every imported field the grid doesn't render yet,
490
+ * so a field mapped to "Import as new field" is actually visible - using the
491
+ * user's name / kind / format / dropdown options / visibility. */
492
+ function ensureColumns() {
493
+ if (!api || !createColumns) return
494
+ const present = new Set(gridFields.map((g) => g.field))
495
+ for (const c of previewCols) {
496
+ if (present.has(c.field)) continue
497
+ const def = c.isNew && c.cfg
498
+ ? columnDefForConfig(c.field, c.cfg)
499
+ : { field: c.field, header: c.header }
500
+ api.addColumn(def as never)
501
+ present.add(c.field)
502
+ }
503
+ }
504
+
505
+ function commit() {
506
+ if (!api || !built || !canImport) return
507
+ busy = true
508
+ error = null
509
+ try {
510
+ ensureColumns()
511
+ // Replace mode + "Replace columns": drop grid columns the file lacks, so
512
+ // the grid ends up matching the import exactly. Runs after ensureColumns
513
+ // so freshly-added columns (which ARE in the file) are never pruned.
514
+ if (mode === 'replace' && pruneColumns) {
515
+ const keep = new Set(previewCols.map((c) => c.field))
516
+ for (const col of api.getColumns()) {
517
+ if (col.field && !keep.has(col.field)) api.removeColumn(col.id)
518
+ }
519
+ }
520
+ if (built.rows.length > 0) {
521
+ if (mode === 'replace') {
522
+ // Clear the grid's current rows + add the imported ones in one update.
523
+ const existing = api.getData()
524
+ api.applyTransaction({
525
+ remove: existing.length ? [...existing] : [],
526
+ add: built.rows,
527
+ })
528
+ } else {
529
+ api.addRows(built.rows, commitAt)
530
+ }
531
+ }
532
+ onImported?.({
533
+ headers: sourceHeaders,
534
+ rows: built.rows,
535
+ errors: built.errors,
536
+ skipped: built.skipped,
537
+ total: truncated ? sourceTotal : built.total,
538
+ format: (format || 'csv') as ImportResult<TData>['format'],
539
+ errorsTruncated: built.errorsTruncated,
540
+ deduped: built.deduped,
541
+ truncated,
542
+ })
543
+ closeDialog()
544
+ } catch (err) {
545
+ error = err instanceof Error ? err.message : String(err)
546
+ busy = false
547
+ }
548
+ }
549
+
550
+ function cancel() {
551
+ loadController?.abort()
552
+ remapController?.abort()
553
+ busy = false
554
+ remapping = false
555
+ }
556
+
557
+ // --- Focus trap -----------------------------------------------------------
558
+ function focusable(): HTMLElement[] {
559
+ if (!dialogEl) return []
560
+ return Array.from(
561
+ dialogEl.querySelectorAll<HTMLElement>(
562
+ 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex="0"]',
563
+ ),
564
+ ).filter((el) => el.offsetParent !== null || el === document.activeElement)
565
+ }
566
+
567
+ function onKeydown(e: KeyboardEvent) {
568
+ if (e.key === 'Escape') {
569
+ e.preventDefault()
570
+ closeDialog()
571
+ return
572
+ }
573
+ if (e.key !== 'Tab') return
574
+ const items = focusable()
575
+ if (items.length === 0) return
576
+ const first = items[0]!
577
+ const last = items[items.length - 1]!
578
+ const active = document.activeElement as HTMLElement
579
+ if (e.shiftKey && (active === first || active === dialogEl)) {
580
+ e.preventDefault()
581
+ last.focus()
582
+ } else if (!e.shiftKey && active === last) {
583
+ e.preventDefault()
584
+ first.focus()
585
+ }
586
+ }
587
+ </script>
588
+
589
+ <button
590
+ type="button"
591
+ class="sv-import-btn"
592
+ disabled={!api}
593
+ bind:this={triggerEl}
594
+ onclick={openDialog}
595
+ >
596
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
597
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
598
+ <path d="M12 21V9M7 14l5 5 5-5M5 3h14" />
599
+ </svg>
600
+ {label}
601
+ </button>
602
+
603
+ {#if open}
604
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
605
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
606
+ <div class="sv-import-backdrop" onclick={closeDialog}></div>
607
+ <div
608
+ class="sv-import-dialog"
609
+ role="dialog"
610
+ aria-modal="true"
611
+ aria-label={title}
612
+ tabindex="-1"
613
+ bind:this={dialogEl}
614
+ onkeydown={onKeydown}
615
+ >
616
+ <header class="sv-import-head">
617
+ <h2 class="sv-import-title">{title}</h2>
618
+ <button type="button" class="sv-import-x" aria-label="Close" onclick={closeDialog}>&times;</button>
619
+ </header>
620
+
621
+ <div class="sv-import-body">
622
+ {#if !matrix}
623
+ <!-- Stage 1: pick a source. -->
624
+ <div class="sv-import-tabs">
625
+ <button type="button" class="sv-import-tab" class:on={!pasteMode} onclick={() => (pasteMode = false)}>File</button>
626
+ <button type="button" class="sv-import-tab" class:on={pasteMode} onclick={() => (pasteMode = true)}>Paste</button>
627
+ </div>
628
+
629
+ {#if pasteMode}
630
+ <textarea
631
+ class="sv-import-paste"
632
+ placeholder="Paste rows from Excel / Google Sheets, or CSV / JSON text…"
633
+ bind:value={pasteText}
634
+ ></textarea>
635
+ <div class="sv-import-paste-actions">
636
+ <button type="button" class="sv-import-primary" disabled={!pasteText.trim() || busy} onclick={loadPasted}>
637
+ {busy ? 'Reading…' : 'Load pasted data'}
638
+ </button>
639
+ </div>
640
+ {:else}
641
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
642
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
643
+ <div
644
+ class="sv-import-drop"
645
+ class:drag={dragging}
646
+ onclick={() => fileInput?.click()}
647
+ ondragover={(e) => { e.preventDefault(); dragging = true }}
648
+ ondragleave={() => (dragging = false)}
649
+ ondrop={onDrop}
650
+ >
651
+ <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor"
652
+ stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
653
+ <path d="M12 16V4M8 8l4-4 4 4M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />
654
+ </svg>
655
+ <p class="sv-import-drop-title">{busy ? 'Reading…' : 'Drop a file or click to browse'}</p>
656
+ <p class="sv-import-drop-sub">Excel .xlsx, CSV, TSV, or JSON · up to {fmtBytes(maxBytes)}</p>
657
+ </div>
658
+ <input
659
+ bind:this={fileInput}
660
+ class="sv-import-file"
661
+ type="file"
662
+ {accept}
663
+ onchange={onPick}
664
+ />
665
+ {/if}
666
+ {:else}
667
+ <!-- Stage 2: map + preview. -->
668
+ <div class="sv-import-summary">
669
+ <span class="sv-import-badge">{format.toUpperCase()}</span>
670
+ <span class="sv-import-fname" title={fileName}>{fileName}</span>
671
+ <span class="sv-import-stat">{rowCount.toLocaleString()} row{rowCount === 1 ? '' : 's'}</span>
672
+ {#if built?.skipped}<span class="sv-import-stat">· {built.skipped} blank skipped</span>{/if}
673
+ {#if built?.deduped}<span class="sv-import-stat">· {built.deduped} duplicate{built.deduped === 1 ? '' : 's'} merged</span>{/if}
674
+ <button type="button" class="sv-import-rechoose" onclick={reset}>Choose another…</button>
675
+ </div>
676
+
677
+ {#if truncated}
678
+ <p class="sv-import-conflict" role="alert">
679
+ File has {sourceTotal.toLocaleString()} rows; importing the first
680
+ {rowCount.toLocaleString()} (over the {maxRows?.toLocaleString()} limit).
681
+ </p>
682
+ {/if}
683
+
684
+ <div class="sv-import-section-label">Column mapping</div>
685
+ <div class="sv-import-map">
686
+ {#each sourceHeaders as h (h)}
687
+ <div class="sv-import-map-row">
688
+ <div class="sv-import-map-main">
689
+ <span class="sv-import-src" title={h}>{h || '(unnamed)'}</span>
690
+ <span class="sv-import-arrow" aria-hidden="true">→</span>
691
+ <select class="sv-import-select" bind:value={mapping[h]}>
692
+ <option value={DEFAULT}>Import as new field</option>
693
+ {#each gridFields as g (g.field)}
694
+ <option value={g.field}>{g.header}{g.type ? ` (${g.type})` : ''}</option>
695
+ {/each}
696
+ <option value={SKIP}>Skip this column</option>
697
+ </select>
698
+ </div>
699
+ {#if (!mapping[h] || mapping[h] === DEFAULT) && newCols[h]}
700
+ <div class="sv-import-newcol">
701
+ <input
702
+ class="sv-import-newname"
703
+ bind:value={newCols[h].header}
704
+ placeholder="Column name"
705
+ aria-label="New column name for {h}"
706
+ />
707
+ <select class="sv-import-newtype" bind:value={newCols[h].kind} onchange={() => onKindChange(h)} aria-label="Type for {h}">
708
+ <option value="string">Text</option>
709
+ <option value="number">Number</option>
710
+ <option value="integer">Integer</option>
711
+ <option value="currency">Currency</option>
712
+ <option value="percent">Percent</option>
713
+ <option value="boolean">Boolean</option>
714
+ <option value="date">Date</option>
715
+ <option value="datetime">Date &amp; time</option>
716
+ <option value="select">Dropdown</option>
717
+ <option value="json">JSON</option>
718
+ </select>
719
+ <label class="sv-import-vis" title="Show this column in the grid">
720
+ <input type="checkbox" bind:checked={newCols[h].visible} />
721
+ Visible
722
+ </label>
723
+ </div>
724
+ {#if newCols[h].kind === 'select'}
725
+ <div class="sv-import-newopts">
726
+ <input
727
+ class="sv-import-optsinput"
728
+ bind:value={newCols[h].options}
729
+ placeholder="Dropdown options, comma-separated (blank = use the file's values)"
730
+ aria-label="Dropdown options for {h}"
731
+ />
732
+ </div>
733
+ {/if}
734
+ {/if}
735
+ </div>
736
+ {/each}
737
+ </div>
738
+
739
+ {#if conflicts.length > 0}
740
+ <p class="sv-import-conflict" role="alert">
741
+ Two columns map to the same field ({conflicts.join(', ')}); the later one wins.
742
+ </p>
743
+ {/if}
744
+
745
+ {#if built && errorCount > 0}
746
+ <div class="sv-import-warn" role="alert">
747
+ <strong>{errorCount}{built.errorsTruncated ? '+' : ''}</strong>
748
+ cell{errorCount === 1 ? '' : 's'} could not be converted.
749
+ {#if strict}They must be fixed in the source before importing.{:else}They keep their raw text - fix in-grid after import, or adjust the mapping.{/if}
750
+ <ul class="sv-import-errlist">
751
+ {#each built.errors.slice(0, 4) as e (e.rowIndex + ':' + e.field + ':' + e.message)}
752
+ <li>Row {e.rowIndex + 1}, <code>{e.field}</code>: {e.message}</li>
753
+ {/each}
754
+ {#if built.errors.length > 4}<li>…and {built.errors.length - 4}{built.errorsTruncated ? '+' : ''} more</li>{/if}
755
+ </ul>
756
+ </div>
757
+ {/if}
758
+
759
+ <div class="sv-import-section-label">
760
+ Preview
761
+ {#if remapping}<span class="sv-import-working">· mapping {Math.round(progress * 100)}%</span>{/if}
762
+ </div>
763
+ <div class="sv-import-preview-wrap">
764
+ <table class="sv-import-preview">
765
+ <thead>
766
+ <tr>
767
+ <th class="sv-import-rownum">#</th>
768
+ {#each previewCols as c (c.field)}<th>{c.header}</th>{/each}
769
+ </tr>
770
+ </thead>
771
+ <tbody>
772
+ {#each (built?.rows ?? []).slice(0, previewRows) as row, i (i)}
773
+ <tr>
774
+ <td class="sv-import-rownum">{i + 1}</td>
775
+ {#each previewCols as c (c.field)}
776
+ <td class:err={errorKeys.has(`${i}:${c.field}`)}>
777
+ {cellText((row as Record<string, unknown>)[c.field])}
778
+ </td>
779
+ {/each}
780
+ </tr>
781
+ {/each}
782
+ </tbody>
783
+ </table>
784
+ {#if rowCount > previewRows}
785
+ <p class="sv-import-more">…and {(rowCount - previewRows).toLocaleString()} more rows</p>
786
+ {/if}
787
+ </div>
788
+ {/if}
789
+
790
+ {#if error}<p class="sv-import-error" role="alert">{error}</p>{/if}
791
+ </div>
792
+
793
+ <footer class="sv-import-foot">
794
+ {#if busy || remapping}
795
+ <div class="sv-import-progress">
796
+ <div class="sv-import-track"><div class="sv-import-bar" style:width={`${Math.round(progress * 100)}%`}></div></div>
797
+ </div>
798
+ <button type="button" class="sv-import-ghost" onclick={cancel}>Cancel</button>
799
+ {:else}
800
+ <button type="button" class="sv-import-ghost" onclick={closeDialog}>Cancel</button>
801
+ {#if matrix && allowReplace}
802
+ <div class="sv-import-mode" role="radiogroup" aria-label="Import mode">
803
+ <button type="button" class="sv-import-mode-opt" class:on={mode === 'append'} role="radio" aria-checked={mode === 'append'} onclick={() => (mode = 'append')}>Append</button>
804
+ <button type="button" class="sv-import-mode-opt" class:on={mode === 'replace'} role="radio" aria-checked={mode === 'replace'} onclick={() => (mode = 'replace')}>Replace all</button>
805
+ </div>
806
+ {/if}
807
+ {#if matrix && allowReplace && allowColumnPrune && mode === 'replace'}
808
+ <label class="sv-import-prune" title="Remove grid columns that aren't in the imported file">
809
+ <input type="checkbox" bind:checked={pruneColumns} />
810
+ Replace columns
811
+ </label>
812
+ {/if}
813
+ {#if matrix}
814
+ <button type="button" class="sv-import-primary" disabled={!canImport} onclick={commit}>
815
+ {mode === 'replace' ? 'Replace with' : 'Import'} {rowCount.toLocaleString()} row{rowCount === 1 ? '' : 's'}
816
+ </button>
817
+ {/if}
818
+ {/if}
819
+ </footer>
820
+ </div>
821
+ {/if}
822
+
823
+ <style>
824
+ .sv-import-btn {
825
+ display: inline-flex;
826
+ align-items: center;
827
+ gap: 6px;
828
+ padding: 6px 12px;
829
+ font: inherit;
830
+ font-size: 13px;
831
+ font-weight: 500;
832
+ border: 1px solid var(--sg-border, #d0d7de);
833
+ border-radius: 8px;
834
+ background: var(--sg-header-bg, #f6f8fa);
835
+ color: var(--sg-fg, #1f2328);
836
+ cursor: pointer;
837
+ }
838
+ .sv-import-btn:disabled { opacity: 0.5; cursor: not-allowed; }
839
+
840
+ .sv-import-backdrop {
841
+ position: fixed;
842
+ inset: 0;
843
+ z-index: 60;
844
+ background: rgba(15, 23, 42, 0.42);
845
+ }
846
+ .sv-import-dialog {
847
+ position: fixed;
848
+ z-index: 61;
849
+ top: 50%;
850
+ left: 50%;
851
+ transform: translate(-50%, -50%);
852
+ display: flex;
853
+ flex-direction: column;
854
+ width: min(680px, calc(100vw - 32px));
855
+ max-height: min(80vh, 760px);
856
+ border: 1px solid var(--sg-border, #d0d7de);
857
+ border-radius: 14px;
858
+ background: var(--sg-bg, #fff);
859
+ color: var(--sg-fg, #1f2328);
860
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
861
+ outline: none;
862
+ }
863
+ .sv-import-head {
864
+ display: flex;
865
+ align-items: center;
866
+ justify-content: space-between;
867
+ padding: 14px 16px;
868
+ border-bottom: 1px solid var(--sg-border, #eaecef);
869
+ }
870
+ .sv-import-title { margin: 0; font-size: 15px; font-weight: 600; }
871
+ .sv-import-x {
872
+ border: none; background: transparent; font-size: 22px; line-height: 1;
873
+ color: var(--sg-muted, #8b949e); cursor: pointer; padding: 0 4px;
874
+ }
875
+ .sv-import-body { padding: 14px 16px; overflow-y: auto; }
876
+
877
+ .sv-import-tabs { display: flex; gap: 6px; margin-bottom: 12px; }
878
+ .sv-import-tab {
879
+ font: inherit; font-size: 12.5px; padding: 5px 14px; border-radius: 999px;
880
+ border: 1px solid var(--sg-border, #d0d7de); background: var(--sg-header-bg, #f6f8fa);
881
+ color: var(--sg-fg, #1f2328); cursor: pointer;
882
+ }
883
+ .sv-import-tab.on { border-color: var(--site-accent, #3b82f6); background: var(--site-accent, #3b82f6); color: #fff; }
884
+
885
+ .sv-import-drop {
886
+ display: flex; flex-direction: column; align-items: center; gap: 6px;
887
+ padding: 34px 16px; text-align: center;
888
+ border: 2px dashed var(--sg-border, #d0d7de); border-radius: 12px;
889
+ color: var(--sg-muted, #8b949e); cursor: pointer;
890
+ }
891
+ .sv-import-drop.drag { border-color: var(--site-accent, #3b82f6); background: var(--sg-header-bg, #f6f8fa); }
892
+ .sv-import-drop-title { margin: 4px 0 0; font-size: 13.5px; font-weight: 500; color: var(--sg-fg, #1f2328); }
893
+ .sv-import-drop-sub { margin: 0; font-size: 12px; }
894
+ .sv-import-file { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
895
+
896
+ .sv-import-paste {
897
+ width: 100%; min-height: 150px; resize: vertical; box-sizing: border-box;
898
+ padding: 10px; font: inherit; font-size: 12.5px; border-radius: 10px;
899
+ border: 1px solid var(--sg-border, #d0d7de); background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
900
+ }
901
+ .sv-import-paste-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
902
+
903
+ .sv-import-summary {
904
+ display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
905
+ padding-bottom: 12px; margin-bottom: 4px; border-bottom: 1px solid var(--sg-border, #eaecef);
906
+ }
907
+ .sv-import-badge {
908
+ font-size: 10px; font-weight: 700; letter-spacing: 0.05em;
909
+ padding: 2px 7px; border-radius: 5px; background: var(--site-accent, #3b82f6); color: #fff;
910
+ }
911
+ .sv-import-fname { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
912
+ .sv-import-stat { font-size: 12px; color: var(--sg-muted, #8b949e); }
913
+ .sv-import-rechoose {
914
+ margin-left: auto; font: inherit; font-size: 12px; border: none; background: transparent;
915
+ color: var(--site-accent-2, #6366f1); cursor: pointer;
916
+ }
917
+
918
+ .sv-import-section-label {
919
+ margin: 12px 0 6px; font-size: 10px; font-weight: 700; text-transform: uppercase;
920
+ letter-spacing: 0.07em; color: var(--sg-muted, #8b949e);
921
+ }
922
+ .sv-import-working { font-weight: 600; color: var(--site-accent, #3b82f6); }
923
+ .sv-import-map {
924
+ display: grid; grid-template-columns: 1fr; gap: 6px;
925
+ max-height: 220px; overflow-y: auto;
926
+ }
927
+ .sv-import-map-row { display: flex; flex-direction: column; gap: 4px; }
928
+ .sv-import-map-main { display: flex; align-items: center; gap: 8px; }
929
+ .sv-import-src {
930
+ flex: 0 0 38%; font-size: 12.5px; font-weight: 500;
931
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
932
+ }
933
+ .sv-import-arrow { color: var(--sg-muted, #8b949e); flex-shrink: 0; }
934
+ .sv-import-select {
935
+ flex: 1; min-width: 0; font: inherit; font-size: 12.5px; padding: 4px 6px;
936
+ border-radius: 6px; border: 1px solid var(--sg-border, #d0d7de);
937
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
938
+ }
939
+ /* New-column config controls, indented under the source -> target row. */
940
+ .sv-import-newcol { display: flex; align-items: center; gap: 6px; padding-left: calc(38% + 20px); }
941
+ .sv-import-newopts { display: flex; padding-left: calc(38% + 20px); }
942
+ .sv-import-newname {
943
+ flex: 1; min-width: 0; font: inherit; font-size: 12px; padding: 3px 6px;
944
+ border-radius: 6px; border: 1px solid var(--sg-border, #d0d7de);
945
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
946
+ }
947
+ .sv-import-newtype {
948
+ flex: 0 0 30%; font: inherit; font-size: 12px; padding: 3px 6px;
949
+ border-radius: 6px; border: 1px solid var(--sg-border, #d0d7de);
950
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
951
+ }
952
+ .sv-import-vis {
953
+ display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px;
954
+ color: var(--sg-muted, #8b949e); cursor: pointer; white-space: nowrap; flex-shrink: 0;
955
+ }
956
+ .sv-import-optsinput {
957
+ flex: 1; min-width: 0; font: inherit; font-size: 12px; padding: 3px 6px;
958
+ border-radius: 6px; border: 1px solid var(--sg-border, #d0d7de);
959
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
960
+ }
961
+
962
+ .sv-import-conflict { margin: 10px 0 0; font-size: 12px; color: #b45309; }
963
+ .sv-import-warn {
964
+ margin-top: 12px; padding: 8px 10px; border-radius: 8px;
965
+ background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 12px;
966
+ }
967
+ .sv-import-errlist { margin: 6px 0 0; padding-left: 18px; }
968
+ .sv-import-errlist code { font-size: 11px; }
969
+
970
+ .sv-import-preview-wrap { overflow-x: auto; border: 1px solid var(--sg-border, #eaecef); border-radius: 8px; }
971
+ .sv-import-preview { border-collapse: collapse; width: 100%; font-size: 12px; }
972
+ .sv-import-preview th, .sv-import-preview td {
973
+ padding: 4px 8px; text-align: left; white-space: nowrap;
974
+ border-bottom: 1px solid var(--sg-border, #eaecef);
975
+ }
976
+ .sv-import-preview thead th {
977
+ position: sticky; top: 0; background: var(--sg-header-bg, #f6f8fa);
978
+ font-weight: 600; color: var(--sg-fg, #1f2328);
979
+ }
980
+ .sv-import-rownum { color: var(--sg-muted, #8b949e); text-align: right; font-variant-numeric: tabular-nums; }
981
+ .sv-import-preview td.err { background: #fee2e2; color: #991b1b; }
982
+ .sv-import-more { margin: 6px 2px 0; font-size: 11px; color: var(--sg-muted, #8b949e); }
983
+
984
+ .sv-import-error { margin: 10px 0 0; font-size: 12px; color: #dc2626; }
985
+
986
+ .sv-import-foot {
987
+ display: flex; align-items: center; justify-content: flex-end; gap: 8px;
988
+ padding: 12px 16px; border-top: 1px solid var(--sg-border, #eaecef);
989
+ }
990
+ .sv-import-mode {
991
+ display: inline-flex; border: 1px solid var(--sg-border, #d0d7de);
992
+ border-radius: 8px; overflow: hidden;
993
+ }
994
+ .sv-import-mode-opt {
995
+ font: inherit; font-size: 12.5px; padding: 6px 12px; border: none; cursor: pointer;
996
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328);
997
+ }
998
+ .sv-import-mode-opt + .sv-import-mode-opt { border-left: 1px solid var(--sg-border, #d0d7de); }
999
+ .sv-import-mode-opt.on { background: var(--site-accent, #3b82f6); color: #fff; }
1000
+ .sv-import-prune {
1001
+ display: inline-flex; align-items: center; gap: 6px; font-size: 12px;
1002
+ color: var(--sg-fg, #1f2328); cursor: pointer; white-space: nowrap;
1003
+ }
1004
+ .sv-import-progress { flex: 1; min-width: 0; }
1005
+ .sv-import-track {
1006
+ height: 6px; border-radius: 999px; background: var(--sg-border, #eaecef); overflow: hidden;
1007
+ }
1008
+ .sv-import-bar {
1009
+ height: 100%; border-radius: 999px; background: var(--site-accent, #3b82f6); transition: width 120ms ease;
1010
+ }
1011
+ .sv-import-primary {
1012
+ font: inherit; font-size: 13px; font-weight: 500; padding: 7px 16px; border-radius: 8px;
1013
+ border: 1px solid var(--site-accent, #3b82f6); background: var(--site-accent, #3b82f6); color: #fff; cursor: pointer;
1014
+ }
1015
+ .sv-import-primary:disabled { opacity: 0.5; cursor: not-allowed; }
1016
+ .sv-import-ghost {
1017
+ font: inherit; font-size: 13px; padding: 7px 14px; border-radius: 8px;
1018
+ border: 1px solid var(--sg-border, #d0d7de); background: var(--sg-bg, #fff); color: var(--sg-fg, #1f2328); cursor: pointer;
1019
+ }
1020
+ </style>