e572af0771
- Remove hardcoded startDate from La Porra project - Add gitRepoUrl field to Project struct for dynamic date fetching - Implement backend logic to fetch first commit date from git repositories - Add processProjectDates function to calculate dates dynamically - Update template to display computed dates and dynamic "Present/Presente" - Add support for both static and git-based project start dates When a project has a gitRepoUrl, the system automatically fetches the first commit date from the repository. For current projects, it displays "Present" (English) or "Presente" (Spanish) dynamically from the backend. The La Porra project now uses git repository path for date calculation instead of hardcoded JSON values.
415 lines
10 KiB
Go
415 lines
10 KiB
Go
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
|
|
}
|
|
|
|
// 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,
|
|
"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
|
|
}
|
|
|
|
// 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,
|
|
"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
|
|
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
|
|
}
|
|
|
|
// Construct URL to generate PDF from
|
|
// Use localhost instead of the actual server address to avoid network overhead
|
|
url := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang)
|
|
|
|
log.Printf("Generating PDF from URL: %s", url)
|
|
|
|
// Generate PDF
|
|
pdfData, err := h.pdfGenerator.GenerateFromURL(r.Context(), url)
|
|
if err != nil {
|
|
log.Printf("PDF generation failed: %v", err)
|
|
HandleError(w, r, InternalError(err))
|
|
return
|
|
}
|
|
|
|
// Set response headers
|
|
filename := fmt.Sprintf("CV-Juan-Andres-Moreno-Rubio-%s.pdf", lang)
|
|
w.Header().Set("Content-Type", "application/pdf")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData)))
|
|
|
|
// Write PDF data
|
|
if _, err := w.Write(pdfData); err != nil {
|
|
log.Printf("Failed to write PDF response: %v", err)
|
|
return
|
|
}
|
|
|
|
log.Printf("Successfully generated PDF: %s (%d bytes)", filename, len(pdfData))
|
|
}
|
|
|
|
// 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 ""
|
|
}
|