@quazardous/qdadm 2.32.0 → 2.33.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 (36) hide show
  1. package/dist/styles/qdadm.css +1 -1
  2. package/package.json +8 -2
  3. package/src/components/display/EmailPreview.vue +227 -0
  4. package/src/components/display/EntityTimeline.vue +408 -0
  5. package/src/components/display/LogStream.vue +364 -0
  6. package/src/components/display/PayloadDiff.vue +236 -0
  7. package/src/components/display/PayloadViewer.vue +246 -0
  8. package/src/components/index.ts +8 -0
  9. package/src/components/layout/AppLayout.vue +8 -0
  10. package/src/components/tasks/TaskDialog.vue +95 -0
  11. package/src/components/tasks/TaskHost.vue +45 -0
  12. package/src/components/tasks/TaskTray.vue +54 -0
  13. package/src/composables/index.ts +11 -0
  14. package/src/composables/useListPage.filters.ts +26 -1
  15. package/src/composables/useLogStream.ts +264 -0
  16. package/src/email/index.ts +46 -0
  17. package/src/entity/storage/SdkStorage.ts +53 -1
  18. package/src/entity/storage/index.ts +2 -0
  19. package/src/i18n/defaults/core.en.generated.ts +85 -0
  20. package/src/i18n/defaults/core.en.yml +85 -0
  21. package/src/i18n/defaults/core.fr.generated.ts +85 -0
  22. package/src/i18n/defaults/core.fr.yml +85 -0
  23. package/src/index.ts +21 -0
  24. package/src/kernel/Kernel.vue.ts +9 -2
  25. package/src/styles/_email.scss +188 -0
  26. package/src/styles/_log-stream.scss +167 -0
  27. package/src/styles/_payload.scss +312 -0
  28. package/src/styles/_tasks.scss +212 -0
  29. package/src/styles/_timeline.scss +229 -0
  30. package/src/styles/index.scss +5 -0
  31. package/src/tasks/taskStore.ts +460 -0
  32. package/src/tasks/useTaskRunner.ts +52 -0
  33. package/src/utils/ansi.ts +127 -0
  34. package/src/utils/email.ts +270 -0
  35. package/src/utils/index.ts +32 -0
  36. package/src/utils/payload.ts +480 -0
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Payload helpers for PayloadViewer / PayloadDiff (#4112).
3
+ *
4
+ * What an integration sends its providers is rarely JSON: XML to suppliers
5
+ * and SOAP marketplaces, CSV feeds, form-encoded payment calls, raw text,
6
+ * files. These read each format without a dependency, and never through the
7
+ * DOM: XML is tokenized by hand, so nothing in a captured message is ever
8
+ * interpreted as HTML.
9
+ */
10
+
11
+ export type PayloadFormat = 'json' | 'xml' | 'csv' | 'form' | 'text' | 'binary'
12
+
13
+ export type PayloadBody = string | Record<string, unknown> | unknown[] | ArrayBuffer | Uint8Array | null | undefined
14
+
15
+ /** Decide what a value hides: return the replacement, or undefined to keep it. */
16
+ export type PayloadMask = (key: string, value: string) => string | undefined
17
+
18
+ // --- Format detection ---
19
+
20
+ // Not instanceof: bytes from another realm (an iframe, a worker, Node's
21
+ // TextEncoder under jsdom) are not instances of this realm's classes.
22
+ const isArrayBuffer = (v: unknown): v is ArrayBuffer => Object.prototype.toString.call(v) === '[object ArrayBuffer]'
23
+ const isByteView = (v: unknown): v is Uint8Array => ArrayBuffer.isView(v) && !(Object.prototype.toString.call(v) === '[object DataView]')
24
+
25
+ export function isBinary(body: PayloadBody): body is ArrayBuffer | Uint8Array {
26
+ return isArrayBuffer(body) || isByteView(body)
27
+ }
28
+
29
+ /** Bytes of a binary body, or of a string (UTF-8). */
30
+ export function toBytes(body: PayloadBody): Uint8Array {
31
+ if (isByteView(body)) return new Uint8Array(body.buffer, body.byteOffset, body.byteLength)
32
+ if (isArrayBuffer(body)) return new Uint8Array(body)
33
+ return new TextEncoder().encode(typeof body === 'string' ? body : JSON.stringify(body ?? null))
34
+ }
35
+
36
+ export function formatFromContentType(contentType: string | null | undefined): PayloadFormat | null {
37
+ if (!contentType) return null
38
+ const ct = contentType.toLowerCase()
39
+ if (ct.includes('json')) return 'json'
40
+ if (ct.includes('xml')) return 'xml'
41
+ if (ct.includes('csv') || ct.includes('tab-separated-values')) return 'csv'
42
+ if (ct.includes('x-www-form-urlencoded')) return 'form'
43
+ if (ct.startsWith('text/')) return 'text'
44
+ if (ct.startsWith('application/octet-stream') || ct.startsWith('image/') || ct.includes('pdf') || ct.includes('zip')) {
45
+ return 'binary'
46
+ }
47
+ return null
48
+ }
49
+
50
+ const CSV_SEPARATORS = [',', ';', '\t', '|']
51
+
52
+ /** The separator that splits the first lines into the same number of columns, if any. */
53
+ export function detectCsvSeparator(text: string): string | null {
54
+ const lines = text.split(/\r?\n/).filter((l) => l.trim() !== '').slice(0, 6)
55
+ if (lines.length < 2) return null
56
+ let best: string | null = null
57
+ let bestCols = 1
58
+ for (const sep of CSV_SEPARATORS) {
59
+ const counts = lines.map((l) => parseCsvLine(l, sep).length)
60
+ if (counts.every((c) => c === counts[0]) && counts[0]! > bestCols) {
61
+ best = sep
62
+ bestCols = counts[0]!
63
+ }
64
+ }
65
+ return best
66
+ }
67
+
68
+ /** The format of a body: the explicit one, else the content type, else what it looks like. */
69
+ export function detectFormat(body: PayloadBody, contentType?: string | null, format?: PayloadFormat | null): PayloadFormat {
70
+ if (format) return format
71
+ if (isBinary(body)) return formatFromContentType(contentType) === 'binary' || !looksLikeText(body) ? 'binary' : sniff(new TextDecoder().decode(body))
72
+ if (body !== null && typeof body === 'object') return 'json'
73
+ const fromType = formatFromContentType(contentType)
74
+ if (fromType) return fromType
75
+ return sniff(String(body ?? ''))
76
+ }
77
+
78
+ function looksLikeText(bytes: ArrayBuffer | Uint8Array): boolean {
79
+ const view = toBytes(bytes)
80
+ const sample = view.subarray(0, 512)
81
+ for (const b of sample) if (b === 0) return false
82
+ return true
83
+ }
84
+
85
+ function sniff(text: string): PayloadFormat {
86
+ const t = text.trim()
87
+ if (t === '') return 'text'
88
+ if (text.includes('\u0000')) return 'binary'
89
+ if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
90
+ try {
91
+ JSON.parse(t)
92
+ return 'json'
93
+ } catch {
94
+ // brackets, but not JSON
95
+ }
96
+ }
97
+ if (t.startsWith('<') && t.endsWith('>')) return 'xml'
98
+ if (/^[\w.%+\-[\]]+=[^\s&]*(&[\w.%+\-[\]]+=[^\s&]*)*$/.test(t)) return 'form'
99
+ if (detectCsvSeparator(t)) return 'csv'
100
+ return 'text'
101
+ }
102
+
103
+ /** The body as text: decoded bytes, pretty JSON for an object. */
104
+ export function bodyText(body: PayloadBody): string {
105
+ if (body === null || body === undefined) return ''
106
+ if (isBinary(body)) return new TextDecoder().decode(body)
107
+ if (typeof body === 'object') return JSON.stringify(body, null, 2)
108
+ return String(body)
109
+ }
110
+
111
+ export const FORMAT_EXTENSIONS: Record<PayloadFormat, string> = {
112
+ json: 'json',
113
+ xml: 'xml',
114
+ csv: 'csv',
115
+ form: 'txt',
116
+ text: 'txt',
117
+ binary: 'bin',
118
+ }
119
+
120
+ // --- Masking ---
121
+
122
+ const DEFAULT_MASKED = ['password', 'passwd', 'secret', 'token', 'access_token', 'refresh_token', 'api_key', 'apikey', 'authorization', 'cookie', 'set-cookie', 'card_number', 'cvv', 'cvc']
123
+
124
+ /**
125
+ * A mask that hides the values of the given keys, compared without case.
126
+ * `maskKeys()` hides the usual suspects (password, token, authorization…).
127
+ */
128
+ export function maskKeys(keys: string[] = DEFAULT_MASKED, replacement = '••••••'): PayloadMask {
129
+ const set = new Set(keys.map((k) => k.toLowerCase()))
130
+ return (key) => (set.has(key.toLowerCase()) ? replacement : undefined)
131
+ }
132
+
133
+ function maskValue(mask: PayloadMask | null | undefined, key: string, value: string): string {
134
+ if (!mask) return value
135
+ return mask(key, value) ?? value
136
+ }
137
+
138
+ /** Apply a mask to every key of a JSON value, at any depth. */
139
+ export function maskJson(value: unknown, mask: PayloadMask | null | undefined): unknown {
140
+ if (!mask) return value
141
+ if (Array.isArray(value)) return value.map((v) => maskJson(v, mask))
142
+ if (value !== null && typeof value === 'object') {
143
+ const out: Record<string, unknown> = {}
144
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
145
+ if (v !== null && typeof v === 'object') out[k] = maskJson(v, mask)
146
+ else {
147
+ const masked = mask(k, v === undefined ? '' : String(v))
148
+ out[k] = masked === undefined ? v : masked
149
+ }
150
+ }
151
+ return out
152
+ }
153
+ return value
154
+ }
155
+
156
+ // --- JSON ---
157
+
158
+ export function parseJson(body: PayloadBody): { ok: true; value: unknown } | { ok: false; error: string } {
159
+ if (body !== null && typeof body === 'object' && !isBinary(body)) return { ok: true, value: body }
160
+ try {
161
+ return { ok: true, value: JSON.parse(bodyText(body)) }
162
+ } catch (err) {
163
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
164
+ }
165
+ }
166
+
167
+ /** JSON with keys sorted at every depth, so two payloads differing only by key order compare equal. */
168
+ export function stableJson(value: unknown): string {
169
+ const sort = (v: unknown): unknown => {
170
+ if (Array.isArray(v)) return v.map(sort)
171
+ if (v !== null && typeof v === 'object') {
172
+ return Object.fromEntries(Object.keys(v as object).sort().map((k) => [k, sort((v as Record<string, unknown>)[k])]))
173
+ }
174
+ return v
175
+ }
176
+ return JSON.stringify(sort(value), null, 2)
177
+ }
178
+
179
+ // --- XML ---
180
+
181
+ export interface XmlToken {
182
+ kind: 'open' | 'close' | 'self' | 'text' | 'comment' | 'cdata' | 'decl' | 'doctype'
183
+ raw: string
184
+ name?: string
185
+ attrs?: Array<{ name: string; value: string; quote: string }>
186
+ }
187
+
188
+ const XML_TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<!DOCTYPE[^>[]*(?:\[[\s\S]*?\])?\s*>|<\/?[^\s/>!?]+(?:\s+[^\s=/>]+(?:\s*=\s*(?:"[^"]*"|'[^']*'))?)*\s*\/?>|[^<]+/gi
189
+ const XML_ATTR = /([^\s=/>]+)(?:\s*=\s*("[^"]*"|'[^']*'))?/g
190
+
191
+ export function tokenizeXml(text: string): XmlToken[] | null {
192
+ const tokens: XmlToken[] = []
193
+ let consumed = 0
194
+ XML_TOKEN.lastIndex = 0
195
+ for (let m = XML_TOKEN.exec(text); m; m = XML_TOKEN.exec(text)) {
196
+ if (m.index !== consumed) return null
197
+ consumed = m.index + m[0].length
198
+ const raw = m[0]
199
+ if (raw.startsWith('<!--')) tokens.push({ kind: 'comment', raw })
200
+ else if (raw.startsWith('<![CDATA[')) tokens.push({ kind: 'cdata', raw })
201
+ else if (raw.startsWith('<?')) tokens.push({ kind: 'decl', raw })
202
+ else if (/^<!DOCTYPE/i.test(raw)) tokens.push({ kind: 'doctype', raw })
203
+ else if (raw.startsWith('</')) tokens.push({ kind: 'close', raw, name: raw.slice(2, -1).trim() })
204
+ else if (raw.startsWith('<')) {
205
+ const self = raw.endsWith('/>')
206
+ const inner = raw.slice(1, self ? -2 : -1)
207
+ const name = inner.match(/^[^\s/>]+/)![0]
208
+ const attrs: XmlToken['attrs'] = []
209
+ XML_ATTR.lastIndex = 0
210
+ const rest = inner.slice(name.length)
211
+ for (let a = XML_ATTR.exec(rest); a; a = XML_ATTR.exec(rest)) {
212
+ const quoted = a[2] ?? '""'
213
+ attrs.push({ name: a[1]!, value: quoted.slice(1, -1), quote: quoted[0]! })
214
+ }
215
+ tokens.push({ kind: self ? 'self' : 'open', raw, name, attrs })
216
+ } else tokens.push({ kind: 'text', raw })
217
+ }
218
+ if (consumed !== text.length) return null
219
+ return tokens
220
+ }
221
+
222
+ /** A highlighted XML line: pieces with a class, never HTML. */
223
+ export type XmlPiece = { text: string; cls?: string }
224
+
225
+ export interface PrettyXml {
226
+ ok: boolean
227
+ /** Why it could not be read as XML, when `ok` is false. */
228
+ error?: string
229
+ lines: XmlPiece[][]
230
+ }
231
+
232
+ function tagPieces(token: XmlToken, mask: PayloadMask | null | undefined): XmlPiece[] {
233
+ const close = token.kind === 'close'
234
+ const pieces: XmlPiece[] = [{ text: close ? '</' : '<', cls: 'pl-punct' }, { text: token.name!, cls: 'pl-tag' }]
235
+ for (const attr of token.attrs ?? []) {
236
+ pieces.push({ text: ' ' }, { text: attr.name, cls: 'pl-attr' }, { text: '=', cls: 'pl-punct' })
237
+ pieces.push({ text: `${attr.quote}${maskValue(mask, attr.name, attr.value)}${attr.quote}`, cls: 'pl-value' })
238
+ }
239
+ pieces.push({ text: token.kind === 'self' ? '/>' : '>', cls: 'pl-punct' })
240
+ return pieces
241
+ }
242
+
243
+ /**
244
+ * Indent XML, one element per line, an element holding only text on one line.
245
+ * Unbalanced or unreadable XML comes back `ok: false` with its raw lines.
246
+ */
247
+ export function prettyXml(text: string, mask?: PayloadMask | null): PrettyXml {
248
+ const raw = (): XmlPiece[][] => text.split(/\r?\n/).map((l) => [{ text: l }])
249
+ const tokens = tokenizeXml(text.trim())
250
+ if (!tokens) return { ok: false, error: 'not well-formed', lines: raw() }
251
+
252
+ const lines: XmlPiece[][] = []
253
+ const stack: string[] = []
254
+ const indent = () => [{ text: ' '.repeat(stack.length) }]
255
+
256
+ for (let i = 0; i < tokens.length; i++) {
257
+ const tok = tokens[i]!
258
+ if (tok.kind === 'text') {
259
+ const value = tok.raw.trim()
260
+ if (value) lines.push([...indent(), { text: maskValue(mask, stack.at(-1) ?? '', value), cls: 'pl-text' }])
261
+ continue
262
+ }
263
+ if (tok.kind === 'open') {
264
+ const next = tokens[i + 1]
265
+ const after = tokens[i + 2]
266
+ // <name>text</name> stays on one line.
267
+ if (next?.kind === 'text' && after?.kind === 'close' && after.name === tok.name) {
268
+ lines.push([...indent(), ...tagPieces(tok, mask), { text: maskValue(mask, tok.name!, next.raw.trim()), cls: 'pl-text' }, ...tagPieces(after, mask)])
269
+ i += 2
270
+ continue
271
+ }
272
+ lines.push([...indent(), ...tagPieces(tok, mask)])
273
+ stack.push(tok.name!)
274
+ continue
275
+ }
276
+ if (tok.kind === 'close') {
277
+ if (stack.at(-1) !== tok.name) {
278
+ return { ok: false, error: `</${tok.name}> does not close <${stack.at(-1) ?? '—'}>`, lines: raw() }
279
+ }
280
+ stack.pop()
281
+ lines.push([...indent(), ...tagPieces(tok, mask)])
282
+ continue
283
+ }
284
+ if (tok.kind === 'self') {
285
+ lines.push([...indent(), ...tagPieces(tok, mask)])
286
+ continue
287
+ }
288
+ const cls = tok.kind === 'comment' ? 'pl-comment' : tok.kind === 'cdata' ? 'pl-cdata' : 'pl-decl'
289
+ for (const part of tok.raw.split(/\r?\n/)) lines.push([...indent(), { text: part, cls }])
290
+ }
291
+ if (stack.length) return { ok: false, error: `<${stack.at(-1)}> is never closed`, lines: raw() }
292
+ return { ok: true, lines }
293
+ }
294
+
295
+ export const piecesText = (line: XmlPiece[]) => line.map((p) => p.text).join('')
296
+
297
+ // --- CSV ---
298
+
299
+ /** One CSV line, with quoted fields ("a, b" and doubled quotes). */
300
+ export function parseCsvLine(line: string, sep: string): string[] {
301
+ const out: string[] = []
302
+ let field = ''
303
+ let quoted = false
304
+ for (let i = 0; i < line.length; i++) {
305
+ const c = line[i]!
306
+ if (quoted) {
307
+ if (c === '"' && line[i + 1] === '"') {
308
+ field += '"'
309
+ i++
310
+ } else if (c === '"') quoted = false
311
+ else field += c
312
+ } else if (c === '"' && field === '') quoted = true
313
+ else if (c === sep) {
314
+ out.push(field)
315
+ field = ''
316
+ } else field += c
317
+ }
318
+ out.push(field)
319
+ return out
320
+ }
321
+
322
+ /** Rows of a CSV text; a quoted field may span lines. */
323
+ export function parseCsv(text: string, sep?: string | null): { separator: string; rows: string[][] } {
324
+ const separator = sep ?? detectCsvSeparator(text) ?? ','
325
+ const rows: string[][] = []
326
+ let pending = ''
327
+ for (const line of text.replace(/\r\n?/g, '\n').split('\n')) {
328
+ pending = pending ? `${pending}\n${line}` : line
329
+ // An odd count of quotes: the field goes on to the next line.
330
+ if ((pending.match(/"/g)?.length ?? 0) % 2 === 1) continue
331
+ if (pending.trim() !== '') rows.push(parseCsvLine(pending, separator))
332
+ pending = ''
333
+ }
334
+ if (pending.trim() !== '') rows.push(parseCsvLine(pending, separator))
335
+ return { separator, rows }
336
+ }
337
+
338
+ /** Mask the columns whose header the mask names. */
339
+ export function maskCsv(rows: string[][], mask: PayloadMask | null | undefined, hasHeader: boolean): string[][] {
340
+ if (!mask || !hasHeader || rows.length === 0) return rows
341
+ const header = rows[0]!
342
+ return [header, ...rows.slice(1).map((row) => row.map((cell, i) => maskValue(mask, header[i] ?? '', cell)))]
343
+ }
344
+
345
+ // --- Form-encoded ---
346
+
347
+ export function parseForm(text: string, mask?: PayloadMask | null): Array<[string, string]> {
348
+ const params = new URLSearchParams(text.trim().replace(/^\?/, ''))
349
+ return [...params.entries()].map(([k, v]) => [k, maskValue(mask, k, v)])
350
+ }
351
+
352
+ // --- Headers ---
353
+
354
+ export function maskHeaders(headers: Record<string, unknown> | null | undefined, mask: PayloadMask | null | undefined): Array<[string, string]> {
355
+ return Object.entries(headers ?? {}).map(([k, v]) => [k, maskValue(mask, k, Array.isArray(v) ? v.join(', ') : String(v ?? ''))])
356
+ }
357
+
358
+ // --- Hex ---
359
+
360
+ export function hexDump(bytes: Uint8Array, limit = 256): string[] {
361
+ const out: string[] = []
362
+ const view = bytes.subarray(0, limit)
363
+ for (let off = 0; off < view.length; off += 16) {
364
+ const chunk = view.subarray(off, off + 16)
365
+ const hex = [...chunk].map((b) => b.toString(16).padStart(2, '0')).join(' ')
366
+ const ascii = [...chunk].map((b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : '.')).join('')
367
+ out.push(`${off.toString(16).padStart(8, '0')} ${hex.padEnd(47)} ${ascii}`)
368
+ }
369
+ return out
370
+ }
371
+
372
+ // --- Normalising for a diff ---
373
+
374
+ /**
375
+ * The body as lines worth comparing: pretty JSON with sorted keys, indented
376
+ * XML, sorted form fields. Formatting noise disappears, so a line diff of the
377
+ * result reads as a diff of the content.
378
+ */
379
+ export function normalizeLines(body: PayloadBody, format: PayloadFormat, mask?: PayloadMask | null): string[] {
380
+ if (format === 'json') {
381
+ const parsed = parseJson(body)
382
+ if (parsed.ok) return stableJson(maskJson(parsed.value, mask)).split('\n')
383
+ }
384
+ if (format === 'xml') {
385
+ const pretty = prettyXml(bodyText(body), mask)
386
+ if (pretty.ok) return pretty.lines.map(piecesText)
387
+ }
388
+ if (format === 'form') {
389
+ return parseForm(bodyText(body), mask).map(([k, v]) => `${k}=${v}`).sort()
390
+ }
391
+ if (format === 'binary') return hexDump(toBytes(body), Infinity)
392
+ return bodyText(body).replace(/\r\n?/g, '\n').split('\n')
393
+ }
394
+
395
+ // --- Line diff (Myers) ---
396
+
397
+ export type DiffOp = { type: 'equal' | 'delete' | 'insert'; left?: number; right?: number; text: string }
398
+
399
+ /** Myers' O((N+M)·D) diff of two line lists. */
400
+ export function diffLines(a: string[], b: string[]): DiffOp[] {
401
+ const n = a.length
402
+ const m = b.length
403
+ const max = n + m
404
+ const offset = max + 1
405
+ const v = new Int32Array(2 * max + 3)
406
+ const trace: Int32Array[] = []
407
+ outer: for (let d = 0; d <= max; d++) {
408
+ trace.push(v.slice())
409
+ for (let k = -d; k <= d; k += 2) {
410
+ let x = k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!) ? v[offset + k + 1]! : v[offset + k - 1]! + 1
411
+ let y = x - k
412
+ while (x < n && y < m && a[x] === b[y]) {
413
+ x++
414
+ y++
415
+ }
416
+ v[offset + k] = x
417
+ if (x >= n && y >= m) break outer
418
+ }
419
+ }
420
+ // Walk the trace back from the end.
421
+ const ops: DiffOp[] = []
422
+ let x = n
423
+ let y = m
424
+ for (let d = trace.length - 1; d >= 0; d--) {
425
+ const vd = trace[d]!
426
+ const k = x - y
427
+ const prevK = k === -d || (k !== d && vd[offset + k - 1]! < vd[offset + k + 1]!) ? k + 1 : k - 1
428
+ const prevX = vd[offset + prevK]!
429
+ const prevY = prevX - prevK
430
+ while (x > prevX && y > prevY) {
431
+ x--
432
+ y--
433
+ ops.push({ type: 'equal', left: x, right: y, text: a[x]! })
434
+ }
435
+ if (d > 0) {
436
+ if (x === prevX) {
437
+ y--
438
+ ops.push({ type: 'insert', right: y, text: b[y]! })
439
+ } else {
440
+ x--
441
+ ops.push({ type: 'delete', left: x, text: a[x]! })
442
+ }
443
+ }
444
+ }
445
+ return ops.reverse()
446
+ }
447
+
448
+ // --- Keyed CSV diff ---
449
+
450
+ export interface CsvRowDiff {
451
+ status: 'same' | 'changed' | 'added' | 'removed'
452
+ key: string
453
+ left?: string[]
454
+ right?: string[]
455
+ /** Indexes (in `header`) of the cells that differ. */
456
+ changed: number[]
457
+ }
458
+
459
+ /** Rows matched on a key column; cells compared by header name, so a reordered column is not a change. */
460
+ export function diffCsv(left: string[][], right: string[][], key: string): { header: string[]; rows: CsvRowDiff[] } | null {
461
+ const lh = left[0] ?? []
462
+ const rh = right[0] ?? []
463
+ if (!lh.includes(key) || !rh.includes(key)) return null
464
+ const header = [...lh, ...rh.filter((h) => !lh.includes(h))]
465
+ const align = (h: string[], row: string[]) => header.map((name) => (h.includes(name) ? row[h.indexOf(name)] ?? '' : ''))
466
+ const index = (h: string[], rows: string[][]) => new Map(rows.slice(1).map((r) => [r[h.indexOf(key)] ?? '', align(h, r)]))
467
+ const L = index(lh, left)
468
+ const R = index(rh, right)
469
+ const rows: CsvRowDiff[] = []
470
+ for (const [k, l] of L) {
471
+ const r = R.get(k)
472
+ if (!r) rows.push({ status: 'removed', key: k, left: l, changed: [] })
473
+ else {
474
+ const changed = header.map((_, i) => i).filter((i) => l[i] !== r[i])
475
+ rows.push({ status: changed.length ? 'changed' : 'same', key: k, left: l, right: r, changed })
476
+ }
477
+ }
478
+ for (const [k, r] of R) if (!L.has(k)) rows.push({ status: 'added', key: k, right: r, changed: [] })
479
+ return { header, rows }
480
+ }