From d7650308a974f672588492319fe6409b189dbe74 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 24 Aug 2026 16:45:36 -0300 Subject: [PATCH] feat(v2): add robust GLPI text normalization (HTML/entities, multi-level escaping) Handles: numeric entities (< >), named entities (&   etc), HTML tags, multi-level escaping (up to 5 decode iterations, capped to avoid infinite loop), whitespace/newline collapsing. Preserves dates, names, numbers, error codes, hostnames, versions -- only strips markup. Co-Authored-By: Claude Sonnet 5 --- logic/normalize.js | 58 +++++++++++++++++++++++++++++++++++++ logic/normalize.test.js | 63 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 logic/normalize.js create mode 100644 logic/normalize.test.js diff --git a/logic/normalize.js b/logic/normalize.js new file mode 100644 index 0000000..b72ecb9 --- /dev/null +++ b/logic/normalize.js @@ -0,0 +1,58 @@ +const NAMED_ENTITIES = { + ' ': ' ', + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ''': "'", +}; + +const MAX_DECODE_ITERATIONS = 5; + +function decodeEntitiesOnce(text) { + let result = text.replace(/&#(\d+);/g, (match, code) => { + const num = Number(code); + return Number.isFinite(num) ? String.fromCodePoint(num) : match; + }); + result = result.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => { + const num = parseInt(hex, 16); + return Number.isFinite(num) ? String.fromCodePoint(num) : match; + }); + for (const [entity, char] of Object.entries(NAMED_ENTITIES)) { + result = result.split(entity).join(char); + } + return result; +} + +function stripHtmlTags(text) { + return text.replace(/<[^>]*>/g, ' '); +} + +// Decodifica HTML/entities (inclusive escapado multiplas vezes, ate MAX_DECODE_ITERATIONS +// para nunca travar em loop) e remove tags -- sem apagar informacao (datas, nomes, numeros, +// codigos de erro, hostname, versao continuam intactos: nenhuma dessas regras mexe em texto +// que nao seja literalmente uma entidade HTML ou uma tag). +function normalizeGlpiText(rawText) { + if (rawText === null || rawText === undefined) return ''; + let text = String(rawText); + + for (let i = 0; i < MAX_DECODE_ITERATIONS; i += 1) { + const decoded = decodeEntitiesOnce(text); + if (decoded === text) break; + text = decoded; + } + + text = stripHtmlTags(text); + + text = text + .split('\n') + .map((line) => line.replace(/[ \t]+/g, ' ').trim()) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return text; +} + +module.exports = { normalizeGlpiText, decodeEntitiesOnce, stripHtmlTags }; diff --git a/logic/normalize.test.js b/logic/normalize.test.js new file mode 100644 index 0000000..01b4a71 --- /dev/null +++ b/logic/normalize.test.js @@ -0,0 +1,63 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { normalizeGlpiText } = require('./normalize'); + +test('HTML normal e removido, texto preservado', () => { + assert.equal(normalizeGlpiText('

Aguardando cliente

'), 'Aguardando cliente'); +}); + +test('entidades HTML escapadas uma vez sao decodificadas e as tags removidas', () => { + const entrada = '<p>Aguardando retorno de Patricia sobre troca da carcaça</p>'; + assert.equal(normalizeGlpiText(entrada), 'Aguardando retorno de Patricia sobre troca da carcaça'); +}); + +test('entidades HTML escapadas duas vezes sao decodificadas iterativamente', () => { + const entrada = '&#60;p&#62;texto&#60;/p&#62;'; + assert.equal(normalizeGlpiText(entrada), 'texto'); +}); + +test('entidades nomeadas comuns (amp, nbsp) sao decodificadas', () => { + assert.equal(normalizeGlpiText('Suporte & Manutenção'), 'Suporte & Manutenção'); +}); + +test('texto sem HTML permanece intacto (so trim de espacos)', () => { + assert.equal(normalizeGlpiText(' Servidor não responde '), 'Servidor não responde'); +}); + +test('caracteres acentuados sao preservados', () => { + assert.equal(normalizeGlpiText('

Impressão não está funcionando corretamente na área de atendimento

'), 'Impressão não está funcionando corretamente na área de atendimento'); +}); + +test('codigo tecnico (numeros, versao, hostname) e preservado', () => { + const entrada = '

Erro 0x8007000E no servidor SRV-FILE01, versao 10.2.3, IP 192.168.1.50

'; + assert.equal(normalizeGlpiText(entrada), 'Erro 0x8007000E no servidor SRV-FILE01, versao 10.2.3, IP 192.168.1.50'); +}); + +test('string vazia retorna string vazia', () => { + assert.equal(normalizeGlpiText(''), ''); +}); + +test('null e undefined retornam string vazia sem lancar excecao', () => { + assert.doesNotThrow(() => normalizeGlpiText(null)); + assert.doesNotThrow(() => normalizeGlpiText(undefined)); + assert.equal(normalizeGlpiText(null), ''); + assert.equal(normalizeGlpiText(undefined), ''); +}); + +test('quebras de linha excessivas e espacos duplicados sao colapsados', () => { + const entrada = '

Linha 1

\n\n\n\n

Linha 2 com espacos

'; + const resultado = normalizeGlpiText(entrada); + assert.ok(!resultado.includes('\n\n\n')); + assert.ok(!/ {2,}/.test(resultado)); +}); + +test('decodificacao para no limite de iteracoes sem lancar excecao (protecao contra loop)', () => { + const entradaMaliciosa = '&'.repeat(20) + '#60;p>'; + assert.doesNotThrow(() => normalizeGlpiText(entradaMaliciosa)); +}); + +test('conteudo normalizado de duas codificacoes diferentes do mesmo texto e identico', () => { + const versaoHtml = '

Aguardando cliente

'; + const versaoEscapada = '<p>Aguardando cliente</p>'; + assert.equal(normalizeGlpiText(versaoHtml), normalizeGlpiText(versaoEscapada)); +});