348 lines
11 KiB
Go
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)
|
|
}
|