Files
cv-app/renderer/carbone-service.js

734 lines
27 KiB
JavaScript

/**
* Carbone Template Service - Node.js + Express
*
* Self-hosted Carbone (Community Edition) integration for CV template management.
* Uses LibreOffice for DOCX -> PDF conversion.
*
* Endpoints:
* GET /api/carbone/templates - List saved templates
* GET /api/carbone/master-template - Download master blank DOCX template
* POST /api/carbone/templates/upload - Upload a custom .docx template
* POST /api/carbone/templates/:id/render - Render a template with CV data -> PDF
* DELETE /api/carbone/templates/:id - Delete a template
*/
const express = require('express');
const multer = require('multer');
const carbone = require('carbone');
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
// ============================================================
// CONFIGURATION
// ============================================================
const TEMPLATES_DIR = path.join(__dirname, 'carbone-templates');
const MASTER_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'master_cv_template.docx');
// Ensure directories exist
fs.mkdirSync(TEMPLATES_DIR, { recursive: true });
// Multer config for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, TEMPLATES_DIR),
filename: (req, file, cb) => {
const id = uuidv4();
const ext = path.extname(file.originalname) || '.docx';
cb(null, `${id}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
fileFilter: (req, file, cb) => {
const allowed = ['.docx', '.doc', '.odt', '.rtf'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowed.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`File type ${ext} not supported. Use .docx, .doc, .odt, or .rtf`));
}
}
});
// Simple in-memory template registry (persisted to JSON file)
const REGISTRY_PATH = path.join(TEMPLATES_DIR, 'registry.json');
function loadRegistry() {
try {
return JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8'));
} catch {
return [];
}
}
function saveRegistry(registry) {
fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2));
}
// ============================================================
// CARBONE TAG DOCUMENTATION
// ============================================================
/**
* CARBONE TAG REFERENCE (for use inside .docx templates)
* ============================================
*
* Carbone uses {d.property} syntax to map JSON data into the document.
* Our application's data schema maps as follows:
*
* SIMPLE FIELDS (text replacement):
* {d.firstName} -> Candidate's first name
* {d.lastName} -> Candidate's last name
* {d.email} -> Email address
* {d.phone} -> Phone number
* {d.address} -> Physical address
* {d.linkedin} -> LinkedIn URL
* {d.github} -> GitHub URL
* {d.website} -> Website URL
* {d.summary} -> Professional summary text
*
* LOOP TAGS (for arrays - repeat a section for each item):
*
* Work Experience loop:
* {d.experience[i].position} -> Job title
* {d.experience[i].company} -> Company name
* {d.experience[i].location} -> Location
* {d.experience[i].startDate} -> Start date (formatted)
* {d.experience[i].endDate} -> End date or "Present"
* {d.experience[i].description} -> Job description
* {d.experience[i].duration} -> Calculated duration (e.g., "2.5 years")
*
* To create a loop in Carbone, wrap the repeated section:
* {d.experience[i]:begin} ...content... {d.experience[i]:end}
*
* Achievements sub-loop (inside experience loop):
* {d.experience[i].achievements[j]:begin}
* • {d.experience[i].achievements[j]}
* {d.experience[i].achievements[j]:end}
*
* Skills loop:
* {d.skills[i].name} -> Skill name
* {d.skills[i].category} -> Skill category
* {d.skills[i].proficiency} -> Proficiency level
* {d.skills[i].years} -> Years of experience (dynamic)
*
* Skills grouped by category:
* {d.skillsByCategory[i].category:begin}
* {d.skillsByCategory[i].category}
* {d.skillsByCategory[i].skills[j].name:begin}
* • {d.skillsByCategory[i].skills[j].name} ({d.skillsByCategory[i].skills[j].years}y)
* {d.skillsByCategory[i].skills[j].name:end}
* {d.skillsByCategory[i].category:end}
*
* Education loop:
* {d.education[i].degree} -> Degree name
* {d.education[i].field} -> Field of study
* {d.education[i].institution} -> Institution name
* {d.education[i].startDate} -> Start date
* {d.education[i].endDate} -> End date
* {d.education[i].grade} -> Grade/Result
*
* Certifications loop:
* {d.certifications[i].name} -> Certification name
* {d.certifications[i].issuer} -> Issuing organization
* {d.certifications[i].date} -> Issue date
*
* CONDITIONALS:
* {d.summary:ifExist:begin} ...shows only if summary exists... {d.summary:ifExist:end}
* {d.linkedin:ifExist:begin} LinkedIn: {d.linkedin} {d.linkedin:ifExist:end}
*
* FORMATTING:
* {d.experience[i].startDate:formatD(MMM YYYY)} -> Formats date as "Jan 2022"
* {d.experience[i].endDate:formatD(MMM YYYY):ifEmpty(Present)}
*
* COMPOSITE (for full name):
* {d.firstName} {d.lastName}
*/
// ============================================================
// DATA TRANSFORMER
// ============================================================
/**
* Transform our application's candidate data into the flat structure
* that Carbone template tags expect. This handles date formatting,
* dynamic experience calculation, and grouping skills by category.
*
* @param {Object} candidateData - Our app's candidate data (from /api/candidates/:id)
* @param {string} referenceDate - Date for dynamic experience calc (YYYY-MM-DD)
* @returns {Object} Flattened data object for Carbone
*/
function transformCandidateData(candidateData, referenceDate) {
const refDate = referenceDate ? new Date(referenceDate) : new Date();
const candidate = candidateData.candidate || candidateData;
// Helper: format date as "Mon YYYY"
const fmtDate = (d) => {
if (!d) return '';
try {
const date = new Date(d);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
} catch { return String(d); }
};
// Helper: calculate years between dates
const calcYears = (start, end) => {
if (!start) return 0;
const endDate = end ? new Date(end) : refDate;
const startDate = new Date(start);
const diff = (endDate - startDate) / (365.25 * 24 * 3600 * 1000);
return Math.round(diff * 10) / 10;
};
// Transform experience
const experience = (candidateData.experience || []).map(exp => {
const achievements = Array.isArray(exp.achievements) ? exp.achievements :
(typeof exp.achievements === 'string' ? safeJsonParse(exp.achievements, []) : []);
const skillsUsed = Array.isArray(exp.skills_used) ? exp.skills_used :
(typeof exp.skills_used === 'string' ? safeJsonParse(exp.skills_used, []) : []);
return {
position: exp.position || '',
company: exp.company || '',
location: exp.location || '',
startDate: fmtDate(exp.start_date),
endDate: exp.end_date ? fmtDate(exp.end_date) : 'Present',
description: exp.description || '',
duration: calcYears(exp.start_date, exp.end_date) + ' years',
achievements: achievements,
skillsUsed: skillsUsed.join(', ')
};
});
// Transform skills with dynamic years
const skills = (candidateData.skills || []).map(s => ({
name: s.skill_name || '',
category: s.skill_category || 'Other',
proficiency: s.proficiency || '',
years: s.years_experience || calcYears(s.start_date, s.end_date),
startYear: s.start_date ? new Date(s.start_date).getFullYear() : ''
}));
// Group skills by category for the grouped loop
const categoryMap = {};
for (const s of skills) {
if (!categoryMap[s.category]) categoryMap[s.category] = [];
categoryMap[s.category].push(s);
}
const skillsByCategory = Object.entries(categoryMap).map(([category, items]) => ({
category,
skills: items
}));
// Transform education
const education = (candidateData.education || []).map(e => ({
degree: e.degree || '',
field: e.field_of_study || '',
institution: e.institution || '',
startDate: fmtDate(e.start_date),
endDate: fmtDate(e.end_date),
grade: e.grade || ''
}));
// Transform certifications
const certifications = (candidateData.certifications || []).map(c => ({
name: c.name || '',
issuer: c.issuer || '',
date: fmtDate(c.issue_date),
expiryDate: fmtDate(c.expiry_date)
}));
return {
// Personal details
firstName: candidate.first_name || '',
lastName: candidate.last_name || '',
fullName: `${candidate.first_name || ''} ${candidate.last_name || ''}`.trim(),
email: candidate.email || '',
phone: candidate.phone || '',
address: candidate.address || '',
linkedin: candidate.linkedin || '',
github: candidate.github || '',
website: candidate.website || '',
summary: candidate.summary || '',
// Arrays (for loops)
experience: experience,
skills: skills,
skillsByCategory: skillsByCategory,
education: education,
certifications: certifications,
// Metadata
generationDate: refDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
referenceDate: refDate.toISOString().split('T')[0]
};
}
function safeJsonParse(str, fallback) {
try { return JSON.parse(str); } catch { return fallback; }
}
// ============================================================
// MASTER TEMPLATE GENERATOR
// ============================================================
/**
* Generate the master blank CV template (.docx) with all Carbone tags.
* This is the file users download, customize in Word, and re-upload.
*
* We build it programmatically using the docx library so we don't need
* a pre-made binary file.
*/
async function generateMasterTemplate() {
// We'll use a simple approach: create a minimal DOCX with the tags as text.
// Carbone processes the text content of the DOCX, so we just need the tags
// present in the document body.
const docxContent = createMasterDocxXML();
const templatePath = MASTER_TEMPLATE_PATH;
// Write as a .docx (which is a ZIP file with XML inside)
// For simplicity, we use the 'docx' npm package if available,
// otherwise we create a minimal valid DOCX manually.
try {
const DocxTemplater = require('docx');
// If docx package is available, use it
// But for now, we'll create a minimal DOCX manually
} catch (e) {
// docx package not available — use manual approach
}
// Create a minimal valid DOCX file (ZIP with required XML parts)
const archiver = require('archiver') ? require('archiver') : null;
if (archiver) {
// Use archiver to create the ZIP
}
// Simplest approach: use carbone's built-in template creation
// Actually, the easiest way is to create the DOCX using python-docx
// which is already installed in our venv
const { execSync } = require('child_process');
const pythonScript = path.join(__dirname, 'generate_master_template.py');
fs.writeFileSync(pythonScript, MASTER_TEMPLATE_PYTHON);
execSync(`python3 ${pythonScript} "${templatePath}"`, { timeout: 30000 });
return templatePath;
}
// Python script content for generating the master DOCX template
const MASTER_TEMPLATE_PYTHON = `
import sys
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
def create_master_template(output_path):
doc = Document()
# Set margins
for section in doc.sections:
section.top_margin = Inches(0.5)
section.bottom_margin = Inches(0.5)
section.left_margin = Inches(0.75)
section.right_margin = Inches(0.75)
# === HEADER: Name + Contact ===
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run('{d.fullName}')
run.font.size = Pt(24)
run.font.bold = True
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run('{d.email} | {d.phone} | {d.address}')
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run('{d.linkedin:ifExist:begin}LinkedIn: {d.linkedin} | {d.linkedin:ifExist:end}{d.github:ifExist:begin}GitHub: {d.github} | {d.github:ifExist:end}{d.website:ifExist:begin}Web: {d.website}{d.website:ifExist:end}')
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
# Divider
doc.add_paragraph('_' * 80)
# === PROFESSIONAL SUMMARY ===
h = doc.add_heading('Professional Summary', level=2)
p = doc.add_paragraph('{d.summary}')
# === WORK EXPERIENCE (loop) ===
doc.add_heading('Work Experience', level=2)
# Carbone loop: {d.experience[i]:begin} ... {d.experience[i]:end}
p = doc.add_paragraph('{d.experience[i]:begin}')
p = doc.add_paragraph()
run = p.add_run('{d.experience[i].position}')
run.font.bold = True
run.font.size = Pt(12)
run = p.add_run(' at {d.experience[i].company}')
run.font.size = Pt(12)
p = doc.add_paragraph('{d.experience[i].startDate} - {d.experience[i].endDate} ({d.experience[i].duration})')
p.runs[0].font.size = Pt(10)
p.runs[0].font.italic = True
p.runs[0].font.color.rgb = RGBColor(0x88, 0x88, 0x88)
p = doc.add_paragraph('{d.experience[i].description}')
# Achievements sub-loop
p = doc.add_paragraph('{d.experience[i].achievements[j]:begin}')
p = doc.add_paragraph('{d.experience[i].achievements[j]}', style='List Bullet')
p = doc.add_paragraph('{d.experience[i].achievements[j]:end}')
p = doc.add_paragraph('Skills: {d.experience[i].skillsUsed}')
p.runs[0].font.size = Pt(10)
p.runs[0].font.italic = True
p = doc.add_paragraph('{d.experience[i]:end}')
# === SKILLS (grouped by category) ===
doc.add_heading('Skills', level=2)
p = doc.add_paragraph('{d.skillsByCategory[i]:begin}')
p = doc.add_paragraph()
run = p.add_run('{d.skillsByCategory[i].category}: ')
run.font.bold = True
run.font.size = Pt(11)
p = doc.add_paragraph('{d.skillsByCategory[i].skills[j]:begin}')
p = doc.add_paragraph('{d.skillsByCategory[i].skills[j].name} ({d.skillsByCategory[i].skills[j].years}y){d.skillsByCategory[i].skills[j]:end}', style='List Bullet')
p = doc.add_paragraph('{d.skillsByCategory[i].end}')
# === EDUCATION (loop) ===
doc.add_heading('Education', level=2)
p = doc.add_paragraph('{d.education[i]:begin}')
p = doc.add_paragraph()
run = p.add_run('{d.education[i].degree}')
run.font.bold = True
run = p.add_run(' in {d.education[i].field}')
p = doc.add_paragraph('{d.education[i].institution}')
p.runs[0].font.size = Pt(11)
p = doc.add_paragraph('{d.education[i].startDate} - {d.education[i].endDate}')
p.runs[0].font.size = Pt(10)
p.runs[0].font.color.rgb = RGBColor(0x88, 0x88, 0x88)
p = doc.add_paragraph('{d.education[i].grade:ifExist:begin}Grade: {d.education[i].grade}{d.education[i].grade:ifExist:end}')
p = doc.add_paragraph('{d.education[i]:end}')
# === CERTIFICATIONS (loop) ===
doc.add_heading('Certifications', level=2)
p = doc.add_paragraph('{d.certifications[i]:begin}')
p = doc.add_paragraph('{d.certifications[i].name} - {d.certifications[i].issuer} ({d.certifications[i].date})', style='List Bullet')
p = doc.add_paragraph('{d.certifications[i]:end}')
# Footer
doc.add_paragraph()
p = doc.add_paragraph('Generated on {d.generationDate}')
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.runs[0].font.size = Pt(8)
p.runs[0].font.color.rgb = RGBColor(0xAA, 0xAA, 0xAA)
doc.save(output_path)
print(f'Master template saved to {output_path}')
if __name__ == '__main__':
create_master_template(sys.argv[1])
`;
// ============================================================
// API ROUTES
// ============================================================
// GET /api/carbone/templates - List all saved templates
router.get('/templates', (req, res) => {
const registry = loadRegistry();
res.json({ templates: registry });
});
// GET /api/carbone/master-template - Download master template
router.get('/master-template', async (req, res) => {
try {
// Generate master template if it doesn't exist
if (!fs.existsSync(MASTER_TEMPLATE_PATH)) {
await generateMasterTemplate();
}
res.download(MASTER_TEMPLATE_PATH, 'master_cv_template.docx');
} catch (err) {
console.error('Master template error:', err);
res.status(500).json({ error: 'Failed to generate master template: ' + err.message });
}
});
// GET /api/carbone/help-docx - Download tags reference as .docx
router.get('/help-docx', (req, res) => {
const helpPath = path.join(TEMPLATES_DIR, 'HELP_TAGS_REFERENCE.docx');
if (fs.existsSync(helpPath)) {
res.download(helpPath, 'Carbone_Tags_Reference.docx');
} else {
res.status(404).json({ error: 'Help file not found' });
}
});
// GET /api/carbone/help - Get tags reference as JSON (for in-app viewing)
router.get('/help', (req, res) => {
res.json({
sections: [
{
title: '1. Personal Details',
type: 'simple',
tags: [
{ tag: '{d.fullName}', desc: 'Full name (first + last)' },
{ tag: '{d.firstName}', desc: 'First name only' },
{ tag: '{d.lastName}', desc: 'Last name only' },
{ tag: '{d.email}', desc: 'Email address' },
{ tag: '{d.phone}', desc: 'Phone number' },
{ tag: '{d.address}', desc: 'Physical address' },
{ tag: '{d.linkedin}', desc: 'LinkedIn profile URL' },
{ tag: '{d.github}', desc: 'GitHub profile URL' },
{ tag: '{d.website}', desc: 'Personal website' },
{ tag: '{d.summary}', desc: 'Professional summary paragraph' },
{ tag: '{d.generationDate}', desc: 'Date the CV was generated' }
]
},
{
title: '2. Work Experience (Loop)',
type: 'loop',
note: 'Carbone auto-detects loops from [i]. Section repeats for each job.',
tags: [
{ tag: '{d.experience[i].position}', desc: 'Job title' },
{ tag: '{d.experience[i].company}', desc: 'Company name' },
{ tag: '{d.experience[i].location}', desc: 'Work location' },
{ tag: '{d.experience[i].startDate}', desc: 'Start date (Mon YYYY)' },
{ tag: '{d.experience[i].endDate}', desc: 'End date or "Present"' },
{ tag: '{d.experience[i].duration}', desc: 'Calculated duration (e.g., 4.5 years)' },
{ tag: '{d.experience[i].description}', desc: 'Job description' },
{ tag: '{d.experience[i].skillsUsed}', desc: 'Comma-separated skills used' },
{ tag: '{d.experience[i].achievements[j]}', desc: 'Achievement bullet (sub-loop)' }
]
},
{
title: '3. Skills',
type: 'loop',
tags: [
{ tag: '{d.skills[i].name}', desc: 'Skill name' },
{ tag: '{d.skills[i].category}', desc: 'Skill category' },
{ tag: '{d.skills[i].proficiency}', desc: 'Proficiency level' },
{ tag: '{d.skills[i].years}', desc: 'Years of experience (dynamic)' },
{ tag: '{d.skillsByCategory[i].category}', desc: 'Category name (grouped)' },
{ tag: '{d.skillsByCategory[i].skills[j].name}', desc: 'Skill in category (grouped)' },
{ tag: '{d.skillsByCategory[i].skills[j].years}', desc: 'Years in category (grouped)' }
]
},
{
title: '4. Education (Loop)',
type: 'loop',
tags: [
{ tag: '{d.education[i].degree}', desc: 'Degree name' },
{ tag: '{d.education[i].field}', desc: 'Field of study' },
{ tag: '{d.education[i].institution}', desc: 'University/institution' },
{ tag: '{d.education[i].startDate}', desc: 'Start date' },
{ tag: '{d.education[i].endDate}', desc: 'End date' },
{ tag: '{d.education[i].grade}', desc: 'Grade/result' }
]
},
{
title: '5. Certifications (Loop)',
type: 'loop',
tags: [
{ tag: '{d.certifications[i].name}', desc: 'Certification name' },
{ tag: '{d.certifications[i].issuer}', desc: 'Issuing organization' },
{ tag: '{d.certifications[i].date}', desc: 'Issue date' },
{ tag: '{d.certifications[i].expiryDate}', desc: 'Expiry date (if any)' }
]
},
{
title: '6. Conditionals (Show/Hide)',
type: 'simple',
note: 'Show content only when a field has a value (or is empty).',
tags: [
{ tag: '{d.field:showBegin}...{d.field:showEnd}', desc: 'Show if NOT empty' },
{ tag: '{d.field:hideBegin}...{d.field:hideEnd}', desc: 'Show if empty' },
{ tag: '{d.field:ifEM(replacement text)}', desc: 'Show text if field is empty' },
{ tag: '{d.field:ifNEM(replacement text)}', desc: 'Show text if field is not empty' },
{ tag: '{d.field:ifEQ(value):showBegin}...{d.field:showEnd}', desc: 'Show if equals value' },
{ tag: '{d.field:ifContain(text):showBegin}...{d.field:showEnd}', desc: 'Show if contains text' },
{ tag: '{d.field:ifGT(5):showBegin}...{d.field:showEnd}', desc: 'Show if greater than value' }
]
},
{
title: '7. Formatting',
type: 'simple',
tags: [
{ tag: '{d.field:formatD(YYYY-MM-DD)}', desc: 'Format date: 2022-01-01' },
{ tag: '{d.field:formatD(MMM YYYY)}', desc: 'Format date: Jan 2022' },
{ tag: '{d.field:formatN(0.0)}', desc: 'Format number with 1 decimal' },
{ tag: '{d.field:upperCase}', desc: 'Convert to UPPERCASE' },
{ tag: '{d.field:lowerCase}', desc: 'Convert to lowercase' },
{ tag: '{d.field:ucWords}', desc: 'Capitalize Each Word' },
{ tag: '{d.field:substr(0, 100)}', desc: 'First 100 characters' }
]
},
{
title: '8. Array Operations',
type: 'simple',
tags: [
{ tag: '{d.skills:arrayJoin(, )}', desc: 'Join all items with separator' },
{ tag: '{d.skills:arrayMap(name):arrayJoin(, )}', desc: 'Extract field and join' },
{ tag: '{d.experience:count}', desc: 'Count items in array' }
]
}
]
});
});
// POST /api/carbone/templates/upload - Upload a custom .docx template
router.post('/templates/upload', upload.single('template'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const name = req.body.name || path.basename(req.file.originalname, path.extname(req.file.originalname));
const id = path.basename(req.file.filename, path.extname(req.file.filename));
const registry = loadRegistry();
const entry = {
id: id,
name: name,
filename: req.file.filename,
originalName: req.file.originalname,
path: req.file.path,
uploadedAt: new Date().toISOString()
};
registry.push(entry);
saveRegistry(registry);
res.json({ success: true, template: entry });
});
// POST /api/carbone/templates/:id/render - Render template with data -> PDF
router.post('/templates/:id/render', async (req, res) => {
const templateId = req.params.id;
const registry = loadRegistry();
const template = registry.find(t => t.id === templateId);
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
if (!fs.existsSync(template.path)) {
return res.status(404).json({ error: 'Template file missing from disk' });
}
// Get candidate data from request body
const candidateData = req.body.candidateData || req.body;
const referenceDate = req.body.referenceDate || req.body.generation_date;
// Transform data for Carbone
const carboneData = transformCandidateData(candidateData, referenceDate);
// Step 1: Use Carbone to merge data into the DOCX template (no PDF conversion)
const mergePromise = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Carbone merge timed out')), 30000);
carbone.render(template.path, carboneData, {}, (err, result) => {
clearTimeout(timer);
if (err) reject(err);
else resolve(result);
});
});
try {
const docxBuffer = await mergePromise;
// Step 2: Convert merged DOCX to PDF using LibreOffice directly
// (Carbone's built-in converter has issues with pipe communication)
const tmpDir = path.join(__dirname, 'output');
fs.mkdirSync(tmpDir, { recursive: true });
const tmpId = uuidv4();
const tmpDocx = path.join(tmpDir, `${tmpId}.docx`);
const tmpPdf = path.join(tmpDir, `${tmpId}.pdf`);
fs.writeFileSync(tmpDocx, docxBuffer);
// Call LibreOffice headless for PDF conversion
const { execFile } = require('child_process');
const convertPromise = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('LibreOffice conversion timed out after 60s')), 60000);
execFile('soffice', [
'--headless', '--invisible', '--nocrashreport', '--nodefault',
'--nologo', '--nofirststartwizard', '--norestore',
'--convert-to', 'pdf',
'--outdir', tmpDir,
tmpDocx
], { timeout: 60000 }, (err, stdout, stderr) => {
clearTimeout(timer);
if (err) reject(err);
else if (!fs.existsSync(tmpPdf)) reject(new Error('PDF was not generated'));
else resolve(tmpPdf);
});
});
const pdfPath = await convertPromise;
const pdfBuffer = fs.readFileSync(pdfPath);
// Clean up temp files
try { fs.unlinkSync(tmpDocx); } catch {}
try { fs.unlinkSync(tmpPdf); } catch {}
// Set headers for PDF download
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${template.name.replace(/\s+/g, '_')}_CV.pdf"`);
res.setHeader('Content-Length', pdfBuffer.length);
res.send(pdfBuffer);
} catch (err) {
console.error('Carbone render error:', err);
if (err.message.includes('timed out')) {
res.status(504).json({ error: 'PDF generation timed out. LibreOffice may be busy. Try again.' });
} else if (err.message.includes('corrupt') || err.message.includes('invalid')) {
res.status(400).json({ error: 'Template file appears to be corrupted or invalid' });
} else {
res.status(500).json({ error: 'PDF generation failed: ' + err.message });
}
}
});
// DELETE /api/carbone/templates/:id - Delete a template
router.delete('/templates/:id', (req, res) => {
const templateId = req.params.id;
const registry = loadRegistry();
const template = registry.find(t => t.id === templateId);
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
// Delete file from disk
if (fs.existsSync(template.path)) {
fs.unlinkSync(template.path);
}
// Remove from registry
const newRegistry = registry.filter(t => t.id !== templateId);
saveRegistry(newRegistry);
res.json({ success: true, message: 'Template deleted' });
});
module.exports = { router, transformCandidateData, generateMasterTemplate };