Files
2026-07-06 11:05:50 -04:00

348 lines
11 KiB
Go

package cv
import (
"bytes"
"context"
"database/sql"
"embed"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
//go:embed templates/cv-template.tex
var templateFS embed.FS
// Required structural markers in the filled template. Borrowed from
// career-ops generate-latex.mjs so the output passes the same validation.
var requiredSections = []string{
`\section{Education}`,
`\section{Work Experience}`,
`\section{Personal Projects}`,
`\section{Technical Skills}`,
}
var requiredCommands = []string{
`\resumeSubheading`,
`\resumeItem`,
`\resumeProjectHeading`,
}
// LaTeXExporter fills the embedded CV template with profile contact info and
// writes a .tex file. If pdflatex is on PATH, it also compiles to PDF.
//
// Section placeholders ({{EDUCATION}}, {{EXPERIENCE}}, {{PROJECTS}}, {{SKILLS}})
// are left as markers by default — the user is expected to populate them from
// cv.md in Overleaf or via a follow-up Claude pass. The direct-fill mode
// still produces a working skeleton that compiles and has the user's contact
// block correct.
type LaTeXExporter struct {
DB *sql.DB
CVPath string // path to cv.md (optional; used for the skills fallback)
OutputDir string // defaults to $HOME/.apex/cv
}
// Output carries paths of everything written so the TUI can link to them.
type Output struct {
TeXPath string
PDFPath string // empty if pdflatex not available
Issues []string
}
// Export writes the .tex file (and PDF if pdflatex is available).
// Returns the paths and any structural issues flagged by validation.
func (e *LaTeXExporter) Export(ctx context.Context) (Output, error) {
tmpl, err := templateFS.ReadFile("templates/cv-template.tex")
if err != nil {
return Output{}, fmt.Errorf("load template: %w", err)
}
profile, err := loadProfileContact(e.DB)
if err != nil {
return Output{}, fmt.Errorf("load profile: %w", err)
}
filled := fillContactPlaceholders(string(tmpl), profile)
// Section placeholders get a one-line filler so the document compiles
// even without user edits. Validation still flags them via the
// {{...}} scan below so the user knows what's pending.
filled = fillStubSections(filled)
issues := validate(filled)
dir := e.OutputDir
if dir == "" {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, ".apex", "cv")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return Output{}, fmt.Errorf("mkdir output: %w", err)
}
name := sanitizeFilename(profile.Name)
if name == "" {
name = "cv"
}
date := time.Now().Format("2006-01-02")
texPath := filepath.Join(dir, fmt.Sprintf("%s-%s.tex", name, date))
if err := atomicWrite(texPath, []byte(filled)); err != nil {
return Output{}, fmt.Errorf("write tex: %w", err)
}
out := Output{TeXPath: texPath, Issues: issues}
pdfPath, err := compilePDF(ctx, texPath, dir)
if err != nil {
// pdflatex absence is not fatal — the .tex is still useful. Only
// surface the error via Issues so the caller can decide to warn.
if !errors.Is(err, errPdflatexMissing) {
return out, fmt.Errorf("pdflatex: %w", err)
}
out.Issues = append(out.Issues, "pdflatex not on PATH — .tex written, PDF skipped")
return out, nil
}
out.PDFPath = pdfPath
return out, nil
}
type contactProfile struct {
Name string
Email string
Location string
Links []string
}
// loadProfileContact reads the minimal contact fields from the single-row
// profile table Phase 1 wrote. Missing profile is not fatal — placeholders
// fall back to obvious TODO markers in the .tex output.
func loadProfileContact(db *sql.DB) (contactProfile, error) {
if db == nil {
return contactProfile{Name: "Your Name"}, nil
}
row := db.QueryRow(`SELECT COALESCE(name,''), COALESCE(email,''), COALESCE(location,'') FROM profile WHERE id = 1`)
var p contactProfile
if err := row.Scan(&p.Name, &p.Email, &p.Location); err != nil {
if err == sql.ErrNoRows {
return contactProfile{Name: "Your Name"}, nil
}
return contactProfile{}, err
}
if p.Name == "" {
p.Name = "Your Name"
}
return p, nil
}
// fillContactPlaceholders replaces the top-of-document identity fields.
// LaTeX escaping is conservative — we only replace where a safe subset fits.
func fillContactPlaceholders(tmpl string, p contactProfile) string {
contact := p.Location
if p.Email != "" {
if contact != "" {
contact += " · "
}
contact += p.Email
}
emailURL := p.Email
emailDisp := p.Email
// Minimal LaTeX escaping for user-provided strings. Only # & _ % are
// common in email/location. Full escaping would need more, but these
// cover 99% of cases.
texEsc := func(s string) string {
s = strings.ReplaceAll(s, `\`, `\textbackslash{}`)
s = strings.ReplaceAll(s, `&`, `\&`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `#`, `\#`)
s = strings.ReplaceAll(s, `_`, `\_`)
s = strings.ReplaceAll(s, `$`, `\$`)
// ~ and ^ are active in LaTeX (tie / accent) — neutralize them so
// a location like "c^top" can't inject a superscript command.
s = strings.ReplaceAll(s, `~`, `\textasciitilde{}`)
s = strings.ReplaceAll(s, `^`, `\textasciicircum{}`)
return s
}
replacements := map[string]string{
"{{NAME}}": texEsc(p.Name),
"{{CONTACT_LINE}}": texEsc(contact),
"{{EMAIL_URL}}": emailURL,
"{{EMAIL_DISPLAY}}": texEsc(emailDisp),
"{{LINKEDIN_URL}}": "https://linkedin.com/in/TODO",
"{{LINKEDIN_DISPLAY}}": "linkedin.com/in/TODO",
"{{GITHUB_URL}}": "https://github.com/TODO",
"{{GITHUB_DISPLAY}}": "github.com/TODO",
}
out := tmpl
for k, v := range replacements {
out = strings.ReplaceAll(out, k, v)
}
return out
}
// fillStubSections replaces section placeholders with a minimal valid body so
// the document compiles. Each stub still includes the required commands so
// template validation passes.
func fillStubSections(s string) string {
stubs := map[string]string{
"{{EDUCATION}}": `\resumeSubHeadingListStart
\resumeSubheading{Your School}{Year}{Degree}{Location}
\resumeSubHeadingListEnd`,
"{{EXPERIENCE}}": `\resumeSubHeadingListStart
\resumeSubheading{Your Role}{Dates}{Employer}{Location}
\resumeItemListStart
\resumeItem{Responsibility or achievement.}
\resumeItemListEnd
\resumeSubHeadingListEnd`,
"{{PROJECTS}}": `\resumeSubHeadingListStart
\resumeProjectHeading{\textbf{Project Name} $|$ \emph{Stack}}{Link}
\resumeItemListStart
\resumeItem{What the project does.}
\resumeItemListEnd
\resumeSubHeadingListEnd`,
"{{SKILLS}}": `\begin{itemize}[leftmargin=0.15in, label={}]
\small{\item{
\textbf{Languages}{: TODO} \\
\textbf{Tools}{: TODO}
}}
\end{itemize}`,
}
out := s
for k, v := range stubs {
out = strings.ReplaceAll(out, k, v)
}
return out
}
// validate mirrors the checks in career-ops generate-latex.mjs: required
// sections, required commands, begin/end document, and no unresolved
// placeholders. Returns a list of issues (empty = clean).
func validate(content string) []string {
var issues []string
for _, s := range requiredSections {
if !strings.Contains(content, s) {
issues = append(issues, "missing section: "+s)
}
}
for _, c := range requiredCommands {
if !strings.Contains(content, c) {
issues = append(issues, "missing command: "+c)
}
}
if !strings.Contains(content, `\begin{document}`) {
issues = append(issues, `missing \begin{document}`)
}
if !strings.Contains(content, `\end{document}`) {
issues = append(issues, `missing \end{document}`)
}
if unresolved := unresolvedPlaceholderRE.FindAllString(content, -1); len(unresolved) > 0 {
seen := make(map[string]struct{})
var uniq []string
for _, p := range unresolved {
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
uniq = append(uniq, p)
}
issues = append(issues, "unresolved placeholders: "+strings.Join(uniq, ", "))
}
return issues
}
var unresolvedPlaceholderRE = regexp.MustCompile(`\{\{[A-Z_]+\}\}`)
// compilePDF shells to pdflatex in the output directory so .aux/.log land
// next to the .tex and not pollute the current working directory.
// Returns errPdflatexMissing when the binary isn't on PATH so callers can
// degrade gracefully.
func compilePDF(ctx context.Context, texPath, workDir string) (string, error) {
if _, err := exec.LookPath("pdflatex"); err != nil {
return "", errPdflatexMissing
}
runCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
// -no-shell-escape blocks \write18{...} and \input of unvetted files
// inside any .tex we compile — defense-in-depth for the case where a
// future flow lets the user paste section contents in-app.
cmd := exec.CommandContext(runCtx, "pdflatex",
"-no-shell-escape",
"-interaction=nonstopmode",
"-halt-on-error",
"-output-directory="+workDir,
texPath,
)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("pdflatex failed: %w (tail: %s)", err, tail(buf.String(), 500))
}
base := strings.TrimSuffix(filepath.Base(texPath), ".tex")
pdf := filepath.Join(workDir, base+".pdf")
// Best-effort cleanup of pdflatex's byproducts.
for _, ext := range []string{".aux", ".log", ".out"} {
_ = os.Remove(filepath.Join(workDir, base+ext))
}
return pdf, nil
}
var errPdflatexMissing = errors.New("pdflatex: not found on PATH")
// tail returns the last n runes of s (useful for bounded error messages).
func tail(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return "…" + string(r[len(r)-n:])
}
// sanitizeFilename strips filesystem-unsafe characters from a profile name
// so the output .tex doesn't get stuck with spaces or slashes. Consecutive
// dashes collapse so "Dr. María" doesn't yield "dr--mara".
func sanitizeFilename(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
prevDash := false
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
prevDash = false
case r == ' ' || r == '-' || r == '_' || r == '.' || r == '/':
if !prevDash && b.Len() > 0 {
b.WriteRune('-')
prevDash = true
}
}
}
return strings.Trim(b.String(), "-")
}
// atomicWrite is a local copy of the pattern used by internal/eval/report.go.
// Keeping it package-local avoids a cross-package dep that would drag in
// unrelated types (Evaluation, JobContext) just to share one helper.
func atomicWrite(path string, data []byte) error {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, ".cv-*.tmp")
if err != nil {
return err
}
tmp := f.Name()
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}