#!/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 */ 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 type { ProductData } from './pdf/model/types'; import { generateDatasheetPdfBuffer } from './pdf/react-pdf/generate-datasheet-pdf'; import { generateFileName, normalizeValue, stripHtml } from './pdf/model/utils'; 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/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; type CmsProduct = { slug: string; title: string; sku: string; categories: string[]; images: string[]; descriptionHtml: string; applicationHtml: string; }; type CmsIndex = Map; // 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 { const idx: CmsIndex = new Map(); 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) 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 = ''; // Application usually part of description in Payload now const slug = doc.slug || ''; idx.set(normalizeExcelKey(title), { slug, title, sku, categories, images, descriptionHtml, applicationHtml, }); } } catch (error) { console.error(`[Payload] Failed to fetch products for CMS index (${locale}):`, error); } return idx; } function findKeyByHeaderValue(headerRow: Record, 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> { 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 []; return XLSX.utils.sheet_to_json(sheet, { defval: '', raw: false, blankrows: false, }) as Array>; } function readDesignationsFromExcelFile(filePath: string): Map { 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) || findKeyByHeaderValue(headerRow, /^part\s*number$/i) || '__EMPTY'; const out = new Map(); 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; // Keep first-seen designation string (stable filenames from MDX slug). if (!out.has(key)) out.set(key, pn); } return out; } function loadAllExcelDesignations(): Map { const out = new Map(); 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 { 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, ''); // Only the product description comes from CMS. Everything else is Excel-driven // during model building (technicalItems + voltage tables). const descriptionHtml = cmsItem?.descriptionHtml || ''; products.push({ id: id++, name: title, shortDescriptionHtml: '', descriptionHtml, applicationHtml: cmsItem?.applicationHtml || '', 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; })(), }); }); // 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)); } async function processChunk( products: ProductData[], chunkIndex: number, totalChunks: number, ): Promise { console.log( `\nProcessing chunk ${chunkIndex + 1}/${totalChunks} (${products.length} products)...`, ); 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); // Determine subfolder based on voltage type 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 }); } 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); } } } async function processProductsInChunks(): Promise { console.log('Starting PDF generation (React-PDF)'); 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; } // 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(); 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 { 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 };