Files
klz-cables.com/scripts/generate-pdf-datasheets.tsx
Marc Mintel d575e5924a
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 2m20s
Build & Deploy / 🏗️ Build (push) Successful in 3m56s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m30s
Build & Deploy / 🔔 Notify (push) Successful in 2s
fix(pdf): fix missing logos and product images in datasheets, update pipeline
2026-03-08 01:55:52 +01:00

375 lines
12 KiB
TypeScript

#!/usr/bin/env ts-node
/**
* PDF Datasheet Generator (React-PDF)
*
* Renders PDFs via `@react-pdf/renderer`.
*
* Source of truth:
* - 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';
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 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'),
chunkSize: 10,
} as const;
const EXCEL_FILES = [
{ path: path.join(process.cwd(), 'data/excel/high-voltage.xlsx'), voltageType: 'high-voltage' },
{
path: path.join(process.cwd(), 'data/excel/medium-voltage-KM.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;
type CmsProduct = {
slug: string;
title: string;
sku: string;
categories: string[];
images: string[];
descriptionHtml: string;
applicationHtml: string;
};
type CmsIndex = Map<string, CmsProduct>; // key: normalized designation/title
function ensureOutputDir(): void {
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
}
function normalizeExcelKey(value: string): string {
return String(value || '')
.toUpperCase()
.replace(/-\d+$/g, '')
.replace(/[^A-Z0-9]+/g, '');
}
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({
collection: 'products',
where: {
...(!isDev ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
pagination: false,
});
for (const doc of result.docs) {
if (!doc.title) continue;
const title = normalizeValue(String(doc.title));
const sku = normalizeValue(String(doc.sku || ''));
const categories = Array.isArray(doc.categories)
? doc.categories.map((c: any) => normalizeValue(String(c.category || c))).filter(Boolean)
: [];
const images = Array.isArray(doc.images)
? doc.images
.map((i: any) => normalizeValue(String(typeof i === 'string' ? i : i.url)))
.filter(Boolean)
: [];
const descriptionHtml = normalizeValue(String(doc.description || ''));
const applicationHtml = '';
const slug = doc.slug || '';
idx.set(normalizeExcelKey(title), {
slug,
title,
sku,
categories,
images,
descriptionHtml,
applicationHtml,
});
}
} catch (error) {
console.warn(
`[Payload] Warning: Could not fetch CMS index (${locale}). Using Excel data only.`,
error instanceof Error ? error.message : error,
);
}
return idx;
}
function findKeyByHeaderValue(headerRow: Record<string, unknown>, 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 readExcelRows(filePath: string): Array<Record<string, unknown>> {
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>>;
} 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();
const headerRow = rows[0] || {};
const partNumberKey =
(Object.prototype.hasOwnProperty.call(headerRow, 'Part Number') ? 'Part Number' : null) ||
findKeyByHeaderValue(headerRow, /^part\s*number$/i) ||
'__EMPTY';
const out = new Map<string, string>();
for (const r of rows) {
const pn = normalizeValue(String(r?.[partNumberKey] ?? ''));
if (!pn || pn === 'Units' || pn === 'Part Number') continue;
const key = normalizeExcelKey(pn);
if (!key) continue;
if (!out.has(key)) out.set(key, pn);
}
return out;
}
function loadAllExcelDesignations(): Map<string, { designation: string; voltageType: string }> {
const out = new Map<string, { designation: string; voltageType: string }>();
for (const file of EXCEL_FILES) {
const m = readDesignationsFromExcelFile(file.path);
Array.from(m.entries()).forEach(([k, v]) => {
if (!out.has(k)) out.set(k, { designation: v, voltageType: file.voltageType });
});
}
return out;
}
async function loadProductsFromExcelAndCms(locale: 'en' | 'de'): Promise<ProductData[]> {
const cmsIndex = await buildCmsIndex(locale);
const excelDesignations = loadAllExcelDesignations();
const products: ProductData[] = [];
let id = 1;
Array.from(excelDesignations.entries()).forEach(([key, data]) => {
const cmsItem = cmsIndex.get(key) || null;
const title = cmsItem?.title || data.designation;
const slug =
cmsItem?.slug ||
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const descriptionHtml = cmsItem?.descriptionHtml || '';
products.push({
id: id++,
name: title,
shortDescriptionHtml: '',
descriptionHtml,
images: cmsItem?.images || [],
featuredImage: (cmsItem?.images && cmsItem.images[0]) || null,
sku: cmsItem?.sku || title,
slug,
translationKey: slug,
locale,
categories: (cmsItem?.categories || []).map((name) => ({ name })),
attributes: [],
voltageType: (() => {
const cats = (cmsItem?.categories || []).map((c) => String(c));
const isMV = cats.some((c) => /medium[-\s]?voltage|mittelspannung/i.test(c));
if (isMV && data.voltageType === 'high-voltage') return 'medium-voltage';
return data.voltageType;
})(),
});
});
products.sort(
(a, b) => (a.slug || '').localeCompare(b.slug || '') || a.name.localeCompare(b.name),
);
return products.filter((p) => stripHtml(p.name));
}
async function processChunk(
products: ProductData[],
chunkIndex: number,
totalChunks: number,
): Promise<void> {
console.log(
`\nProcessing chunk ${chunkIndex + 1}/${totalChunks} (${products.length} products)...`,
);
for (const product of products) {
try {
const locale = (product.locale || 'en') as 'en' | 'de';
console.log(`[${product.id}] Starting: ${product.name} (${locale})`);
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);
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} (${product.name}):`, error);
}
}
}
async function processProductsInChunks(): Promise<void> {
console.log('Starting PDF generation (React-PDF - Unified Component)');
ensureOutputDir();
const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase();
const locales: Array<'en' | 'de'> =
onlyLocale === 'de' || onlyLocale === 'en' ? [onlyLocale] : ['en', 'de'];
const allProducts: ProductData[] = [];
for (const locale of locales) {
const products = await loadProductsFromExcelAndCms(locale);
allProducts.push(...products);
}
if (allProducts.length === 0) {
console.log('No products found');
return;
}
let products = allProducts;
const match = normalizeValue(String(process.env.PDF_MATCH || '')).toLowerCase();
if (match) {
products = products.filter((p) => {
const hay = [p.slug, p.translationKey, p.sku, p.name].filter(Boolean).join(' ').toLowerCase();
return hay.includes(match);
});
}
const limit = Number(process.env.PDF_LIMIT || '0');
products = Number.isFinite(limit) && limit > 0 ? products.slice(0, limit) : products;
const enProducts = products.filter((p) => (p.locale || 'en') === 'en');
const deProducts = products.filter((p) => (p.locale || 'en') === 'de');
console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`);
const totalChunks = Math.ceil(products.length / CONFIG.chunkSize);
for (let i = 0; i < totalChunks; i++) {
const chunk = products.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize);
await processChunk(chunk, i, totalChunks);
}
console.log('\n✅ PDF generation completed!');
console.log(`Generated ${enProducts.length} EN + ${deProducts.length} DE PDFs`);
console.log(`Output: ${CONFIG.outputDir}`);
}
async function main(): Promise<void> {
const start = Date.now();
try {
await processProductsInChunks();
console.log(`\nTime: ${((Date.now() - start) / 1000).toFixed(2)}s`);
} catch (error) {
console.error('Fatal error:', error);
process.exit(1);
}
}
main().catch(console.error);
export { main as generatePDFDatasheets };