297 lines
9.4 KiB
Go
297 lines
9.4 KiB
Go
package data
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/santifer/career-ops/dashboard/internal/model"
|
|
)
|
|
|
|
// PDFManifestEntry is one row of data/pdf-index.tsv, written by
|
|
// generate-pdf.mjs each time a PDF is produced. ReportNumber may be empty
|
|
// for PDFs generated without --report; those rows still help the glob
|
|
// matcher via their recorded paths.
|
|
type PDFManifestEntry struct {
|
|
ReportNumber string
|
|
PDFPath string // relative to the career-ops root
|
|
HTMLPath string // relative to the career-ops root, "" if unknown
|
|
Format string // "letter" or "a4"
|
|
Date string // YYYY-MM-DD generation date
|
|
}
|
|
|
|
// PDFManifest indexes manifest entries by normalized report number (leading
|
|
// zeros stripped, so "008" and "8" collide deliberately). Rows without a
|
|
// report number are not indexed (the glob path covers them).
|
|
type PDFManifest map[string]PDFManifestEntry
|
|
|
|
// isSafeRepoRelativePath returns true iff p is a non-empty, non-absolute path
|
|
// with no ".." traversal components. Used to reject crafted TSV rows before
|
|
// they reach dashboard open/regenerate flows.
|
|
func isSafeRepoRelativePath(p string) bool {
|
|
p = filepath.Clean(filepath.FromSlash(strings.TrimSpace(p)))
|
|
if p == "." || p == "" {
|
|
return false
|
|
}
|
|
if filepath.IsAbs(p) {
|
|
return false
|
|
}
|
|
if p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// normalizeReportKey strips leading zeros so the zero-padded report-link
|
|
// form ("008") and the unpadded tracker-# form ("8") key identically.
|
|
func normalizeReportKey(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
trimmed := strings.TrimLeft(s, "0")
|
|
if trimmed == "" && s != "" {
|
|
return "0"
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
// Lookup finds the manifest entry for an application, trying the report-link
|
|
// number first (the NNN in reports/NNN-….md) and falling back to the tracker
|
|
// # column. The two usually agree but are scrambled in real trackers, and
|
|
// callers passing --report may have used either — tolerate both.
|
|
func (m PDFManifest) Lookup(app model.CareerApplication) (PDFManifestEntry, bool) {
|
|
if key := normalizeReportKey(app.ReportNumber); key != "" {
|
|
if entry, ok := m[key]; ok {
|
|
return entry, true
|
|
}
|
|
}
|
|
if app.Number > 0 {
|
|
if entry, ok := m[strconv.Itoa(app.Number)]; ok {
|
|
return entry, true
|
|
}
|
|
}
|
|
return PDFManifestEntry{}, false
|
|
}
|
|
|
|
// LoadPDFEntriesByPath reads data/pdf-index.tsv and returns all entries indexed
|
|
// by their relative PDF path. Unlike LoadPDFManifest, this includes rows that
|
|
// were generated without a --report flag. Later rows in the file win.
|
|
func LoadPDFEntriesByPath(careerOpsPath string) map[string]PDFManifestEntry {
|
|
byPath := make(map[string]PDFManifestEntry)
|
|
raw, err := os.ReadFile(filepath.Join(careerOpsPath, "data", "pdf-index.tsv"))
|
|
if err != nil {
|
|
return byPath
|
|
}
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
line = strings.TrimRight(line, "\r")
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
fields := strings.Split(line, "\t")
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
entry := PDFManifestEntry{
|
|
ReportNumber: strings.TrimSpace(fields[0]),
|
|
PDFPath: strings.TrimSpace(fields[1]),
|
|
}
|
|
if entry.PDFPath == "" || !isSafeRepoRelativePath(entry.PDFPath) {
|
|
continue
|
|
}
|
|
if len(fields) > 2 {
|
|
entry.HTMLPath = strings.TrimSpace(fields[2])
|
|
if entry.HTMLPath != "" && !isSafeRepoRelativePath(entry.HTMLPath) {
|
|
entry.HTMLPath = ""
|
|
}
|
|
}
|
|
if len(fields) > 3 {
|
|
entry.Format = strings.TrimSpace(fields[3])
|
|
}
|
|
if len(fields) > 4 {
|
|
entry.Date = strings.TrimSpace(fields[4])
|
|
}
|
|
byPath[entry.PDFPath] = entry
|
|
}
|
|
return byPath
|
|
}
|
|
|
|
// LoadPDFManifest reads data/pdf-index.tsv under careerOpsPath. A missing
|
|
// file is not an error — the manifest is optional and absent until the
|
|
// first generate-pdf.mjs run that writes it. Later rows win over earlier
|
|
// ones for the same report number, so regenerated PDFs supersede stale
|
|
// entries without any compaction step.
|
|
func LoadPDFManifest(careerOpsPath string) PDFManifest {
|
|
manifest := make(PDFManifest)
|
|
raw, err := os.ReadFile(filepath.Join(careerOpsPath, "data", "pdf-index.tsv"))
|
|
if err != nil {
|
|
return manifest
|
|
}
|
|
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
line = strings.TrimRight(line, "\r")
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
fields := strings.Split(line, "\t")
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
entry := PDFManifestEntry{
|
|
ReportNumber: strings.TrimSpace(fields[0]),
|
|
PDFPath: strings.TrimSpace(fields[1]),
|
|
}
|
|
if len(fields) > 2 {
|
|
entry.HTMLPath = strings.TrimSpace(fields[2])
|
|
if entry.HTMLPath != "" && !isSafeRepoRelativePath(entry.HTMLPath) {
|
|
entry.HTMLPath = ""
|
|
}
|
|
}
|
|
if len(fields) > 3 {
|
|
entry.Format = strings.TrimSpace(fields[3])
|
|
}
|
|
if len(fields) > 4 {
|
|
entry.Date = strings.TrimSpace(fields[4])
|
|
}
|
|
if entry.ReportNumber == "" || entry.PDFPath == "" || !isSafeRepoRelativePath(entry.PDFPath) {
|
|
continue
|
|
}
|
|
manifest[normalizeReportKey(entry.ReportNumber)] = entry
|
|
}
|
|
return manifest
|
|
}
|
|
|
|
// rePDFDate extracts the trailing YYYY-MM-DD stamp from generated CV
|
|
// filenames (cv-{candidate}-{slug}-{date}.pdf).
|
|
var rePDFDate = regexp.MustCompile(`(\d{4}-\d{2}-\d{2})\.pdf$`)
|
|
|
|
// ResolvePDFs returns candidate PDF paths (relative to careerOpsPath) for an
|
|
// application, best match first.
|
|
//
|
|
// Precedence:
|
|
// 1. Manifest entry for the application's report number, when the file
|
|
// still exists. This is exact — generate-pdf.mjs recorded the linkage.
|
|
// 2. Filename match: output/cv-*.pdf whose name contains the kebab-cased
|
|
// company. This covers every PDF generated before the manifest existed.
|
|
// Multiple matches are all returned (newest first) so the caller can
|
|
// offer a picker instead of guessing — one company can have several
|
|
// role-variant CVs from the same day.
|
|
func ResolvePDFs(careerOpsPath string, app model.CareerApplication, manifest PDFManifest) []string {
|
|
if entry, ok := manifest.Lookup(app); ok {
|
|
if _, err := os.Stat(filepath.Join(careerOpsPath, filepath.FromSlash(entry.PDFPath))); err == nil {
|
|
return []string{entry.PDFPath}
|
|
}
|
|
}
|
|
|
|
slug := kebabCase(app.Company)
|
|
if slug == "" {
|
|
return nil
|
|
}
|
|
|
|
globbed, err := filepath.Glob(filepath.Join(careerOpsPath, "output", "cv-*.pdf"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var matches []string
|
|
for _, p := range globbed {
|
|
base := strings.ToLower(filepath.Base(p))
|
|
if matchesCompanySlug(base, slug) {
|
|
if rel, err := filepath.Rel(careerOpsPath, p); err == nil {
|
|
matches = append(matches, filepath.ToSlash(rel))
|
|
}
|
|
}
|
|
}
|
|
|
|
sortPDFsNewestFirst(careerOpsPath, matches)
|
|
return matches
|
|
}
|
|
|
|
// matchesCompanySlug reports whether a generated CV filename refers to the
|
|
// company. Short slugs (< 3 runes) require a full "-slug-" segment so a
|
|
// company like "X" can't match every file; longer slugs use substring
|
|
// containment, which tolerates role-variant suffixes (cv-…-anthropic-staff-
|
|
// ui-….pdf matches "anthropic").
|
|
func matchesCompanySlug(base, slug string) bool {
|
|
if len([]rune(slug)) < 3 {
|
|
return strings.Contains(base, "-"+slug+"-")
|
|
}
|
|
return strings.Contains(base, slug)
|
|
}
|
|
|
|
// sortPDFsNewestFirst orders candidate paths by the date stamp embedded in
|
|
// the filename (descending), falling back to file mtime when the stamp is
|
|
// missing or equal. A regenerated CV from today therefore outranks last
|
|
// week's, and same-day variants get a stable mtime ordering.
|
|
func sortPDFsNewestFirst(careerOpsPath string, paths []string) {
|
|
dateOf := func(p string) string {
|
|
if m := rePDFDate.FindStringSubmatch(p); m != nil {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
}
|
|
mtimeOf := func(p string) int64 {
|
|
info, err := os.Stat(filepath.Join(careerOpsPath, filepath.FromSlash(p)))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return info.ModTime().UnixNano()
|
|
}
|
|
sort.SliceStable(paths, func(i, j int) bool {
|
|
di, dj := dateOf(paths[i]), dateOf(paths[j])
|
|
if di != dj {
|
|
return di > dj
|
|
}
|
|
return mtimeOf(paths[i]) > mtimeOf(paths[j])
|
|
})
|
|
}
|
|
|
|
// kebabCase lowercases s and collapses every non-alphanumeric run into a
|
|
// single hyphen: "Monarch Money" → "monarch-money".
|
|
func kebabCase(s string) string {
|
|
var b strings.Builder
|
|
lastHyphen := true // suppress leading hyphen
|
|
for _, r := range strings.ToLower(s) {
|
|
isAlnum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
|
if isAlnum {
|
|
b.WriteRune(r)
|
|
lastHyphen = false
|
|
} else if !lastHyphen {
|
|
b.WriteByte('-')
|
|
lastHyphen = true
|
|
}
|
|
}
|
|
return strings.TrimRight(b.String(), "-")
|
|
}
|
|
|
|
// ResolveHTML returns the newest output/cv-*{companySlug}*.html for app and
|
|
// a companion PDF path derived from it (same base, .pdf extension). Returns
|
|
// ("","") when no matching HTML file is found. Used by the D handler to allow
|
|
// PDF generation for entries that have a tailored CV HTML but no prior PDF.
|
|
func ResolveHTML(careerOpsPath string, app model.CareerApplication) (htmlPath, pdfPath string) {
|
|
slug := kebabCase(app.Company)
|
|
if slug == "" {
|
|
return "", ""
|
|
}
|
|
globbed, err := filepath.Glob(filepath.Join(careerOpsPath, "output", "cv-*.html"))
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
var matches []string
|
|
for _, p := range globbed {
|
|
base := strings.ToLower(filepath.Base(p))
|
|
if matchesCompanySlug(base, slug) {
|
|
if rel, err := filepath.Rel(careerOpsPath, p); err == nil {
|
|
matches = append(matches, filepath.ToSlash(rel))
|
|
}
|
|
}
|
|
}
|
|
if len(matches) == 0 {
|
|
return "", ""
|
|
}
|
|
sortPDFsNewestFirst(careerOpsPath, matches)
|
|
relHTML := matches[0]
|
|
relPDF := strings.TrimSuffix(relHTML, ".html") + ".pdf"
|
|
return relHTML, relPDF
|
|
}
|