734 lines
27 KiB
JavaScript
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 }; |