From 23d7527e4f8eceb9bce52c4d03fa7e40c1dc9a9b Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Sat, 24 Oct 2026 03:33:38 +0200 Subject: [PATCH] Decode escaped text in admin JSON views --- index.html | 44 ++++++++++++++++++++++--- scripts/check-frontend.sh | 3 ++ tests/admin-conversation-trace.test.cjs | 15 +++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index 33e12a2..909a99a 100644 --- a/index.html +++ b/index.html @@ -55,8 +55,10 @@ .json-value-null { color: #be123c; font-style: italic; } .json-empty { padding: .25rem .65rem; color: #475569; font-style: italic; } .json-long-value { border-inline-start: 2px solid #94a3b8; margin-inline-start: .5rem; } - .json-long-value > summary { cursor: pointer; padding: .3rem .45rem; overflow-wrap: anywhere; } + .json-long-value > summary, .json-raw-value > summary { cursor: pointer; padding: .3rem .45rem; overflow-wrap: anywhere; } .json-long-text { margin: .2rem .5rem .5rem; padding: .5rem; max-height: 24rem; overflow: auto; background: #e2e8f0; white-space: pre-wrap; overflow-wrap: anywhere; } + .json-raw-value { margin-inline-start: .5rem; color: #475569; font-size: .9em; } + .json-raw-value > .json-long-text { border-inline-start: 2px solid #94a3b8; } @media (max-width: 42rem) { .json-entry { grid-template-columns: 1fr; gap: .05rem; } } @media (prefers-color-scheme: dark) { .json-view { border-color: #475569; background: #111827; color: #e5e7eb; } @@ -69,6 +71,7 @@ .json-value-number { color: #c4b5fd; } .json-value-boolean { color: #fcd34d; } .json-value-null { color: #fda4af; } + .json-raw-value { color: #cbd5e1; } } #admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; } #player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; } @@ -656,6 +659,24 @@ parent.append(element); return element; } + function decodeEscapedSequences(text) { + const source = String(text).replace(/\\(?:\r\n|\n|\r)[ \t]*/g, ''); + const escapes = { + '0': '\0', a: '\x07', b: '\b', t: '\t', n: '\n', v: '\x0b', + f: '\f', r: '\r', e: '\x1b', ' ': ' ', '"': '"', "'": "'", + '/': '/', '\\': '\\', N: '\u0085', _: '\u00a0', L: '\u2028', P: '\u2029', + }; + return source.replace(/\\(U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (sequence, escape) => { + if (Object.hasOwn(escapes, escape)) return escapes[escape]; + if (/^x[0-9a-fA-F]{2}$/.test(escape)) return String.fromCharCode(Number.parseInt(escape.slice(1), 16)); + if (/^u[0-9a-fA-F]{4}$/.test(escape)) return String.fromCharCode(Number.parseInt(escape.slice(1), 16)); + if (/^U[0-9a-fA-F]{8}$/.test(escape)) { + const point = Number.parseInt(escape.slice(1), 16); + if (point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)) return String.fromCodePoint(point); + } + return sequence; + }); + } function renderJsonTree(container, value) { container.className = 'json-view'; container.setAttribute('aria-label', 'Utfällbart JSON-svar'); @@ -677,7 +698,19 @@ const groupSummary = item => Array.isArray(item) ? `Lista · ${item.length} ${item.length === 1 ? 'post' : 'poster'}` : `Objekt · ${Object.keys(item).length} fält`; + const addRawText = (parent, value) => { + const raw = document.createElement('details'); + raw.className = 'json-raw-value'; + const rawSummary = document.createElement('summary'); + rawSummary.textContent = 'Visa lagrad text med escape-tecken'; + const rawText = document.createElement('div'); + rawText.className = 'json-long-text'; + rawText.textContent = value; + raw.append(rawSummary, rawText); + parent.append(raw); + }; const addLeaf = (parent, label, item) => { + const readable = typeof item === 'string' ? decodeEscapedSequences(item) : item; if (typeof item === 'string' && item.length > 180) { const details = document.createElement('details'); details.className = 'json-long-value'; @@ -685,13 +718,15 @@ summary.append(document.createTextNode(`${label ? `${label} · ` : ''}Text · ${item.length} tecken · `)); const preview = document.createElement('span'); preview.className = 'json-value-string'; - preview.textContent = `${item.slice(0, 100)}${item.length > 100 ? '…' : ''}`; + const previewText = readable.slice(0, 100); + preview.textContent = `${previewText}${readable.length > 100 ? '…' : ''}`; summary.append(preview); const fullText = document.createElement('div'); fullText.className = 'json-long-text'; - fullText.textContent = item; + fullText.textContent = readable; details.append(summary, fullText); parent.append(details); + if (readable !== item) addRawText(parent, item); return; } const row = document.createElement('div'); @@ -705,9 +740,10 @@ const valueNode = document.createElement('span'); const type = item === null ? 'null' : typeof item; valueNode.className = `json-value-${type === 'boolean' ? 'boolean' : type}`; - valueNode.textContent = typeof item === 'string' ? JSON.stringify(item) : String(item); + valueNode.textContent = typeof readable === 'string' ? readable : String(readable); row.append(valueNode); parent.append(row); + if (typeof item === 'string' && readable !== item) addRawText(parent, item); }; const populate = (details, item, depth) => { if (details.dataset.loaded === 'true') return; diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh index 0da6a6b..e3b9e5f 100755 --- a/scripts/check-frontend.sh +++ b/scripts/check-frontend.sh @@ -100,6 +100,9 @@ grep -q "query AdminPlayerTrace" index.html grep -q 'adminPlayerTrace(accountId: \$accountId, limit: \$limit, offset: \$offset)' index.html grep -q 'function renderDecisionTrace' index.html grep -q 'function renderJsonTree' index.html +grep -q 'function decodeEscapedSequences' index.html +grep -q 'decodeEscapedSequences(item)' index.html +grep -q "Visa lagrad text med escape-tecken" index.html grep -q "'Öppna alla block'" index.html grep -q "'Fäll ihop nästlade block'" index.html grep -q "nested.className = 'json-node'" index.html diff --git a/tests/admin-conversation-trace.test.cjs b/tests/admin-conversation-trace.test.cjs index 342f6f5..dd64e5c 100644 --- a/tests/admin-conversation-trace.test.cjs +++ b/tests/admin-conversation-trace.test.cjs @@ -64,3 +64,18 @@ test('admin JSON responses are readable as accessible collapsible trees', () => assert.match(page, /JSON\.stringify\(npc\.profile, null, '\\t'\)/); assert.doesNotMatch(page, /jsnview|cdn\.jsdelivr/); }); + +test('admin JSON text decodes common JSON and YAML escapes while retaining the stored form', () => { + const decoder = page.match(/function decodeEscapedSequences\(text\) \{[\s\S]*?\n \}/); + assert.ok(decoder, 'escape decoder should be present'); + assert.match(page, /Visa lagrad text med escape-tecken/); + assert.match(page, /decodeEscapedSequences\(item\)/); + const decode = new Function(`${decoder[0]}; return decodeEscapedSequences;`)(); + + assert.equal(decode(String.raw`Rad 1\nRad 2`), 'Rad 1\nRad 2'); + assert.equal(decode(String.raw`Kolumn 1\tKolumn 2`), 'Kolumn 1\tKolumn 2'); + assert.equal(decode(String.raw`citat: \'hej\'`), "citat: 'hej'"); + assert.equal(decode(String.raw`\u00E5\x21`), 'å!'); + assert.equal(decode(String.raw`\U0001F642`), '🙂'); + assert.equal(decode(String.raw`okänt: \q`), String.raw`okänt: \q`); +});