@quazardous/qdadm 2.32.0 → 2.33.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/dist/styles/qdadm.css +1 -1
- package/package.json +9 -2
- package/src/components/display/EmailPreview.vue +227 -0
- package/src/components/display/EntityTimeline.vue +408 -0
- package/src/components/display/LogStream.vue +364 -0
- package/src/components/display/PayloadDiff.vue +236 -0
- package/src/components/display/PayloadViewer.vue +246 -0
- package/src/components/index.ts +8 -0
- package/src/components/layout/AppLayout.vue +8 -0
- package/src/components/tasks/TaskDialog.vue +95 -0
- package/src/components/tasks/TaskHost.vue +45 -0
- package/src/components/tasks/TaskTray.vue +54 -0
- package/src/composables/index.ts +11 -0
- package/src/composables/useListPage.filters.ts +26 -1
- package/src/composables/useLogStream.ts +264 -0
- package/src/email/index.ts +46 -0
- package/src/entity/storage/SdkStorage.ts +53 -1
- package/src/entity/storage/index.ts +2 -0
- package/src/i18n/defaults/core.en.generated.ts +85 -0
- package/src/i18n/defaults/core.en.yml +85 -0
- package/src/i18n/defaults/core.fr.generated.ts +85 -0
- package/src/i18n/defaults/core.fr.yml +85 -0
- package/src/index.ts +21 -0
- package/src/kernel/Kernel.vue.ts +9 -2
- package/src/kernel/Module.ts +3 -2
- package/src/modules/debug/DebugModule.ts +3 -1
- package/src/modules/debug/styles.generated.css +2895 -0
- package/src/notifications/NotificationModule.ts +1 -1
- package/src/notifications/{styles.scss → styles.css} +5 -5
- package/src/styles/_email.scss +188 -0
- package/src/styles/_log-stream.scss +167 -0
- package/src/styles/_payload.scss +312 -0
- package/src/styles/_tasks.scss +212 -0
- package/src/styles/_timeline.scss +229 -0
- package/src/styles/index.scss +5 -0
- package/src/tasks/taskStore.ts +460 -0
- package/src/tasks/useTaskRunner.ts +52 -0
- package/src/utils/ansi.ts +127 -0
- package/src/utils/email.ts +270 -0
- package/src/utils/index.ts +32 -0
- 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
|
+
}
|