portfolio/scripts/generate-pdfs.js
Michael Winter 711b5b93c2 Clean up data files and fix icon/PDF links
- Clean all JSON data files: convert MongoDB format to clean JSON
  - works.json, images.json, publications.json, pubs.json, talks.json
  - releases.json, album_art.json, scores.json, my_image_gallery.json
  - events.json (with legacy program → program transformation)
  - resume.json (simplified structure)
- Simplify all API routes (remove cleanData functions)
- Fix PDF links to open in new tab (scores, writings, albums)
- Upgrade to Nuxt 4.3.1 and fix carousel (nuxt-swiper)
- Replace nuxt-icon with @nuxt/icon
- Fix IconButton component for new tab links
- Update cv.vue for resume data structure changes
- Add icon collections (@iconify-json packages)
2026-02-18 20:16:09 +01:00

243 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
const DATA_DIR = path.join(__dirname, '../server/data')
const PUBLIC_DIR = path.join(__dirname, '../public')
function readJson(filename) {
const data = fs.readFileSync(path.join(DATA_DIR, filename), 'utf-8')
const parsed = JSON.parse(data)
function cleanDates(obj) {
if (Array.isArray(obj)) return obj.map(cleanDates)
if (obj && typeof obj === 'object') {
if (obj.$date?.$numberLong) return new Date(parseInt(obj.$date.$numberLong)).toISOString()
const cleaned = {}
for (const [k, v] of Object.entries(obj)) cleaned[k] = cleanDates(v)
return cleaned
}
return obj
}
return cleanDates(parsed)
}
function formatMonth(dateStr) {
if (!dateStr) return 'Present'
const date = new Date(dateStr)
if (isNaN(date)) return dateStr
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
}
function formatYear(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
if (isNaN(date)) return dateStr
return date.getFullYear()
}
function formatShortDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
if (isNaN(date)) return dateStr
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function escapeLatex(str) {
if (!str) return ''
str = str.replace(/[\u0300-\u036f]/g, '')
return str.replace(/[&%$#_{}]/g, '\\$&').replace(/~/g, '\\textasciitilde{}').replace(/\^/g, '\\textasciicircum{}').replace(/ä/g, '{\\"a}').replace(/ö/g, '{\\"o}').replace(/ü/g, '{\\"u}').replace(/Ä/g, '{\\"A}').replace(/Ö/g, '{\\"O}').replace(/Ü/g, '{\\"U}').replace(/á/g, "\\'a").replace(/é/g, "\\'e").replace(/í/g, "\\'i").replace(/ó/g, "\\'o").replace(/ú/g, "\\'u").replace(/à/g, "\\`a").replace(/è/g, "\\`e").replace(/ì/g, "\\`i").replace(/ò/g, "\\`o").replace(/ù/g, "\\`u").replace(/ã/g, '{\\~a}').replace(/ñ/g, '{\\~n}').replace(//g, '--').replace(/—/g, '---').replace(/ç/g, '\\c c')
}
function generateCvLatex(resume, talks) {
const basics = resume.basics || {}
const talksByYear = {}
for (const talk of talks || []) {
if (!talk.date) continue
const year = new Date(talk.date).getFullYear()
if (!talksByYear[year]) talksByYear[year] = []
talksByYear[year].push(talk)
}
const sortedYears = Object.keys(talksByYear).sort((a, b) => b - a)
let latex = '\\documentclass[11pt]{article}\n'
latex += '\\usepackage[utf8]{inputenc}\n'
latex += '\\usepackage[T1]{fontenc}\n'
latex += '\\usepackage{geometry}\n'
latex += '\\geometry{letterpaper, top=1in, bottom=1in, left=1in, right=1in}\n'
latex += '\\usepackage{enumitem}\n'
latex += '\\usepackage{titlesec}\n'
latex += '\\pagestyle{empty}\n'
latex += '\\titleformat{\\section}{\\bfseries\\Large}{\\thesection}{1em}{}\n'
latex += '\\titleformat{\\subsection}{\\bfseries\\large}{\\thesubsection}{1em}{}\n'
latex += '\\titlespacing{\\section}{0pt}{12pt}{6pt}\n'
latex += '\\titlespacing{\\subsection}{0pt}{8pt}{4pt}\n'
latex += '\\begin{document}\n'
// Header
latex += '\\begin{center}\n'
latex += '{\\LARGE\\bfseries ' + escapeLatex(basics.name || '') + '}\\\\[4pt]\n'
latex += '{\\large Curriculum Vitae}\\\\[8pt]\n'
latex += escapeLatex(basics.email || '') + ' \\quad ' + escapeLatex(basics.phone || '') + ' \\quad ' + escapeLatex(basics.website || '') + '\n'
latex += '\\end{center}\n'
latex += '\\hrulefill\\\\[12pt]\n'
// Education
latex += '\\section{Education}\n'
for (const edu of resume.education || []) {
latex += '{\\bfseries ' + escapeLatex(edu.studyType) + ' in ' + escapeLatex(edu.area) + '}, ' + escapeLatex(edu.institution) + ', ' + formatYear(edu.endDate) + '\\\\[4pt]\n'
}
// Teaching
latex += '\\section{Teaching}\n'
for (const teach of resume.teaching || []) {
latex += '{\\bfseries ' + escapeLatex(teach.company) + '}, \\textit{' + escapeLatex(teach.position) + '}\\\\[2pt]\n'
latex += escapeLatex(formatMonth(teach.startDate)) + ' -- ' + escapeLatex(formatMonth(teach.endDate)) + '\\\\[8pt]\n'
}
// Lectures
latex += '\\section{Lectures}\n'
for (const year of sortedYears) {
latex += '\\subsection{' + year + '}\n'
for (const talk of talksByYear[year]) {
latex += '{\\bfseries ' + escapeLatex(talk.location) + '}\\\\\n'
const titles = Array.isArray(talk.title) ? talk.title : [talk.title]
for (const t of titles) latex += '\\hspace{8pt}\\textit{' + escapeLatex(t) + '}\\\\\n'
}
latex += '\\\\[4pt]\n'
}
// Relevant Work
latex += '\\section{Relevant Work}\n'
for (const w of resume.work || []) {
latex += '{\\bfseries ' + escapeLatex(w.company) + '}, \\textit{' + escapeLatex(w.position) + '}\\\\[2pt]\n'
latex += escapeLatex(formatMonth(w.startDate)) + ' -- ' + escapeLatex(formatMonth(w.endDate)) + '\\\\[8pt]\n'
}
// Skills
latex += '\\section{Skills}\n'
latex += (resume.skills?.[0]?.keywords?.map(s => escapeLatex(s)).join(', ') || '') + '\\\\[8pt]\n'
// Languages
latex += '\\section{Languages}\n'
latex += (resume.languages || []).map(l => escapeLatex(l.language) + ' (' + escapeLatex(l.fluency) + ')').join(', ') + '\\\\[8pt]\n'
// Recordings
latex += '\\section{Recordings}\n'
latex += '\\subsection{Solo Albums}\n'
for (const rel of resume.solo_releases || []) {
latex += '{\\bfseries ' + escapeLatex(rel.title) + '}. ' + escapeLatex(rel.publisher) + '. ' + escapeLatex(rel.media_type) + '. ' + escapeLatex(rel.date) + '.\\\\[4pt]\n'
}
latex += '\\subsection{Compilation Albums}\n'
for (const rel of resume.compilation_releases || []) {
latex += '{\\bfseries ' + escapeLatex(rel.title) + '}. ' + escapeLatex(rel.publisher) + '. ' + escapeLatex(rel.media_type) + '. ' + escapeLatex(rel.date) + '. \\textit{featuring ' + escapeLatex(rel.work) + '}.\\\\[4pt]\n'
}
// Residencies
latex += '\\section{Residencies and Awards}\n'
for (const res of resume.residencies || []) {
latex += '{\\bfseries ' + escapeLatex(res.org) + '}, ' + escapeLatex(res.date) + '\\\\[4pt]\n'
}
// References
latex += '\\section{References}\n'
for (const ref of resume.references || []) {
latex += '{\\bfseries ' + escapeLatex(ref.name) + '}\\\\[2pt]\n'
latex += escapeLatex(ref.position) + '\\\\[2pt]\n'
latex += escapeLatex(ref.email) + '\\\\[8pt]\n'
}
latex += '\\end{document}\n'
return latex
}
function generateWorksListLatex(resume, works, events) {
const basics = resume.basics || {}
const worksByYear = {}
for (const work of works || []) {
const year = work.date ? new Date(work.date).getFullYear() : 'Unknown'
if (!worksByYear[year]) worksByYear[year] = []
const workEvents = (events || []).filter(e => e.program && e.program.some(p => p.work?.toLowerCase().includes(work.title.toLowerCase())))
worksByYear[year].push({ ...work, events: workEvents })
}
const sortedYears = Object.keys(worksByYear).sort((a, b) => b - a)
let latex = '\\documentclass[11pt]{article}\n'
latex += '\\usepackage[utf8]{inputenc}\n'
latex += '\\usepackage[T1]{fontenc}\n'
latex += '\\usepackage{geometry}\n'
latex += '\\geometry{letterpaper, top=1in, bottom=1in, left=1in, right=1in}\n'
latex += '\\usepackage{enumitem}\n'
latex += '\\usepackage{titlesec}\n'
latex += '\\pagestyle{empty}\n'
latex += '\\titleformat{\\section}{\\bfseries\\Large}{\\thesection}{1em}{}\n'
latex += '\\titlespacing{\\section}{0pt}{12pt}{6pt}\n'
latex += '\\begin{document}\n'
// Header
latex += '\\begin{center}\n'
latex += '{\\LARGE\\bfseries ' + escapeLatex(basics.name || '') + '}\\\\[4pt]\n'
latex += '{\\large Works List with Presentation History}\\\\[8pt]\n'
latex += escapeLatex(basics.email || '') + ' \\quad ' + escapeLatex(basics.phone || '') + ' \\quad ' + escapeLatex(basics.website || '') + '\n'
latex += '\\end{center}\n'
latex += '\\hrulefill\\\\[12pt]\n'
latex += 'A chronological performance / exhibition history, scores, and recordings are available at\\\\\n'
latex += 'www.unboundedpress.org.\\\\\n'
latex += 'All scores are also published or forthcoming through Frog Peak at\\\\\n'
latex += 'www.frogpeak.org/fpartists/fpwinter.html.\\\\[12pt]\n'
latex += '\\hrulefill\\\\[12pt]\n'
for (const year of sortedYears) {
latex += '\\section{' + year + '}\n'
for (const work of worksByYear[year]) {
latex += '{\\bfseries\\textit{' + escapeLatex(work.title) + '}}\\\\[-4pt]\n'
if (work.instrument_tags) latex += '\\hspace{8pt}' + escapeLatex(work.instrument_tags.join(', ')) + '\\\\[-4pt]\n'
for (const event of work.events || []) {
const venue = event.venue || {}
latex += '\\hspace{8pt}' + escapeLatex(venue.name) + '; ' + escapeLatex(venue.city) + ', ' + escapeLatex(venue.state) + ' -- ' + escapeLatex(formatShortDate(event.start_date)) + '\\\\\n'
}
latex += '\\\\[4pt]\n'
}
}
latex += '\\end{document}\n'
return latex
}
function compileLatex(latexContent, outputPath) {
const tempDir = path.join(__dirname, 'temp')
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true })
const texPath = path.join(tempDir, 'temp.tex')
fs.writeFileSync(texPath, latexContent)
try {
execSync('cd ' + tempDir + ' && pdflatex temp.tex', { stdio: 'pipe' })
const pdfPath = path.join(tempDir, 'temp.pdf')
if (fs.existsSync(pdfPath)) {
fs.copyFileSync(pdfPath, outputPath)
console.log('Generated:', outputPath)
}
} catch (e) {
fs.writeFileSync(path.join(PUBLIC_DIR, 'debug.tex'), latexContent)
console.error('Error generating PDF')
}
}
const resume = readJson('resume.json')[0]
const talks = readJson('talks.json')
const works = readJson('works.json')
const events = readJson('events.json')
console.log('Generating CV...')
compileLatex(generateCvLatex(resume, talks), path.join(PUBLIC_DIR, 'cv.pdf'))
console.log('Generating Works List...')
compileLatex(generateWorksListLatex(resume, works, events), path.join(PUBLIC_DIR, 'works_list.pdf'))
console.log('Done!')