init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,296 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user