const fs = require('fs'); const path = require('path'); const mapCache = new Map(); const CONTROL_TOKEN_MAP = { VT: '\u000b', FS: '\u001c', STX: '\u0002', ETX: '\u0003', CR: '\r', LF: '\n' }; const SELECTOR_PATTERN = /^([A-Za-z][A-Za-z0-9_]*)\[(\d+)(?:\.(\d+))?\]$/; const FIXED_WIDTH_DIRECTIVE_PATTERN = /^([A-Za-z][A-Za-z0-9_]*):(\d+)(?::(?:"([^"]*)"|'([^']*)'))?$/; function buildCanonical(entry, parsedPayload, connector) { const translator = entry && typeof entry.translator === 'object' ? entry.translator : {}; const canonical = { ...parsedPayload }; if (translator.forceInstrumentId !== false) { canonical.instrument_id = entry.instrument_id; } canonical.meta = { ...(parsedPayload.meta || {}), ...(translator.meta && typeof translator.meta === 'object' ? translator.meta : {}), connector, instrument_config: entry.config }; return canonical; } function resolveTranslatorFilePath(filePath, configFilePath) { if (!filePath || typeof filePath !== 'string') return ''; if (path.isAbsolute(filePath)) return filePath; const candidates = [ path.resolve(process.cwd(), filePath) ]; if (configFilePath) { candidates.push(path.resolve(path.dirname(configFilePath), filePath)); } const matched = candidates.find((candidate) => fs.existsSync(candidate)); return matched || candidates[0]; } function parseMapFile(fileContent, filePath) { const lines = fileContent.split(/\r?\n/); const messages = new Map(); const fields = new Map(); const settings = { field_sep: '|', component_sep: '^' }; let pendingSection = null; let multiline = null; function parseKeyValue(line, index) { const separator = line.indexOf('='); if (separator < 0) { throw new Error(`${filePath}:${index + 1} invalid mapping line (expected KEY = value)`); } const key = line.slice(0, separator).trim(); const value = line.slice(separator + 1).trim(); if (!key) { throw new Error(`${filePath}:${index + 1} mapping key is required`); } if (key === 'field_sep' || key === 'component_sep') { settings[key] = value; return; } if (SELECTOR_PATTERN.test(value)) { fields.set(key, value); return; } messages.set(key, value); } lines.forEach((line, index) => { const trimmed = line.trim(); if (multiline) { if (trimmed === '>>') { messages.set(multiline.key, multiline.lines.join('\n')); multiline = null; return; } multiline.lines.push(line); return; } if (pendingSection) { if (!trimmed) return; if (trimmed === '<<') { multiline = { key: pendingSection, lines: [] }; pendingSection = null; return; } messages.set(pendingSection, line.trim()); pendingSection = null; return; } if (!trimmed) return; const sectionMatch = trimmed.match(/^#\s*([A-Za-z0-9_.-]+)\s*$/); if (sectionMatch && !trimmed.includes('=')) { pendingSection = sectionMatch[1]; return; } if (trimmed.startsWith('#')) return; parseKeyValue(line, index); }); if (multiline) { throw new Error(`${filePath} unterminated multiline section for ${multiline.key} (expected >>)`); } if (pendingSection) { throw new Error(`${filePath} section ${pendingSection} is missing a body line`); } return { messages, fields, settings }; } function loadMapFile(filePath) { const stat = fs.statSync(filePath); const cached = mapCache.get(filePath); if (cached && cached.mtimeMs === stat.mtimeMs) { return cached.parsed; } const content = fs.readFileSync(filePath, 'utf8'); const parsed = parseMapFile(content, filePath); mapCache.set(filePath, { mtimeMs: stat.mtimeMs, parsed }); return parsed; } function decodeControlTokens(value) { return String(value).replace(/<(VT|FS|STX|ETX|CR|LF)>/gi, (_, token) => CONTROL_TOKEN_MAP[token.toUpperCase()] || ''); } function parseSelector(selector) { const match = String(selector || '').trim().match(SELECTOR_PATTERN); if (!match) return null; return { recordType: match[1], fieldIndex: Number(match[2]), componentIndex: match[3] ? Number(match[3]) : null }; } function parseRecordLine(line, fieldSeparator) { const text = String(line || '') .replace(/[\u0002\u0003\u000b\u001c]/g, '') .trim(); if (!text) return null; if (!text.includes(fieldSeparator)) return null; const fields = text.split(fieldSeparator); const type = String(fields[0] || '').trim(); if (!type) return null; return { type, fields, raw: text }; } function extractRawPayloadCandidates(parsedPayload) { const candidates = []; if (typeof parsedPayload.raw_payload === 'string') { candidates.push(parsedPayload.raw_payload); } if (typeof parsedPayload.meta?.raw_payload === 'string') { candidates.push(parsedPayload.meta.raw_payload); } if (Array.isArray(parsedPayload.results)) { parsedPayload.results.forEach((result) => { if (result && String(result.test_code || '').toUpperCase() === 'RAW' && typeof result.value === 'string') { candidates.push(result.value); } }); } return candidates; } function stripFrameControlChars(value) { return String(value || '') .replace(/^[\u0002\u0003\u000b\u001c\r\n]+/, '') .replace(/[\u0002\u0003\u000b\u001c\r\n]+$/, ''); } function getFixedWidthSource(parsedPayload) { const candidates = extractRawPayloadCandidates(parsedPayload); for (let i = 0; i < candidates.length; i += 1) { const stripped = stripFrameControlChars(candidates[i]); if (stripped) return stripped; } return ''; } function parseRawPayloadRecords(rawPayload, fieldSeparator) { const normalized = String(rawPayload || '') .replace(/\r\n/g, '\n') .replace(/\r/g, '\n'); return normalized .split('\n') .map((line) => parseRecordLine(line, fieldSeparator)) .filter(Boolean); } function buildRecordCollections(parsedPayload, settings) { const explicitSource = Array.isArray(parsedPayload.records) ? parsedPayload.records : Array.isArray(parsedPayload.meta?.records) ? parsedPayload.meta.records : []; const source = Array.isArray(explicitSource) ? [...explicitSource] : []; const records = []; const fieldSeparator = settings.field_sep || '|'; if (!source.length) { const rawCandidates = extractRawPayloadCandidates(parsedPayload); for (let i = 0; i < rawCandidates.length; i += 1) { const parsed = parseRawPayloadRecords(rawCandidates[i], fieldSeparator); if (parsed.length) { source.push(...parsed.map((item) => item.raw)); break; } } } source.forEach((item) => { if (typeof item === 'string') { const parsed = parseRecordLine(item, fieldSeparator); if (parsed) records.push(parsed); return; } if (!item || typeof item !== 'object') return; if (Array.isArray(item.fields) && item.type) { records.push({ type: String(item.type), fields: item.fields.map((value) => String(value ?? '')), raw: '' }); } }); const recordsByType = new Map(); records.forEach((record) => { if (!recordsByType.has(record.type)) recordsByType.set(record.type, []); recordsByType.get(record.type).push(record); }); return { records, recordsByType }; } function resolveSelector(selector, context) { const parsed = parseSelector(selector); if (!parsed) return ''; const { recordType, fieldIndex, componentIndex } = parsed; if (fieldIndex < 1) return ''; const record = context.currentRecord && context.currentRecord.type === recordType ? context.currentRecord : (context.recordsByType.get(recordType) || [])[0]; if (!record) return ''; const field = record.fields[fieldIndex - 1]; if (field === undefined || field === null) return ''; if (!componentIndex) return field; if (componentIndex < 1) return ''; const components = String(field).split(context.settings.component_sep || '^'); return components[componentIndex - 1] || ''; } function resolveFieldAlias(name, context, stack = new Set()) { if (stack.has(name)) return ''; if (!context.fields.has(name)) return ''; stack.add(name); const selector = context.fields.get(name); const value = resolveSelector(selector, context); stack.delete(name); return value; } function getPlaceholderValue(name, context) { if (Object.hasOwn(context.flat, name)) { return context.flat[name]; } if (context.fields.has(name)) { return resolveFieldAlias(name, context); } if (SELECTOR_PATTERN.test(name)) { return resolveSelector(name, context); } if (!name.includes('.')) { return ''; } const parts = name.split('.').filter(Boolean); let current = context.root; for (let i = 0; i < parts.length; i += 1) { if (!current || typeof current !== 'object') return ''; current = current[parts[i]]; } return current === undefined || current === null ? '' : current; } function parseFixedWidthDirective(name) { const match = String(name || '').match(FIXED_WIDTH_DIRECTIVE_PATTERN); if (!match) return null; return { fieldName: match[1], length: Number(match[2]) }; } function consumeFixedWidthField(directive, context) { if (!directive || !context.fixedWidth) return null; const { fieldName, length } = directive; if (!Number.isFinite(length) || length < 0) return ''; const start = context.fixedWidth.cursor; const end = start + length; const value = context.fixedWidth.source.slice(start, end); context.fixedWidth.cursor = end; if (fieldName.toLowerCase() === 'skip') return ''; return value; } function parseLoopDirective(value) { const recordMatch = value.match(/^@for\s+([A-Za-z][A-Za-z0-9_]*)$/); if (recordMatch) { return { type: 'record', variable: recordMatch[1] }; } const rangeMatch = value.match(/^@for\s+([A-Za-z][A-Za-z0-9_]*)\s+in\s+(\d+)\.\.(\d+)$/); if (!rangeMatch) return null; return { type: 'range', variable: rangeMatch[1], start: Number(rangeMatch[2]), end: Number(rangeMatch[3]) }; } function renderTemplate(template, context) { const lines = String(template).split('\n'); const outputLines = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const trimmed = line.trim(); const loop = parseLoopDirective(trimmed); if (loop) { let endIndex = index + 1; const loopBody = []; while (endIndex < lines.length && lines[endIndex].trim() !== '@end') { loopBody.push(lines[endIndex]); endIndex += 1; } if (endIndex >= lines.length) { throw new Error(`unterminated loop block for ${trimmed} (expected @end)`); } if (loop.type === 'record') { const records = context.recordsByType.get(loop.variable) || []; records.forEach((record) => { const nestedContext = { ...context, currentRecord: record }; const body = renderTemplate(loopBody.join('\n'), nestedContext); if (body) outputLines.push(body); }); } else { const step = loop.start <= loop.end ? 1 : -1; for (let value = loop.start; step > 0 ? value <= loop.end : value >= loop.end; value += step) { const nestedContext = { ...context, flat: { ...context.flat, [loop.variable]: value } }; const body = renderTemplate(loopBody.join('\n'), nestedContext); if (body) outputLines.push(body); } } index = endIndex; continue; } if (trimmed === '@end') { throw new Error('unexpected @end without matching @for'); } const rendered = line.replace(/\{([^{}]+)\}/g, (_, rawName) => { const name = String(rawName || '').trim(); if (!name) return ''; const fixedDirective = parseFixedWidthDirective(name); if (fixedDirective) { return consumeFixedWidthField(fixedDirective, context); } const value = getPlaceholderValue(name, context); return value === undefined || value === null ? '' : String(value); }); outputLines.push(decodeControlTokens(rendered)); } return outputLines.join('\n'); } function buildTemplateContext(entry, parsedPayload, connector, mapDefinition) { const root = { ...parsedPayload, instrument_id: parsedPayload.instrument_id || entry.instrument_id, connector, config: entry.config || {}, meta: parsedPayload.meta || {} }; const flat = { ...root, ...(root.meta && typeof root.meta === 'object' ? root.meta : {}), ...(root.config && typeof root.config === 'object' ? root.config : {}) }; if (Array.isArray(parsedPayload.results)) { flat.order_tests = parsedPayload.results .map((item) => item && item.test_code) .filter(Boolean) .map((testCode) => `^^^${testCode}`) .join('\\'); } const { records, recordsByType } = buildRecordCollections(parsedPayload, mapDefinition.settings || {}); return { root, flat, fields: mapDefinition.fields || new Map(), settings: mapDefinition.settings || {}, records, recordsByType, currentRecord: null, fixedWidth: { source: getFixedWidthSource(parsedPayload), cursor: 0 } }; } function translateOverrides(entry, parsedPayload, connector) { const translator = entry && typeof entry.translator === 'object' ? entry.translator : {}; const overrides = translator.overrides && typeof translator.overrides === 'object' ? translator.overrides : {}; const canonical = buildCanonical(entry, { ...parsedPayload, ...overrides }, connector); return canonical; } function translateTemplate(entry, parsedPayload, connector) { const translator = entry && typeof entry.translator === 'object' ? entry.translator : {}; if (!translator.file || typeof translator.file !== 'string') { throw new Error('translator.file is required for template engine'); } const resolvedFilePath = resolveTranslatorFilePath(translator.file, entry?.files?.config); if (!fs.existsSync(resolvedFilePath)) { throw new Error(`translator file not found: ${translator.file}`); } const mapDefinition = loadMapFile(resolvedFilePath); const messageKeys = Array.isArray(translator.messages) && translator.messages.length ? translator.messages.map((value) => String(value)) : Array.from(mapDefinition.messages.keys()); const context = buildTemplateContext(entry, parsedPayload, connector, mapDefinition); const renderedMessages = messageKeys.map((messageKey) => { if (!mapDefinition.messages.has(messageKey)) { throw new Error(`translator message key not found in map file: ${messageKey}`); } const messageContext = { ...context, fixedWidth: { ...context.fixedWidth, cursor: 0 } }; return { key: messageKey, body: renderTemplate(mapDefinition.messages.get(messageKey), messageContext) }; }); const canonical = buildCanonical(entry, parsedPayload, connector); canonical.meta.rendered_messages = renderedMessages; canonical.meta.translator_file = resolvedFilePath; return canonical; } const registry = new Map([ ['overrides', { translate: translateOverrides }], ['template', { translate: translateTemplate }] ]); function resolve(name) { if (!name) return registry.get('overrides'); const key = String(name).trim().toLowerCase(); return registry.get(key) || null; } function translate(entry, parsedPayload, connector, engineName) { const engine = resolve(engineName); if (!engine) { const options = engineName ? ` (requested: ${engineName})` : ''; throw new Error(`translator engine not found${options}`); } return engine.translate(entry, parsedPayload, connector); } module.exports = { resolve, translate };