package handlers import ( "fmt" "log" "net/http" "os" "os/exec" "strings" "time" "github.com/juanatsap/cv-site/internal/models" "github.com/juanatsap/cv-site/internal/pdf" "github.com/juanatsap/cv-site/internal/templates" ) // CVHandler handles CV-related requests type CVHandler struct { templates *templates.Manager pdfGenerator *pdf.Generator serverAddr string } // NewCVHandler creates a new CV handler func NewCVHandler(tmpl *templates.Manager, serverAddr string) *CVHandler { return &CVHandler{ templates: tmpl, pdfGenerator: pdf.NewGenerator(30 * time.Second), serverAddr: serverAddr, } } // Home renders the full CV page func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) { // Get language from query parameter, default to English lang := r.URL.Query().Get("lang") if lang == "" { lang = "en" } // Validate language if lang != "en" && lang != "es" { HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) return } // Load CV data cv, err := models.LoadCV(lang) if err != nil { HandleError(w, r, DataLoadError(err, "CV")) return } // Load UI translations ui, err := models.LoadUI(lang) if err != nil { HandleError(w, r, DataLoadError(err, "UI")) return } // Calculate duration for each experience for i := range cv.Experience { cv.Experience[i].Duration = calculateDuration( cv.Experience[i].StartDate, cv.Experience[i].EndDate, cv.Experience[i].Current, lang, ) } // Process projects for dynamic dates for i := range cv.Projects { processProjectDates(&cv.Projects[i], lang) } // Split skills between left and right sidebars skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) // Calculate years of experience yearsOfExperience := calculateYearsOfExperience() // Get current year currentYear := time.Now().Year() // Prepare template data data := map[string]interface{}{ "CV": cv, "UI": ui, "Lang": lang, "SkillsLeft": skillsLeft, "SkillsRight": skillsRight, "YearsOfExperience": yearsOfExperience, "CurrentYear": currentYear, } // Render template tmpl, err := h.templates.Render("index.html") if err != nil { HandleError(w, r, TemplateError(err, "index.html")) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { HandleError(w, r, TemplateError(err, "index.html")) return } } // CVContent renders just the CV content for HTMX swaps func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) { // Get language from query parameter lang := r.URL.Query().Get("lang") if lang == "" { lang = "en" } // Validate language if lang != "en" && lang != "es" { HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) return } // Load CV data cv, err := models.LoadCV(lang) if err != nil { HandleError(w, r, DataLoadError(err, "CV")) return } // Load UI translations ui, err := models.LoadUI(lang) if err != nil { HandleError(w, r, DataLoadError(err, "UI")) return } // Calculate duration for each experience for i := range cv.Experience { cv.Experience[i].Duration = calculateDuration( cv.Experience[i].StartDate, cv.Experience[i].EndDate, cv.Experience[i].Current, lang, ) } // Process projects for dynamic dates for i := range cv.Projects { processProjectDates(&cv.Projects[i], lang) } // Split skills between left and right sidebars skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) // Calculate years of experience yearsOfExperience := calculateYearsOfExperience() // Get current year currentYear := time.Now().Year() // Prepare template data data := map[string]interface{}{ "CV": cv, "UI": ui, "Lang": lang, "SkillsLeft": skillsLeft, "SkillsRight": skillsRight, "YearsOfExperience": yearsOfExperience, "CurrentYear": currentYear, } // Render template tmpl, err := h.templates.Render("cv-content.html") if err != nil { HandleError(w, r, TemplateError(err, "cv-content.html")) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { HandleError(w, r, TemplateError(err, "cv-content.html")) return } } // ExportPDF handles PDF export requests using chromedp // TEMPORARILY DISABLED - Work in progress func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) { // Get language from query parameter lang := r.URL.Query().Get("lang") if lang == "" { lang = "en" } // Validate language if lang != "en" && lang != "es" { HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) return } log.Printf("PDF export requested but temporarily disabled (redirecting to print friendly)") // Return HTML with message and redirect to print friendly message := "PDF Export - Work in Progress" body := "The PDF export feature is currently being improved. Please use the Print Friendly button instead (Ctrl+P or Cmd+P to save as PDF)." if lang == "es" { message = "Exportación PDF - En Desarrollo" body = "La función de exportación a PDF está siendo mejorada. Por favor, usa el botón Imprimir Amigable en su lugar (Ctrl+P o Cmd+P para guardar como PDF)." } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) redirectMsg := "Redirecting in 5 seconds..." if lang == "es" { redirectMsg = "Redirigiendo en 5 segundos..." } html := fmt.Sprintf(` %s
🚧

%s

%s

%s
`, lang, message, lang, message, body, redirectMsg) w.Write([]byte(html)) } // splitSkills splits skill categories between left (page 1) and right (page 2) sidebars // Each category explicitly specifies which sidebar it belongs to via the "sidebar" field func splitSkills(skills []models.SkillCategory) (left, right []models.SkillCategory) { if len(skills) == 0 { return nil, nil } // Filter by sidebar field for _, skill := range skills { if skill.Sidebar == "right" { right = append(right, skill) } else { // Default to left if not specified or if set to "left" left = append(left, skill) } } return left, right } // calculateYearsOfExperience calculates years of experience since April 1, 2005 // This matches the original React implementation that calculated from 01/04/2005 func calculateYearsOfExperience() int { // First day at work: April 1, 2005 firstDay := time.Date(2005, time.April, 1, 9, 0, 0, 0, time.UTC) // Current date now := time.Now() // Calculate the difference in years years := now.Year() - firstDay.Year() // Adjust if we haven't reached the anniversary this year yet if now.Month() < firstDay.Month() || (now.Month() == firstDay.Month() && now.Day() < firstDay.Day()) { years-- } return years } // calculateDuration calculates the duration between two dates in years and months // Date format expected: "YYYY-MM" (e.g., "2021-01") // Returns a formatted string like "3 years 6 months" or "6 months" func calculateDuration(startDate, endDate string, current bool, lang string) string { // Parse start date start, err := time.Parse("2006-01", startDate) if err != nil { return "" } // Determine end date var end time.Time if current { end = time.Now() } else { end, err = time.Parse("2006-01", endDate) if err != nil { return "" } } // Calculate total months totalMonths := (end.Year()-start.Year())*12 + int(end.Month()-start.Month()) // If end date is before start date, return empty if totalMonths < 0 { return "" } years := totalMonths / 12 months := totalMonths % 12 // Format the duration string based on language var result string if lang == "es" { if years > 0 && months > 0 { yearStr := "años" if years == 1 { yearStr = "año" } monthStr := "meses" if months == 1 { monthStr = "mes" } result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr) } else if years > 0 { yearStr := "años" if years == 1 { yearStr = "año" } result = fmt.Sprintf("(%d %s)", years, yearStr) } else { monthStr := "meses" if months == 1 { monthStr = "mes" } result = fmt.Sprintf("(%d %s)", months, monthStr) } } else { if years > 0 && months > 0 { yearStr := "years" if years == 1 { yearStr = "year" } monthStr := "months" if months == 1 { monthStr = "month" } result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr) } else if years > 0 { yearStr := "years" if years == 1 { yearStr = "year" } result = fmt.Sprintf("(%d %s)", years, yearStr) } else { monthStr := "months" if months == 1 { monthStr = "month" } result = fmt.Sprintf("(%d %s)", months, monthStr) } } return result } // processProjectDates calculates dynamic dates for projects // If a project has a gitRepoUrl, it fetches the first commit date // For current projects, it sets the current system date func processProjectDates(project *models.Project, lang string) { now := time.Now() // Set dynamic current date for ongoing projects if project.Current { if lang == "es" { project.DynamicDate = "Presente" } else { project.DynamicDate = "Present" } } // If project has a git repository URL, fetch the first commit date if project.GitRepoUrl != "" { commitDate := getGitRepoFirstCommitDate(project.GitRepoUrl) if commitDate != "" { project.ComputedStartDate = commitDate } } // If no computed date and no static date, use current date for current projects if project.ComputedStartDate == "" && project.StartDate == "" && project.Current { project.ComputedStartDate = now.Format("2006-01") } // If we have a computed date but no static date, use the computed one if project.ComputedStartDate != "" && project.StartDate == "" { project.StartDate = project.ComputedStartDate } } // getGitRepoFirstCommitDate fetches the first commit date from a git repository // Supports local git repository paths func getGitRepoFirstCommitDate(repoPath string) string { // Check if the path exists and is a directory info, err := os.Stat(repoPath) if err != nil || !info.IsDir() { return "" } // Execute git command to get the first commit date // Format: YYYY-MM (to match StartDate format) cmd := exec.Command("git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m") cmd.Dir = repoPath output, err := cmd.Output() if err != nil { return "" } // Parse the output to get the first commit date lines := strings.Split(strings.TrimSpace(string(output)), "\n") if len(lines) == 0 { return "" } // Extract YYYY-MM from the first commit timestamp // Format of output: "2024-06-15 10:30:45 +0200" firstLine := lines[0] parts := strings.Fields(firstLine) if len(parts) > 0 { datePart := parts[0] // "2024-06-15" dateParts := strings.Split(datePart, "-") if len(dateParts) >= 2 { return dateParts[0] + "-" + dateParts[1] // "2024-06" } } return "" }