wip
This commit is contained in:
73
lib/data.ts
73
lib/data.ts
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import wordpressData from '../data/processed/wordpress-data.json';
|
||||
import { getExcelTechnicalDataForProduct } from './excel-products';
|
||||
|
||||
export interface SiteInfo {
|
||||
title: string;
|
||||
@@ -69,6 +70,12 @@ export interface Product {
|
||||
variations: any[];
|
||||
updatedAt: string;
|
||||
translation: TranslationReference | null;
|
||||
// Excel-derived technical data
|
||||
excelConfigurations?: string[];
|
||||
excelAttributes?: Array<{
|
||||
name: string;
|
||||
options: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductCategory {
|
||||
@@ -279,4 +286,68 @@ export const getPostsForLocale = (locale: string): Post[] => {
|
||||
|
||||
export const getProductsForLocale = (locale: string): Product[] => {
|
||||
return data.content.products.filter(p => p.locale === locale);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Enrich a product with Excel-derived technical data
|
||||
* This function merges Excel data into the product's attributes
|
||||
*/
|
||||
export function enrichProductWithExcelData(product: Product): Product {
|
||||
// Skip if already enriched
|
||||
if (product.excelConfigurations || product.excelAttributes) {
|
||||
return product;
|
||||
}
|
||||
|
||||
const excelData = getExcelTechnicalDataForProduct({
|
||||
name: product.name,
|
||||
slug: product.slug,
|
||||
sku: product.sku,
|
||||
translationKey: product.translationKey,
|
||||
});
|
||||
|
||||
if (!excelData) {
|
||||
return product;
|
||||
}
|
||||
|
||||
// Create a copy of the product with Excel data
|
||||
const enrichedProduct: Product = {
|
||||
...product,
|
||||
excelConfigurations: excelData.configurations,
|
||||
excelAttributes: excelData.attributes,
|
||||
};
|
||||
|
||||
return enrichedProduct;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single product by slug with Excel enrichment
|
||||
*/
|
||||
export function getProductBySlugWithExcel(slug: string, locale: string): Product | undefined {
|
||||
const product = getProductBySlug(slug, locale);
|
||||
if (!product) return undefined;
|
||||
return enrichProductWithExcelData(product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all products for a locale with Excel enrichment
|
||||
*/
|
||||
export function getProductsForLocaleWithExcel(locale: string): Product[] {
|
||||
const products = getProductsForLocale(locale);
|
||||
return products.map(p => enrichProductWithExcelData(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products by category with Excel enrichment
|
||||
*/
|
||||
export function getProductsByCategoryWithExcel(categoryId: number, locale: string): Product[] {
|
||||
const products = getProductsByCategory(categoryId, locale);
|
||||
return products.map(p => enrichProductWithExcelData(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products by category slug with Excel enrichment
|
||||
*/
|
||||
export function getProductsByCategorySlugWithExcel(categorySlug: string, locale: string): Product[] {
|
||||
const products = getProductsByCategorySlug(categorySlug, locale);
|
||||
return products.map(p => enrichProductWithExcelData(p));
|
||||
}
|
||||
388
lib/excel-products.ts
Normal file
388
lib/excel-products.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Excel Products Module
|
||||
*
|
||||
* Provides typed access to technical product data from Excel source files.
|
||||
* Reuses the parsing logic from the PDF datasheet generator.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
// Configuration
|
||||
const EXCEL_SOURCE_FILES = [
|
||||
path.join(process.cwd(), 'data/source/high-voltage.xlsx'),
|
||||
path.join(process.cwd(), 'data/source/medium-voltage-KM.xlsx'),
|
||||
path.join(process.cwd(), 'data/source/low-voltage-KM.xlsx'),
|
||||
path.join(process.cwd(), 'data/source/solar-cables.xlsx'),
|
||||
];
|
||||
|
||||
// Types
|
||||
export type ExcelRow = Record<string, string | number | boolean | Date>;
|
||||
|
||||
export interface ExcelMatch {
|
||||
rows: ExcelRow[];
|
||||
units: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TechnicalData {
|
||||
configurations: string[];
|
||||
attributes: Array<{
|
||||
name: string;
|
||||
options: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductLookupParams {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
sku?: string;
|
||||
translationKey?: string;
|
||||
}
|
||||
|
||||
// Cache singleton
|
||||
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
|
||||
|
||||
/**
|
||||
* Normalize Excel key to match product identifiers
|
||||
* Examples:
|
||||
* - "NA2XS(FL)2Y" -> "NA2XSFL2Y"
|
||||
* - "na2xsfl2y-3" -> "NA2XSFL2Y"
|
||||
*/
|
||||
function normalizeExcelKey(value: string): string {
|
||||
return String(value || '')
|
||||
.toUpperCase()
|
||||
.replace(/-\d+$/g, '')
|
||||
.replace(/[^A-Z0-9]+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize value (strip HTML, trim whitespace)
|
||||
*/
|
||||
function normalizeValue(value: string): string {
|
||||
if (!value) return '';
|
||||
return String(value)
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value looks numeric
|
||||
*/
|
||||
function looksNumeric(value: string): boolean {
|
||||
const v = normalizeValue(value).replace(/,/g, '.');
|
||||
return /^-?\d+(?:\.\d+)?$/.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Excel rows from a file using xlsx library
|
||||
*/
|
||||
function loadExcelRows(filePath: string): ExcelRow[] {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(`[excel-products] File not found: ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const workbook = XLSX.readFile(filePath, {
|
||||
cellDates: false,
|
||||
cellNF: false,
|
||||
cellText: false
|
||||
});
|
||||
|
||||
// Get the first sheet
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// Convert to JSON
|
||||
const rows = XLSX.utils.sheet_to_json(worksheet, {
|
||||
defval: '',
|
||||
raw: false
|
||||
}) as ExcelRow[];
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error(`[excel-products] Error reading ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Excel index from all source files
|
||||
*/
|
||||
function getExcelIndex(): Map<string, ExcelMatch> {
|
||||
if (EXCEL_INDEX) return EXCEL_INDEX;
|
||||
|
||||
const idx = new Map<string, ExcelMatch>();
|
||||
|
||||
for (const file of EXCEL_SOURCE_FILES) {
|
||||
const rows = loadExcelRows(file);
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
// Find units row (if present)
|
||||
const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null;
|
||||
const units: Record<string, string> = {};
|
||||
|
||||
if (unitsRow) {
|
||||
for (const [k, v] of Object.entries(unitsRow)) {
|
||||
if (k === 'Part Number') continue;
|
||||
const unit = normalizeValue(String(v ?? ''));
|
||||
if (unit) units[k] = unit;
|
||||
}
|
||||
}
|
||||
|
||||
// Index rows by Part Number
|
||||
for (const r of rows) {
|
||||
const pn = r?.['Part Number'];
|
||||
if (!pn || pn === 'Units') continue;
|
||||
|
||||
const key = normalizeExcelKey(String(pn));
|
||||
if (!key) continue;
|
||||
|
||||
const cur = idx.get(key);
|
||||
if (!cur) {
|
||||
idx.set(key, { rows: [r], units });
|
||||
} else {
|
||||
cur.rows.push(r);
|
||||
// Keep the most comprehensive units
|
||||
if (Object.keys(cur.units).length < Object.keys(units).length) {
|
||||
cur.units = units;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EXCEL_INDEX = idx;
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Excel match for a product using various identifiers
|
||||
*/
|
||||
function findExcelForProduct(params: ProductLookupParams): ExcelMatch | null {
|
||||
const idx = getExcelIndex();
|
||||
|
||||
const candidates = [
|
||||
params.name,
|
||||
params.slug ? params.slug.replace(/-\d+$/g, '') : '',
|
||||
params.sku,
|
||||
params.translationKey,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const c of candidates) {
|
||||
const key = normalizeExcelKey(c);
|
||||
const match = idx.get(key);
|
||||
if (match && match.rows.length) return match;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess column key based on patterns
|
||||
*/
|
||||
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);
|
||||
|
||||
// Specific exclusions to prevent wrong matches
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique non-empty values from an array
|
||||
*/
|
||||
function getUniqueNonEmpty(options: string[]): string[] {
|
||||
const uniq: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const v of options.map(normalizeValue).filter(Boolean)) {
|
||||
const k = v.toLowerCase();
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
uniq.push(v);
|
||||
}
|
||||
return uniq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get technical data for a product from Excel files
|
||||
*/
|
||||
export function getExcelTechnicalDataForProduct(params: ProductLookupParams): TechnicalData | null {
|
||||
const match = findExcelForProduct(params);
|
||||
if (!match || match.rows.length === 0) return null;
|
||||
|
||||
const rows = match.rows;
|
||||
const sample = rows[0];
|
||||
|
||||
// Find cross-section column
|
||||
const csKey = guessColumnKey(sample, [
|
||||
/number of cores and cross-section/i,
|
||||
/cross.?section/i,
|
||||
/ross section conductor/i,
|
||||
]);
|
||||
|
||||
if (!csKey) return null;
|
||||
|
||||
// Extract configurations
|
||||
const voltageKey = guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]);
|
||||
|
||||
const configurations = rows
|
||||
.map(r => {
|
||||
const cs = normalizeValue(String(r?.[csKey] ?? ''));
|
||||
const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : '';
|
||||
if (!cs) return '';
|
||||
if (!v) return cs;
|
||||
const vHasUnit = /\bkv\b/i.test(v);
|
||||
const vText = vHasUnit ? v : `${v} kV`;
|
||||
return `${cs} - ${vText}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (configurations.length === 0) return null;
|
||||
|
||||
// Extract technical attributes
|
||||
const attributes: Array<{ name: string; options: string[] }> = [];
|
||||
|
||||
// Key technical columns
|
||||
const outerKey = guessColumnKey(sample, [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /außen/i]);
|
||||
const weightKey = guessColumnKey(sample, [/weight\b/i, /gewicht/i, /cable weight/i]);
|
||||
const dcResKey = guessColumnKey(sample, [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]);
|
||||
const ratedVoltKey = voltageKey;
|
||||
const testVoltKey = guessColumnKey(sample, [/test voltage/i, /prüfspannung/i]);
|
||||
const tempRangeKey = guessColumnKey(sample, [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]);
|
||||
const conductorKey = guessColumnKey(sample, [/^conductor$/i]);
|
||||
const insulationKey = guessColumnKey(sample, [/^insulation$/i]);
|
||||
const sheathKey = guessColumnKey(sample, [/^sheath$/i]);
|
||||
const normKey = guessColumnKey(sample, [/^norm$/i, /^standard$/i]);
|
||||
const cprKey = guessColumnKey(sample, [/cpr class/i]);
|
||||
const packagingKey = guessColumnKey(sample, [/^packaging$/i]);
|
||||
const shapeKey = guessColumnKey(sample, [/shape of conductor/i]);
|
||||
const flameKey = guessColumnKey(sample, [/flame retardant/i]);
|
||||
const diamCondKey = guessColumnKey(sample, [/diameter conductor/i]);
|
||||
const diamInsKey = guessColumnKey(sample, [/diameter over insulation/i]);
|
||||
const diamScreenKey = guessColumnKey(sample, [/diameter over screen/i]);
|
||||
const metalScreenKey = guessColumnKey(sample, [/metallic screen/i]);
|
||||
const capacitanceKey = guessColumnKey(sample, [/capacitance/i]);
|
||||
const reactanceKey = guessColumnKey(sample, [/reactance/i]);
|
||||
const electricalStressKey = guessColumnKey(sample, [/electrical stress/i]);
|
||||
const pullingForceKey = guessColumnKey(sample, [/max\. pulling force/i, /pulling force/i]);
|
||||
const heatingTrefoilKey = guessColumnKey(sample, [/heating time constant.*trefoil/i]);
|
||||
const heatingFlatKey = guessColumnKey(sample, [/heating time constant.*flat/i]);
|
||||
const currentAirTrefoilKey = guessColumnKey(sample, [/current ratings in air.*trefoil/i]);
|
||||
const currentAirFlatKey = guessColumnKey(sample, [/current ratings in air.*flat/i]);
|
||||
const currentGroundTrefoilKey = guessColumnKey(sample, [/current ratings in ground.*trefoil/i]);
|
||||
const currentGroundFlatKey = guessColumnKey(sample, [/current ratings in ground.*flat/i]);
|
||||
const scCurrentCondKey = guessColumnKey(sample, [/conductor shortcircuit current/i]);
|
||||
const scCurrentScreenKey = guessColumnKey(sample, [/screen shortcircuit current/i]);
|
||||
const minLayKey = guessColumnKey(sample, [/minimal temperature for laying/i]);
|
||||
const minStoreKey = guessColumnKey(sample, [/minimal storage temperature/i]);
|
||||
const maxOpKey = guessColumnKey(sample, [/maximal operating conductor temperature/i, /max\. operating/i]);
|
||||
const maxScKey = guessColumnKey(sample, [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]);
|
||||
const insThkKey = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]);
|
||||
const sheathThkKey = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i]);
|
||||
const maxResKey = guessColumnKey(sample, [/maximum resistance of conductor/i]);
|
||||
const bendKey = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]);
|
||||
|
||||
// Helper to add attribute
|
||||
const addAttr = (name: string, key: string | null, unit?: string) => {
|
||||
if (!key) return;
|
||||
const options = rows
|
||||
.map(r => normalizeValue(String(r?.[key] ?? '')))
|
||||
.map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v))
|
||||
.filter(Boolean);
|
||||
|
||||
if (options.length === 0) return;
|
||||
|
||||
const uniqueOptions = getUniqueNonEmpty(options);
|
||||
attributes.push({ name, options: uniqueOptions });
|
||||
};
|
||||
|
||||
// Add attributes
|
||||
addAttr('Outer diameter', outerKey, 'mm');
|
||||
addAttr('Weight', weightKey, 'kg/km');
|
||||
addAttr('DC resistance at 20 °C', dcResKey, 'Ω/km');
|
||||
addAttr('Rated voltage', ratedVoltKey, '');
|
||||
addAttr('Test voltage', testVoltKey, '');
|
||||
addAttr('Operating temperature range', tempRangeKey, '');
|
||||
addAttr('Minimal temperature for laying', minLayKey, '');
|
||||
addAttr('Minimal storage temperature', minStoreKey, '');
|
||||
addAttr('Maximal operating conductor temperature', maxOpKey, '');
|
||||
addAttr('Maximal short-circuit temperature', maxScKey, '');
|
||||
addAttr('Nominal insulation thickness', insThkKey, 'mm');
|
||||
addAttr('Nominal sheath thickness', sheathThkKey, 'mm');
|
||||
addAttr('Maximum resistance of conductor', maxResKey, 'Ω/km');
|
||||
addAttr('Conductor', conductorKey, '');
|
||||
addAttr('Insulation', insulationKey, '');
|
||||
addAttr('Sheath', sheathKey, '');
|
||||
addAttr('Standard', normKey, '');
|
||||
addAttr('Conductor diameter', diamCondKey, 'mm');
|
||||
addAttr('Insulation diameter', diamInsKey, 'mm');
|
||||
addAttr('Screen diameter', diamScreenKey, 'mm');
|
||||
addAttr('Metallic screen', metalScreenKey, '');
|
||||
addAttr('Max. pulling force', pullingForceKey, '');
|
||||
addAttr('Electrical stress conductor', electricalStressKey, '');
|
||||
addAttr('Electrical stress insulation', electricalStressKey, '');
|
||||
addAttr('Reactance', reactanceKey, '');
|
||||
addAttr('Heating time constant trefoil', heatingTrefoilKey, 's');
|
||||
addAttr('Heating time constant flat', heatingFlatKey, 's');
|
||||
addAttr('Flame retardant', flameKey, '');
|
||||
addAttr('CPR class', cprKey, '');
|
||||
addAttr('Packaging', packagingKey, '');
|
||||
addAttr('Bending radius', bendKey, 'mm');
|
||||
addAttr('Shape of conductor', shapeKey, '');
|
||||
addAttr('Capacitance', capacitanceKey, 'μF/km');
|
||||
addAttr('Current ratings in air, trefoil', currentAirTrefoilKey, 'A');
|
||||
addAttr('Current ratings in air, flat', currentAirFlatKey, 'A');
|
||||
addAttr('Current ratings in ground, trefoil', currentGroundTrefoilKey, 'A');
|
||||
addAttr('Current ratings in ground, flat', currentGroundFlatKey, 'A');
|
||||
addAttr('Conductor shortcircuit current', scCurrentCondKey, 'kA');
|
||||
addAttr('Screen shortcircuit current', scCurrentScreenKey, 'kA');
|
||||
|
||||
return {
|
||||
configurations,
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw Excel rows for a product (for detailed inspection)
|
||||
*/
|
||||
export function getExcelRowsForProduct(params: ProductLookupParams): ExcelRow[] {
|
||||
const match = findExcelForProduct(params);
|
||||
return match?.rows || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the Excel index cache (useful for development)
|
||||
*/
|
||||
export function clearExcelCache(): void {
|
||||
EXCEL_INDEX = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload Excel data on module initialization
|
||||
* This ensures the cache is built during build time
|
||||
*/
|
||||
export function preloadExcelData(): void {
|
||||
getExcelIndex();
|
||||
}
|
||||
|
||||
// Preload when imported
|
||||
if (require.main === module) {
|
||||
preloadExcelData();
|
||||
}
|
||||
Reference in New Issue
Block a user