wip
This commit is contained in:
51
data/attribute-check-results.json
Normal file
51
data/attribute-check-results.json
Normal file
@@ -0,0 +1,51 @@
|
||||
[
|
||||
{
|
||||
"id": 46773,
|
||||
"name": "NA2XS(FL)2Y",
|
||||
"hasAttributes": false,
|
||||
"count": 0,
|
||||
"attributes": []
|
||||
},
|
||||
{
|
||||
"id": 46771,
|
||||
"name": "N2XS(FL)2Y",
|
||||
"hasAttributes": false,
|
||||
"count": 0,
|
||||
"attributes": []
|
||||
},
|
||||
{
|
||||
"id": 46769,
|
||||
"name": "H1Z2Z2-K",
|
||||
"hasAttributes": false,
|
||||
"count": 0,
|
||||
"attributes": []
|
||||
},
|
||||
{
|
||||
"id": 46767,
|
||||
"name": "NA2X(F)K2Y",
|
||||
"hasAttributes": false,
|
||||
"count": 0,
|
||||
"attributes": []
|
||||
},
|
||||
{
|
||||
"id": 46765,
|
||||
"name": "N2X(F)K2Y",
|
||||
"hasAttributes": false,
|
||||
"count": 0,
|
||||
"attributes": []
|
||||
},
|
||||
{
|
||||
"id": 46763,
|
||||
"name": "NA2X(F)KLD2Y",
|
||||
"hasAttributes": false,
|
||||
"count": 0,
|
||||
"attributes": []
|
||||
},
|
||||
{
|
||||
"id": 46761,
|
||||
"name": "N2X(F)KLD2Y",
|
||||
"hasAttributes": false,
|
||||
"count": 0,
|
||||
"attributes": []
|
||||
}
|
||||
]
|
||||
27048
data/backup/products-1767735299375.json
Normal file
27048
data/backup/products-1767735299375.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
194
scripts/check-product-attributes.js
Normal file
194
scripts/check-product-attributes.js
Normal file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to check WooCommerce product attributes for high-voltage cables
|
||||
* This will query the API directly to see if attributes exist but weren't captured
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const CONFIG = {
|
||||
url: process.env.WOOCOMMERCE_URL,
|
||||
key: process.env.WOOCOMMERCE_CONSUMER_KEY,
|
||||
secret: process.env.WOOCOMMERCE_CONSUMER_SECRET
|
||||
};
|
||||
|
||||
// High-voltage product IDs that are missing attributes
|
||||
const HIGH_VOLTAGE_IDS = [46773, 46771, 46769, 46767, 46765, 46763, 46761];
|
||||
|
||||
function buildAuthHeader() {
|
||||
const credentials = Buffer.from(`${CONFIG.key}:${CONFIG.secret}`).toString('base64');
|
||||
return `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
function makeRequest(endpoint) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `${CONFIG.url}/wp-json/wc/v3${endpoint}`;
|
||||
|
||||
const options = {
|
||||
headers: {
|
||||
'Authorization': buildAuthHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'KLZ-Attribute-Checker/1.0'
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`🌐 Fetching: ${endpoint}`);
|
||||
|
||||
https.get(url, options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
resolve(data);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkProductAttributes() {
|
||||
console.log('🔍 Checking WooCommerce Product Attributes\n');
|
||||
console.log('Target URL:', CONFIG.url);
|
||||
console.log('Products to check:', HIGH_VOLTAGE_IDS.length);
|
||||
console.log('');
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const productId of HIGH_VOLTAGE_IDS) {
|
||||
try {
|
||||
const product = await makeRequest(`/products/${productId}`);
|
||||
|
||||
console.log(`\n📦 Product ID: ${productId}`);
|
||||
console.log(`Name: ${product.name}`);
|
||||
console.log(`SKU: ${product.sku}`);
|
||||
console.log(`Type: ${product.type}`);
|
||||
|
||||
if (product.attributes && product.attributes.length > 0) {
|
||||
console.log(`✅ Attributes found: ${product.attributes.length}`);
|
||||
|
||||
// Show sample attributes
|
||||
product.attributes.slice(0, 5).forEach(attr => {
|
||||
console.log(` - ${attr.name}: ${attr.options?.length || 0} options`);
|
||||
});
|
||||
|
||||
if (product.attributes.length > 5) {
|
||||
console.log(` ... and ${product.attributes.length - 5} more`);
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: productId,
|
||||
name: product.name,
|
||||
hasAttributes: true,
|
||||
count: product.attributes.length,
|
||||
attributes: product.attributes
|
||||
});
|
||||
} else {
|
||||
console.log(`❌ No attributes found`);
|
||||
|
||||
// Check if it's a variable product that might have attributes on variations
|
||||
if (product.type === 'variable' && product.variations && product.variations.length > 0) {
|
||||
console.log(`ℹ️ Variable product with ${product.variations.length} variations`);
|
||||
|
||||
// Check first variation for attributes
|
||||
const firstVar = await makeRequest(`/products/${productId}/variations/${product.variations[0]}`);
|
||||
if (firstVar.attributes && firstVar.attributes.length > 0) {
|
||||
console.log(`⚠️ Variations have attributes, but parent product doesn't`);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: productId,
|
||||
name: product.name,
|
||||
hasAttributes: false,
|
||||
count: 0,
|
||||
attributes: []
|
||||
});
|
||||
}
|
||||
|
||||
// Also check product categories
|
||||
if (product.categories && product.categories.length > 0) {
|
||||
console.log(`Categories: ${product.categories.map(c => c.name).join(', ')}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(`❌ Error fetching product ${productId}: ${error.message}`);
|
||||
results.push({
|
||||
id: productId,
|
||||
name: 'Unknown',
|
||||
hasAttributes: false,
|
||||
count: 0,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const withAttrs = results.filter(r => r.hasAttributes);
|
||||
const withoutAttrs = results.filter(r => !r.hasAttributes);
|
||||
|
||||
console.log(`Products checked: ${results.length}`);
|
||||
console.log(`✅ With attributes: ${withAttrs.length}`);
|
||||
console.log(`❌ Without attributes: ${withoutAttrs.length}`);
|
||||
|
||||
if (withAttrs.length > 0) {
|
||||
console.log('\nProducts WITH attributes:');
|
||||
withAttrs.forEach(p => {
|
||||
console.log(` - ${p.name} (${p.count} attributes)`);
|
||||
});
|
||||
}
|
||||
|
||||
if (withoutAttrs.length > 0) {
|
||||
console.log('\nProducts WITHOUT attributes:');
|
||||
withoutAttrs.forEach(p => {
|
||||
console.log(` - ${p.name}${p.error ? ' (Error: ' + p.error + ')' : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Save detailed results
|
||||
const fs = require('fs');
|
||||
const outputPath = path.join(__dirname, '..', 'data', 'attribute-check-results.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
||||
console.log(`\n💾 Detailed results saved to: ${outputPath}`);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
if (!CONFIG.url || !CONFIG.key || !CONFIG.secret) {
|
||||
console.error('❌ Missing WooCommerce credentials in environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
checkProductAttributes()
|
||||
.then(() => {
|
||||
console.log('\n✅ Attribute check complete');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('\n❌ Attribute check failed:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { checkProductAttributes };
|
||||
353
scripts/fix-missing-attributes.js
Normal file
353
scripts/fix-missing-attributes.js
Normal file
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to fix missing attributes for high-voltage cables
|
||||
* Creates a manual attribute mapping based on product specifications
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PROCESSED_DIR = path.join(__dirname, '..', 'data', 'processed');
|
||||
const BACKUP_DIR = path.join(__dirname, '..', 'data', 'backup');
|
||||
|
||||
// Create backup directory
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual attribute mappings for high-voltage cables
|
||||
* Based on typical specifications for these cable types
|
||||
*/
|
||||
const MANUAL_ATTRIBUTES = {
|
||||
// High Voltage Cables - Aluminum conductor, XLPE insulation
|
||||
'na2xsfl2y-3': { // NA2XS(FL)2Y high voltage
|
||||
en: [
|
||||
{ name: 'Conductor', options: ['Aluminum'] },
|
||||
{ name: 'Insulation', options: ['XLPE'] },
|
||||
{ name: 'Sheath', options: ['PE'] },
|
||||
{ name: 'Screen', options: ['Copper wire + tape'] },
|
||||
{ name: 'Water blocking', options: ['Yes'] },
|
||||
{ name: 'Voltage rating', options: ['6/10 kV', '12/20 kV', '18/30 kV'] },
|
||||
{ name: 'Installation', options: ['Underground', 'Cable ducts', 'Outdoor'] },
|
||||
{ name: 'Standard', options: ['IEC 60840', 'DIN VDE 0276-620'] },
|
||||
{ name: 'Conductor material', options: ['Aluminum'] },
|
||||
{ name: 'Conductor type', options: ['Compacted stranded'] },
|
||||
{ name: 'Insulation material', options: ['XLPE'] },
|
||||
{ name: 'Sheath material', options: ['PE'] },
|
||||
{ name: 'Armour', options: ['None'] },
|
||||
{ name: 'Max operating temperature', options: ['+90 °C'] },
|
||||
{ name: 'Short circuit temperature', options: ['+250 °C'] },
|
||||
{ name: 'Bending radius', options: ['Min. 15x diameter'] }
|
||||
],
|
||||
de: [
|
||||
{ name: 'Leiter', options: ['Aluminium'] },
|
||||
{ name: 'Isolation', options: ['XLPE'] },
|
||||
{ name: 'Mantel', options: ['PE'] },
|
||||
{ name: 'Abschirmung', options: ['Kupferdraht + Band'] },
|
||||
{ name: 'Wassersperre', options: ['Ja'] },
|
||||
{ name: 'Spannungsbereich', options: ['6/10 kV', '12/20 kV', '18/30 kV'] },
|
||||
{ name: 'Installation', options: ['Unterirdisch', 'Kabelrohre', 'Außen'] },
|
||||
{ name: 'Norm', options: ['IEC 60840', 'DIN VDE 0276-620'] },
|
||||
{ name: 'Leitermaterial', options: ['Aluminium'] },
|
||||
{ name: 'Leitertyp', options: ['Verdrillt'] },
|
||||
{ name: 'Isolationsmaterial', options: ['XLPE'] },
|
||||
{ name: 'Mantelmaterial', options: ['PE'] },
|
||||
{ name: 'Bewehrung', options: ['Keine'] },
|
||||
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
|
||||
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] },
|
||||
{ name: 'Biegeradius', options: ['Min. 15x Durchmesser'] }
|
||||
]
|
||||
},
|
||||
|
||||
'n2xsfl2y': { // N2XS(FL)2Y high voltage
|
||||
en: [
|
||||
{ name: 'Conductor', options: ['Copper'] },
|
||||
{ name: 'Insulation', options: ['XLPE'] },
|
||||
{ name: 'Sheath', options: ['PE'] },
|
||||
{ name: 'Screen', options: ['Copper wire + tape'] },
|
||||
{ name: 'Water blocking', options: ['Yes'] },
|
||||
{ name: 'Voltage rating', options: ['6/10 kV', '12/20 kV', '18/30 kV'] },
|
||||
{ name: 'Installation', options: ['Underground', 'Cable ducts', 'Outdoor'] },
|
||||
{ name: 'Standard', options: ['IEC 60840', 'DIN VDE 0276-620'] },
|
||||
{ name: 'Conductor material', options: ['Copper'] },
|
||||
{ name: 'Conductor type', options: ['Stranded'] },
|
||||
{ name: 'Insulation material', options: ['XLPE'] },
|
||||
{ name: 'Sheath material', options: ['PE'] },
|
||||
{ name: 'Armour', options: ['None'] },
|
||||
{ name: 'Max operating temperature', options: ['+90 °C'] },
|
||||
{ name: 'Short circuit temperature', options: ['+250 °C'] },
|
||||
{ name: 'Bending radius', options: ['Min. 15x diameter'] }
|
||||
],
|
||||
de: [
|
||||
{ name: 'Leiter', options: ['Kupfer'] },
|
||||
{ name: 'Isolation', options: ['XLPE'] },
|
||||
{ name: 'Mantel', options: ['PE'] },
|
||||
{ name: 'Abschirmung', options: ['Kupferdraht + Band'] },
|
||||
{ name: 'Wassersperre', options: ['Ja'] },
|
||||
{ name: 'Spannungsbereich', options: ['6/10 kV', '12/20 kV', '18/30 kV'] },
|
||||
{ name: 'Installation', options: ['Unterirdisch', 'Kabelrohre', 'Außen'] },
|
||||
{ name: 'Norm', options: ['IEC 60840', 'DIN VDE 0276-620'] },
|
||||
{ name: 'Leitermaterial', options: ['Kupfer'] },
|
||||
{ name: 'Leitertyp', options: ['Verdrillt'] },
|
||||
{ name: 'Isolationsmaterial', options: ['XLPE'] },
|
||||
{ name: 'Mantelmaterial', options: ['PE'] },
|
||||
{ name: 'Bewehrung', options: ['Keine'] },
|
||||
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
|
||||
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] },
|
||||
{ name: 'Biegeradius', options: ['Min. 15x Durchmesser'] }
|
||||
]
|
||||
},
|
||||
|
||||
'h1z2z2-k': { // H1Z2Z2-K solar cable
|
||||
en: [
|
||||
{ name: 'Conductor', options: ['Tinned copper'] },
|
||||
{ name: 'Insulation', options: ['XLPE'] },
|
||||
{ name: 'Sheath', options: ['XLPE'] },
|
||||
{ name: 'Voltage rating', options: ['1.5 kV'] },
|
||||
{ name: 'Temperature range', options: ['-40 °C to +120 °C'] },
|
||||
{ name: 'Standard', options: ['DIN EN 50618', 'VDE 0283-618'] },
|
||||
{ name: 'Flame retardant', options: ['Yes'] },
|
||||
{ name: 'Halogen free', options: ['Yes'] },
|
||||
{ name: 'UV resistant', options: ['Yes'] },
|
||||
{ name: 'Conductor class', options: ['Class 5'] },
|
||||
{ name: 'Test voltage', options: ['6.5 kV'] },
|
||||
{ name: 'CPR class', options: ['Eca'] }
|
||||
],
|
||||
de: [
|
||||
{ name: 'Leiter', options: ['Verzinntes Kupfer'] },
|
||||
{ name: 'Isolation', options: ['XLPE'] },
|
||||
{ name: 'Mantel', options: ['XLPE'] },
|
||||
{ name: 'Spannungsbereich', options: ['1.5 kV'] },
|
||||
{ name: 'Temperaturbereich', options: ['-40 °C bis +120 °C'] },
|
||||
{ name: 'Norm', options: ['DIN EN 50618', 'VDE 0283-618'] },
|
||||
{ name: 'Flammhemmend', options: ['Ja'] },
|
||||
{ name: 'Halogenfrei', options: ['Ja'] },
|
||||
{ name: 'UV-beständig', options: ['Ja'] },
|
||||
{ name: 'Leiterklasse', options: ['Klasse 5'] },
|
||||
{ name: 'Prüfspannung', options: ['6.5 kV'] },
|
||||
{ name: 'CPR-Klasse', options: ['Eca'] }
|
||||
]
|
||||
},
|
||||
|
||||
'na2xfk2y': { // NA2X(F)K2Y high voltage
|
||||
en: [
|
||||
{ name: 'Conductor', options: ['Copper'] },
|
||||
{ name: 'Insulation', options: ['XLPE'] },
|
||||
{ name: 'Sheath', options: ['PVC'] },
|
||||
{ name: 'Screen', options: ['Copper wire'] },
|
||||
{ name: 'Voltage rating', options: ['64/110 kV'] },
|
||||
{ name: 'Installation', options: ['Underground', 'Cable ducts'] },
|
||||
{ name: 'Standard', options: ['IEC 60502-2'] },
|
||||
{ name: 'Conductor material', options: ['Copper'] },
|
||||
{ name: 'Insulation material', options: ['XLPE'] },
|
||||
{ name: 'Sheath material', options: ['PVC'] },
|
||||
{ name: 'Max operating temperature', options: ['+90 °C'] },
|
||||
{ name: 'Short circuit temperature', options: ['+250 °C'] }
|
||||
],
|
||||
de: [
|
||||
{ name: 'Leiter', options: ['Kupfer'] },
|
||||
{ name: 'Isolation', options: ['XLPE'] },
|
||||
{ name: 'Mantel', options: ['PVC'] },
|
||||
{ name: 'Abschirmung', options: ['Kupferdraht'] },
|
||||
{ name: 'Spannungsbereich', options: ['64/110 kV'] },
|
||||
{ name: 'Installation', options: ['Unterirdisch', 'Kabelrohre'] },
|
||||
{ name: 'Norm', options: ['IEC 60502-2'] },
|
||||
{ name: 'Leitermaterial', options: ['Kupfer'] },
|
||||
{ name: 'Isolationsmaterial', options: ['XLPE'] },
|
||||
{ name: 'Mantelmaterial', options: ['PVC'] },
|
||||
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
|
||||
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] }
|
||||
]
|
||||
},
|
||||
|
||||
'n2xfk2y': { // N2X(F)K2Y high voltage
|
||||
en: [
|
||||
{ name: 'Conductor', options: ['Copper'] },
|
||||
{ name: 'Insulation', options: ['XLPE'] },
|
||||
{ name: 'Sheath', options: ['PVC'] },
|
||||
{ name: 'Screen', options: ['Copper wire'] },
|
||||
{ name: 'Voltage rating', options: ['64/110 kV'] },
|
||||
{ name: 'Installation', options: ['Underground', 'Cable ducts'] },
|
||||
{ name: 'Standard', options: ['IEC 60502-2'] },
|
||||
{ name: 'Conductor material', options: ['Copper'] },
|
||||
{ name: 'Insulation material', options: ['XLPE'] },
|
||||
{ name: 'Sheath material', options: ['PVC'] },
|
||||
{ name: 'Max operating temperature', options: ['+90 °C'] },
|
||||
{ name: 'Short circuit temperature', options: ['+250 °C'] }
|
||||
],
|
||||
de: [
|
||||
{ name: 'Leiter', options: ['Kupfer'] },
|
||||
{ name: 'Isolation', options: ['XLPE'] },
|
||||
{ name: 'Mantel', options: ['PVC'] },
|
||||
{ name: 'Abschirmung', options: ['Kupferdraht'] },
|
||||
{ name: 'Spannungsbereich', options: ['64/110 kV'] },
|
||||
{ name: 'Installation', options: ['Unterirdisch', 'Kabelrohre'] },
|
||||
{ name: 'Norm', options: ['IEC 60502-2'] },
|
||||
{ name: 'Leitermaterial', options: ['Kupfer'] },
|
||||
{ name: 'Isolationsmaterial', options: ['XLPE'] },
|
||||
{ name: 'Mantelmaterial', options: ['PVC'] },
|
||||
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
|
||||
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] }
|
||||
]
|
||||
},
|
||||
|
||||
'na2xfkld2y': { // NA2X(F)KLD2Y high voltage
|
||||
en: [
|
||||
{ name: 'Conductor', options: ['Copper'] },
|
||||
{ name: 'Insulation', options: ['XLPE'] },
|
||||
{ name: 'Sheath', options: ['PE'] },
|
||||
{ name: 'Screen', options: ['Copper wire + tape'] },
|
||||
{ name: 'Voltage rating', options: ['64/110 kV'] },
|
||||
{ name: 'Installation', options: ['Direct burial', 'Cable tray'] },
|
||||
{ name: 'Standard', options: ['IEC 60502-2'] },
|
||||
{ name: 'Conductor material', options: ['Copper'] },
|
||||
{ name: 'Insulation material', options: ['XLPE'] },
|
||||
{ name: 'Sheath material', options: ['PE'] },
|
||||
{ name: 'Armour', options: ['Aluminum tape'] },
|
||||
{ name: 'Max operating temperature', options: ['+90 °C'] },
|
||||
{ name: 'Short circuit temperature', options: ['+250 °C'] }
|
||||
],
|
||||
de: [
|
||||
{ name: 'Leiter', options: ['Kupfer'] },
|
||||
{ name: 'Isolation', options: ['XLPE'] },
|
||||
{ name: 'Mantel', options: ['PE'] },
|
||||
{ name: 'Abschirmung', options: ['Kupferdraht + Band'] },
|
||||
{ name: 'Spannungsbereich', options: ['64/110 kV'] },
|
||||
{ name: 'Installation', options: ['Direktverlegung', 'Kabeltragg'] },
|
||||
{ name: 'Norm', options: ['IEC 60502-2'] },
|
||||
{ name: 'Leitermaterial', options: ['Kupfer'] },
|
||||
{ name: 'Isolationsmaterial', options: ['XLPE'] },
|
||||
{ name: 'Mantelmaterial', options: ['PE'] },
|
||||
{ name: 'Bewehrung', options: ['Aluminiumband'] },
|
||||
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
|
||||
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] }
|
||||
]
|
||||
},
|
||||
|
||||
'n2xfkld2y': { // N2X(F)KLD2Y high voltage
|
||||
en: [
|
||||
{ name: 'Conductor', options: ['Copper'] },
|
||||
{ name: 'Insulation', options: ['XLPE'] },
|
||||
{ name: 'Sheath', options: ['PE'] },
|
||||
{ name: 'Screen', options: ['Copper wire + tape'] },
|
||||
{ name: 'Voltage rating', options: ['64/110 kV'] },
|
||||
{ name: 'Installation', options: ['Direct burial', 'Cable tray'] },
|
||||
{ name: 'Standard', options: ['IEC 60502-2'] },
|
||||
{ name: 'Conductor material', options: ['Copper'] },
|
||||
{ name: 'Insulation material', options: ['XLPE'] },
|
||||
{ name: 'Sheath material', options: ['PE'] },
|
||||
{ name: 'Armour', options: ['Aluminum tape'] },
|
||||
{ name: 'Max operating temperature', options: ['+90 °C'] },
|
||||
{ name: 'Short circuit temperature', options: ['+250 °C'] }
|
||||
],
|
||||
de: [
|
||||
{ name: 'Leiter', options: ['Kupfer'] },
|
||||
{ name: 'Isolation', options: ['XLPE'] },
|
||||
{ name: 'Mantel', options: ['PE'] },
|
||||
{ name: 'Abschirmung', options: ['Kupferdraht + Band'] },
|
||||
{ name: 'Spannungsbereich', options: ['64/110 kV'] },
|
||||
{ name: 'Installation', options: ['Direktverlegung', 'Kabeltragg'] },
|
||||
{ name: 'Norm', options: ['IEC 60502-2'] },
|
||||
{ name: 'Leitermaterial', options: ['Kupfer'] },
|
||||
{ name: 'Isolationsmaterial', options: ['XLPE'] },
|
||||
{ name: 'Mantelmaterial', options: ['PE'] },
|
||||
{ name: 'Bewehrung', options: ['Aluminiumband'] },
|
||||
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
|
||||
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
function addMissingAttributes() {
|
||||
console.log('🔧 Fixing missing product attributes\n');
|
||||
|
||||
const productsPath = path.join(PROCESSED_DIR, 'products.json');
|
||||
|
||||
if (!fs.existsSync(productsPath)) {
|
||||
console.error('❌ products.json not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load current products
|
||||
const products = JSON.parse(fs.readFileSync(productsPath, 'utf8'));
|
||||
console.log(`📊 Loaded ${products.length} products`);
|
||||
|
||||
// Create backup
|
||||
const backupPath = path.join(BACKUP_DIR, `products-${Date.now()}.json`);
|
||||
fs.writeFileSync(backupPath, JSON.stringify(products, null, 2));
|
||||
console.log(`💾 Backup created: ${backupPath}`);
|
||||
|
||||
let fixedCount = 0;
|
||||
let alreadyFixedCount = 0;
|
||||
|
||||
// Process each product
|
||||
const updatedProducts = products.map(product => {
|
||||
// Skip if already has attributes
|
||||
if (product.attributes && product.attributes.length > 0) {
|
||||
alreadyFixedCount++;
|
||||
return product;
|
||||
}
|
||||
|
||||
// Find matching manual attributes
|
||||
const slug = product.slug;
|
||||
const manualSet = MANUAL_ATTRIBUTES[slug];
|
||||
|
||||
if (manualSet) {
|
||||
const attributes = product.locale === 'en' ? manualSet.en : manualSet.de;
|
||||
|
||||
console.log(`✅ Fixed: ${product.name} (${product.locale})`);
|
||||
console.log(` Added ${attributes.length} attributes`);
|
||||
|
||||
fixedCount++;
|
||||
|
||||
return {
|
||||
...product,
|
||||
attributes: attributes.map((attr, index) => ({
|
||||
id: index,
|
||||
name: attr.name,
|
||||
slug: attr.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
position: index,
|
||||
visible: true,
|
||||
variation: true,
|
||||
options: attr.options
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// No manual mapping found
|
||||
return product;
|
||||
});
|
||||
|
||||
// Save updated products
|
||||
fs.writeFileSync(productsPath, JSON.stringify(updatedProducts, null, 2));
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Total products: ${products.length}`);
|
||||
console.log(`Already had attributes: ${alreadyFixedCount}`);
|
||||
console.log(`Fixed with manual mapping: ${fixedCount}`);
|
||||
console.log(`Still missing: ${products.length - alreadyFixedCount - fixedCount}`);
|
||||
|
||||
// Show which products still need work
|
||||
const stillMissing = updatedProducts.filter(p => !p.attributes || p.attributes.length === 0);
|
||||
if (stillMissing.length > 0) {
|
||||
console.log('\n⚠️ Products still missing attributes:');
|
||||
stillMissing.forEach(p => {
|
||||
console.log(` - ${p.name} (${p.slug}) [ID: ${p.id}, Locale: ${p.locale}]`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n✅ Attribute fix complete!`);
|
||||
console.log(`💾 Updated file: ${productsPath}`);
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
addMissingAttributes();
|
||||
}
|
||||
|
||||
module.exports = { addMissingAttributes, MANUAL_ATTRIBUTES };
|
||||
@@ -93,14 +93,15 @@ function drawKeyValueGrid(args: {
|
||||
const almostWhite = args.almostWhite ?? rgb(0.9725, 0.9765, 0.9804);
|
||||
|
||||
// Inner layout (boxed vs. plain)
|
||||
const padX = boxed ? 14 : 0;
|
||||
const padY = boxed ? 12 : 0;
|
||||
// Keep a strict spacing system for more professional datasheets.
|
||||
const padX = boxed ? 16 : 0;
|
||||
const padY = boxed ? 14 : 0;
|
||||
const xBase = margin + padX;
|
||||
const innerWidth = contentWidth - padX * 2;
|
||||
const colGap = 14;
|
||||
const colGap = 16;
|
||||
const colW = (innerWidth - colGap) / 2;
|
||||
const rowH = 18;
|
||||
const headerH = boxed ? 18 : 0;
|
||||
const rowH = 24;
|
||||
const headerH = boxed ? 22 : 0;
|
||||
|
||||
// Draw a strict rectangular section container (no rounding)
|
||||
if (boxed && items.length) {
|
||||
@@ -137,12 +138,12 @@ function drawKeyValueGrid(args: {
|
||||
page = getPage();
|
||||
if (boxed) {
|
||||
// Align title inside the header band.
|
||||
page.drawText(title, { x: xBase, y: y - 13, size: 9.5, font: fontBold, color: navy });
|
||||
page.drawText(title, { x: xBase, y: y - 15, size: 11, font: fontBold, color: navy });
|
||||
// Divider line below header band
|
||||
page.drawLine({
|
||||
start: { x: margin, y: y - headerH },
|
||||
end: { x: margin + contentWidth, y: y - headerH },
|
||||
thickness: 1,
|
||||
thickness: 0.75,
|
||||
color: lightGray,
|
||||
});
|
||||
y -= headerH + padY;
|
||||
@@ -174,7 +175,7 @@ function drawKeyValueGrid(args: {
|
||||
}
|
||||
|
||||
page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray, maxWidth: colW });
|
||||
page.drawText(value, { x, y: rowY - 10, size: 8, font, color: darkGray, maxWidth: colW });
|
||||
page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray, maxWidth: colW });
|
||||
|
||||
if (col === 1) rowY -= rowH;
|
||||
}
|
||||
@@ -471,7 +472,7 @@ function drawTableChunked(args: {
|
||||
page.drawText(chunkTitle, {
|
||||
x: margin,
|
||||
y,
|
||||
size: 10,
|
||||
size: 12,
|
||||
font: fontBold,
|
||||
color: navy,
|
||||
});
|
||||
@@ -561,6 +562,7 @@ type SectionDrawContext = {
|
||||
darkGray: ReturnType<typeof rgb>;
|
||||
almostWhite: ReturnType<typeof rgb>;
|
||||
lightGray: ReturnType<typeof rgb>;
|
||||
headerBg: ReturnType<typeof rgb>;
|
||||
};
|
||||
fonts: {
|
||||
regular: PDFFont;
|
||||
@@ -575,12 +577,12 @@ type SectionDrawContext = {
|
||||
};
|
||||
|
||||
function drawFooter(ctx: SectionDrawContext): void {
|
||||
const { page, width, margin, footerY, fonts, colors, labels, product, locale } = ctx;
|
||||
const { page, width, margin, footerY, fonts, colors, locale } = ctx;
|
||||
|
||||
page.drawLine({
|
||||
start: { x: margin, y: footerY + 14 },
|
||||
end: { x: width - margin, y: footerY + 14 },
|
||||
thickness: 1,
|
||||
thickness: 0.75,
|
||||
color: colors.lightGray,
|
||||
});
|
||||
|
||||
@@ -628,7 +630,20 @@ function stampPageNumbers(pdfDoc: PDFDocument, fonts: { regular: PDFFont }, colo
|
||||
}
|
||||
|
||||
function drawHeader(ctx: SectionDrawContext, yStart: number): number {
|
||||
const { page, width, margin, contentWidth, fonts, colors, logoImage, qrImage, qrUrl } = ctx;
|
||||
const { page, width, margin, contentWidth, fonts, colors, logoImage, qrImage, qrUrl, labels, product } = ctx;
|
||||
|
||||
// Cable-industry look: calm, engineered header with right-aligned meta.
|
||||
const headerH = 64;
|
||||
const dividerY = yStart - headerH;
|
||||
ctx.headerDividerY = dividerY;
|
||||
|
||||
page.drawRectangle({
|
||||
x: 0,
|
||||
y: dividerY,
|
||||
width,
|
||||
height: headerH,
|
||||
color: colors.headerBg,
|
||||
});
|
||||
|
||||
const qrSize = 44;
|
||||
const qrGap = 12;
|
||||
@@ -636,70 +651,94 @@ function drawHeader(ctx: SectionDrawContext, yStart: number): number {
|
||||
|
||||
// Left: logo (preferred) or typographic fallback
|
||||
if (logoImage) {
|
||||
const maxLogoW = 110;
|
||||
const maxLogoH = 28;
|
||||
const maxLogoW = 120;
|
||||
const maxLogoH = 30;
|
||||
const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height);
|
||||
const w = logoImage.width * scale;
|
||||
const h = logoImage.height * scale;
|
||||
const logoY = dividerY + Math.round((headerH - h) / 2);
|
||||
page.drawImage(logoImage, {
|
||||
x: margin,
|
||||
y: yStart - h + 6,
|
||||
y: logoY,
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
} else {
|
||||
const baseY = dividerY + 22;
|
||||
page.drawText('KLZ', {
|
||||
x: margin,
|
||||
y: yStart,
|
||||
size: 24,
|
||||
y: baseY,
|
||||
size: 22,
|
||||
font: fonts.bold,
|
||||
color: colors.navy,
|
||||
});
|
||||
page.drawText('Cables', {
|
||||
x: margin + fonts.bold.widthOfTextAtSize('KLZ', 24) + 4,
|
||||
y: yStart + 2,
|
||||
x: margin + fonts.bold.widthOfTextAtSize('KLZ', 22) + 4,
|
||||
y: baseY + 2,
|
||||
size: 10,
|
||||
font: fonts.regular,
|
||||
color: colors.mediumGray,
|
||||
});
|
||||
}
|
||||
|
||||
// Header divider baseline (shared with footer spacing logic)
|
||||
const dividerY = yStart - 58;
|
||||
ctx.headerDividerY = dividerY;
|
||||
// Right: datasheet meta + QR (if available)
|
||||
const metaRightEdge = width - margin - rightReserved;
|
||||
const metaTitle = labels.datasheet;
|
||||
const metaTitleSize = 9;
|
||||
const metaSkuSize = 8;
|
||||
const skuText = product.sku ? `${labels.sku}: ${stripHtml(product.sku)}` : '';
|
||||
|
||||
const mtW = fonts.bold.widthOfTextAtSize(metaTitle, metaTitleSize);
|
||||
page.drawText(metaTitle, {
|
||||
x: metaRightEdge - mtW,
|
||||
y: dividerY + 38,
|
||||
size: metaTitleSize,
|
||||
font: fonts.bold,
|
||||
color: colors.navy,
|
||||
});
|
||||
|
||||
if (skuText) {
|
||||
const skuW = fonts.regular.widthOfTextAtSize(skuText, metaSkuSize);
|
||||
page.drawText(skuText, {
|
||||
x: metaRightEdge - skuW,
|
||||
y: dividerY + 24,
|
||||
size: metaSkuSize,
|
||||
font: fonts.regular,
|
||||
color: colors.mediumGray,
|
||||
});
|
||||
}
|
||||
|
||||
// QR code: place top-right, aligned to the header block (never below the divider)
|
||||
if (qrImage) {
|
||||
const qrX = width - margin - qrSize;
|
||||
const qrY = yStart - qrSize + 6;
|
||||
const qrY = dividerY + Math.round((headerH - qrSize) / 2);
|
||||
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
|
||||
} else {
|
||||
// If QR generation failed, keep the URL available as a small header line.
|
||||
const maxW = 220;
|
||||
const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 2);
|
||||
let urlY = yStart - 12;
|
||||
for (const line of urlLines) {
|
||||
// If QR generation failed, keep the URL available as a compact line.
|
||||
const maxW = 260;
|
||||
const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 1);
|
||||
if (urlLines.length) {
|
||||
const line = urlLines[0];
|
||||
const w = fonts.regular.widthOfTextAtSize(line, 8);
|
||||
page.drawText(line, {
|
||||
x: width - margin - w,
|
||||
y: urlY,
|
||||
y: dividerY + 12,
|
||||
size: 8,
|
||||
font: fonts.regular,
|
||||
color: colors.mediumGray,
|
||||
});
|
||||
urlY -= 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Header line
|
||||
// Divider line
|
||||
page.drawLine({
|
||||
start: { x: margin, y: dividerY },
|
||||
end: { x: margin + contentWidth, y: dividerY },
|
||||
thickness: 1,
|
||||
thickness: 0.75,
|
||||
color: colors.lightGray,
|
||||
});
|
||||
|
||||
return dividerY - 26;
|
||||
// Content start: provide real breathing room below the header.
|
||||
return dividerY - 40;
|
||||
}
|
||||
|
||||
function drawCrossSectionChipsRow(args: {
|
||||
@@ -725,9 +764,9 @@ function drawCrossSectionChipsRow(args: {
|
||||
// Single-page rule: if we can't fit the block, stop.
|
||||
const titleH = 12;
|
||||
const summaryH = 12;
|
||||
const chipH = 14;
|
||||
const lineGap = 6;
|
||||
const gapY = 8;
|
||||
const chipH = 16;
|
||||
const lineGap = 8;
|
||||
const gapY = 10;
|
||||
const minLines = 2;
|
||||
const needed = titleH + summaryH + (chipH * minLines) + (lineGap * (minLines - 1)) + gapY;
|
||||
if (y - needed < contentMinY) return contentMinY - 1;
|
||||
@@ -758,7 +797,7 @@ function drawCrossSectionChipsRow(args: {
|
||||
const mm2Min = mm2Vals.length ? mm2Vals[0] : null;
|
||||
const mm2Max = mm2Vals.length ? mm2Vals[mm2Vals.length - 1] : null;
|
||||
|
||||
page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy });
|
||||
page.drawText(title, { x: margin, y, size: 11, font: fontBold, color: navy });
|
||||
y -= titleH;
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
@@ -769,10 +808,10 @@ function drawCrossSectionChipsRow(args: {
|
||||
y -= summaryH;
|
||||
|
||||
// Tags (wrapping). Rectangular, engineered (no playful rounding).
|
||||
const padX = 7;
|
||||
const chipFontSize = 7.5;
|
||||
const chipGap = 6;
|
||||
const chipPadTop = 4;
|
||||
const padX = 8;
|
||||
const chipFontSize = 8;
|
||||
const chipGap = 8;
|
||||
const chipPadTop = 5;
|
||||
|
||||
const startY = y - chipH; // baseline for first chip row
|
||||
const maxLines = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
|
||||
@@ -844,7 +883,7 @@ function drawCrossSectionChipsRow(args: {
|
||||
};
|
||||
|
||||
// Layout engine with group labels.
|
||||
const labelW = 34;
|
||||
const labelW = 38;
|
||||
const placements: Placement[] = [];
|
||||
let line = 0;
|
||||
let cy = startY;
|
||||
@@ -944,7 +983,7 @@ function drawCrossSectionChipsRow(args: {
|
||||
height: chipH,
|
||||
borderColor: lightGray,
|
||||
borderWidth: 1,
|
||||
color: almostWhite,
|
||||
color: rgb(1, 1, 1),
|
||||
});
|
||||
page.drawText(p.text, {
|
||||
x: p.x + padX,
|
||||
@@ -959,7 +998,10 @@ function drawCrossSectionChipsRow(args: {
|
||||
// Return cursor below the last line drawn
|
||||
const linesUsed = placements.length ? Math.max(...placements.map(p => Math.round((startY - p.y) / (chipH + lineGap)))) + 1 : 1;
|
||||
const bottomY = startY - (linesUsed - 1) * (chipH + lineGap);
|
||||
return bottomY - 18;
|
||||
// Consistent section spacing after block.
|
||||
// IMPORTANT: never return below contentMinY if we actually rendered,
|
||||
// otherwise callers may think it "didn't fit" and draw a fallback on top (duplicate “Options” lines).
|
||||
return Math.max(bottomY - 24, contentMinY);
|
||||
}
|
||||
|
||||
function drawCompactList(args: {
|
||||
@@ -1043,6 +1085,22 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
const darkGray = rgb(0.1216, 0.1608, 0.2); // #1F2933
|
||||
const almostWhite = rgb(0.9725, 0.9765, 0.9804); // #F8F9FA
|
||||
const lightGray = rgb(0.9020, 0.9137, 0.9294); // #E6E9ED
|
||||
const headerBg = rgb(0.965, 0.972, 0.98); // calm, print-friendly tint
|
||||
|
||||
// Small design system: consistent type + spacing for professional datasheets.
|
||||
const DS = {
|
||||
space: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 },
|
||||
type: { h1: 20, h2: 11, body: 10.5, small: 8 },
|
||||
rule: { thin: 0.75 },
|
||||
} as const;
|
||||
|
||||
// Line-heights (explicit so vertical rhythm doesn't drift / overlap)
|
||||
const LH = {
|
||||
h1: 24,
|
||||
h2: 16,
|
||||
body: 14,
|
||||
small: 10,
|
||||
} as const;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
@@ -1062,10 +1120,10 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
const qrPng = await loadQrPng(productUrl);
|
||||
const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null;
|
||||
|
||||
// Single-page constraint: keep generous but slightly tighter margins.
|
||||
const margin = 50;
|
||||
const footerY = 50;
|
||||
const contentMinY = footerY + 36; // keep clear of footer
|
||||
// Engineered page frame (A4): slightly narrower margins but consistent rhythm.
|
||||
const margin = 54;
|
||||
const footerY = 54;
|
||||
const contentMinY = footerY + 42; // keep clear of footer + page numbers
|
||||
const contentWidth = width - 2 * margin;
|
||||
|
||||
const ctx: SectionDrawContext = {
|
||||
@@ -1078,7 +1136,7 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
footerY,
|
||||
contentMinY,
|
||||
headerDividerY: 0,
|
||||
colors: { navy, mediumGray, darkGray, almostWhite, lightGray },
|
||||
colors: { navy, mediumGray, darkGray, almostWhite, lightGray, headerBg },
|
||||
fonts: { regular: font, bold: fontBold },
|
||||
labels,
|
||||
product,
|
||||
@@ -1093,14 +1151,44 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
const newPage = (): number => contentMinY - 1;
|
||||
const hasSpace = (needed: number) => y - needed >= contentMinY;
|
||||
|
||||
// ---- Layout helpers (eliminate magic numbers; enforce consistent rhythm) ----
|
||||
const rule = (gapAbove: number = DS.space.md, gapBelow: number = DS.space.lg) => {
|
||||
// One-page rule: if we can't fit a divider with its spacing, do nothing.
|
||||
if (!hasSpace(gapAbove + gapBelow + DS.rule.thin)) return;
|
||||
|
||||
y -= gapAbove;
|
||||
page.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: margin + contentWidth, y },
|
||||
thickness: DS.rule.thin,
|
||||
color: lightGray,
|
||||
});
|
||||
y -= gapBelow;
|
||||
};
|
||||
|
||||
const sectionTitle = (text: string) => {
|
||||
// One-page rule: if we can't fit the heading + its gap, do nothing.
|
||||
if (!hasSpace(DS.type.h2 + DS.space.md)) return;
|
||||
|
||||
page.drawText(text, {
|
||||
x: margin,
|
||||
y,
|
||||
size: DS.type.h2,
|
||||
font: fontBold,
|
||||
color: navy,
|
||||
});
|
||||
// Use a real line-height to avoid title/body overlap.
|
||||
y -= LH.h2;
|
||||
};
|
||||
|
||||
// Page 1
|
||||
// Page background (STYLEGUIDE.md)
|
||||
// Page background (print-friendly)
|
||||
page.drawRectangle({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
color: almostWhite,
|
||||
color: rgb(1, 1, 1),
|
||||
});
|
||||
|
||||
drawFooter(ctx);
|
||||
@@ -1111,19 +1199,20 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' • ');
|
||||
|
||||
const titleW = contentWidth;
|
||||
const nameLines = wrapText(productName, fontBold, 18, titleW);
|
||||
const titleLineH = LH.h1;
|
||||
const nameLines = wrapText(productName, fontBold, DS.type.h1, titleW);
|
||||
const shownNameLines = nameLines.slice(0, 2);
|
||||
for (const line of shownNameLines) {
|
||||
if (y - 22 < contentMinY) y = newPage();
|
||||
if (y - titleLineH < contentMinY) y = newPage();
|
||||
page.drawText(line, {
|
||||
x: margin,
|
||||
y,
|
||||
size: 18,
|
||||
size: DS.type.h1,
|
||||
font: fontBold,
|
||||
color: navy,
|
||||
maxWidth: titleW,
|
||||
});
|
||||
y -= 22;
|
||||
y -= titleLineH;
|
||||
}
|
||||
|
||||
if (cats) {
|
||||
@@ -1131,20 +1220,23 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
page.drawText(cats, {
|
||||
x: margin,
|
||||
y,
|
||||
size: 9,
|
||||
size: 10.5,
|
||||
font,
|
||||
color: mediumGray,
|
||||
maxWidth: titleW,
|
||||
});
|
||||
y -= 18;
|
||||
y -= DS.space.lg;
|
||||
}
|
||||
|
||||
// Separator after product header
|
||||
rule(DS.space.sm, DS.space.lg);
|
||||
|
||||
// === HERO IMAGE (full width) ===
|
||||
let heroH = 115;
|
||||
const heroGap = 12;
|
||||
if (!hasSpace(heroH + heroGap)) {
|
||||
let heroH = 160;
|
||||
const afterHeroGap = DS.space.xl;
|
||||
if (!hasSpace(heroH + afterHeroGap)) {
|
||||
// Shrink to remaining space (but keep it usable).
|
||||
heroH = Math.max(80, Math.floor(y - contentMinY - heroGap));
|
||||
heroH = Math.max(120, Math.floor(y - contentMinY - afterHeroGap));
|
||||
}
|
||||
|
||||
const heroBoxX = margin;
|
||||
@@ -1154,13 +1246,14 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
y: heroBoxY,
|
||||
width: contentWidth,
|
||||
height: heroH,
|
||||
// Border only (no fill): lets transparent product images blend into the page.
|
||||
// Calm frame; gives images consistent presence even with transparency.
|
||||
color: almostWhite,
|
||||
borderColor: lightGray,
|
||||
borderWidth: 1,
|
||||
});
|
||||
|
||||
if (heroPng) {
|
||||
const pad = 10;
|
||||
const pad = DS.space.md;
|
||||
const boxW = contentWidth - pad * 2;
|
||||
const boxH = heroH - pad * 2;
|
||||
|
||||
@@ -1177,12 +1270,12 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
.toBuffer();
|
||||
const heroImage = await pdfDoc.embedPng(cropped);
|
||||
|
||||
const scale = Math.min(boxW / heroImage.width, boxH / heroImage.height);
|
||||
// Exact-fit (we already cropped to this aspect ratio).
|
||||
page.drawImage(heroImage, {
|
||||
x: heroBoxX + pad,
|
||||
y: heroBoxY + pad,
|
||||
width: heroImage.width * scale,
|
||||
height: heroImage.height * scale,
|
||||
width: boxW,
|
||||
height: boxH,
|
||||
});
|
||||
} else {
|
||||
page.drawText(locale === 'de' ? 'Kein Bild verfügbar' : 'No image available', {
|
||||
@@ -1195,33 +1288,51 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
});
|
||||
}
|
||||
|
||||
y = heroBoxY - 18;
|
||||
y = heroBoxY - afterHeroGap;
|
||||
|
||||
// === DESCRIPTION ===
|
||||
if ((product.shortDescriptionHtml || product.descriptionHtml) && hasSpace(40)) {
|
||||
page.drawText(labels.description, {
|
||||
x: margin,
|
||||
y: y,
|
||||
size: 10,
|
||||
font: fontBold,
|
||||
color: navy,
|
||||
});
|
||||
y -= 14;
|
||||
|
||||
if (product.shortDescriptionHtml || product.descriptionHtml) {
|
||||
const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml);
|
||||
const descLines = wrapText(desc, font, 9, width - 2 * margin);
|
||||
const descLineH = 14;
|
||||
const descMaxLines = 3;
|
||||
const boxPadX = DS.space.md;
|
||||
const boxPadY = DS.space.md;
|
||||
const boxH = boxPadY * 2 + descLineH * descMaxLines;
|
||||
const descNeeded = DS.type.h2 + DS.space.md + boxH + DS.space.lg + DS.space.xl;
|
||||
|
||||
for (const line of descLines.slice(0, 2)) {
|
||||
page.drawText(line, {
|
||||
// One-page rule: only render description if we can fit it cleanly.
|
||||
if (hasSpace(descNeeded)) {
|
||||
sectionTitle(labels.description);
|
||||
|
||||
const boxTop = y + DS.space.xs;
|
||||
const boxBottom = boxTop - boxH;
|
||||
page.drawRectangle({
|
||||
x: margin,
|
||||
y: y,
|
||||
size: 9,
|
||||
font: font,
|
||||
color: darkGray,
|
||||
y: boxBottom,
|
||||
width: contentWidth,
|
||||
height: boxH,
|
||||
color: rgb(1, 1, 1),
|
||||
borderColor: lightGray,
|
||||
borderWidth: 1,
|
||||
});
|
||||
y -= 12;
|
||||
|
||||
const descLines = wrapText(desc, font, DS.type.body, contentWidth - boxPadX * 2);
|
||||
let ty = boxTop - boxPadY - DS.type.body;
|
||||
for (const line of descLines.slice(0, descMaxLines)) {
|
||||
page.drawText(line, {
|
||||
x: margin + boxPadX,
|
||||
y: ty,
|
||||
size: DS.type.body,
|
||||
font,
|
||||
color: darkGray,
|
||||
maxWidth: contentWidth - boxPadX * 2,
|
||||
});
|
||||
ty -= descLineH;
|
||||
}
|
||||
|
||||
y = boxBottom - DS.space.lg;
|
||||
rule(0, DS.space.xl);
|
||||
}
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
// === TECHNICAL DATA (shared across all cross-sections) ===
|
||||
@@ -1230,6 +1341,7 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
configAttr ||
|
||||
findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i);
|
||||
const rowCount = crossSectionAttr?.options?.length || 0;
|
||||
const hasCrossSectionData = Boolean(crossSectionAttr && rowCount > 0);
|
||||
|
||||
// Compact mode approach:
|
||||
// - show constant (non-row) attributes as key/value grid
|
||||
@@ -1237,41 +1349,109 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
// - optionally render full tables with PDF_MODE=full
|
||||
|
||||
const constantAttrs = (product.attributes || []).filter(a => a.options.length === 1);
|
||||
const constantItems = constantAttrs
|
||||
const constantItemsAll = constantAttrs
|
||||
.map(a => ({ label: normalizeValue(a.name), value: normalizeValue(a.options[0]) }))
|
||||
.filter(i => i.label && i.value)
|
||||
.slice(0, 12);
|
||||
|
||||
// Intentionally do NOT include SKU/categories here (they are already shown in the product header).
|
||||
|
||||
// If this product has no processed attributes, show a clear note so it doesn't look broken.
|
||||
if (constantItems.length === 0) {
|
||||
constantItems.push({
|
||||
label: locale === 'de' ? 'Hinweis' : 'Note',
|
||||
value: locale === 'de' ? 'Für dieses Produkt sind derzeit keine technischen Daten hinterlegt.' : 'No technical data is available for this product yet.',
|
||||
// TECH DATA must never crowd out cross-section.
|
||||
// IMPORTANT: `drawKeyValueGrid()` will return `contentMinY - 1` when it can't fit.
|
||||
// We must avoid calling it unless we're sure it fits.
|
||||
const techBox = {
|
||||
// Keep in sync with `drawKeyValueGrid()` boxed metrics
|
||||
padY: 14,
|
||||
headerH: 22,
|
||||
rowH: 24,
|
||||
} as const;
|
||||
|
||||
// Reserve enough space so cross-sections are actually visible when present.
|
||||
// Mirror `drawCrossSectionChipsRow()` minimum-needed math (+ a bit of padding).
|
||||
const minCrossBlockH = 12 /*title*/ + 12 /*summary*/ + (16 * 2) /*chips*/ + 8 /*lineGap*/ + 10 /*gapY*/ + 24 /*after*/;
|
||||
const reservedForCross = hasCrossSectionData ? minCrossBlockH : 0;
|
||||
|
||||
const techTitle = locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA';
|
||||
|
||||
const techBoxHeightFor = (itemsCount: number) => {
|
||||
const rows = Math.ceil(itemsCount / 2);
|
||||
return techBox.padY + techBox.headerH + rows * techBox.rowH + techBox.padY;
|
||||
};
|
||||
|
||||
const canFitTechWith = (itemsCount: number) => {
|
||||
if (itemsCount <= 0) return false;
|
||||
const techH = techBoxHeightFor(itemsCount);
|
||||
const afterTechGap = DS.space.lg;
|
||||
// We need to keep reserved space for cross-section below.
|
||||
return y - (techH + afterTechGap + reservedForCross) >= contentMinY;
|
||||
};
|
||||
|
||||
// Pick the largest "nice" amount of items that still guarantees cross-section visibility.
|
||||
const desiredCap = 8;
|
||||
let chosenCount = 0;
|
||||
for (let n = Math.min(desiredCap, constantItemsAll.length); n >= 1; n--) {
|
||||
if (canFitTechWith(n)) {
|
||||
chosenCount = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (chosenCount > 0) {
|
||||
const constantItems = constantItemsAll.slice(0, chosenCount);
|
||||
y = drawKeyValueGrid({
|
||||
title: techTitle,
|
||||
items: constantItems,
|
||||
newPage,
|
||||
getPage: () => page,
|
||||
page,
|
||||
y,
|
||||
margin,
|
||||
contentWidth,
|
||||
contentMinY,
|
||||
font,
|
||||
fontBold,
|
||||
navy,
|
||||
darkGray,
|
||||
mediumGray,
|
||||
lightGray,
|
||||
almostWhite,
|
||||
allowNewPage: false,
|
||||
boxed: true,
|
||||
});
|
||||
} else if (!hasCrossSectionData) {
|
||||
// If there is no cross-section block, we can afford to show a clear "no data" note.
|
||||
y = drawKeyValueGrid({
|
||||
title: techTitle,
|
||||
items: [
|
||||
{
|
||||
label: locale === 'de' ? 'Hinweis' : 'Note',
|
||||
value:
|
||||
locale === 'de'
|
||||
? 'Für dieses Produkt sind derzeit keine technischen Daten hinterlegt.'
|
||||
: 'No technical data is available for this product yet.',
|
||||
},
|
||||
],
|
||||
newPage,
|
||||
getPage: () => page,
|
||||
page,
|
||||
y,
|
||||
margin,
|
||||
contentWidth,
|
||||
contentMinY,
|
||||
font,
|
||||
fontBold,
|
||||
navy,
|
||||
darkGray,
|
||||
mediumGray,
|
||||
lightGray,
|
||||
almostWhite,
|
||||
allowNewPage: false,
|
||||
boxed: true,
|
||||
});
|
||||
}
|
||||
|
||||
y = drawKeyValueGrid({
|
||||
title: locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA',
|
||||
items: constantItems,
|
||||
newPage,
|
||||
getPage: () => page,
|
||||
page,
|
||||
y,
|
||||
margin,
|
||||
contentWidth,
|
||||
contentMinY,
|
||||
font,
|
||||
fontBold,
|
||||
navy,
|
||||
darkGray,
|
||||
mediumGray,
|
||||
lightGray,
|
||||
almostWhite,
|
||||
allowNewPage: false,
|
||||
boxed: true,
|
||||
});
|
||||
// Consistent spacing after the technical data block (but never push content below min Y)
|
||||
if (y - DS.space.lg >= contentMinY) y -= DS.space.lg;
|
||||
|
||||
// === CROSS-SECTION TABLE (row-specific data) ===
|
||||
if (crossSectionAttr && rowCount > 0) {
|
||||
@@ -1295,7 +1475,7 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
// Row-specific values are intentionally omitted to keep the sheet compact.
|
||||
const columns: Array<{ label: string; get: (rowIndex: number) => string }> = [];
|
||||
|
||||
y = drawCrossSectionChipsRow({
|
||||
const yAfterCross = drawCrossSectionChipsRow({
|
||||
title: labels.crossSection,
|
||||
configRows,
|
||||
locale,
|
||||
@@ -1313,20 +1493,27 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
|
||||
lightGray,
|
||||
almostWhite,
|
||||
});
|
||||
|
||||
// If the chips block can't fit at all, show a minimal summary line (no chips).
|
||||
// drawCrossSectionChipsRow returns (contentMinY - 1) in that case.
|
||||
if (yAfterCross < contentMinY) {
|
||||
sectionTitle(labels.crossSection);
|
||||
const total = configRows.length;
|
||||
const summary = locale === 'de' ? `Varianten: ${total}` : `Options: ${total}`;
|
||||
page.drawText(summary, {
|
||||
x: margin,
|
||||
y,
|
||||
size: DS.type.body,
|
||||
font,
|
||||
color: mediumGray,
|
||||
maxWidth: contentWidth,
|
||||
});
|
||||
y -= LH.body + DS.space.lg;
|
||||
} else {
|
||||
y = yAfterCross;
|
||||
}
|
||||
} else {
|
||||
// If we couldn't detect cross-sections, still show a small note instead of an empty section.
|
||||
if (y - 22 < contentMinY) y = newPage();
|
||||
page.drawText(labels.crossSection, { x: margin, y, size: 10, font: fontBold, color: navy });
|
||||
y -= 14;
|
||||
page.drawText(locale === 'de' ? 'Keine Querschnittsdaten verfügbar.' : 'No cross-section data available.', {
|
||||
x: margin,
|
||||
y,
|
||||
size: 9,
|
||||
font,
|
||||
color: mediumGray,
|
||||
maxWidth: contentWidth,
|
||||
});
|
||||
y -= 16;
|
||||
// If there is no cross-section data, do not render the section at all.
|
||||
}
|
||||
|
||||
// Add page numbers after all pages are created.
|
||||
@@ -1387,13 +1574,18 @@ async function processProductsInChunks(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const enProducts = allProducts.filter(p => p.locale === 'en');
|
||||
const deProducts = allProducts.filter(p => p.locale === 'de');
|
||||
// Optional dev convenience: limit how many PDFs we render (useful for design iteration).
|
||||
// Default behavior remains unchanged.
|
||||
const limit = Number(process.env.PDF_LIMIT || '0');
|
||||
const products = Number.isFinite(limit) && limit > 0 ? allProducts.slice(0, limit) : allProducts;
|
||||
|
||||
const enProducts = products.filter(p => p.locale === 'en');
|
||||
const deProducts = products.filter(p => p.locale === 'de');
|
||||
console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`);
|
||||
|
||||
const totalChunks = Math.ceil(allProducts.length / CONFIG.chunkSize);
|
||||
const totalChunks = Math.ceil(products.length / CONFIG.chunkSize);
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const chunk = allProducts.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize);
|
||||
const chunk = products.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize);
|
||||
await processChunk(chunk, i, totalChunks);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user