import * as fs from 'fs'; import * as path from 'path'; import type { DatasheetModel, DatasheetVoltageTable, KeyValueItem, ProductData } from './types'; import type { ExcelMatch } from './excel-index'; import { findExcelForProduct } from './excel-index'; import { getLabels, getProductUrl, normalizeValue, stripHtml } from './utils'; type ExcelRow = Record; type VoltageTableModel = { voltageLabel: string; metaItems: KeyValueItem[]; crossSections: string[]; columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>; }; type BuildExcelModelResult = { ok: boolean; technicalItems: KeyValueItem[]; voltageTables: VoltageTableModel[] }; type AssetMap = Record; const ASSET_MAP_FILE = path.join(process.cwd(), 'data/processed/asset-map.json'); function readAssetMap(): AssetMap { try { if (!fs.existsSync(ASSET_MAP_FILE)) return {}; return JSON.parse(fs.readFileSync(ASSET_MAP_FILE, 'utf8')) as AssetMap; } catch { return {}; } } const ASSET_MAP: AssetMap = readAssetMap(); function normalizeUnit(unitRaw: string): string { const u = normalizeValue(unitRaw); if (!u) return ''; if (/^c$/i.test(u) || /^°c$/i.test(u)) return '°C'; return u.replace(/Ω/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u'); } function formatExcelHeaderLabel(key: string, unit?: string): string { const k = normalizeValue(key); if (!k) return ''; const u = normalizeValue(unit || ''); const compact = k.replace(/\s*\(approx\.?\)\s*/gi, ' (approx.) ').replace(/\s+/g, ' ').trim(); if (!u) return compact; if (new RegExp(`\\(${u.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\)`, 'i').test(compact)) return compact; return `${compact} (${u})`; } function normalizeVoltageLabel(raw: string): string { const v = normalizeValue(raw); if (!v) return ''; const cleaned = v.replace(/\s+/g, ' '); if (/\bkv\b/i.test(cleaned)) return cleaned.replace(/\bkv\b/i, 'kV'); const num = cleaned.match(/\d+(?:[.,]\d+)?(?:\s*\/\s*\d+(?:[.,]\d+)?)?/); if (!num) return cleaned; if (/[a-z]/i.test(cleaned)) return cleaned; return `${cleaned} kV`; } function parseVoltageSortKey(voltageLabel: string): number { const v = normalizeVoltageLabel(voltageLabel); const nums = v .replace(/,/g, '.') .match(/\d+(?:\.\d+)?/g) ?.map(n => Number(n)) .filter(n => Number.isFinite(n)); if (!nums || nums.length === 0) return Number.POSITIVE_INFINITY; return nums[nums.length - 1]; } function compactNumericForLocale(value: string, locale: 'en' | 'de'): string { const v = normalizeValue(value); if (!v) return ''; const hasDigit = /\d/.test(v); if (!hasDigit) return v; const trimmed = v.replace(/\s+/g, ' ').trim(); const parts = trimmed.split(/(–|-)/); const out = parts.map(p => { if (p === '–' || p === '-') return p; const s = p.trim(); if (!/^-?\d+(?:[.,]\d+)?$/.test(s)) return p; const n = s.replace(/,/g, '.'); const compact = n .replace(/\.0+$/, '') .replace(/(\.\d*?)0+$/, '$1') .replace(/\.$/, ''); const hadPlus = /^\+/.test(s); const withPlus = hadPlus && !/^\+/.test(compact) ? `+${compact}` : compact; return locale === 'de' ? withPlus.replace(/\./g, ',') : withPlus; }); return out.join(''); } function compactCellForDenseTable(value: string, unit: string | undefined, locale: 'en' | 'de'): string { let v = normalizeValue(value); if (!v) return ''; const u = normalizeValue(unit || ''); if (u) { const esc = u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); v = v.replace(new RegExp(`\\s*${esc}\\b`, 'ig'), '').trim(); v = v .replace(/\bkg\s*\/\s*km\b/gi, '') .replace(/\bohm\s*\/\s*km\b/gi, '') .replace(/\bΩ\s*\/\s*km\b/gi, '') .replace(/\bu\s*f\s*\/\s*km\b/gi, '') .replace(/\bmh\s*\/\s*km\b/gi, '') .replace(/\bkA\b/gi, '') .replace(/\bmm\b/gi, '') .replace(/\bkv\b/gi, '') .replace(/\b°?c\b/gi, '') .replace(/\s+/g, ' ') .trim(); } v = v.replace(/\s*–\s*/g, '-').replace(/\s*-\s*/g, '-').replace(/\s*\/\s*/g, '/').replace(/\s+/g, ' ').trim(); return compactNumericForLocale(v, locale); } function resolveMediaToLocalPath(urlOrPath: string | null | undefined): string | null { if (!urlOrPath) return null; if (urlOrPath.startsWith('/')) return urlOrPath; if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`; const mapped = ASSET_MAP[urlOrPath]; if (mapped) { if (mapped.startsWith('/')) return mapped; if (/^public\//i.test(mapped)) return `/${mapped.replace(/^public\//i, '')}`; if (/^media\//i.test(mapped)) return `/${mapped}`; return mapped; } return urlOrPath; } function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null { const keys = Object.keys(row || {}); for (const re of patterns) { const k = keys.find(x => { const key = String(x); if (re.test('conductor') && /ross section conductor/i.test(key)) return false; if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false; if (re.test('conductor') && !/^conductor$/i.test(key)) return false; if (re.test('insulation') && !/^insulation$/i.test(key)) return false; if (re.test('sheath') && !/^sheath$/i.test(key)) return false; if (re.test('norm') && !/^norm$/i.test(key)) return false; return re.test(key); }); if (k) return k; } return null; } function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string { if (args.locale === 'en') return normalizeValue(args.excelKey); const raw = normalizeValue(args.excelKey); if (!raw) return ''; return raw .replace(/\(approx\.?\)/gi, '(ca.)') .replace(/\bcapacitance\b/gi, 'Kapazität') .replace(/\binductance\b/gi, 'Induktivität') .replace(/\breactance\b/gi, 'Reaktanz') .replace(/\btest voltage\b/gi, 'Prüfspannung') .replace(/\brated voltage\b/gi, 'Nennspannung') .replace(/\boperating temperature range\b/gi, 'Temperaturbereich') .replace(/\bminimum sheath thickness\b/gi, 'Manteldicke (min.)') .replace(/\bsheath thickness\b/gi, 'Manteldicke') .replace(/\bnominal insulation thickness\b/gi, 'Isolationsdicke (nom.)') .replace(/\binsulation thickness\b/gi, 'Isolationsdicke') .replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC-Leiterwiderstand (20 °C)') .replace(/\bouter diameter(?: of cable)?\b/gi, 'Außen-Ø') .replace(/\bbending radius\b/gi, 'Biegeradius') .replace(/\bpackaging\b/gi, 'Verpackung') .replace(/\bce\s*-?conformity\b/gi, 'CE-Konformität'); } function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string { const key = normalizeValue(args.key); if (args.locale === 'de') { switch (key) { case 'test_volt': return 'Prüfspannung'; case 'temp_range': return 'Temperaturbereich'; case 'max_op_temp': return 'Leitertemperatur (max.)'; case 'max_sc_temp': return 'Kurzschlusstemperatur (max.)'; case 'min_lay_temp': return 'Minimale Verlegetemperatur'; case 'min_store_temp': return 'Minimale Lagertemperatur'; case 'cpr': return 'CPR-Klasse'; case 'flame': return 'Flammhemmend'; default: return formatExcelHeaderLabel(args.excelKey); } } switch (key) { case 'test_volt': return 'Test voltage'; case 'temp_range': return 'Operating temperature range'; case 'max_op_temp': return 'Conductor temperature (max.)'; case 'max_sc_temp': return 'Short-circuit temperature (max.)'; case 'min_lay_temp': return 'Minimum laying temperature'; case 'min_store_temp': return 'Minimum storage temperature'; case 'cpr': return 'CPR class'; case 'flame': return 'Flame retardant'; default: return formatExcelHeaderLabel(args.excelKey); } } function denseAbbrevLabel(args: { key: string; locale: 'en' | 'de'; unit?: string }): string { const u = normalizeUnit(args.unit || ''); const unitSafe = u.replace(/Ω/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u'); const suffix = unitSafe ? ` [${unitSafe}]` : ''; switch (args.key) { case 'DI': case 'RI': case 'Wi': case 'Ibl': case 'Ibe': case 'Wm': case 'Rbv': case 'Fzv': case 'G': return `${args.key}${suffix}`; case 'Ik_cond': return `Ik${suffix}`; case 'Ik_screen': return `Ik_s${suffix}`; case 'Ø': return `Ø${suffix}`; case 'Cond': return args.locale === 'de' ? 'Leiter' : 'Cond.'; case 'shape': return args.locale === 'de' ? 'Form' : 'Shape'; case 'test_volt': return `U_test${suffix}`; case 'rated_volt': return `U0/U${suffix}`; case 'temp_range': return `T${suffix}`; case 'max_op_temp': return `T_op${suffix}`; case 'max_sc_temp': return `T_sc${suffix}`; case 'min_store_temp': return `T_st${suffix}`; case 'min_lay_temp': return `T_lay${suffix}`; case 'cpr': return `CPR${suffix}`; case 'flame': return `FR${suffix}`; default: return args.key || ''; } } function summarizeOptions(options: string[] | undefined, maxItems: number = 3): string { const vals = (options || []).map(normalizeValue).filter(Boolean); if (vals.length === 0) return ''; const uniq = Array.from(new Set(vals)); if (uniq.length === 1) return uniq[0]; if (uniq.length <= maxItems) return uniq.join(' / '); return `${uniq.slice(0, maxItems).join(' / ')} / ...`; } function parseNumericOption(value: string): number | null { const v = normalizeValue(value).replace(/,/g, '.'); const m = v.match(/-?\d+(?:\.\d+)?/); if (!m) return null; const n = Number(m[0]); return Number.isFinite(n) ? n : null; } function summarizeNumericRange(options: string[] | undefined): { ok: boolean; text: string } { const vals = (options || []).map(parseNumericOption).filter((n): n is number => n !== null); if (vals.length < 3) return { ok: false, text: '' }; const uniq = Array.from(new Set(vals)); if (uniq.length < 4) return { ok: false, text: '' }; uniq.sort((a, b) => a - b); const min = uniq[0]; const max = uniq[uniq.length - 1]; const fmt = (n: number) => (Number.isInteger(n) ? String(n) : String(n)).replace(/\.0+$/, ''); return { ok: true, text: `${fmt(min)}–${fmt(max)}` }; } function summarizeSmartOptions(label: string, options: string[] | undefined): string { const range = summarizeNumericRange(options); if (range.ok) return range.text; return summarizeOptions(options, 3); } function buildExcelModel(args: { product: ProductData; locale: 'en' | 'de' }): BuildExcelModelResult { const match = findExcelForProduct(args.product) as ExcelMatch | null; if (!match || match.rows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] }; const units = match.units || {}; const rows = match.rows; let sample = rows.find(r => r && Object.keys(r).length > 0) || {}; let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length; for (const r of rows) { const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length; if (cols > maxColumns) { sample = r; maxColumns = cols; } } const columnMapping: Record = { 'number of cores and cross-section': { header: 'Cross-section', unit: '', key: 'cross_section' }, 'ross section conductor': { header: 'Cross-section', unit: '', key: 'cross_section' }, 'diameter over insulation': { header: 'DI', unit: 'mm', key: 'DI' }, 'diameter over insulation (approx.)': { header: 'DI', unit: 'mm', key: 'DI' }, 'dc resistance at 20 °C': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, 'dc resistance at 20°C': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, 'resistance conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, 'maximum resistance of conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, 'insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' }, 'nominal insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' }, 'current ratings in air, trefoil': { header: 'Ibl', unit: 'A', key: 'Ibl' }, 'current ratings in air, trefoil*': { header: 'Ibl', unit: 'A', key: 'Ibl' }, 'current ratings in ground, trefoil': { header: 'Ibe', unit: 'A', key: 'Ibe' }, 'current ratings in ground, trefoil*': { header: 'Ibe', unit: 'A', key: 'Ibe' }, 'conductor shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_cond' }, 'screen shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_screen' }, 'sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, 'minimum sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, 'nominal sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, 'bending radius': { header: 'Rbv', unit: 'mm', key: 'Rbv' }, 'bending radius (min.)': { header: 'Rbv', unit: 'mm', key: 'Rbv' }, 'outer diameter': { header: 'Ø', unit: 'mm', key: 'Ø' }, 'outer diameter (approx.)': { header: 'Ø', unit: 'mm', key: 'Ø' }, 'outer diameter of cable': { header: 'Ø', unit: 'mm', key: 'Ø' }, 'pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' }, 'max. pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' }, 'conductor aluminum': { header: 'Cond.', unit: '', key: 'Cond' }, 'conductor copper': { header: 'Cond.', unit: '', key: 'Cond' }, 'weight': { header: 'G', unit: 'kg/km', key: 'G' }, 'weight (approx.)': { header: 'G', unit: 'kg/km', key: 'G' }, 'cable weight': { header: 'G', unit: 'kg/km', key: 'G' }, 'shape of conductor': { header: 'Conductor shape', unit: '', key: 'shape' }, 'operating temperature range': { header: 'Operating temp range', unit: '°C', key: 'temp_range' }, 'maximal operating conductor temperature': { header: 'Max operating temp', unit: '°C', key: 'max_op_temp' }, 'maximal short-circuit temperature': { header: 'Max short-circuit temp', unit: '°C', key: 'max_sc_temp' }, 'minimal storage temperature': { header: 'Min storage temp', unit: '°C', key: 'min_store_temp' }, 'minimal temperature for laying': { header: 'Min laying temp', unit: '°C', key: 'min_lay_temp' }, 'test voltage': { header: 'Test voltage', unit: 'kV', key: 'test_volt' }, 'rated voltage': { header: 'Rated voltage', unit: 'kV', key: 'rated_volt' }, 'cpr class': { header: 'CPR class', unit: '', key: 'cpr' }, 'flame retardant': { header: 'Flame retardant', unit: '', key: 'flame' }, 'self-extinguishing of single cable': { header: 'Flame retardant', unit: '', key: 'flame' }, }; const excelKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units'); const matchedColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = []; for (const excelKey of excelKeys) { const normalized = normalizeValue(excelKey).toLowerCase(); for (const [pattern, mapping] of Object.entries(columnMapping)) { if (normalized === pattern.toLowerCase() || new RegExp(pattern, 'i').test(normalized)) { matchedColumns.push({ excelKey, mapping }); break; } } } const seenKeys = new Set(); const deduplicated: typeof matchedColumns = []; for (const item of matchedColumns) { if (!seenKeys.has(item.mapping.key)) { seenKeys.add(item.mapping.key); deduplicated.push(item); } } const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); const compatibleRows = rows.filter(r => { const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); return JSON.stringify(rKeys) === JSON.stringify(sampleKeys); }); if (compatibleRows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] }; const csKey = guessColumnKey(sample, [/number of cores and cross-section/i, /cross.?section/i, /ross section conductor/i]) || null; const voltageKey = guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /nennspannung/i, /spannungs/i]) || null; if (!csKey) return { ok: false, technicalItems: [], voltageTables: [] }; const byVoltage = new Map(); for (let i = 0; i < compatibleRows.length; i++) { const cs = normalizeValue(String(compatibleRows[i]?.[csKey] ?? '')); if (!cs) continue; const rawV = voltageKey ? normalizeValue(String(compatibleRows[i]?.[voltageKey] ?? '')) : ''; const voltageLabel = normalizeVoltageLabel(rawV || ''); const key = voltageLabel || (args.locale === 'de' ? 'Spannung unbekannt' : 'Voltage unknown'); const arr = byVoltage.get(key) ?? []; arr.push(i); byVoltage.set(key, arr); } const voltageKeysSorted = Array.from(byVoltage.keys()).sort((a, b) => { const na = parseVoltageSortKey(a); const nb = parseVoltageSortKey(b); if (na !== nb) return na - nb; return a.localeCompare(b); }); const technicalItems: KeyValueItem[] = []; const globalConstantColumns = new Set(); for (const { excelKey, mapping } of deduplicated) { const values = compatibleRows.map(r => normalizeValue(String(r?.[excelKey] ?? ''))).filter(Boolean); const unique = Array.from(new Set(values.map(v => v.toLowerCase()))); if (unique.length === 1 && values.length > 0) { globalConstantColumns.add(excelKey); const unit = normalizeUnit(units[excelKey] || mapping.unit || ''); const labelBase = technicalFullLabel({ key: mapping.key, excelKey, locale: args.locale }); const label = formatExcelHeaderLabel(labelBase, unit); const value = compactCellForDenseTable(values[0], unit, args.locale); if (!technicalItems.find(t => t.label === label)) technicalItems.push({ label, value, unit }); } } technicalItems.sort((a, b) => a.label.localeCompare(b.label)); const voltageTables: VoltageTableModel[] = []; for (const vKey of voltageKeysSorted) { const indices = byVoltage.get(vKey) || []; if (!indices.length) continue; const crossSections = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[csKey] ?? ''))); const metaItems: KeyValueItem[] = []; const metaCandidates = new Map(); if (voltageKey) { const rawV = normalizeValue(String(compatibleRows[indices[0]]?.[voltageKey] ?? '')); metaItems.push({ label: args.locale === 'de' ? 'Spannung' : 'Voltage', value: normalizeVoltageLabel(rawV || ''), }); } const metaKeyPriority = [ 'test_volt', 'temp_range', 'max_op_temp', 'max_sc_temp', 'min_lay_temp', 'min_store_temp', 'cpr', 'flame', ]; const metaKeyPrioritySet = new Set(metaKeyPriority); const denseTableKeyOrder = [ 'Cond', 'shape', 'DI', 'RI', 'Wi', 'Ibl', 'Ibe', 'Ik_cond', 'Wm', 'Rbv', 'Ø', 'Fzv', 'G', ] as const; const denseTableKeys = new Set(denseTableKeyOrder); const tableColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = []; for (const { excelKey, mapping } of deduplicated) { if (excelKey === csKey || excelKey === voltageKey) continue; const values = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? ''))).filter(Boolean); if (!values.length) continue; const unique = Array.from(new Set(values.map(v => v.toLowerCase()))); const unit = normalizeUnit(units[excelKey] || mapping.unit || ''); if (denseTableKeys.has(mapping.key)) { tableColumns.push({ excelKey, mapping }); continue; } if (globalConstantColumns.has(excelKey) && !metaKeyPrioritySet.has(mapping.key)) { continue; } const value = unique.length === 1 ? compactCellForDenseTable(values[0], unit, args.locale) : summarizeSmartOptions(excelKey, values); const label = metaFullLabel({ key: mapping.key, excelKey, locale: args.locale }); metaCandidates.set(mapping.key, { label, value, unit }); } for (const k of metaKeyPriority) { const item = metaCandidates.get(k); if (item && item.label && item.value) metaItems.push(item); } const mappedByKey = new Map(); for (const c of tableColumns) { if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c); } const orderedTableColumns = denseTableKeyOrder .filter(k => mappedByKey.has(k)) .map(k => mappedByKey.get(k)!) .map(({ excelKey, mapping }) => { const unit = normalizeUnit(units[excelKey] || mapping.unit || ''); return { key: mapping.key, label: denseAbbrevLabel({ key: mapping.key, locale: args.locale, unit }) || formatExcelHeaderLabel(excelKey, unit), get: (rowIndex: number) => { const srcRowIndex = indices[rowIndex]; const raw = excelKey ? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? '')) : ''; return compactCellForDenseTable(raw, unit, args.locale); }, }; }); voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns: orderedTableColumns }); } return { ok: true, technicalItems, voltageTables }; } export function buildDatasheetModel(args: { product: ProductData; locale: 'en' | 'de' }): DatasheetModel { const labels = getLabels(args.locale); const categoriesLine = (args.product.categories || []).map(c => stripHtml(c.name)).join(' • '); const descriptionText = stripHtml(args.product.shortDescriptionHtml || args.product.descriptionHtml || ''); const heroSrc = resolveMediaToLocalPath(args.product.featuredImage || args.product.images?.[0] || null); const productUrl = getProductUrl(args.product); const excelModel = buildExcelModel({ product: args.product, locale: args.locale }); const voltageTables: DatasheetVoltageTable[] = excelModel.ok ? excelModel.voltageTables.map(t => { const columns = t.columns.map(c => ({ key: c.key, label: c.label })); const rows = t.crossSections.map((configuration, rowIndex) => ({ configuration, cells: t.columns.map(c => compactNumericForLocale(c.get(rowIndex), args.locale)), })); return { voltageLabel: t.voltageLabel, metaItems: t.metaItems, columns, rows, }; }) : []; return { locale: args.locale, product: { id: args.product.id, name: stripHtml(args.product.name), sku: args.product.sku, categoriesLine, descriptionText, heroSrc, productUrl, }, labels, technicalItems: excelModel.ok ? excelModel.technicalItems : [], voltageTables, }; }