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
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:
@@ -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!"
|
||||
|
||||
@@ -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;
|
||||
|
||||
41
scripts/create-agent-admin.ts
Normal file
41
scripts/create-agent-admin.ts
Normal 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
64
scripts/debug-cms.ts
Normal 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
31
scripts/debug-product.ts
Normal 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();
|
||||
74
scripts/export-legacy-products-json.ts
Normal file
74
scripts/export-legacy-products-json.ts
Normal 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();
|
||||
510
scripts/generate-brochure.ts
Normal file
510
scripts/generate-brochure.ts
Normal 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);
|
||||
222
scripts/generate-excel-datasheets.ts
Normal file
222
scripts/generate-excel-datasheets.ts
Normal 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
@@ -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
17
scripts/inspect-pages.ts
Normal 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
30
scripts/inspect-start.ts
Normal 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
14
scripts/inspect-start2.ts
Normal 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);
|
||||
998
scripts/lib/excel-data-parser.ts
Normal file
998
scripts/lib/excel-data-parser.ts
Normal 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
18
scripts/list-pages.ts
Normal 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();
|
||||
897
scripts/pdf/model/build-datasheet-model.ts
Normal file
897
scripts/pdf/model/build-datasheet-model.ts
Normal 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 || [],
|
||||
};
|
||||
}
|
||||
204
scripts/pdf/model/excel-index.ts
Normal file
204
scripts/pdf/model/excel-index.ts
Normal 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;
|
||||
}
|
||||
51
scripts/pdf/model/types.ts
Normal file
51
scripts/pdf/model/types.ts
Normal 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[];
|
||||
};
|
||||
74
scripts/pdf/model/utils.ts
Normal file
74
scripts/pdf/model/utils.ts
Normal 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];
|
||||
}
|
||||
90
scripts/pdf/react-pdf/DatasheetDocument.tsx
Normal file
90
scripts/pdf/react-pdf/DatasheetDocument.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
scripts/pdf/react-pdf/assets.ts
Normal file
87
scripts/pdf/react-pdf/assets.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
231
scripts/pdf/react-pdf/components/DenseTable.tsx
Normal file
231
scripts/pdf/react-pdf/components/DenseTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
scripts/pdf/react-pdf/components/Footer.tsx
Normal file
22
scripts/pdf/react-pdf/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
scripts/pdf/react-pdf/components/Header.tsx
Normal file
29
scripts/pdf/react-pdf/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
scripts/pdf/react-pdf/components/KeyValueGrid.tsx
Normal file
51
scripts/pdf/react-pdf/components/KeyValueGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
scripts/pdf/react-pdf/components/Section.tsx
Normal file
22
scripts/pdf/react-pdf/components/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
scripts/pdf/react-pdf/generate-datasheet-pdf.tsx
Normal file
27
scripts/pdf/react-pdf/generate-datasheet-pdf.tsx
Normal 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);
|
||||
}
|
||||
155
scripts/pdf/react-pdf/styles.ts
Normal file
155
scripts/pdf/react-pdf/styles.ts
Normal 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
18
scripts/test-axios.ts
Normal 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();
|
||||
Reference in New Issue
Block a user