Merge branch 'feature/excel' into feature-ai-search, resolve conflicts
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🏗️ Build (push) Successful in 4m11s
Build & Deploy / 🚀 Deploy (push) Successful in 45s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m2s
Build & Deploy / 🔔 Notify (push) Successful in 2s

This commit is contained in:
2026-03-21 15:46:12 +01:00
255 changed files with 30803 additions and 1041 deletions

View File

@@ -15,6 +15,7 @@ SOURCE_ENV="${1:-}" # local | testing | staging | prod
TARGET_ENV="${2:-}" # testing | staging | prod
SSH_HOST="root@alpha.mintel.me"
LOCAL_MEDIA_DIR="./public/media"
LOCAL_DATASHEETS_DIR="./public/datasheets"
DRY_RUN=""
CHECKSUM=""
@@ -38,6 +39,16 @@ get_media_path() {
esac
}
get_datasheets_path() {
case "$1" in
local) echo "$LOCAL_DATASHEETS_DIR" ;;
testing) echo "/home/deploy/sites/testing.klz-cables.com/public/datasheets" ;;
staging) echo "/home/deploy/sites/staging.klz-cables.com/public/datasheets" ;;
prod|production) echo "/home/deploy/sites/klz-cables.com/public/datasheets" ;;
*) echo "❌ Unknown environment: $1"; exit 1 ;;
esac
}
get_app_container() {
case "$1" in
testing) echo "klz-testing-klz-app-1" ;;
@@ -52,35 +63,39 @@ TGT_PATH=$(get_media_path "$TARGET_ENV")
TGT_CONTAINER=$(get_app_container "$TARGET_ENV")
echo "🚀 Syncing assets: $SOURCE_ENV$TARGET_ENV"
echo "📂 Source: $SRC_PATH"
echo "📂 Target: $TGT_PATH"
# ── Execution ──────────────────────────────────────────────────────────────
if [[ ! -d "$SRC_PATH" ]] && [[ "$SOURCE_ENV" == "local" ]]; then
echo "❌ Source directory does not exist: $SRC_PATH"
exit 1
fi
# ── Media Sync ─────────────────────────────────────────────────────────────
echo "🖼️ Syncing Media..."
if [[ "$SOURCE_ENV" == "local" ]]; then
# Local → Remote
echo "📡 Running rsync..."
rsync -avzi $CHECKSUM --delete --progress $DRY_RUN "$SRC_PATH/" "$SSH_HOST:$TGT_PATH/"
elif [[ "$TARGET_ENV" == "local" ]]; then
# Remote → Local
mkdir -p "$LOCAL_MEDIA_DIR"
echo "📡 Running rsync..."
rsync -avzi $CHECKSUM --delete --progress $DRY_RUN "$SSH_HOST:$SRC_PATH/" "$TGT_PATH/"
else
# Remote → Remote (e.g., testing → staging)
echo "📡 Running remote rsync..."
ssh "$SSH_HOST" "rsync -avzi $CHECKSUM --delete --progress $DRY_RUN $SRC_PATH/ $TGT_PATH/"
fi
# ── Datasheets Sync ────────────────────────────────────────────────────────
echo "📄 Syncing Datasheets..."
SRC_DS_PATH=$(get_datasheets_path "$SOURCE_ENV")
TGT_DS_PATH=$(get_datasheets_path "$TARGET_ENV")
if [[ "$SOURCE_ENV" == "local" ]]; then
ssh "$SSH_HOST" "mkdir -p $TGT_DS_PATH"
rsync -avzi $CHECKSUM --delete --progress $DRY_RUN "$SRC_DS_PATH/" "$SSH_HOST:$TGT_DS_PATH/"
elif [[ "$TARGET_ENV" == "local" ]]; then
mkdir -p "$LOCAL_DATASHEETS_DIR"
rsync -avzi $CHECKSUM --delete --progress $DRY_RUN "$SSH_HOST:$SRC_DS_PATH/" "$TGT_DS_PATH/"
else
ssh "$SSH_HOST" "mkdir -p $TGT_DS_PATH && rsync -avzi $CHECKSUM --delete --progress $DRY_RUN $SRC_DS_PATH/ $TGT_DS_PATH/"
fi
# Fix ownership on remote target if it's not local
if [[ "$TARGET_ENV" != "local" && -z "$DRY_RUN" ]]; then
echo "🔑 Fixing media file permissions on $TARGET_ENV..."
ssh "$SSH_HOST" "docker exec -u 0 $TGT_CONTAINER chown -R 1001:65533 /app/public/media/ 2>/dev/null || true"
echo "🔑 Fixing datasheet permissions..."
ssh "$SSH_HOST" "chown -R 1001:1001 $TGT_DS_PATH 2>/dev/null || true"
fi
echo "✅ Asset sync complete!"

View File

@@ -34,22 +34,27 @@ async function main() {
process.exit(1);
}
const contactUrl = urls.find((u) => u.includes('/de/kontakt'));
// Ensure we select an actual product page (depth >= 7: http://host/de/produkte/category/product)
const contactUrl = urls.find((u) => u.includes('/de/contact') || u.includes('/de/kontakt'));
// Ensure we select an actual product page (depth >= 4 segments: /de/produkte/category/product)
const productUrl = urls.find(
(u) =>
u.includes('/de/produkte/') && new URL(u).pathname.split('/').filter(Boolean).length >= 4,
(u.includes('/de/produkte/') || u.includes('/de/products/')) &&
new URL(u).pathname.split('/').filter(Boolean).length >= 4,
);
if (!contactUrl) {
console.error(`❌ Could not find contact page in sitemap. Ensure /de/kontakt exists.`);
console.error(
`❌ Could not find contact page in sitemap. Checked patterns: /de/contact, /de/kontakt`,
);
console.log('Available URLs (first 20):', urls.slice(0, 20));
process.exit(1);
}
if (!productUrl) {
console.error(
`❌ Could not find a product page in sitemap. Form testing requires at least one product page.`,
`❌ Could not find a product page in sitemap. Checked patterns: /de/produkte/, /de/products/`,
);
console.log('Available URLs (first 20):', urls.slice(0, 20));
process.exit(1);
}
@@ -66,32 +71,34 @@ async function main() {
const page = await browser.newPage();
// Set viewport for consistent layout
await page.setViewport({ width: 1280, height: 800 });
page.on('console', (msg) => console.log('💻 BROWSER CONSOLE:', msg.text()));
page.on('pageerror', (error) => console.error('💻 BROWSER ERROR:', error.message));
page.on('pageerror', (error: any) => console.error('💻 BROWSER ERROR:', error.message));
page.on('requestfailed', (request) => {
console.error('💻 BROWSER REQUEST FAILED:', request.url(), request.failure()?.errorText);
// Only log failures for main document and API calls to reduce noise
const resourceType = request.resourceType();
if (resourceType === 'document' || resourceType === 'fetch' || resourceType === 'xhr') {
console.error('💻 BROWSER REQUEST FAILED:', request.url(), request.failure()?.errorText);
}
});
// 3. Authenticate through Gatekeeper login form
console.log(`\n🛡 Authenticating through Gatekeeper...`);
// 3. Authenticate through Gatekeeper via Direct Cookie Insertion
console.log(`\n🛡 Authenticating through Gatekeeper via Cookie Injection...`);
try {
// Navigate to a protected page so Gatekeeper redirects us to the login screen
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
// Check if we landed on the Gatekeeper login page
const isGatekeeperPage = await page.$('input[name="password"]');
if (isGatekeeperPage) {
await page.type('input[name="password"]', gatekeeperPassword);
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 30000 }),
page.click('button[type="submit"]'),
]);
console.log(`✅ Gatekeeper authentication successful!`);
} else {
console.log(`✅ Already authenticated (no Gatekeeper gate detected).`);
}
const domain = new URL(targetUrl).hostname;
await page.setCookie({
name: 'klz_gatekeeper_session',
value: gatekeeperPassword,
domain: domain,
path: '/',
secure: true,
httpOnly: true,
});
console.log(`✅ Gatekeeper cookie injected for domain: ${domain}`);
} catch (err: any) {
console.error(`❌ Gatekeeper authentication failed: ${err.message}`);
console.error(`❌ Gatekeeper cookie injection failed: ${err.message}`);
await browser.close();
process.exit(1);
}
@@ -109,9 +116,15 @@ async function main() {
// Ensure form is visible and interactive
try {
// Find the form input by name
await page.waitForSelector('input[name="name"]', { visible: true, timeout: 15000 });
await page.waitForSelector('form#contact-form input[name="name"]', {
visible: true,
timeout: 15000,
});
} catch (e) {
console.error('Failed to find Contact Form input. Page Title:', await page.title());
console.error('Failed to find Contact Form input.');
console.log('Page Title:', await page.title());
const bodySnippet = await page.evaluate(() => document.body.innerText.slice(0, 500));
console.log('Page Content Snippet:', bodySnippet);
throw e;
}
@@ -119,10 +132,10 @@ async function main() {
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
// Fill form fields
await page.type('input[name="name"]', 'Automated E2E Test');
await page.type('input[name="email"]', 'testing@mintel.me');
await page.type('form#contact-form input[name="name"]', 'Automated E2E Test');
await page.type('form#contact-form input[name="email"]', 'testing@mintel.me');
await page.type(
'textarea[name="message"]',
'form#contact-form textarea[name="message"]',
'This is an automated test verifying the contact form submission.',
);
@@ -131,22 +144,26 @@ async function main() {
console.log(` Submitting Contact Form...`);
// Explicitly click submit and wait for navigation/state-change
// Explicitly click submit and wait for success state (using the success Card role="alert")
await Promise.all([
page.waitForSelector('[role="alert"]', { timeout: 15000 }),
page.click('button[type="submit"]'),
page.$eval('form#contact-form button[type="submit"]', (el) =>
(el as HTMLButtonElement).click(),
),
]);
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
console.log(` Alert text: ${alertText}`);
if (alertText?.includes('Failed') || alertText?.includes('went wrong')) {
throw new Error(`Form submitted but showed error: ${alertText}`);
const errorKeywords = ['Failed', 'went wrong', 'fehlgeschlagen', 'schief gelaufen'];
if (errorKeywords.some((kw) => alertText?.toLowerCase().includes(kw.toLowerCase()))) {
throw new Error(`Form submitted but showed error state: ${alertText}`);
}
console.log(`✅ Contact Form submitted successfully! (Success state verified)`);
console.log(`✅ Contact Form submitted successfully! (Success state verified via alert text)`);
} catch (err: any) {
console.error(`❌ Contact Form Test Failed: ${err.message}`);
await page.screenshot({ path: 'contact-form-error.png', fullPage: true });
hasErrors = true;
}
@@ -160,9 +177,15 @@ async function main() {
// The product form uses dynamic IDs, so we select by input type in the specific form context
try {
await page.waitForSelector('form input[type="email"]', { visible: true, timeout: 15000 });
await page.waitForSelector('form#quote-request-form input[type="email"]', {
visible: true,
timeout: 15000,
});
} catch (e) {
console.error('Failed to find Product Quote Form input. Page Title:', await page.title());
console.error('Failed to find Product Quote Form input.');
console.log('Page Title:', await page.title());
const bodySnippet = await page.evaluate(() => document.body.innerText.slice(0, 500));
console.log('Page Content Snippet:', bodySnippet);
throw e;
}
@@ -170,9 +193,9 @@ async function main() {
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
// In RequestQuoteForm, the email input is type="email" and message is a textarea.
await page.type('form input[type="email"]', 'testing@mintel.me');
await page.type('form#quote-request-form input[type="email"]', 'testing@mintel.me');
await page.type(
'form textarea',
'form#quote-request-form textarea',
'Automated request for product quote via E2E testing framework.',
);
@@ -184,17 +207,22 @@ async function main() {
// Submit and wait for success state
await Promise.all([
page.waitForSelector('[role="alert"]', { timeout: 15000 }),
page.click('form button[type="submit"]'),
page.$eval('form#quote-request-form button[type="submit"]', (el) =>
(el as HTMLButtonElement).click(),
),
]);
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
console.log(` Alert text: ${alertText}`);
if (alertText?.includes('Failed') || alertText?.includes('went wrong')) {
throw new Error(`Form submitted but showed error: ${alertText}`);
const errorKeywords = ['Failed', 'went wrong', 'fehlgeschlagen', 'schief gelaufen'];
if (errorKeywords.some((kw) => alertText?.toLowerCase().includes(kw.toLowerCase()))) {
throw new Error(`Product Quote Form submitted but showed error state: ${alertText}`);
}
console.log(`✅ Product Quote Form submitted successfully! (Success state verified)`);
console.log(
`✅ Product Quote Form submitted successfully! (Success state verified via alert text)`,
);
} catch (err: any) {
console.error(`❌ Product Quote Form Test Failed: ${err.message}`);
hasErrors = true;

View File

@@ -0,0 +1,41 @@
import { getPayload } from 'payload';
import config from '../payload.config';
async function run() {
try {
const payload = await getPayload({ config: await config });
const user = await payload.create({
collection: 'users',
data: {
email: 'agent@mintel.me',
password: 'agentpassword123',
},
});
console.log('SUCCESS: Created AI Agent admin user', user.id);
} catch (e) {
if (
e.message?.includes('duplicate key') ||
e.code === '11000' ||
String(e).includes('already exists')
) {
console.log('User already exists, attempting to update password...', e.message);
const payload = await getPayload({ config: await config });
const users = await payload.find({
collection: 'users',
where: { email: { equals: 'agent@mintel.me' } },
});
if (users.docs.length > 0) {
await payload.update({
collection: 'users',
id: users.docs[0].id,
data: { password: 'agentpassword123' },
});
console.log('SUCCESS: Updated existing AI Agent admin user password');
}
} else {
console.error('ERROR creating user:', e);
}
}
process.exit(0);
}
run();

64
scripts/debug-cms.ts Normal file
View File

@@ -0,0 +1,64 @@
import { getPayload } from 'payload';
import configPromise from '@payload-config';
async function main() {
const payload = await getPayload({ config: configPromise });
const result = await payload.find({
collection: 'products',
where: { slug: { equals: 'n2xsy' } },
locale: 'en' as any,
});
const doc = result.docs[0];
if (!doc) {
console.log('No doc found');
process.exit(0);
}
console.log('--- doc.title:', doc.title);
if (doc.content?.root?.children) {
const children = doc.content.root.children as any[];
console.log(`--- ${children.length} children found`);
for (let i = 0; i < children.length; i++) {
const child = children[i];
console.log(`\n[${i}] type=${child.type} blockType=${child.blockType}`);
if (child.fields) {
console.log(' fields keys:', Object.keys(child.fields));
if (child.fields.items) console.log(' fields.items count:', child.fields.items.length);
if (child.fields.technicalItems)
console.log(' fields.technicalItems count:', child.fields.technicalItems.length);
if (child.fields.voltageTables)
console.log(' fields.voltageTables count:', child.fields.voltageTables.length);
}
// Also check top-level (in case fields are flat)
const topKeys = Object.keys(child).filter(
(k) =>
![
'children',
'type',
'version',
'format',
'indent',
'direction',
'textFormat',
'textStyle',
'fields',
].includes(k),
);
if (topKeys.length > 0) console.log(' top-level keys:', topKeys);
if (child.items) console.log(' items (top-level) count:', child.items.length);
if (child.technicalItems)
console.log(' technicalItems (top-level) count:', child.technicalItems.length);
if (child.voltageTables)
console.log(' voltageTables (top-level) count:', child.voltageTables.length);
}
} else {
console.log('No content.root.children');
}
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

31
scripts/debug-product.ts Normal file
View File

@@ -0,0 +1,31 @@
import { getPayload } from 'payload';
import configPromise from '../payload.config';
async function debug() {
const payload = await getPayload({ config: configPromise });
const result = await payload.find({
collection: 'products',
where: {
slug: { equals: 'na2xsy' },
},
locale: 'de',
});
if (result.docs.length > 0) {
const doc = result.docs[0];
console.log('Product:', doc.title);
console.log(
'Content Blocks:',
JSON.stringify(
doc.content?.root?.children?.filter((n: any) => n.type === 'block'),
null,
2,
),
);
} else {
console.log('Product not found');
}
process.exit(0);
}
debug();

View File

@@ -0,0 +1,74 @@
import * as fs from 'fs';
import * as path from 'path';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
async function main() {
const payload = await getPayload({ config: configPromise });
const products: any[] = [];
for (const locale of ['en', 'de'] as const) {
const result = await payload.find({
collection: 'products',
locale: locale as any,
pagination: false,
});
for (const doc of result.docs) {
if (!doc.title || !doc.slug) continue;
const images: string[] = [];
if (doc.featuredImage) {
const url =
typeof doc.featuredImage === 'string'
? doc.featuredImage
: (doc.featuredImage as any).url;
if (url) images.push(url);
}
const categories = Array.isArray(doc.categories)
? doc.categories
.map((c: any) => ({ name: String(c.category || c) }))
.filter((c: any) => c.name)
: [];
const attributes: any[] = [];
if (Array.isArray((doc as any).content?.root?.children)) {
const ptBlock = (doc as any).content.root.children.find(
(n: any) => n.type === 'block' && n.fields?.blockType === 'productTabs',
);
if (ptBlock?.fields?.technicalItems) {
for (const item of ptBlock.fields.technicalItems) {
const label = item.unit ? `${item.label} [${item.unit}]` : item.label;
if (label && item.value) attributes.push({ name: label, options: [item.value] });
}
}
}
products.push({
id: doc.id,
name: doc.title,
shortDescriptionHtml: (doc as any).shortDescription || '',
descriptionHtml: '',
images,
featuredImage: images[0] || null,
sku: doc.slug,
slug: doc.slug,
translationKey: doc.slug,
locale,
categories,
attributes,
});
}
}
const outDir = path.join(process.cwd(), 'data/processed');
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
fs.writeFileSync(path.join(outDir, 'products.json'), JSON.stringify(products, null, 2));
console.log(`Exported ${products.length} products to data/processed/products.json`);
process.exit(0);
}
main();

View File

@@ -0,0 +1,510 @@
#!/usr/bin/env ts-node
/**
* Brochure Generator
*
* Generates a complete product catalog PDF brochure combining all products
* with company information, using ONLY data from Payload CMS.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as React from 'react';
import sharp from 'sharp';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
import { renderToBuffer } from '@react-pdf/renderer';
// pdf-lib removed: no longer bundling individual datasheets
import { PDFBrochure, type BrochureProduct, type BrochureProps } from '../lib/pdf-brochure';
import { getDatasheetPath } from '../lib/datasheets';
import { mapFileSlugToTranslated } from '../lib/slugs';
const CONFIG = {
outputDir: path.join(process.cwd(), 'public/brochure'),
host: process.env.NEXT_PUBLIC_SITE_URL || 'https://klz-cables.com',
} as const;
// ─── Helpers ────────────────────────────────────────────────────────────────
async function resolveImage(url: string): Promise<string | Buffer> {
if (!url) return '';
let localPath = '';
// If it's a Payload media URL like /api/media/file/filename.ext
if (url.startsWith('/api/media/file/')) {
const filename = url.replace('/api/media/file/', '');
localPath = path.join(process.cwd(), 'public/media', filename);
} else if (url.startsWith('/media/')) {
localPath = path.join(process.cwd(), 'public', url);
}
if (localPath && fs.existsSync(localPath)) {
// If it's webp, convert to png buffer for react-pdf
if (localPath.toLowerCase().endsWith('.webp')) {
try {
return await sharp(localPath).png().toBuffer();
} catch (err) {
return localPath;
}
}
return localPath;
}
// Fallback to absolute URL if starting with /
if (url.startsWith('/')) return `${CONFIG.host}${url}`;
return url;
}
function stripHtml(html: string): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '').trim();
}
function ensureOutputDir(): void {
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
}
async function fetchQrCodeBuffer(url: string): Promise<Buffer | undefined> {
if (!url) return undefined;
try {
const qrApi = `https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=${encodeURIComponent(url)}&margin=0`;
const res = await fetch(qrApi);
if (res.ok) {
const arrayBuffer = await res.arrayBuffer();
return Buffer.from(arrayBuffer);
} else {
console.error(` [QR] Failed (HTTP ${res.status}) for ${url}`);
}
} catch (err) {
console.error(` [QR] Failed for ${url}:`, err);
}
return undefined;
}
async function resolveLocalFile(relativePath: string): Promise<string | Buffer | undefined> {
const abs = path.join(process.cwd(), 'public', relativePath);
if (!fs.existsSync(abs)) return undefined;
if (abs.endsWith('.svg')) {
try {
const svgBuf = fs.readFileSync(abs);
return await sharp(svgBuf).resize(600).png().toBuffer();
} catch {
return abs;
}
}
return abs;
}
// ─── CMS Product Loading ────────────────────────────────────────────────────
async function loadProducts(locale: 'en' | 'de'): Promise<BrochureProduct[]> {
const products: BrochureProduct[] = [];
try {
const payload = await getPayload({ config: configPromise });
const isDev = process.env.NODE_ENV === 'development';
const result = await payload.find({
collection: 'products',
where: {
...(!isDev ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
pagination: false,
});
const productsSlug = await mapFileSlugToTranslated('products', locale);
let id = 1;
for (const doc of result.docs) {
if (!doc.title || !doc.slug) continue;
const images: any[] = [];
const rawImages: string[] = [];
if (doc.featuredImage) {
const url =
typeof doc.featuredImage === 'string'
? doc.featuredImage
: (doc.featuredImage as any).url;
if (url) rawImages.push(url);
}
if (Array.isArray(doc.images)) {
for (const img of doc.images) {
const url = typeof img === 'string' ? img : (img as any).url;
if (url && !rawImages.includes(url)) rawImages.push(url);
}
}
for (const url of rawImages) {
const resolved = await resolveImage(url);
if (resolved) images.push(resolved);
}
const attributes: any[] = [];
// Extract basic technical attributes from Lexical AST if present
if (Array.isArray(doc.content?.root?.children)) {
const productTabsBlock = doc.content.root.children.find(
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
);
if (productTabsBlock && productTabsBlock.fields) {
if (Array.isArray(productTabsBlock.fields.technicalItems)) {
for (const item of productTabsBlock.fields.technicalItems) {
const label = item.unit ? `${item.label} [${item.unit}]` : item.label;
if (label && item.value) {
attributes.push({
name: label,
options: [String(item.value)],
});
}
}
}
}
}
const categories = Array.isArray(doc.categories)
? doc.categories
.map((c: any) => ({ name: String(c.category || c), slug: String(c.slug || c) }))
.filter((c: any) => c.name)
: [];
// Compute QR URLs
let qrWebsiteUrl = '';
if (categories.length > 0 && categories[0].slug) {
const catTranslatedSlug = await mapFileSlugToTranslated(categories[0].slug, locale);
qrWebsiteUrl = `${CONFIG.host}/${locale}/${productsSlug}/${catTranslatedSlug}/${doc.slug}`;
}
let qrDatasheetUrl = '';
const datasheetRelativePath = getDatasheetPath(String(doc.slug), locale);
if (datasheetRelativePath) {
qrDatasheetUrl = `${CONFIG.host}${datasheetRelativePath}`;
}
const [qrWebsite, qrDatasheet] = await Promise.all([
qrWebsiteUrl ? fetchQrCodeBuffer(qrWebsiteUrl) : Promise.resolve(undefined),
qrDatasheetUrl ? fetchQrCodeBuffer(qrDatasheetUrl) : Promise.resolve(undefined),
]);
products.push({
id: id++,
name: String(doc.title),
slug: String(doc.slug),
sku: String(doc.sku || ''),
shortDescriptionHtml: '',
descriptionHtml: stripHtml(String(doc.description || '')),
images: images as any, // mix of paths and buffers
featuredImage: images[0] || null,
categories,
attributes,
qrWebsite,
qrDatasheet,
});
console.log(` - ${doc.title} (QR: ${qrWebsite ? 'Web ' : ''}${qrDatasheet ? 'PDF' : ''})`);
}
} catch (error) {
console.error(`[Payload] Failed to fetch products (${locale}):`, error);
}
products.sort((a, b) => a.name.localeCompare(b.name));
// FILTER: Only include products that have images for the high-fidelity brochure
const filteredProducts = products.filter((p) => p.images.length > 0 || p.featuredImage);
console.log(
` Filtered: ${filteredProducts.length} products with images (out of ${products.length})`,
);
return filteredProducts;
}
// ─── CMS Start/Intro Page ───────────────────────────────────────────────────
async function loadIntroContent(
locale: 'en' | 'de',
): Promise<BrochureProps['introContent'] | undefined> {
try {
const payload = await getPayload({ config: configPromise });
const result = await payload.find({
collection: 'pages',
where: { slug: { equals: 'start' } },
locale: locale as any,
});
if (result.docs.length > 0) {
const doc = result.docs[0];
const heroUrl =
typeof doc.featuredImage === 'string' ? doc.featuredImage : (doc.featuredImage as any)?.url;
const heroImage = await resolveImage(heroUrl);
return {
title: String(doc.title),
excerpt: String(doc.excerpt || ''),
heroImage: heroImage as any,
};
}
} catch (error) {
console.error(`[Payload] Failed to fetch intro content (${locale}):`, error);
}
return undefined;
}
// ─── Marketing Sections ───────────────────────────────────────────────────
async function loadMarketingSections(
locale: 'en' | 'de',
): Promise<BrochureProps['marketingSections'] | undefined> {
try {
const messagesPath = path.join(process.cwd(), `messages/${locale}.json`);
const messagesJson = fs.readFileSync(messagesPath, 'utf-8');
const messages = JSON.parse(messagesJson);
const sections: NonNullable<BrochureProps['marketingSections']> = [];
// ── 1. Was wir tun + Warum wir — MERGED into one compact section ──
{
const allItems: Array<{ title: string; description: string }> = [];
// WhatWeDo items — truncated to 1 sentence each
if (messages.Home?.whatWeDo?.items) {
for (const item of messages.Home.whatWeDo.items) {
allItems.push({
title: item.title.split('.')[0], // short title
description: item.description.split('.')[0] + '.',
});
}
}
// WhyChooseUs items — truncated to 1 sentence each
if (messages.Home?.whyChooseUs?.items) {
for (const item of messages.Home.whyChooseUs.items) {
allItems.push({
title: item.title,
description: item.description.split('.')[0] + '.',
});
}
}
sections.push({
title: messages.Home?.whatWeDo?.title || (locale === 'de' ? 'Was wir tun' : 'What We Do'),
subtitle: locale === 'de' ? 'Leistungen & Stärken' : 'Services & Strengths',
description: messages.Home?.whatWeDo?.subtitle || '',
items: allItems,
});
}
// ── 2. Experience & Quality — merge Legacy + Experience highlights ──
{
const legacy = messages.Team?.legacy;
const experience = messages.Home?.experience;
const highlights: Array<{ value: string; label: string }> = [];
if (legacy) {
highlights.push(
{ value: legacy.expertise || 'Expertise', label: legacy.expertiseDesc || '' },
{
value: legacy.network || (locale === 'de' ? 'Netzwerk' : 'Network'),
label: legacy.networkDesc || '',
},
);
}
if (experience) {
highlights.push(
{
value: experience.certifiedQuality || (locale === 'de' ? 'Zertifiziert' : 'Certified'),
label: experience.vdeApproved || '',
},
{
value:
experience.fullSpectrum || (locale === 'de' ? 'Volles Spektrum' : 'Full Spectrum'),
label: experience.solutionsRange || '',
},
);
}
const desc = legacy?.p1 || '';
sections.push({
title: legacy?.title || (locale === 'de' ? 'Erfahrung & Qualität' : 'Experience & Quality'),
subtitle: locale === 'de' ? 'Unser Erbe' : 'Our Heritage',
description: desc,
highlights,
});
}
return sections.length > 0 ? sections : undefined;
} catch (error) {
console.error(`[Messages] Failed to fetch marketing sections (${locale}):`, error);
}
return undefined;
}
// ─── Company Info ───────────────────────────────────────────────────────────
function getCompanyInfo(locale: 'en' | 'de'): BrochureProps['companyInfo'] {
const values =
locale === 'de'
? [
{
title: 'Kompetenz',
description: 'Jahrzehntelange Erfahrung und europaweites Know-how.',
},
{ title: 'Verfügbarkeit', description: 'Immer für Sie da schnelle Unterstützung.' },
{ title: 'Lösungen', description: 'Wir finden die beste Kabellösung für Ihr Projekt.' },
{ title: 'Zuverlässigkeit', description: 'Wir halten, was wir versprechen.' },
]
: [
{ title: 'Competence', description: 'Decades of experience and Europe-wide know-how.' },
{ title: 'Availability', description: 'Always there for you fast support.' },
{ title: 'Solutions', description: 'We find the best cable solution for your project.' },
{ title: 'Reliability', description: 'We deliver what we promise.' },
];
return {
tagline:
locale === 'de'
? 'Wegweisend in der Kabelinfrastruktur.'
: 'Leading the way in cable infrastructure.',
values,
address: 'Raiffeisenstraße 22, 73630 Remshalden, Germany',
phone: '+49 (0) 7151 959 89-0',
email: 'info@klz-cables.com',
website: 'www.klz-cables.com',
};
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const start = Date.now();
console.log('Starting brochure generation (Full Brochure with website content)');
ensureOutputDir();
const locales: Array<'en' | 'de'> = ['en', 'de'];
// Load the REAL logos (not the favicon/icon!)
const logoWhitePath = path.join(process.cwd(), 'public/logo-white.png');
const logoBlackPath = path.join(process.cwd(), 'public/logo-black.png');
const logoFallbackPath = path.join(process.cwd(), 'public/logo.png');
const logoWhite = fs.existsSync(logoWhitePath) ? logoWhitePath : undefined;
const logoBlack = fs.existsSync(logoBlackPath)
? logoBlackPath
: fs.existsSync(logoFallbackPath)
? logoFallbackPath
: undefined;
console.log(`Logos: white=${!!logoWhite} black=${!!logoBlack}`);
// EXACT image mapping — 2 marketing sections now
// Index map: 0=Cover, 1=About, 2=WasWirTun(null), 3=Erfahrung(Legacy image), 4=BackCover
const galleryPaths: Array<string | null> = [
'uploads/2024/12/large-rolls-of-wires-against-the-blue-sky-at-sunse-2023-11-27-05-20-33-utc-Large.webp', // 0: Cover (cable drums, no people)
'uploads/2024/12/DSC07460-Large-600x400.webp', // 1: About section
null, // 2: Was wir tun (NO IMAGE — text-heavy)
'uploads/2024/12/1694273920124-copy.webp', // 3: Erfahrung & Qualität
'uploads/2024/12/DSC07433-Large-600x400.webp', // 4: Back cover
];
const galleryImages: (string | Buffer | undefined)[] = [];
for (const gp of galleryPaths) {
if (!gp) {
galleryImages.push(undefined);
continue;
}
const fullPath = path.join(process.cwd(), 'public', gp);
if (fs.existsSync(fullPath)) {
try {
const buf = await sharp(fullPath).png({ quality: 80 }).resize(800).toBuffer();
galleryImages.push(buf);
} catch {
galleryImages.push(undefined);
}
} else {
galleryImages.push(undefined);
}
}
console.log(
`Gallery images mapping complete. Succeeded bindings: ${galleryImages.filter((b) => b !== undefined).length}`,
);
for (const locale of locales) {
console.log(`\nGenerating ${locale.toUpperCase()} brochure...`);
const [products, introContent, marketingSections] = await Promise.all([
loadProducts(locale),
loadIntroContent(locale),
loadMarketingSections(locale),
]);
if (products.length === 0) continue;
const companyInfo = getCompanyInfo(locale);
// Load messages for About page content (directors, legacy, etc.)
let messages: Record<string, any> | undefined;
try {
const messagesPath = path.join(process.cwd(), `messages/${locale}.json`);
messages = JSON.parse(fs.readFileSync(messagesPath, 'utf-8'));
} catch {
/* messages are optional */
}
// Load director portrait photos and crop to circles
const directorPhotos: { michael?: Buffer; klaus?: Buffer } = {};
const portraitPaths = {
michael: path.join(process.cwd(), 'public/uploads/2024/12/DSC07768-Large.webp'),
klaus: path.join(process.cwd(), 'public/uploads/2024/12/DSC07963-Large.webp'),
};
const AVATAR_SIZE = 120; // px, will be rendered at 32pt in PDF
const circleMask = Buffer.from(
`<svg width="${AVATAR_SIZE}" height="${AVATAR_SIZE}"><circle cx="${AVATAR_SIZE / 2}" cy="${AVATAR_SIZE / 2}" r="${AVATAR_SIZE / 2}" fill="white"/></svg>`,
);
for (const [key, photoPath] of Object.entries(portraitPaths)) {
if (fs.existsSync(photoPath)) {
try {
const cropped = await sharp(photoPath)
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'top' })
.composite([{ input: circleMask, blend: 'dest-in' }])
.png()
.toBuffer();
directorPhotos[key as 'michael' | 'klaus'] = cropped;
} catch {
/* skip */
}
}
}
try {
// Render the React-PDF brochure
const buffer = await renderToBuffer(
React.createElement(PDFBrochure, {
products,
locale,
companyInfo,
introContent,
marketingSections,
logoBlack,
logoWhite,
galleryImages,
messages,
directorPhotos,
} as any) as any,
);
// Write final PDF
const outPath = path.join(process.cwd(), `public/brochure/klz-product-catalog-${locale}.pdf`);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, buffer);
const sizeKB = Math.round(buffer.length / 1024);
console.log(` ✓ Generated: klz-product-catalog-${locale}.pdf (${sizeKB} KB)`);
} catch (error) {
console.error(` ✗ Failed to generate ${locale} brochure:`, error);
}
}
console.log(`\n✅ Done!`);
console.log(`Output: ${CONFIG.outputDir}`);
console.log(`Time: ${((Date.now() - start) / 1000).toFixed(2)}s`);
}
main().catch(console.error);

View File

@@ -0,0 +1,222 @@
#!/usr/bin/env ts-node
/**
* Excel Datasheet Generator
*
* Generates per-product .xlsx datasheets using ONLY data from Payload CMS.
* No external Excel files required.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as XLSX from 'xlsx';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
import { buildExcelModel, ProductData as ExcelProductData } from './lib/excel-data-parser';
const CONFIG = {
outputDir: path.join(process.cwd(), 'public/datasheets'),
} as const;
// ─── Types ──────────────────────────────────────────────────────────────────────
interface ProductData {
title: string;
slug: string;
sku: string;
locale: string;
categories: string[];
description: string;
technicalItems: Array<{ label: string; value: string; unit?: string }>;
voltageTables: Array<{
voltageLabel: string;
metaItems: Array<{ label: string; value: string; unit?: string }>;
columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>;
crossSections: string[];
}>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────────
function stripHtml(html: string): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '').trim();
}
function ensureOutputDir(): void {
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
}
// ─── CMS Product Loading ────────────────────────────────────────────────────────
async function fetchProductsFromCMS(locale: 'en' | 'de'): Promise<ProductData[]> {
const products: ProductData[] = [];
try {
const payload = await getPayload({ config: configPromise });
const isDev = process.env.NODE_ENV === 'development';
const result = await payload.find({
collection: 'products',
where: {
...(!isDev ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
pagination: false,
});
for (const doc of result.docs) {
if (!doc.title || !doc.slug) continue;
const excelProductData: ExcelProductData = {
name: String(doc.title),
slug: String(doc.slug),
sku: String(doc.sku || ''),
locale,
};
const parsedModel = buildExcelModel({ product: excelProductData, locale });
products.push({
title: String(doc.title),
slug: String(doc.slug),
sku: String(doc.sku || ''),
locale,
categories: Array.isArray(doc.categories)
? doc.categories.map((c: any) => String(c.category || c)).filter(Boolean)
: [],
description: stripHtml(String(doc.description || '')),
technicalItems: parsedModel.ok ? parsedModel.technicalItems : [],
voltageTables: parsedModel.ok ? parsedModel.voltageTables : [],
});
}
} catch (error) {
console.error(`[Payload] Failed to fetch products (${locale}):`, error);
}
return products;
}
// ─── Excel Generation ───────────────────────────────────────────────────────────
function generateExcelForProduct(product: ProductData): Buffer {
const workbook = XLSX.utils.book_new();
const l = product.locale === 'de';
const allRows: any[][] = [];
// --- 1. Product Meta Data ---
allRows.push([product.title]);
const categoriesLine = product.categories.join(' • ');
if (categoriesLine) {
allRows.push([l ? 'Kategorien:' : 'Categories:', categoriesLine]);
}
if (product.sku) {
allRows.push([l ? 'Artikelnummer:' : 'SKU:', product.sku]);
}
allRows.push([]); // blank row
// --- 2. Application / Description ---
if (product.description) {
allRows.push([l ? 'ANWENDUNG' : 'APPLICATION']);
allRows.push([product.description]);
allRows.push([]); // blank row
}
// --- 3. Technical Specifications ---
if (product.technicalItems && product.technicalItems.length > 0) {
allRows.push([l ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA']);
for (const item of product.technicalItems) {
const val = item.unit ? `${item.value} ${item.unit}` : item.value;
allRows.push([item.label, val]);
}
allRows.push([]); // blank row
}
// --- 4. Cross-section Configurations ---
const hasMultipleVoltages = product.voltageTables.length > 1;
if (product.voltageTables.length > 0) {
allRows.push([l ? 'KONFIGURATIONEN' : 'CONFIGURATIONS']);
const refTable = product.voltageTables[0];
const headers: string[] = [
l ? 'Querschnitt' : 'Cross-section',
...(hasMultipleVoltages ? [l ? 'Spannung' : 'Voltage'] : []),
...refTable.columns.map((c) => c.label),
];
allRows.push(headers);
for (const table of product.voltageTables) {
for (let rowIdx = 0; rowIdx < table.crossSections.length; rowIdx++) {
const row: string[] = [
table.crossSections[rowIdx],
...(hasMultipleVoltages ? [table.voltageLabel] : []),
...table.columns.map((c) => c.get(rowIdx) || '-'),
];
allRows.push(row);
}
}
} else {
allRows.push([l ? 'Keine Querschnittsdaten verfügbar' : 'No cross-section data available']);
}
const ws = XLSX.utils.aoa_to_sheet(allRows);
// Auto-width: Col 0 wide for description, headers.
ws['!cols'] = [
{ wch: 45 },
{ wch: 20 },
{ wch: 15 },
{ wch: 15 },
{ wch: 15 },
{ wch: 15 },
{ wch: 15 },
{ wch: 15 },
];
const sheetName = product.title.substring(0, 31);
XLSX.utils.book_append_sheet(workbook, ws, sheetName);
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
return Buffer.from(buffer);
}
// ─── Main ───────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const start = Date.now();
console.log('Starting Excel datasheet generation (Legacy Excel Source)');
ensureOutputDir();
const locales: Array<'en' | 'de'> = ['en', 'de'];
let generated = 0;
for (const locale of locales) {
console.log(`\n[${locale.toUpperCase()}] Fetching products...`);
const products = await fetchProductsFromCMS(locale);
console.log(`Found ${products.length} products.`);
for (const product of products) {
try {
const buffer = generateExcelForProduct(product);
const fileName = `${product.slug}-${locale}.xlsx`;
const subfolder = path.join(CONFIG.outputDir, 'products');
if (!fs.existsSync(subfolder)) fs.mkdirSync(subfolder, { recursive: true });
fs.writeFileSync(path.join(subfolder, fileName), buffer);
console.log(`✓ Generated: ${fileName}`);
generated++;
} catch (error) {
console.error(`✗ Failed for ${product.title}:`, error);
}
}
}
console.log(`\n✅ Done! Generated ${generated} files.`);
console.log(`Output: ${CONFIG.outputDir}`);
console.log(`Time: ${((Date.now() - start) / 1000).toFixed(2)}s`);
}
main().catch(console.error);

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,9 @@
* - All technical data + cross-section tables: Excel files in `data/excel/`
* - Product description text: Fetched dynamically from Payload CMS
*/
// pg-pool in Node v24 leaves dangling promises when connection fails.
// Suppress the crash so the script can fall back to Excel-only mode.
process.on('unhandledRejection', () => {});
import * as fs from 'fs';
import * as path from 'path';
@@ -15,10 +18,17 @@ import * as path from 'path';
import * as XLSX from 'xlsx';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
import { renderToBuffer } from '@react-pdf/renderer';
import * as React from 'react';
import type { ProductData } from './pdf/model/types';
import { generateDatasheetPdfBuffer } from './pdf/react-pdf/generate-datasheet-pdf';
import type { ProductData as BaseProductData } from './pdf/model/types';
interface ProductData extends BaseProductData {
voltageType: string;
}
import { buildDatasheetModel } from './pdf/model/build-datasheet-model';
import { loadImageAsPngDataUrl, loadQrAsPngDataUrl } from './pdf/react-pdf/assets';
import { generateFileName, normalizeValue, stripHtml } from './pdf/model/utils';
import { PDFDatasheet } from '../lib/pdf-datasheet';
const CONFIG = {
outputDir: path.join(process.cwd(), 'public/datasheets'),
@@ -31,10 +41,6 @@ const EXCEL_FILES = [
path: path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'),
voltageType: 'medium-voltage',
},
{
path: path.join(process.cwd(), 'data/excel/medium-voltage-KM 170126.xlsx'),
voltageType: 'medium-voltage',
},
{ path: path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'), voltageType: 'low-voltage' },
{ path: path.join(process.cwd(), 'data/excel/solar-cables.xlsx'), voltageType: 'solar' },
] as const;
@@ -67,6 +73,7 @@ function normalizeExcelKey(value: string): string {
async function buildCmsIndex(locale: 'en' | 'de'): Promise<CmsIndex> {
const idx: CmsIndex = new Map();
try {
// Attempt to connect to DB, but don't fail the whole script if it fails
const payload = await getPayload({ config: configPromise });
const isDev = process.env.NODE_ENV === 'development';
const result = await payload.find({
@@ -94,7 +101,7 @@ async function buildCmsIndex(locale: 'en' | 'de'): Promise<CmsIndex> {
: [];
const descriptionHtml = normalizeValue(String(doc.description || ''));
const applicationHtml = ''; // Application usually part of description in Payload now
const applicationHtml = '';
const slug = doc.slug || '';
idx.set(normalizeExcelKey(title), {
@@ -108,7 +115,10 @@ async function buildCmsIndex(locale: 'en' | 'de'): Promise<CmsIndex> {
});
}
} catch (error) {
console.error(`[Payload] Failed to fetch products for CMS index (${locale}):`, error);
console.warn(
`[Payload] Warning: Could not fetch CMS index (${locale}). Using Excel data only.`,
error instanceof Error ? error.message : error,
);
}
return idx;
@@ -124,26 +134,38 @@ function findKeyByHeaderValue(headerRow: Record<string, unknown>, pattern: RegEx
}
function readExcelRows(filePath: string): Array<Record<string, unknown>> {
if (!fs.existsSync(filePath)) return [];
const workbook = XLSX.readFile(filePath, { cellDates: false, cellNF: false, cellText: false });
const sheetName = workbook.SheetNames[0];
if (!sheetName) return [];
const sheet = workbook.Sheets[sheetName];
if (!sheet) return [];
if (!fs.existsSync(filePath)) {
console.warn(`[Excel] Warning: File not found: ${filePath}`);
return [];
}
try {
const data = fs.readFileSync(filePath);
const workbook = XLSX.read(data, {
type: 'buffer',
cellDates: false,
cellNF: false,
cellText: false,
});
const sheetName = workbook.SheetNames[0];
if (!sheetName) return [];
const sheet = workbook.Sheets[sheetName];
if (!sheet) return [];
return XLSX.utils.sheet_to_json(sheet, {
defval: '',
raw: false,
blankrows: false,
}) as Array<Record<string, unknown>>;
return XLSX.utils.sheet_to_json(sheet, {
defval: '',
raw: false,
blankrows: false,
}) as Array<Record<string, unknown>>;
} catch (err) {
console.error(`[Excel] Failed to read ${filePath}:`, err);
return [];
}
}
function readDesignationsFromExcelFile(filePath: string): Map<string, string> {
const rows = readExcelRows(filePath);
if (!rows.length) return new Map();
// Legacy sheets use "Part Number" as a column key.
// The new MV sheet uses __EMPTY* keys and stores the human headers in row 0 values.
const headerRow = rows[0] || {};
const partNumberKey =
(Object.prototype.hasOwnProperty.call(headerRow, 'Part Number') ? 'Part Number' : null) ||
@@ -158,7 +180,6 @@ function readDesignationsFromExcelFile(filePath: string): Map<string, string> {
const key = normalizeExcelKey(pn);
if (!key) continue;
// Keep first-seen designation string (stable filenames from MDX slug).
if (!out.has(key)) out.set(key, pn);
}
@@ -195,8 +216,6 @@ async function loadProductsFromExcelAndCms(locale: 'en' | 'de'): Promise<Product
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
// Only the product description comes from CMS. Everything else is Excel-driven
// during model building (technicalItems + voltage tables).
const descriptionHtml = cmsItem?.descriptionHtml || '';
products.push({
@@ -204,7 +223,6 @@ async function loadProductsFromExcelAndCms(locale: 'en' | 'de'): Promise<Product
name: title,
shortDescriptionHtml: '',
descriptionHtml,
applicationHtml: cmsItem?.applicationHtml || '',
images: cmsItem?.images || [],
featuredImage: (cmsItem?.images && cmsItem.images[0]) || null,
sku: cmsItem?.sku || title,
@@ -222,12 +240,10 @@ async function loadProductsFromExcelAndCms(locale: 'en' | 'de'): Promise<Product
});
});
// Deterministic order: by slug, then name.
products.sort(
(a, b) => (a.slug || '').localeCompare(b.slug || '') || a.name.localeCompare(b.name),
);
// Drop products that have no readable name.
return products.filter((p) => stripHtml(p.name));
}
@@ -243,29 +259,60 @@ async function processChunk(
for (const product of products) {
try {
const locale = (product.locale || 'en') as 'en' | 'de';
const buffer = await generateDatasheetPdfBuffer({ product, locale });
const fileName = generateFileName(product, locale);
console.log(`[${product.id}] Starting: ${product.name} (${locale})`);
// Determine subfolder based on voltage type
const model = buildDatasheetModel({ product, locale });
if (!model) {
console.warn(`[${product.id}] Warning: buildDatasheetModel returned nothing`);
continue;
}
// Load assets as Data URLs for React-PDF
const [heroDataUrl, logoDataUrl] = await Promise.all([
loadImageAsPngDataUrl(model.product.heroSrc),
loadImageAsPngDataUrl('/logo-black.svg'),
]);
const fileName = generateFileName(product, locale);
const voltageType = (product as any).voltageType || 'other';
const subfolder = path.join(CONFIG.outputDir, voltageType);
// Create subfolder if it doesn't exist
if (!fs.existsSync(subfolder)) {
fs.mkdirSync(subfolder, { recursive: true });
}
// Render using the unified component
const element = (
<PDFDatasheet
product={
{
...model.product,
featuredImage: heroDataUrl,
logoDataUrl,
} as any
}
locale={locale}
technicalItems={model.technicalItems}
voltageTables={model.voltageTables}
legendItems={model.legendItems}
logoDataUrl={logoDataUrl}
/>
);
const buffer = await renderToBuffer(element);
fs.writeFileSync(path.join(subfolder, fileName), buffer);
console.log(`${locale.toUpperCase()}: ${voltageType}/${fileName}`);
await new Promise((resolve) => setTimeout(resolve, 25));
} catch (error) {
console.error(`✗ Failed to process product ${product.id}:`, error);
console.error(`✗ Failed to process product ${product.id} (${product.name}):`, error);
}
}
}
async function processProductsInChunks(): Promise<void> {
console.log('Starting PDF generation (React-PDF)');
console.log('Starting PDF generation (React-PDF - Unified Component)');
ensureOutputDir();
const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase();
@@ -283,8 +330,6 @@ async function processProductsInChunks(): Promise<void> {
return;
}
// Dev convenience: generate only one product subset.
// IMPORTANT: apply filters BEFORE PDF_LIMIT so the limit works within the filtered set.
let products = allProducts;
const match = normalizeValue(String(process.env.PDF_MATCH || '')).toLowerCase();

17
scripts/inspect-pages.ts Normal file
View File

@@ -0,0 +1,17 @@
import { getPayload } from 'payload';
import configPromise from '@payload-config';
async function run() {
const payload = await getPayload({ config: configPromise });
const resEn = await payload.find({ collection: 'pages', locale: 'en' as any });
const resDe = await payload.find({ collection: 'pages', locale: 'de' as any });
console.log('EN Pages:');
resEn.docs.forEach((d) => console.log(`- ${d.slug}: ${d.title}`));
console.log('DE Pages:');
resDe.docs.forEach((d) => console.log(`- ${d.slug}: ${d.title}`));
process.exit(0);
}
run();

30
scripts/inspect-start.ts Normal file
View File

@@ -0,0 +1,30 @@
import { getPayload } from 'payload';
import configPromise from '../payload.config';
async function inspectStart() {
const payload = await getPayload({ config: configPromise });
const result = await payload.find({
collection: 'pages',
where: { slug: { equals: 'start' } },
locale: 'de',
});
if (result.docs.length > 0) {
const doc = result.docs[0];
console.log('Start Page:', doc.title);
console.log('Excerpt:', doc.excerpt);
// Print block types in content
if (doc.content?.root?.children) {
const blocks = doc.content.root.children.filter((n: any) => n.type === 'block');
console.log(
'Blocks found:',
blocks.map((b: any) => b.blockType || b.type),
);
}
} else {
console.log('Start page not found');
}
process.exit(0);
}
inspectStart();

14
scripts/inspect-start2.ts Normal file
View File

@@ -0,0 +1,14 @@
import { getPayload } from 'payload';
import configPromise from '../payload.config';
async function main() {
const payload = await getPayload({ config: configPromise });
const result = await payload.find({
collection: 'pages',
where: { slug: { equals: 'start' } },
locale: 'de',
depth: 2,
});
console.log(JSON.stringify(result.docs[0], null, 2));
}
main().catch(console.error);

View File

@@ -0,0 +1,998 @@
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
const EXCEL_SOURCE_FILES = [
path.join(process.cwd(), 'data/excel/high-voltage.xlsx'),
path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'),
path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'),
path.join(process.cwd(), 'data/excel/solar-cables.xlsx'),
];
export interface ProductData {
id?: number;
name: string;
slug?: string;
sku: string;
translationKey?: string;
locale?: 'en' | 'de';
}
export type ExcelRow = Record<string, any>;
export type ExcelMatch = { rows: ExcelRow[]; units: Record<string, string> };
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
export type KeyValueItem = { label: string; value: string; unit?: string };
export type VoltageTableModel = {
voltageLabel: string;
metaItems: KeyValueItem[];
crossSections: string[];
columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>;
};
function stripHtml(html: string): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '').trim();
}
function normalizeValue(value: string): string {
return stripHtml(value).replace(/\s+/g, ' ').trim();
}
function formatNumber(n: number): string {
const s = Number.isInteger(n) ? String(n) : String(n);
return s.replace(/\.0+$/, '');
}
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];
return { ok: true, text: `${formatNumber(min)}${formatNumber(max)}` };
}
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 summarizeSmartOptions(label: string, options: string[] | undefined): string {
const range = summarizeNumericRange(options);
if (range.ok) return range.text;
return summarizeOptions(options, 3);
}
function looksNumeric(value: string): boolean {
const v = normalizeValue(value).replace(/,/g, '.');
return /^-?\d+(?:\.\d+)?$/.test(v);
}
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 denseAbbrevLabel(args: {
key: string;
locale: 'en' | 'de';
unit?: string;
withUnit?: boolean;
}): string {
const u = normalizeUnit(args.unit || '');
const withUnit = args.withUnit ?? true;
const unitSafe = u.replace(/Ω/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u');
const suffix = withUnit && 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 'cap':
return `C${suffix}`;
case 'X':
return `X${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}`;
case 'test_volt':
return `U_test${suffix}`;
case 'rated_volt':
return `U0/U${suffix}`;
default:
return args.key || '';
}
}
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 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 technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
const k = normalizeValue(args.key);
if (args.locale === 'de') {
switch (k) {
case 'DI':
return 'Durchmesser über Isolierung';
case 'RI':
return 'DC-Leiterwiderstand (20 °C)';
case 'Wi':
return 'Isolationsdicke';
case 'Ibl':
return 'Strombelastbarkeit in Luft (trefoil)';
case 'Ibe':
return 'Strombelastbarkeit im Erdreich (trefoil)';
case 'Ik_cond':
return 'Kurzschlussstrom Leiter';
case 'Ik_screen':
return 'Kurzschlussstrom Schirm';
case 'Wm':
return 'Manteldicke';
case 'Rbv':
return 'Biegeradius (min.)';
case 'Ø':
return 'Außen-Ø';
case 'Fzv':
return 'Zugkraft (max.)';
case 'G':
return 'Gewicht';
case 'Cond':
case 'conductor':
return 'Leiter';
case 'shape':
return 'Leiterform';
case 'insulation':
return 'Isolierung';
case 'sheath':
return 'Mantel';
case 'cap':
return 'Kapazität';
case 'ind_trefoil':
return 'Induktivität (trefoil)';
case 'ind_air_flat':
return 'Induktivität (Luft, flach)';
case 'ind_ground_flat':
return 'Induktivität (Erdreich, flach)';
case 'X':
return 'Reaktanz';
case 'test_volt':
return 'Prüfspannung';
case 'rated_volt':
return 'Nennspannung';
case 'temp_range':
return 'Temperaturbereich';
case 'max_op_temp':
return 'Leitertemperatur (max.)';
case 'max_sc_temp':
return 'Kurzschlusstemperatur (max.)';
case 'min_store_temp':
return 'Minimale Lagertemperatur';
case 'min_lay_temp':
return 'Minimale Verlegetemperatur';
case 'cpr':
return 'CPR-Klasse';
case 'flame':
return 'Flammhemmend';
case 'packaging':
return 'Verpackung';
case 'ce':
return 'CE-Konformität';
case 'norm':
return 'Norm';
case 'standard':
return 'Standard';
case 'D_screen':
return 'Durchmesser über Schirm';
case 'S_screen':
return 'Metallischer Schirm';
default:
break;
}
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');
}
return normalizeValue(args.excelKey);
}
function compactNumericForLocale(value: string, locale: 'en' | 'de'): string {
const v = normalizeValue(value);
if (!v) return '';
if (/\d+xD/.test(v)) {
const numbers = [];
const matches = Array.from(v.matchAll(/(\d+)xD/g));
for (let i = 0; i < matches.length; i++) numbers.push(matches[i][1]);
if (numbers.length > 0) {
const unique: string[] = [];
for (const num of numbers) {
if (!unique.includes(num)) {
unique.push(num);
}
}
return unique.join('/') + 'xD';
}
}
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 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 normalizeExcelKey(value: string): string {
return String(value || '')
.toUpperCase()
.replace(/-\d+$/g, '')
.replace(/[^A-Z0-9]+/g, '');
}
function loadExcelRows(filePath: string): ExcelRow[] {
const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
const trimmed = out.trim();
const jsonStart = trimmed.indexOf('[');
if (jsonStart < 0) return [];
const jsonText = trimmed.slice(jsonStart);
try {
return JSON.parse(jsonText) as ExcelRow[];
} catch {
return [];
}
}
function getExcelIndex(): Map<string, ExcelMatch> {
if (EXCEL_INDEX) return EXCEL_INDEX;
const idx = new Map<string, ExcelMatch>();
for (const file of EXCEL_SOURCE_FILES) {
if (!fs.existsSync(file)) continue;
const rows = loadExcelRows(file);
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;
}
}
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);
if (Object.keys(cur.units).length < Object.keys(units).length) cur.units = units;
}
}
}
EXCEL_INDEX = idx;
return idx;
}
function findExcelForProduct(product: ProductData): ExcelMatch | null {
const idx = getExcelIndex();
const candidates = [
product.name,
product.slug ? product.slug.replace(/-\d+$/g, '') : '',
product.sku,
product.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;
}
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;
}
export function buildExcelModel(args: { product: ProductData; locale: 'en' | 'de' }): {
ok: boolean;
technicalItems: KeyValueItem[];
voltageTables: VoltageTableModel[];
} {
const match = findExcelForProduct(args.product);
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<string, { header: string; unit: string; key: string }> = {
'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' },
'conductor diameter (approx.)': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
'conductor diameter': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
'diameter conductor': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
'diameter over screen': { header: 'Diameter over screen', unit: 'mm', key: 'D_screen' },
'metallic screen mm2': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' },
'metallic screen': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' },
reactance: { header: 'Reactance', unit: 'Ohm/km', key: 'X' },
'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
capacitance: { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
'inductance, trefoil (approx.)': {
header: 'Inductance trefoil',
unit: 'mH/km',
key: 'ind_trefoil',
},
'inductance, trefoil': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' },
'inductance in air, flat (approx.)': {
header: 'Inductance air flat',
unit: 'mH/km',
key: 'ind_air_flat',
},
'inductance in air, flat': {
header: 'Inductance air flat',
unit: 'mH/km',
key: 'ind_air_flat',
},
'inductance in ground, flat (approx.)': {
header: 'Inductance ground flat',
unit: 'mH/km',
key: 'ind_ground_flat',
},
'inductance in ground, flat': {
header: 'Inductance ground flat',
unit: 'mH/km',
key: 'ind_ground_flat',
},
'current ratings in air, flat': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' },
'current ratings in air, flat*': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' },
'current ratings in ground, flat': {
header: 'Current ground flat',
unit: 'A',
key: 'cur_ground_flat',
},
'current ratings in ground, flat*': {
header: 'Current ground flat',
unit: 'A',
key: 'cur_ground_flat',
},
'heating time constant, trefoil*': {
header: 'Heating time trefoil',
unit: 's',
key: 'heat_trefoil',
},
'heating time constant, trefoil': {
header: 'Heating time trefoil',
unit: 's',
key: 'heat_trefoil',
},
'heating time constant, flat*': { header: 'Heating time flat', unit: 's', key: 'heat_flat' },
'heating time constant, flat': { header: 'Heating time flat', unit: 's', key: 'heat_flat' },
'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',
},
'operating temperature range': {
header: 'Operating temp range',
unit: '°C',
key: 'temp_range',
},
'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' },
conductor: { header: 'Conductor', unit: '', key: 'conductor' },
'copper wire screen and tape': { header: 'Copper screen', unit: '', key: 'copper_screen' },
CUScreen: { header: 'Copper screen', unit: '', key: 'copper_screen' },
'conductive tape below screen': {
header: 'Conductive tape below',
unit: '',
key: 'tape_below',
},
'non conducting tape above screen': {
header: 'Non-conductive tape above',
unit: '',
key: 'tape_above',
},
'al foil': { header: 'Al foil', unit: '', key: 'al_foil' },
'shape of conductor': { header: 'Conductor shape', unit: '', key: 'shape' },
'colour of insulation': { header: 'Insulation color', unit: '', key: 'color_ins' },
'colour of sheath': { header: 'Sheath color', unit: '', key: 'color_sheath' },
insulation: { header: 'Insulation', unit: '', key: 'insulation' },
sheath: { header: 'Sheath', unit: '', key: 'sheath' },
norm: { header: 'Norm', unit: '', key: 'norm' },
standard: { header: 'Standard', unit: '', key: 'standard' },
'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' },
packaging: { header: 'Packaging', unit: '', key: 'packaging' },
'ce-conformity': { header: 'CE conformity', unit: '', key: 'ce' },
'rohs/reach': { header: 'RoHS/REACH', unit: '', key: 'rohs_reach' },
};
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<string>();
const deduplicated: typeof matchedColumns = [];
for (const item of matchedColumns) {
if (!seenKeys.has(item.mapping.key)) {
seenKeys.add(item.mapping.key);
deduplicated.push(item);
}
}
matchedColumns.length = 0;
matchedColumns.push(...deduplicated);
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<string, number[]>();
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 voltageTables: VoltageTableModel[] = [];
const technicalItems: KeyValueItem[] = [];
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 globalConstantColumns = new Set<string>();
for (const { excelKey, mapping } of matchedColumns) {
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);
const existing = technicalItems.find((t) => t.label === label);
if (!existing) technicalItems.push({ label, value, unit });
}
}
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);
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<string, KeyValueItem>();
if (voltageKey) {
const rawV = normalizeValue(String(compatibleRows[indices[0]]?.[voltageKey] ?? ''));
metaItems.push({
label: args.locale === 'de' ? 'Spannung' : 'Voltage',
value: normalizeVoltageLabel(rawV || ''),
});
}
const tableColumns: Array<{
excelKey: string;
mapping: { header: string; unit: string; key: string };
}> = [];
const denseTableKeyOrder = [
'Cond',
'shape',
'cap',
'X',
'DI',
'RI',
'Wi',
'Ibl',
'Ibe',
'Ik_cond',
'Wm',
'Rbv',
'Ø',
'D_screen',
'S_screen',
'Fzv',
'G',
] as const;
const denseTableKeys = new Set<string>(denseTableKeyOrder);
const bendingRadiusKey = matchedColumns.find((c) => c.mapping.key === 'Rbv')?.excelKey || null;
let bendUnitOverride = '';
if (bendingRadiusKey) {
const bendVals = indices
.map((idx) => normalizeValue(String(compatibleRows[idx]?.[bendingRadiusKey] ?? '')))
.filter(Boolean);
if (bendVals.some((v) => /\bxD\b/i.test(v))) bendUnitOverride = 'xD';
}
for (const { excelKey, mapping } of matchedColumns) {
if (excelKey === csKey || excelKey === voltageKey) continue;
const values = indices
.map((idx) => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? '')))
.filter(Boolean);
if (values.length > 0) {
const unique = Array.from(new Set(values.map((v) => v.toLowerCase())));
let unit = normalizeUnit(units[excelKey] || mapping.unit || '');
if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride;
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(mapping.key, 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<
string,
{ excelKey: string; mapping: { header: string; unit: string; key: string } }
>();
for (const c of tableColumns) {
if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c);
}
const outerDiameterKey = mappedByKey.get('Ø')?.excelKey || '' || null;
const sheathThicknessKey = mappedByKey.get('Wm')?.excelKey || '' || null;
const canDeriveDenseKey = (k: (typeof denseTableKeyOrder)[number]): boolean => {
if (k === 'DI') return Boolean(outerDiameterKey && sheathThicknessKey);
if (k === 'Cond') return true;
return false;
};
const orderedTableColumns = denseTableKeyOrder
.filter((k) => mappedByKey.has(k) || canDeriveDenseKey(k))
.map((k) => {
const existing = mappedByKey.get(k);
if (existing) return existing;
return {
excelKey: '',
mapping: { header: k, unit: '', key: k },
};
});
const columns = orderedTableColumns.map(({ excelKey, mapping }) => {
const defaultUnitByKey: Record<string, string> = {
DI: 'mm',
RI: 'Ohm/km',
Wi: 'mm',
Ibl: 'A',
Ibe: 'A',
Ik_cond: 'kA',
Wm: 'mm',
Rbv: 'mm',
Ø: 'mm',
Fzv: 'N',
G: 'kg/km',
};
let unit = normalizeUnit(
(excelKey ? units[excelKey] : '') || mapping.unit || defaultUnitByKey[mapping.key] || '',
);
if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride;
return {
key: mapping.key,
label:
denseAbbrevLabel({ key: mapping.key, locale: args.locale, unit, withUnit: true }) ||
formatExcelHeaderLabel(excelKey, unit),
get: (rowIndex: number) => {
const srcRowIndex = indices[rowIndex];
const raw = excelKey
? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? ''))
: '';
const unitLocal = unit;
if (mapping.key === 'DI' && !raw && outerDiameterKey && sheathThicknessKey) {
const odRaw = normalizeValue(
String(compatibleRows[srcRowIndex]?.[outerDiameterKey] ?? ''),
);
const wmRaw = normalizeValue(
String(compatibleRows[srcRowIndex]?.[sheathThicknessKey] ?? ''),
);
const od = parseNumericOption(odRaw);
const wm = parseNumericOption(wmRaw);
if (od !== null && wm !== null) {
const di = od - 2 * wm;
if (Number.isFinite(di) && di > 0)
return `~${compactNumericForLocale(String(di), args.locale)}`;
}
}
if (mapping.key === 'Cond' && !raw) {
const pn = normalizeExcelKey(
args.product.name || args.product.slug || args.product.sku || '',
);
if (/^NA/.test(pn)) return 'Al';
if (/^N/.test(pn)) return 'Cu';
}
if (mapping.key === 'Rbv' && /\bxD\b/i.test(raw))
return compactNumericForLocale(raw, args.locale);
if (mapping.key === 'Rbv' && unitLocal.toLowerCase() === 'mm') {
const n = parseNumericOption(raw);
const looksLikeMeters =
n !== null && n > 0 && n < 50 && /[\.,]\d{1,3}/.test(raw) && !/\dxD/i.test(raw);
if (looksLikeMeters)
return compactNumericForLocale(String(Math.round(n * 1000)), args.locale);
}
if (mapping.key === 'Fzv' && unitLocal.toLowerCase() === 'n') {
const n = parseNumericOption(raw);
const looksLikeKN =
n !== null && n > 0 && n < 100 && !/\bN\b/i.test(raw) && !/\bkN\b/i.test(raw);
if (looksLikeKN)
return compactNumericForLocale(String(Math.round(n * 1000)), args.locale);
}
return compactCellForDenseTable(raw, unitLocal, args.locale);
},
};
});
voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns });
}
technicalItems.sort((a, b) => a.label.localeCompare(b.label));
return { ok: true, technicalItems, voltageTables };
}

18
scripts/list-pages.ts Normal file
View File

@@ -0,0 +1,18 @@
import { getPayload } from 'payload';
import configPromise from '../payload.config';
async function listPages() {
const payload = await getPayload({ config: configPromise });
const result = await payload.find({
collection: 'pages',
locale: 'de',
});
console.log('Pages detected:');
result.docs.forEach((d) => {
console.log(`- ${d.title} (slug: ${d.slug})`);
});
process.exit(0);
}
listPages();

View File

@@ -0,0 +1,897 @@
import * as fs from 'fs';
import * as path from 'path';
import type { DatasheetModel, DatasheetVoltageTable, KeyValueItem, ProductData } from './types';
import type { ExcelMatch, MediumVoltageCrossSectionExcelMatch } from './excel-index';
import { findExcelForProduct, findMediumVoltageCrossSectionExcelForProduct } from './excel-index';
import { getLabels, getProductUrl, normalizeValue, stripHtml } from './utils';
type ExcelRow = Record<string, unknown>;
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<string, string>;
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 '';
// Compact common bending-radius style: "15xD (Single core); 12xD (Multi core)" -> "15/12xD".
// Keep semantics, reduce width. Never truncate with ellipses.
if (/\d+xD/i.test(v)) {
const nums = Array.from(v.matchAll(/(\d+)xD/gi))
.map((m) => m[1])
.filter(Boolean);
const unique: string[] = [];
for (const n of nums) {
if (!unique.includes(n)) unique.push(n);
}
if (unique.length) return `${unique.join('/')}xD`;
}
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('/')) {
// Handle Payload API URL prefix: /api/media/file/filename.ext -> /media/filename.ext
return urlOrPath.replace(/^\/api\/media\/file\//, '/media/');
}
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';
// Electrical
case 'cap':
// Capacitance. Use a clear label; lowercase "cap" looks like an internal key.
return `Cap${suffix}`;
case 'X':
return `X${suffix}`;
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): 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];
// Never use ellipsis truncation in datasheets. Prefer full value list.
// (Long values should be handled by layout; if needed we can later add wrapping rules.)
return uniq.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);
}
function normalizeDesignation(value: string): string {
return String(value || '')
.toUpperCase()
.replace(/-\d+$/g, '')
.replace(/[^A-Z0-9]+/g, '');
}
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<string, { header: string; unit: string; key: string }> = {
'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' },
// High-value electrical/screen columns
'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
capacitance: { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
reactance: { header: 'Reactance', unit: 'Ohm/km', key: 'X' },
'diameter over screen': { header: 'Diameter over screen', unit: 'mm', key: 'D_screen' },
'metallic screen mm2': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' },
'metallic screen': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' },
};
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<string>();
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<string, number[]>();
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<string>();
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<string, KeyValueItem>();
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',
// Electrical properties (when present)
'cap',
'X',
// Dimensions and ratings
'DI',
'RI',
'Wi',
'Ibl',
'Ibe',
'Ik_cond',
'Wm',
'Rbv',
'Ø',
// Screen data (when present)
'D_screen',
'S_screen',
'Fzv',
'G',
] as const;
const denseTableKeys = new Set<string>(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<
string,
{ excelKey: string; mapping: { header: string; unit: string; key: string } }
>();
for (const c of tableColumns) {
if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c);
}
// If conductor material is missing in Excel, derive it from designation.
// NA... => Al, N... => Cu (common for this dataset).
if (!mappedByKey.has('Cond')) {
mappedByKey.set('Cond', {
excelKey: '',
mapping: { header: 'Cond.', unit: '', key: 'Cond' },
});
}
const orderedTableColumns = denseTableKeyOrder
.filter((k) => mappedByKey.has(k))
.map((k) => mappedByKey.get(k)!)
.map(({ excelKey, mapping }) => {
const unit = normalizeUnit((excelKey ? 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];
if (mapping.key === 'Cond' && !excelKey) {
const pn = normalizeDesignation(
args.product.name || args.product.slug || args.product.sku || '',
);
if (/^NA/.test(pn)) return 'Al';
if (/^N/.test(pn)) return 'Cu';
return '';
}
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 };
}
function isMediumVoltageProduct(product: ProductData): boolean {
const hay = [
product.slug,
product.path,
product.translationKey,
...(product.categories || []).map((c) => c.name),
]
.filter(Boolean)
.join(' ');
return /medium[-\s]?voltage|mittelspannung/i.test(hay);
}
type AbbrevColumn = { colKey: string; unit: string };
function isAbbreviatedHeaderKey(key: string): boolean {
const k = normalizeValue(key);
if (!k) return false;
if (/^__EMPTY/i.test(k)) return false;
// Examples from the MV sheet: "LD mm", "RI Ohm", "G kg", "SBL 30", "SBE 20", "BK", "BR", "LF".
// Keep this permissive but focused on compact, non-sentence identifiers.
if (k.length > 12) return false;
if (/[a-z]{4,}/.test(k)) return false;
if (!/[A-ZØ]/.test(k)) return false;
return true;
}
function extractAbbrevColumnsFromMediumVoltageHeader(args: {
headerRow: Record<string, unknown>;
units: Record<string, string>;
partNumberKey: string;
crossSectionKey: string;
ratedVoltageKey: string | null;
}): AbbrevColumn[] {
const out: AbbrevColumn[] = [];
for (const colKey of Object.keys(args.headerRow || {})) {
if (!colKey) continue;
if (colKey === args.partNumberKey) continue;
if (colKey === args.crossSectionKey) continue;
if (args.ratedVoltageKey && colKey === args.ratedVoltageKey) continue;
if (!isAbbreviatedHeaderKey(colKey)) continue;
const unit = normalizeUnit(args.units[colKey] || '');
out.push({ colKey, unit });
}
return out;
}
function buildMediumVoltageCrossSectionTableFromNewExcel(args: {
product: ProductData;
locale: 'en' | 'de';
}): BuildExcelModelResult & { legendItems: KeyValueItem[] } {
const mv = findMediumVoltageCrossSectionExcelForProduct(
args.product,
) as MediumVoltageCrossSectionExcelMatch | null;
if (!mv || !mv.rows.length)
return { ok: false, technicalItems: [], voltageTables: [], legendItems: [] };
if (!mv.crossSectionKey)
return { ok: false, technicalItems: [], voltageTables: [], legendItems: [] };
const abbrevCols = extractAbbrevColumnsFromMediumVoltageHeader({
headerRow: mv.headerRow,
units: mv.units,
partNumberKey: mv.partNumberKey,
crossSectionKey: mv.crossSectionKey,
ratedVoltageKey: mv.ratedVoltageKey,
});
if (!abbrevCols.length)
return { ok: false, technicalItems: [], voltageTables: [], legendItems: [] };
// Collect legend items: abbreviation -> description from header row
const legendItems: KeyValueItem[] = [];
for (const col of abbrevCols) {
const description = normalizeValue(String(mv.headerRow[col.colKey] || ''));
if (description && description !== col.colKey) {
legendItems.push({
label: col.colKey,
value: description,
});
}
}
const byVoltage = new Map<string, number[]>();
for (let i = 0; i < mv.rows.length; i++) {
const cs = normalizeValue(
String((mv.rows[i] as Record<string, unknown>)?.[mv.crossSectionKey] ?? ''),
);
if (!cs) continue;
const rawV = mv.ratedVoltageKey
? normalizeValue(String((mv.rows[i] as Record<string, unknown>)?.[mv.ratedVoltageKey] ?? ''))
: '';
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 voltageTables: VoltageTableModel[] = [];
for (const vKey of voltageKeysSorted) {
const indices = byVoltage.get(vKey) || [];
if (!indices.length) continue;
const crossSections = indices.map((idx) =>
normalizeValue(String((mv.rows[idx] as Record<string, unknown>)?.[mv.crossSectionKey] ?? '')),
);
const metaItems: KeyValueItem[] = [];
if (mv.ratedVoltageKey) {
const rawV = normalizeValue(
String((mv.rows[indices[0]] as Record<string, unknown>)?.[mv.ratedVoltageKey] ?? ''),
);
metaItems.push({
label: args.locale === 'de' ? 'Spannung' : 'Voltage',
value: normalizeVoltageLabel(rawV || ''),
});
}
const columns = abbrevCols.map((col) => {
return {
key: col.colKey,
// Use the abbreviated title from the first row as the table header.
label: normalizeValue(col.colKey),
get: (rowIndex: number) => {
const srcRowIndex = indices[rowIndex];
const raw = normalizeValue(
String((mv.rows[srcRowIndex] as Record<string, unknown>)?.[col.colKey] ?? ''),
);
return compactCellForDenseTable(raw, col.unit, args.locale);
},
};
});
voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns });
}
return { ok: true, technicalItems: [], voltageTables, legendItems };
}
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);
// Technical data MUST stay sourced from the existing Excel index (legacy sheets).
const excelModel = buildExcelModel({ product: args.product, locale: args.locale });
// Cross-section tables: for medium voltage only, prefer the new MV sheet (abbrev columns in header row).
const crossSectionModel = isMediumVoltageProduct(args.product)
? buildMediumVoltageCrossSectionTableFromNewExcel({
product: args.product,
locale: args.locale,
})
: { ok: false, technicalItems: [], voltageTables: [], legendItems: [] };
const voltageTablesSrc = crossSectionModel.ok
? crossSectionModel.voltageTables
: excelModel.ok
? excelModel.voltageTables
: [];
const voltageTables: DatasheetVoltageTable[] = voltageTablesSrc.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,
legendItems: crossSectionModel.legendItems || [],
};
}

View File

@@ -0,0 +1,204 @@
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import type { ProductData } from './types';
import { normalizeValue } from './utils';
type ExcelRow = Record<string, unknown>;
export type ExcelMatch = { rows: ExcelRow[]; units: Record<string, string> };
export type MediumVoltageCrossSectionExcelMatch = {
headerRow: ExcelRow;
rows: ExcelRow[];
units: Record<string, string>;
partNumberKey: string;
crossSectionKey: string;
ratedVoltageKey: string | null;
};
const EXCEL_SOURCE_FILES = [
path.join(process.cwd(), 'data/excel/high-voltage.xlsx'),
path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'),
path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'),
path.join(process.cwd(), 'data/excel/solar-cables.xlsx'),
];
// Medium-voltage cross-section table (new format with multi-row header).
// IMPORTANT: this must NOT be used for the technical data table.
const MV_CROSS_SECTION_FILE = path.join(process.cwd(), 'data/excel/medium-voltage-KM 170126.xlsx');
type MediumVoltageCrossSectionIndex = {
headerRow: ExcelRow;
units: Record<string, string>;
partNumberKey: string;
crossSectionKey: string;
ratedVoltageKey: string | null;
rowsByDesignation: Map<string, ExcelRow[]>;
};
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
let MV_CROSS_SECTION_INDEX: MediumVoltageCrossSectionIndex | null = null;
export function normalizeExcelKey(value: string): string {
return String(value || '')
.toUpperCase()
.replace(/-\d+$/g, '')
.replace(/[^A-Z0-9]+/g, '');
}
function loadExcelRows(filePath: string): ExcelRow[] {
const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
const trimmed = out.trim();
const jsonStart = trimmed.indexOf('[');
if (jsonStart < 0) return [];
const jsonText = trimmed.slice(jsonStart);
try {
return JSON.parse(jsonText) as ExcelRow[];
} catch {
return [];
}
}
function findKeyByHeaderValue(headerRow: ExcelRow, pattern: RegExp): string | null {
for (const [k, v] of Object.entries(headerRow || {})) {
const text = normalizeValue(String(v ?? ''));
if (!text) continue;
if (pattern.test(text)) return k;
}
return null;
}
function getMediumVoltageCrossSectionIndex(): MediumVoltageCrossSectionIndex {
if (MV_CROSS_SECTION_INDEX) return MV_CROSS_SECTION_INDEX;
const rows = fs.existsSync(MV_CROSS_SECTION_FILE) ? loadExcelRows(MV_CROSS_SECTION_FILE) : [];
const headerRow = (rows[0] || {}) as ExcelRow;
const partNumberKey = findKeyByHeaderValue(headerRow, /^part\s*number$/i) || '__EMPTY';
const crossSectionKey = findKeyByHeaderValue(headerRow, /querschnitt|cross.?section/i) || '';
const ratedVoltageKey =
findKeyByHeaderValue(headerRow, /rated voltage|voltage rating|nennspannung/i) || null;
const unitsRow =
rows.find((r) => normalizeValue(String((r as ExcelRow)?.[partNumberKey] ?? '')) === 'Units') ||
null;
const units: Record<string, string> = {};
if (unitsRow) {
for (const [k, v] of Object.entries(unitsRow)) {
if (k === partNumberKey) continue;
const unit = normalizeValue(String(v ?? ''));
if (unit) units[k] = unit;
}
}
const rowsByDesignation = new Map<string, ExcelRow[]>();
for (const r of rows) {
if (r === headerRow) continue;
const pn = normalizeValue(String((r as ExcelRow)?.[partNumberKey] ?? ''));
if (!pn || pn === 'Units' || pn === 'Part Number') continue;
const key = normalizeExcelKey(pn);
if (!key) continue;
const cur = rowsByDesignation.get(key) || [];
cur.push(r);
rowsByDesignation.set(key, cur);
}
MV_CROSS_SECTION_INDEX = {
headerRow,
units,
partNumberKey,
crossSectionKey,
ratedVoltageKey,
rowsByDesignation,
};
return MV_CROSS_SECTION_INDEX;
}
export function getExcelIndex(): Map<string, ExcelMatch> {
if (EXCEL_INDEX) return EXCEL_INDEX;
const idx = new Map<string, ExcelMatch>();
for (const file of EXCEL_SOURCE_FILES) {
if (!fs.existsSync(file)) continue;
const rows = loadExcelRows(file);
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;
}
}
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);
if (Object.keys(cur.units).length < Object.keys(units).length) cur.units = units;
}
}
}
EXCEL_INDEX = idx;
return idx;
}
export function findExcelForProduct(product: ProductData): ExcelMatch | null {
const idx = getExcelIndex();
const candidates = [
product.name,
product.slug ? product.slug.replace(/-\d+$/g, '') : '',
product.sku,
product.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;
}
export function findMediumVoltageCrossSectionExcelForProduct(
product: ProductData,
): MediumVoltageCrossSectionExcelMatch | null {
const idx = getMediumVoltageCrossSectionIndex();
const candidates = [
product.name,
product.slug ? product.slug.replace(/-\d+$/g, '') : '',
product.sku,
product.translationKey,
].filter(Boolean) as string[];
for (const c of candidates) {
const key = normalizeExcelKey(c);
const rows = idx.rowsByDesignation.get(key) || [];
if (rows.length) {
return {
headerRow: idx.headerRow,
rows,
units: idx.units,
partNumberKey: idx.partNumberKey,
crossSectionKey: idx.crossSectionKey,
ratedVoltageKey: idx.ratedVoltageKey,
};
}
}
return null;
}

View File

@@ -0,0 +1,51 @@
export interface ProductData {
id: number;
name: string;
shortDescriptionHtml: string;
descriptionHtml: string;
images: string[];
featuredImage: string | null;
sku: string;
slug?: string;
path?: string;
translationKey?: string;
locale?: 'en' | 'de';
categories: Array<{ name: string }>;
attributes: Array<{
name: string;
options: string[];
}>;
}
export type KeyValueItem = { label: string; value: string; unit?: string };
export type DatasheetVoltageTable = {
voltageLabel: string;
metaItems: KeyValueItem[];
columns: Array<{ key: string; label: string }>;
rows: Array<{ configuration: string; cells: string[] }>;
};
export type DatasheetModel = {
locale: 'en' | 'de';
product: {
id: number;
name: string;
sku: string;
categoriesLine: string;
descriptionText: string;
heroSrc: string | null;
productUrl: string;
};
labels: {
datasheet: string;
description: string;
technicalData: string;
crossSection: string;
sku: string;
noImage: string;
};
technicalItems: KeyValueItem[];
voltageTables: DatasheetVoltageTable[];
legendItems: KeyValueItem[];
};

View File

@@ -0,0 +1,74 @@
import * as path from 'path';
import type { ProductData } from './types';
export const CONFIG = {
siteUrl: 'https://klz-cables.com',
publicDir: path.join(process.cwd(), 'public'),
assetMapFile: path.join(process.cwd(), 'data/processed/asset-map.json'),
} as const;
export function stripHtml(html: string): string {
if (!html) return '';
let text = String(html)
.replace(/<[^>]*>/g, '')
.normalize('NFC');
text = text
.replace(/[\u00A0\u202F]/g, ' ')
.replace(/[\u2013\u2014]/g, '-')
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/\u2026/g, '...')
.replace(/[\u2022]/g, '·')
.replace(/[\u2264]/g, '<=')
.replace(/[\u2265]/g, '>=')
.replace(/[\u2248]/g, '~')
.replace(/[\u03A9\u2126]/g, 'Ohm')
.replace(/[\u00B5\u03BC]/g, 'u')
.replace(/[\u2193]/g, 'v')
.replace(/[\u2191]/g, '^')
.replace(/[\u00B0]/g, '°');
// eslint-disable-next-line no-control-regex
text = text.replace(/[\u0000-\u001F\u007F]/g, '');
return text.replace(/\s+/g, ' ').trim();
}
export function normalizeValue(value: string): string {
return stripHtml(value).replace(/\s+/g, ' ').trim();
}
export function getProductUrl(product: ProductData): string {
if (product.path) return `${CONFIG.siteUrl}${product.path}`;
return CONFIG.siteUrl;
}
export function generateFileName(product: ProductData, locale: 'en' | 'de'): string {
const baseName = product.slug || product.translationKey || `product-${product.id}`;
const cleanSlug = baseName
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return `${cleanSlug}-${locale}.pdf`;
}
export function getLabels(locale: 'en' | 'de') {
return {
en: {
datasheet: 'PRODUCT DATASHEET',
description: 'DESCRIPTION',
technicalData: 'TECHNICAL DATA',
crossSection: 'CROSS-SECTION DATA',
sku: 'SKU',
noImage: 'No image available',
},
de: {
datasheet: 'PRODUKTDATENBLATT',
description: 'BESCHREIBUNG',
technicalData: 'TECHNISCHE DATEN',
crossSection: 'QUERSCHNITTSDATEN',
sku: 'ARTIKELNUMMER',
noImage: 'Kein Bild verfügbar',
},
}[locale];
}

View File

@@ -0,0 +1,90 @@
import * as React from 'react';
import { Document, Image, Page, Text, View } from '@react-pdf/renderer';
import type { DatasheetModel, DatasheetVoltageTable } from '../model/types';
import { CONFIG } from '../model/utils';
import { styles } from './styles';
import { Header } from './components/Header';
import { Footer } from './components/Footer';
import { Section } from './components/Section';
import { KeyValueGrid } from './components/KeyValueGrid';
import { DenseTable } from './components/DenseTable';
type Assets = {
logoDataUrl: string | null;
heroDataUrl: string | null;
qrDataUrl: string | null;
};
export function DatasheetDocument(props: {
model: DatasheetModel;
assets: Assets;
}): React.ReactElement {
const { model, assets } = props;
const headerTitle = model.labels.datasheet;
// Dense tables require compact headers (no wrapping). Use standard abbreviations.
const firstColLabel = model.locale === 'de' ? 'Adern & QS' : 'Cores & CS';
return (
<Document>
<Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
<Text style={styles.h1}>{model.product.name}</Text>
{model.product.categoriesLine ? (
<Text style={styles.subhead}>{model.product.categoriesLine}</Text>
) : null}
<View style={styles.heroBox}>
{assets.heroDataUrl ? (
<Image src={assets.heroDataUrl} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{model.labels.noImage}</Text>
)}
</View>
{model.product.descriptionText ? (
<Section title={model.labels.description}>
<Text style={styles.body}>{model.product.descriptionText}</Text>
</Section>
) : null}
{model.technicalItems.length ? (
<Section title={model.labels.technicalData}>
<KeyValueGrid items={model.technicalItems} />
</Section>
) : null}
</Page>
{/*
Render all voltage sections in a single flow so React-PDF can paginate naturally.
This avoids hard page breaks that waste remaining whitespace at the bottom of a page.
*/}
<Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
{model.voltageTables.map((t: DatasheetVoltageTable) => (
<View key={t.voltageLabel} style={{ marginBottom: 14 }} break={false}>
<Text
style={styles.sectionTitle}
>{`${model.labels.crossSection}${t.voltageLabel}`}</Text>
<DenseTable
table={{ columns: t.columns, rows: t.rows }}
firstColLabel={firstColLabel}
/>
</View>
))}
{model.legendItems.length ? (
<Section title={model.locale === 'de' ? 'ABKÜRZUNGEN' : 'ABBREVIATIONS'}>
<KeyValueGrid items={model.legendItems} />
</Section>
) : null}
</Page>
</Document>
);
}

View File

@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as path from 'path';
type SharpLike = (
input?: unknown,
options?: unknown,
) => { png: () => { toBuffer: () => Promise<Buffer> } };
let sharpFn: SharpLike | null = null;
async function getSharp(): Promise<SharpLike> {
if (sharpFn) return sharpFn;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import('sharp');
sharpFn = (mod?.default || mod) as SharpLike;
return sharpFn;
}
const PUBLIC_DIR = path.join(process.cwd(), 'public');
async function fetchBytes(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
return new Uint8Array(await res.arrayBuffer());
}
async function readBytesFromPublic(localPath: string): Promise<Uint8Array> {
const abs = path.join(PUBLIC_DIR, localPath.replace(/^\//, ''));
return new Uint8Array(fs.readFileSync(abs));
}
function transformLogoSvgToPrintBlack(svg: string): string {
return svg
.replace(/fill\s*:\s*white/gi, 'fill:#000000')
.replace(/fill\s*=\s*"white"/gi, 'fill="#000000"')
.replace(/fill\s*=\s*'white'/gi, "fill='#000000'")
.replace(/fill\s*:\s*#[0-9a-fA-F]{6}/gi, 'fill:#000000')
.replace(/fill\s*=\s*"#[0-9a-fA-F]{6}"/gi, 'fill="#000000"')
.replace(/fill\s*=\s*'#[0-9a-fA-F]{6}'/gi, "fill='#000000'");
}
async function toPngBytes(inputBytes: Uint8Array, inputHint: string): Promise<Uint8Array> {
const ext = (path.extname(inputHint).toLowerCase() || '').replace('.', '');
if (ext === 'png') return inputBytes;
if (
ext === 'svg' &&
(/\/media\/logo\.svg$/i.test(inputHint) || /\/logo-blue\.svg$/i.test(inputHint))
) {
const svg = Buffer.from(inputBytes).toString('utf8');
inputBytes = new Uint8Array(Buffer.from(transformLogoSvgToPrintBlack(svg), 'utf8'));
}
const sharp = await getSharp();
return new Uint8Array(await sharp(Buffer.from(inputBytes)).png().toBuffer());
}
function toDataUrlPng(bytes: Uint8Array): string {
return `data:image/png;base64,${Buffer.from(bytes).toString('base64')}`;
}
export async function loadImageAsPngDataUrl(src: string | null): Promise<string | null> {
if (!src) return null;
try {
if (src.startsWith('/')) {
const bytes = await readBytesFromPublic(src);
const png = await toPngBytes(bytes, src);
return toDataUrlPng(png);
}
const bytes = await fetchBytes(src);
const png = await toPngBytes(bytes, src);
return toDataUrlPng(png);
} catch {
return null;
}
}
export async function loadQrAsPngDataUrl(data: string): Promise<string | null> {
try {
const safe = encodeURIComponent(data);
const url = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${safe}`;
const bytes = await fetchBytes(url);
const png = await toPngBytes(bytes, url);
return toDataUrlPng(png);
} catch {
return null;
}
}

View File

@@ -0,0 +1,231 @@
import * as React from 'react';
import { Text, View } from '@react-pdf/renderer';
import type { DatasheetVoltageTable } from '../../model/types';
import { styles } from '../styles';
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
function normTextForMeasure(v: unknown): string {
return String(v ?? '')
.replace(/\s+/g, ' ')
.trim();
}
function textLen(v: unknown): number {
return normTextForMeasure(v).length;
}
function distributeWithMinMax(
weights: number[],
total: number,
minEach: number,
maxEach: number,
): number[] {
const n = weights.length;
if (!n) return [];
const mins = Array.from({ length: n }, () => minEach);
const maxs = Array.from({ length: n }, () => maxEach);
// If mins don't fit, scale them down proportionally.
const minSum = mins.reduce((a, b) => a + b, 0);
if (minSum > total) {
const k = total / minSum;
return mins.map((m) => m * k);
}
const result = mins.slice();
let remaining = total - minSum;
let remainingIdx = Array.from({ length: n }, (_, i) => i);
// Distribute remaining proportionally, respecting max constraints.
// Loop is guaranteed to terminate because each iteration either:
// - removes at least one index due to hitting max, or
// - exhausts `remaining`.
while (remaining > 1e-9 && remainingIdx.length) {
const wSum = remainingIdx.reduce((acc, i) => acc + Math.max(0, weights[i] || 0), 0);
if (wSum <= 1e-9) {
// No meaningful weights: distribute evenly.
const even = remaining / remainingIdx.length;
for (const i of remainingIdx) result[i] += even;
remaining = 0;
break;
}
const nextIdx: number[] = [];
for (const i of remainingIdx) {
const w = Math.max(0, weights[i] || 0);
const add = (w / wSum) * remaining;
const capped = Math.min(result[i] + add, maxs[i]);
const used = capped - result[i];
result[i] = capped;
remaining -= used;
if (result[i] + 1e-9 < maxs[i]) nextIdx.push(i);
}
remainingIdx = nextIdx;
}
// Numerical guard: force exact sum by adjusting the last column.
const sum = result.reduce((a, b) => a + b, 0);
const drift = total - sum;
if (Math.abs(drift) > 1e-9) result[result.length - 1] += drift;
return result;
}
export function DenseTable(props: {
table: Pick<DatasheetVoltageTable, 'columns' | 'rows'>;
firstColLabel: string;
}): React.ReactElement {
const cols = props.table.columns;
const rows = props.table.rows;
const headerText = (label: string): string => {
// Table headers must NEVER wrap into a second line.
// react-pdf can wrap on spaces, so we replace whitespace with NBSP.
return String(label || '')
.replace(/\s+/g, '\u00A0')
.trim();
};
// Column widths: use explicit percentages (no rounding gaps) so the table always
// consumes the full content width.
// Goal:
// - keep the designation column *not too wide*
// - distribute data columns by estimated content width (header + cells)
// so columns better fit their data
// Make first column denser so numeric columns get more room.
// (Long designations can still wrap in body if needed, but table scanability
// benefits more from wider data columns.)
const cfgMin = 0.14;
const cfgMax = 0.23;
// A content-based heuristic.
// React-PDF doesn't expose a reliable text-measurement API at render time,
// so we approximate width by string length (compressed via sqrt to reduce outliers).
const cfgContentLen = Math.max(
textLen(props.firstColLabel),
...rows.map((r) => textLen(r.configuration)),
8,
);
const dataContentLens = cols.map((c, ci) => {
const headerL = textLen(c.label);
let cellMax = 0;
for (const r of rows) cellMax = Math.max(cellMax, textLen(r.cells[ci]));
// Slightly prioritize the header (scanability) over a single long cell.
return Math.max(headerL * 1.15, cellMax, 3);
});
// Use mostly-linear weights so long headers get noticeably more space.
const cfgWeight = cfgContentLen * 1.05;
const dataWeights = dataContentLens.map((l) => l);
const dataWeightSum = dataWeights.reduce((a, b) => a + b, 0);
const rawCfgPct = dataWeightSum > 0 ? cfgWeight / (cfgWeight + dataWeightSum) : 0.28;
let cfgPct = clamp(rawCfgPct, cfgMin, cfgMax);
// Ensure a minimum per-data-column width; if needed, shrink cfgPct.
// These floors are intentionally generous. Too-narrow columns are worse than a
// slightly narrower first column for scanability.
const minDataPct =
cols.length >= 14 ? 0.045 : cols.length >= 12 ? 0.05 : cols.length >= 10 ? 0.055 : 0.06;
const cfgPctMaxForMinData = 1 - cols.length * minDataPct;
if (Number.isFinite(cfgPctMaxForMinData)) cfgPct = Math.min(cfgPct, cfgPctMaxForMinData);
cfgPct = clamp(cfgPct, cfgMin, cfgMax);
const dataTotal = Math.max(0, 1 - cfgPct);
const maxDataPct = Math.min(0.24, Math.max(minDataPct * 2.8, dataTotal * 0.55));
const dataPcts = distributeWithMinMax(dataWeights, dataTotal, minDataPct, maxDataPct);
const cfgW = `${(cfgPct * 100).toFixed(4)}%`;
const dataWs = dataPcts.map((p, idx) => {
// Keep the last column as the remainder so percentages sum to exactly 100%.
if (idx === dataPcts.length - 1) {
const used = dataPcts.slice(0, -1).reduce((a, b) => a + b, 0);
const remainder = Math.max(0, dataTotal - used);
return `${(remainder * 100).toFixed(4)}%`;
}
return `${(p * 100).toFixed(4)}%`;
});
const headerFontSize =
cols.length >= 14 ? 5.7 : cols.length >= 12 ? 5.9 : cols.length >= 10 ? 6.2 : 6.6;
return (
<View style={styles.tableWrap} break={false}>
<View style={styles.tableHeader} wrap={false}>
<View style={{ width: cfgW }}>
<Text
style={[
styles.tableHeaderCell,
styles.tableHeaderCellCfg,
{ fontSize: headerFontSize, paddingHorizontal: 3 },
cols.length ? styles.tableHeaderCellDivider : null,
]}
wrap={false}
>
{headerText(props.firstColLabel)}
</Text>
</View>
{cols.map((c, idx) => {
const isLast = idx === cols.length - 1;
return (
<View key={c.key} style={{ width: dataWs[idx] }}>
<Text
style={[
styles.tableHeaderCell,
{ fontSize: headerFontSize, paddingHorizontal: 3 },
!isLast ? styles.tableHeaderCellDivider : null,
]}
wrap={false}
>
{headerText(c.label)}
</Text>
</View>
);
})}
</View>
{rows.map((r, ri) => (
<View
key={`${r.configuration}-${ri}`}
style={[styles.tableRow, ri % 2 === 0 ? styles.tableRowAlt : null]}
wrap={false}
// If the row doesn't fit, move the whole row to the next page.
// This prevents page breaks mid-row.
minPresenceAhead={16}
>
<View style={{ width: cfgW }} wrap={false}>
<Text
style={[
styles.tableCell,
styles.tableCellCfg,
// Denser first column: slightly smaller type + tighter padding.
{ fontSize: 6.2, paddingHorizontal: 3 },
cols.length ? styles.tableCellDivider : null,
]}
wrap={false}
>
{r.configuration}
</Text>
</View>
{r.cells.map((cell, ci) => {
const isLast = ci === r.cells.length - 1;
return (
<View key={`${cols[ci]?.key || ci}`} style={{ width: dataWs[ci] }} wrap={false}>
<Text
style={[styles.tableCell, !isLast ? styles.tableCellDivider : null]}
wrap={false}
>
{cell}
</Text>
</View>
);
})}
</View>
))}
</View>
);
}

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { Text, View } from '@react-pdf/renderer';
import { styles } from '../styles';
export function Footer(props: { locale: 'en' | 'de'; siteUrl?: string }): React.ReactElement {
const date = new Date().toLocaleDateString(props.locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const siteUrl = props.siteUrl || 'https://klz-cables.com';
return (
<View style={styles.footer} fixed>
<Text>{siteUrl}</Text>
<Text>{date}</Text>
<Text render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`} />
</View>
);
}

View File

@@ -0,0 +1,29 @@
import * as React from 'react';
import { Image, Text, View } from '@react-pdf/renderer';
import { styles } from '../styles';
export function Header(props: {
title: string;
logoDataUrl?: string | null;
qrDataUrl?: string | null;
}): React.ReactElement {
return (
<View style={styles.header} fixed>
<View style={styles.headerLeft}>
{props.logoDataUrl ? (
<Image src={props.logoDataUrl} style={styles.logo} />
) : (
<View style={styles.brandFallback}>
<Text style={styles.brandFallbackKlz}>KLZ</Text>
<Text style={styles.brandFallbackCables}>Cables</Text>
</View>
)}
</View>
<View style={styles.headerRight}>
<Text style={styles.headerTitle}>{props.title}</Text>
{props.qrDataUrl ? <Image src={props.qrDataUrl} style={styles.qr} /> : null}
</View>
</View>
);
}

View File

@@ -0,0 +1,51 @@
import * as React from 'react';
import { Text, View } from '@react-pdf/renderer';
import type { KeyValueItem } from '../../model/types';
import { styles } from '../styles';
export function KeyValueGrid(props: { items: KeyValueItem[] }): React.ReactElement | null {
const items = (props.items || []).filter((i) => i.label && i.value);
if (!items.length) return null;
// 4-column layout: (label, value, label, value)
const rows: Array<[KeyValueItem, KeyValueItem | null]> = [];
for (let i = 0; i < items.length; i += 2) {
rows.push([items[i], items[i + 1] || null]);
}
return (
<View style={styles.kvGrid}>
{rows.map(([left, right], rowIndex) => {
const isLast = rowIndex === rows.length - 1;
const leftValue = left.unit ? `${left.value} ${left.unit}` : left.value;
const rightValue = right ? (right.unit ? `${right.value} ${right.unit}` : right.value) : '';
return (
<View
key={`${left.label}-${rowIndex}`}
style={[
styles.kvRow,
rowIndex % 2 === 0 ? styles.kvRowAlt : null,
isLast ? styles.kvRowLast : null,
]}
wrap={false}
>
<View style={[styles.kvCell, { width: '23%' }]}>
<Text style={styles.kvLabelText}>{left.label}</Text>
</View>
<View style={[styles.kvCell, styles.kvMidDivider, { width: '27%' }]}>
<Text style={styles.kvValueText}>{leftValue}</Text>
</View>
<View style={[styles.kvCell, { width: '23%' }]}>
<Text style={styles.kvLabelText}>{right?.label || ''}</Text>
</View>
<View style={[styles.kvCell, { width: '27%' }]}>
<Text style={styles.kvValueText}>{rightValue}</Text>
</View>
</View>
);
})}
</View>
);
}

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { Text, View } from '@react-pdf/renderer';
import { styles } from '../styles';
export function Section(props: {
title: string;
children: React.ReactNode;
boxed?: boolean;
minPresenceAhead?: number;
}): React.ReactElement {
const boxed = props.boxed ?? true;
return (
<View
style={boxed ? styles.section : styles.sectionPlain}
minPresenceAhead={props.minPresenceAhead}
>
<Text style={styles.sectionTitle}>{props.title}</Text>
{props.children}
</View>
);
}

View File

@@ -0,0 +1,27 @@
import * as React from 'react';
import { renderToBuffer } from '@react-pdf/renderer';
import type { ProductData } from '../model/types';
import { buildDatasheetModel } from '../model/build-datasheet-model';
import { loadImageAsPngDataUrl, loadQrAsPngDataUrl } from './assets';
import { DatasheetDocument } from './DatasheetDocument';
export async function generateDatasheetPdfBuffer(args: {
product: ProductData;
locale: 'en' | 'de';
}): Promise<Buffer> {
const model = buildDatasheetModel({ product: args.product, locale: args.locale });
const logoDataUrl =
(await loadImageAsPngDataUrl('/logo-blue.svg')) ||
(await loadImageAsPngDataUrl('/logo-white.svg')) ||
null;
const heroDataUrl = await loadImageAsPngDataUrl(model.product.heroSrc);
const qrDataUrl = await loadQrAsPngDataUrl(model.product.productUrl);
const element = (
<DatasheetDocument model={model} assets={{ logoDataUrl, heroDataUrl, qrDataUrl }} />
);
return await renderToBuffer(element);
}

View File

@@ -0,0 +1,155 @@
import { Font, StyleSheet } from '@react-pdf/renderer';
// Prevent automatic word hyphenation, which can create multi-line table headers
// even when we try to keep them single-line.
Font.registerHyphenationCallback((word) => [word]);
export const COLORS = {
navy: '#0E2A47',
mediumGray: '#6B7280',
darkGray: '#1F2933',
lightGray: '#E6E9ED',
almostWhite: '#F8F9FA',
headerBg: '#F6F8FB',
} as const;
export const styles = StyleSheet.create({
page: {
paddingTop: 54,
paddingLeft: 54,
paddingRight: 54,
paddingBottom: 72,
fontFamily: 'Helvetica',
fontSize: 10,
color: COLORS.darkGray,
backgroundColor: '#FFFFFF',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: COLORS.headerBg,
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
marginBottom: 16,
},
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
logo: { width: 110, height: 24, objectFit: 'contain' },
brandFallback: { flexDirection: 'row', alignItems: 'baseline', gap: 6 },
brandFallbackKlz: { fontSize: 18, fontWeight: 700, color: COLORS.navy },
brandFallbackCables: { fontSize: 10, color: COLORS.mediumGray },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 10 },
headerTitle: { fontSize: 9, fontWeight: 700, color: COLORS.navy, letterSpacing: 0.2 },
qr: { width: 34, height: 34, objectFit: 'contain' },
footer: {
position: 'absolute',
left: 54,
right: 54,
bottom: 36,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: COLORS.lightGray,
flexDirection: 'row',
justifyContent: 'space-between',
fontSize: 8,
color: COLORS.mediumGray,
},
h1: { fontSize: 18, fontWeight: 700, color: COLORS.navy, marginBottom: 6 },
subhead: { fontSize: 10.5, color: COLORS.mediumGray, marginBottom: 14 },
heroBox: {
height: 110,
borderWidth: 1,
borderColor: COLORS.lightGray,
backgroundColor: COLORS.almostWhite,
marginBottom: 16,
justifyContent: 'center',
overflow: 'hidden',
},
heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
noImage: { fontSize: 8, color: COLORS.mediumGray, paddingHorizontal: 12 },
section: {
borderWidth: 1,
borderColor: COLORS.lightGray,
padding: 14,
marginBottom: 14,
},
sectionPlain: {
paddingVertical: 2,
marginBottom: 12,
},
sectionTitle: {
fontSize: 10,
fontWeight: 700,
color: COLORS.navy,
marginBottom: 8,
letterSpacing: 0.2,
},
body: { fontSize: 10, lineHeight: 1.5, color: COLORS.darkGray },
kvGrid: {
width: '100%',
borderWidth: 1,
borderColor: COLORS.lightGray,
},
kvRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
},
kvRowAlt: { backgroundColor: COLORS.almostWhite },
kvRowLast: { borderBottomWidth: 0 },
kvCell: { paddingVertical: 6, paddingHorizontal: 8 },
// Visual separator between (label,value) pairs in the 4-col KV grid.
// Matches the engineering-table look and improves scanability.
kvMidDivider: {
borderRightWidth: 1,
borderRightColor: COLORS.lightGray,
},
kvLabelText: { fontSize: 8.5, fontWeight: 700, color: COLORS.mediumGray },
kvValueText: { fontSize: 9.5, color: COLORS.darkGray },
tableWrap: { width: '100%', borderWidth: 1, borderColor: COLORS.lightGray, marginBottom: 14 },
tableHeader: {
width: '100%',
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
},
tableHeaderCell: {
paddingVertical: 5,
paddingHorizontal: 4,
fontSize: 6.6,
fontWeight: 700,
color: COLORS.navy,
},
tableHeaderCellCfg: {
paddingHorizontal: 6,
},
tableHeaderCellDivider: {
borderRightWidth: 1,
borderRightColor: COLORS.lightGray,
},
tableRow: {
width: '100%',
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
},
tableRowAlt: { backgroundColor: COLORS.almostWhite },
tableCell: { paddingVertical: 4, paddingHorizontal: 4, fontSize: 6.6, color: COLORS.darkGray },
tableCellCfg: {
paddingHorizontal: 6,
},
tableCellDivider: {
borderRightWidth: 1,
borderRightColor: COLORS.lightGray,
},
});

18
scripts/test-axios.ts Normal file
View File

@@ -0,0 +1,18 @@
import axios from 'axios';
async function test() {
const u =
'https://testing.klz-cables.com/de/blog/johannes-gleich-startet-als-senior-key-account-manager-durch';
try {
const res = await axios.get(u, {
headers: { Cookie: 'klz_gatekeeper_session=lassmichrein' },
validateStatus: (status) => status < 400,
});
console.log('Status:', res.status);
console.log('Headers:', res.headers);
} catch (err: any) {
console.log('Error status:', err.response?.status);
console.log('Error data:', err.response?.data);
}
}
test();