refactor: Split monolithic handler into focused files
Split internal/handlers/cv.go (1,001 lines) into 5 focused files: Structure: - cv.go (29 lines) - CVHandler struct + constructor - cv_pages.go (290 lines) - Page handlers (Home, CVContent, DefaultCVShortcut) - cv_pdf.go (153 lines) - PDF export handler (ExportPDF) - cv_htmx.go (218 lines) - HTMX toggle handlers (Length, Icons, Language, Theme) - cv_helpers.go (385 lines) - Helper functions (skills, dates, git, templates, cookies) Benefits: - Single Responsibility: Each file has one clear purpose - Improved Discoverability: Easy to find specific functionality - Reduced Cognitive Load: 200-400 lines per file vs 1,001 - Parallel Development: No conflicts when editing different concerns - Better Organization: Clear section markers and grouping - Maintainability: Trade +74 lines (+7.4%) for better organization Testing: - All Go tests pass (fileutil, handlers, lang, cv, ui) - Server builds and runs correctly - All HTTP endpoints functional - No breaking changes Documentation: - Create _go-learning/refactorings/003-handler-split.md - Document architecture, benefits, and trade-offs - Explain WHY single package vs separate packages
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
# Refactoring #3: Handler Split - From Monolith to Focused Files
|
||||
|
||||
**Date**: 2024-11-20
|
||||
**Type**: Code Organization, Maintainability
|
||||
|
||||
## Problem Statement
|
||||
|
||||
After implementing shared utilities and validation (Refactoring #2), the handler file remained problematic:
|
||||
|
||||
- **Single Monolithic File**: `internal/handlers/cv.go` was 1,001 lines
|
||||
- **Mixed Concerns**: Page rendering, PDF export, HTMX toggles, and helpers all in one file
|
||||
- **Difficult Navigation**: Finding specific functionality required scrolling through hundreds of lines
|
||||
- **Poor Separation**: No clear boundaries between different types of handlers
|
||||
|
||||
## Solution
|
||||
|
||||
Split the monolithic handler into focused files by responsibility:
|
||||
|
||||
1. **cv.go** (29 lines) - CVHandler struct + constructor only
|
||||
2. **cv_pages.go** (290 lines) - Page rendering handlers
|
||||
3. **cv_pdf.go** (153 lines) - PDF export handler
|
||||
4. **cv_htmx.go** (218 lines) - HTMX toggle handlers
|
||||
5. **cv_helpers.go** (385 lines) - Helper functions
|
||||
|
||||
## Architecture
|
||||
|
||||
### Before (Monolithic)
|
||||
|
||||
```
|
||||
internal/handlers/cv.go (1,001 lines)
|
||||
├── CVHandler struct
|
||||
├── NewCVHandler()
|
||||
├── Home() (page handler)
|
||||
├── CVContent() (page handler)
|
||||
├── DefaultCVShortcut() (page handler)
|
||||
├── ExportPDF() (PDF handler)
|
||||
├── ToggleLength() (HTMX handler)
|
||||
├── ToggleIcons() (HTMX handler)
|
||||
├── SwitchLanguage() (HTMX handler)
|
||||
├── ToggleTheme() (HTMX handler)
|
||||
├── splitSkills() (helper)
|
||||
├── calculateYearsOfExperience() (helper)
|
||||
├── calculateDuration() (helper)
|
||||
├── processProjectDates() (helper)
|
||||
├── findProjectRoot() (helper)
|
||||
├── validateRepoPath() (helper)
|
||||
├── getGitRepoFirstCommitDate() (helper)
|
||||
├── prepareTemplateData() (helper)
|
||||
├── getPreferenceCookie() (helper)
|
||||
└── setPreferenceCookie() (helper)
|
||||
```
|
||||
|
||||
### After (Focused Files)
|
||||
|
||||
```
|
||||
internal/handlers/
|
||||
├── cv.go (29 lines)
|
||||
│ ├── CVHandler struct
|
||||
│ └── NewCVHandler()
|
||||
│
|
||||
├── cv_pages.go (290 lines)
|
||||
│ ├── Home() - Full CV page
|
||||
│ ├── CVContent() - HTMX content swap
|
||||
│ └── DefaultCVShortcut() - Shortcut PDF URLs
|
||||
│
|
||||
├── cv_pdf.go (153 lines)
|
||||
│ └── ExportPDF() - PDF generation with options
|
||||
│
|
||||
├── cv_htmx.go (218 lines)
|
||||
│ ├── ToggleLength() - Short/long toggle
|
||||
│ ├── ToggleIcons() - Show/hide icons
|
||||
│ ├── SwitchLanguage() - EN/ES switching
|
||||
│ └── ToggleTheme() - Default/clean theme
|
||||
│
|
||||
└── cv_helpers.go (385 lines)
|
||||
├── Skills helpers:
|
||||
│ └── splitSkills()
|
||||
├── Date/Duration helpers:
|
||||
│ ├── calculateYearsOfExperience()
|
||||
│ ├── calculateDuration()
|
||||
│ └── processProjectDates()
|
||||
├── Git helpers:
|
||||
│ ├── findProjectRoot()
|
||||
│ ├── validateRepoPath()
|
||||
│ └── getGitRepoFirstCommitDate()
|
||||
├── Template helpers:
|
||||
│ └── prepareTemplateData()
|
||||
└── Cookie helpers:
|
||||
├── getPreferenceCookie()
|
||||
└── setPreferenceCookie()
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. Single Responsibility Principle (SRP)
|
||||
|
||||
Each file now has ONE clear purpose:
|
||||
|
||||
**cv.go** - Defines the handler structure
|
||||
```go
|
||||
// CVHandler handles CV-related requests
|
||||
// Methods are split across multiple files for better organization:
|
||||
// - cv_pages.go: Page rendering (Home, CVContent, DefaultCVShortcut)
|
||||
// - cv_pdf.go: PDF export (ExportPDF)
|
||||
// - cv_htmx.go: HTMX toggles (ToggleLength, ToggleIcons, SwitchLanguage, ToggleTheme)
|
||||
// - cv_helpers.go: Helper functions (skills, dates, git, templates, cookies)
|
||||
type CVHandler struct {
|
||||
templates *templates.Manager
|
||||
pdfGenerator *pdf.Generator
|
||||
serverAddr string
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Improved Discoverability
|
||||
|
||||
**Easy to find functionality:**
|
||||
- Need to modify page rendering? → `cv_pages.go`
|
||||
- PDF generation issue? → `cv_pdf.go`
|
||||
- HTMX toggle not working? → `cv_htmx.go`
|
||||
- Helper function bug? → `cv_helpers.go`
|
||||
|
||||
### 3. Reduced Cognitive Load
|
||||
|
||||
**Before**: Navigate 1,001 lines to understand one feature
|
||||
**After**: Open the relevant ~150-400 line file
|
||||
|
||||
### 4. Better Code Organization
|
||||
|
||||
**cv_helpers.go** groups helpers by category with clear section markers:
|
||||
```go
|
||||
// ==============================================================================
|
||||
// SKILLS HELPERS
|
||||
// ==============================================================================
|
||||
|
||||
// splitSkills splits skill categories between left and right sidebars
|
||||
func splitSkills(skills []cvmodel.SkillCategory) (left, right []cvmodel.SkillCategory) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// DATE/DURATION HELPERS
|
||||
// ==============================================================================
|
||||
|
||||
// calculateYearsOfExperience calculates years since April 1, 2005
|
||||
func calculateYearsOfExperience() int {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Parallel Development
|
||||
|
||||
Multiple developers can now work on different handler concerns without conflicts:
|
||||
- Developer A: Adds new HTMX toggle → edits `cv_htmx.go`
|
||||
- Developer B: Modifies PDF export → edits `cv_pdf.go`
|
||||
- Developer C: Adds page handler → edits `cv_pages.go`
|
||||
|
||||
No merge conflicts!
|
||||
|
||||
### 6. Testability
|
||||
|
||||
Each file can have focused tests:
|
||||
- `cv_pages_test.go` - Page rendering tests
|
||||
- `cv_pdf_test.go` - PDF generation tests
|
||||
- `cv_htmx_test.go` - HTMX toggle tests
|
||||
- `cv_helpers_test.go` - Helper function tests
|
||||
|
||||
### 7. Documentation Clarity
|
||||
|
||||
Each file's purpose is immediately clear from its name and can have targeted documentation.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Why These Groupings?
|
||||
|
||||
**cv_pages.go** - All handlers that render full pages or page sections
|
||||
- `Home()` - Complete HTML page
|
||||
- `CVContent()` - HTMX content swap
|
||||
- `DefaultCVShortcut()` - Special PDF shortcut URLs
|
||||
|
||||
**cv_pdf.go** - PDF generation is complex enough to warrant its own file
|
||||
- Handles multiple query parameters (lang, length, icons, version)
|
||||
- Manages PDF generation with chromedp
|
||||
- Complex filename generation logic
|
||||
|
||||
**cv_htmx.go** - All HTMX interactivity handlers
|
||||
- Similar patterns (toggle states, cookies, out-of-band swaps)
|
||||
- All follow same structure: read state → toggle → save → render
|
||||
|
||||
**cv_helpers.go** - All supporting functions
|
||||
- Organized by category with section markers
|
||||
- Pure functions (no HTTP request/response handling)
|
||||
- Reusable across handlers
|
||||
|
||||
### Go Package Benefits
|
||||
|
||||
All files are in the same package (`package handlers`), so:
|
||||
- ✅ Methods can be split across files (Go allows this!)
|
||||
- ✅ Helper functions accessible without imports
|
||||
- ✅ No circular dependency issues
|
||||
- ✅ Same namespace, better organization
|
||||
|
||||
## Code Metrics
|
||||
|
||||
### File Sizes
|
||||
|
||||
| File | Lines | Purpose | Complexity |
|
||||
|------|-------|---------|------------|
|
||||
| cv.go | 29 | Struct + constructor | Very Low |
|
||||
| cv_pages.go | 290 | Page rendering | Medium |
|
||||
| cv_pdf.go | 153 | PDF export | Medium |
|
||||
| cv_htmx.go | 218 | HTMX toggles | Low |
|
||||
| cv_helpers.go | 385 | Helper functions | Low-Medium |
|
||||
| **Total** | **1,075** | | **Average** |
|
||||
|
||||
### Reduction Achievement
|
||||
|
||||
- **Original**: 1 file × 1,001 lines = **1,001 lines**
|
||||
- **New**: 5 files × 215 lines avg = **1,075 lines**
|
||||
- **Net Change**: +74 lines (+7.4%)
|
||||
|
||||
The slight increase is due to:
|
||||
- Comments documenting each file's purpose
|
||||
- Section markers in cv_helpers.go for better organization
|
||||
- More descriptive comments at file level
|
||||
|
||||
**Trade-off**: +74 lines for dramatically improved maintainability and organization.
|
||||
|
||||
### Maintainability Index
|
||||
|
||||
**Before**:
|
||||
- 1,001 lines to search through
|
||||
- 19 functions mixed together
|
||||
- No clear organization
|
||||
|
||||
**After**:
|
||||
- 29-385 lines per file
|
||||
- 3-9 functions per file (focused)
|
||||
- Clear organization by responsibility
|
||||
|
||||
## Testing
|
||||
|
||||
### All Tests Pass
|
||||
|
||||
```bash
|
||||
$ go test ./...
|
||||
ok github.com/juanatsap/cv-site/internal/fileutil 0.432s
|
||||
ok github.com/juanatsap/cv-site/internal/handlers 0.789s
|
||||
ok github.com/juanatsap/cv-site/internal/lang 0.326s
|
||||
ok github.com/juanatsap/cv-site/internal/models/cv 0.463s
|
||||
ok github.com/juanatsap/cv-site/internal/models/ui 0.315s
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
1. **Build**: ✅ `go build` succeeds
|
||||
2. **Tests**: ✅ All unit tests pass
|
||||
3. **Server**: ✅ Server starts and renders pages
|
||||
4. **Endpoints**: ✅ All HTTP endpoints functional
|
||||
|
||||
## Why This Approach?
|
||||
|
||||
### Alternative Considered: Separate Packages
|
||||
|
||||
Could we split into separate packages?
|
||||
|
||||
```
|
||||
internal/
|
||||
├── handlers/pages/
|
||||
├── handlers/pdf/
|
||||
├── handlers/htmx/
|
||||
└── handlers/helpers/
|
||||
```
|
||||
|
||||
**Why NOT:**
|
||||
- Creates circular dependencies (pages need helpers, helpers need CVHandler)
|
||||
- More complex imports
|
||||
- Breaks Go's "methods on types" pattern (can't split CVHandler methods across packages)
|
||||
|
||||
**Why Single Package:**
|
||||
- ✅ Methods can be defined in any file
|
||||
- ✅ Helpers accessible without imports
|
||||
- ✅ Single namespace, no confusion
|
||||
- ✅ Go's design encourages this pattern
|
||||
|
||||
### Go Best Practices
|
||||
|
||||
This approach follows **Go best practices**:
|
||||
|
||||
1. **Package organization by feature, not by layer**
|
||||
- All CV handler code stays in `handlers` package
|
||||
- Files split by sub-feature (pages, PDF, HTMX, helpers)
|
||||
|
||||
2. **Methods split across files**
|
||||
- Go allows defining methods on a type in any file in the same package
|
||||
- CVHandler methods spread across multiple files naturally
|
||||
|
||||
3. **Clear file naming**
|
||||
- Prefix indicates grouping: `cv_pages.go`, `cv_pdf.go`, `cv_htmx.go`
|
||||
- Easy to find related functionality
|
||||
|
||||
## Interview Talking Points
|
||||
|
||||
### 1. Code Organization
|
||||
|
||||
"I refactored a 1,001-line monolithic handler into 5 focused files (29-385 lines each), improving discoverability and maintainability while following Go's single-package-multiple-files pattern."
|
||||
|
||||
### 2. Single Responsibility Principle
|
||||
|
||||
"Each file now has one clear purpose: cv_pages handles page rendering, cv_pdf manages PDF export, cv_htmx handles interactivity, and cv_helpers provides reusable functions."
|
||||
|
||||
### 3. Maintainability Over Brevity
|
||||
|
||||
"I accepted a 7.4% line increase to gain dramatically improved organization. The trade-off of 74 extra lines for better maintainability was worth it."
|
||||
|
||||
### 4. Go Package Patterns
|
||||
|
||||
"I kept all files in one package to avoid circular dependencies and leverage Go's ability to split methods across files, rather than forcing artificial package boundaries."
|
||||
|
||||
### 5. Parallel Development
|
||||
|
||||
"The split enables multiple developers to work on different handler concerns without conflicts, improving team velocity."
|
||||
|
||||
### 6. Progressive Refactoring
|
||||
|
||||
"This is refactoring #3 in a series: #1 separated domain models, #2 added shared utilities and validation, #3 organized handlers. Each step builds on the previous, improving the codebase incrementally."
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Extract Duplicate Logic**: `Home()` and `CVContent()` have similar data preparation - could use `prepareTemplateData()`
|
||||
2. **Handler Tests**: Add focused tests for each handler file
|
||||
3. **Middleware Extraction**: Cookie handling could become middleware
|
||||
4. **Request/Response Types**: Define structs for common request/response patterns
|
||||
5. **Error Handling**: Centralize error response formatting
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Refactoring #1: CV/UI Model Separation](./001-cv-model-separation.md)
|
||||
- [Refactoring #2: Shared Utilities & Validation](./002-shared-utilities-validation.md)
|
||||
- [Server Design: Why Goroutines?](../architecture/server-design.md)
|
||||
|
||||
## Commit Message
|
||||
|
||||
```
|
||||
refactor: Split monolithic handler into focused files
|
||||
|
||||
Split internal/handlers/cv.go (1,001 lines) into 5 focused files:
|
||||
|
||||
Structure:
|
||||
- cv.go (29 lines) - CVHandler struct + constructor
|
||||
- cv_pages.go (290 lines) - Page handlers (Home, CVContent, DefaultCVShortcut)
|
||||
- cv_pdf.go (153 lines) - PDF export handler (ExportPDF)
|
||||
- cv_htmx.go (218 lines) - HTMX toggle handlers (Length, Icons, Language, Theme)
|
||||
- cv_helpers.go (385 lines) - Helper functions (skills, dates, git, templates, cookies)
|
||||
|
||||
Benefits:
|
||||
- Single Responsibility: Each file has one clear purpose
|
||||
- Improved Discoverability: Easy to find specific functionality
|
||||
- Reduced Cognitive Load: 200-400 lines per file vs 1,001
|
||||
- Parallel Development: No conflicts when editing different concerns
|
||||
- Better Organization: Clear section markers and grouping
|
||||
- Maintainability: Trade +74 lines (+7.4%) for better organization
|
||||
|
||||
Testing:
|
||||
- All Go tests pass (fileutil, handlers, lang, cv, ui)
|
||||
- Server builds and runs correctly
|
||||
- All HTTP endpoints functional
|
||||
- No breaking changes
|
||||
|
||||
Documentation:
|
||||
- Create _go-learning/refactorings/003-handler-split.md
|
||||
- Document architecture, benefits, and trade-offs
|
||||
- Explain WHY single package vs separate packages
|
||||
```
|
||||
+5
-977
@@ -1,23 +1,18 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
|
||||
uimodel "github.com/juanatsap/cv-site/internal/models/ui"
|
||||
"github.com/juanatsap/cv-site/internal/pdf"
|
||||
"github.com/juanatsap/cv-site/internal/templates"
|
||||
)
|
||||
|
||||
// CVHandler handles CV-related requests
|
||||
// Methods are split across multiple files for better organization:
|
||||
// - cv_pages.go: Page rendering (Home, CVContent, DefaultCVShortcut)
|
||||
// - cv_pdf.go: PDF export (ExportPDF)
|
||||
// - cv_htmx.go: HTMX toggles (ToggleLength, ToggleIcons, SwitchLanguage, ToggleTheme)
|
||||
// - cv_helpers.go: Helper functions (skills, dates, git, templates, cookies)
|
||||
type CVHandler struct {
|
||||
templates *templates.Manager
|
||||
pdfGenerator *pdf.Generator
|
||||
@@ -32,970 +27,3 @@ func NewCVHandler(tmpl *templates.Manager, serverAddr string) *CVHandler {
|
||||
serverAddr: serverAddr,
|
||||
}
|
||||
}
|
||||
|
||||
// Home renders the full CV page
|
||||
func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if this is a shortcut URL request (cv-jamr-{year}-{lang}.pdf)
|
||||
if strings.HasPrefix(r.URL.Path, "/cv-jamr-") && strings.HasSuffix(r.URL.Path, ".pdf") {
|
||||
h.DefaultCVShortcut(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 := cvmodel.LoadCV(lang)
|
||||
if err != nil {
|
||||
HandleError(w, r, DataLoadError(err, "CV"))
|
||||
return
|
||||
}
|
||||
|
||||
// Load UI translations
|
||||
ui, err := uimodel.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()
|
||||
|
||||
// Read user preferences from cookies
|
||||
cvLength := getPreferenceCookie(r, "cv-length", "short")
|
||||
cvIcons := getPreferenceCookie(r, "cv-icons", "show")
|
||||
cvTheme := getPreferenceCookie(r, "cv-theme", "default")
|
||||
|
||||
// Migrate old preference values to new ones (one-time auto-migration)
|
||||
if cvLength == "extended" {
|
||||
cvLength = "long"
|
||||
setPreferenceCookie(w, "cv-length", "long")
|
||||
}
|
||||
switch cvIcons {
|
||||
case "true":
|
||||
cvIcons = "show"
|
||||
setPreferenceCookie(w, "cv-icons", "show")
|
||||
case "false":
|
||||
cvIcons = "hide"
|
||||
setPreferenceCookie(w, "cv-icons", "hide")
|
||||
}
|
||||
|
||||
// Prepare CV length class
|
||||
cvLengthClass := "cv-short"
|
||||
if cvLength == "long" {
|
||||
cvLengthClass = "cv-long"
|
||||
}
|
||||
|
||||
// Prepare template data
|
||||
data := map[string]interface{}{
|
||||
"CV": cv,
|
||||
"UI": ui,
|
||||
"Lang": lang,
|
||||
"SkillsLeft": skillsLeft,
|
||||
"SkillsRight": skillsRight,
|
||||
"YearsOfExperience": yearsOfExperience,
|
||||
"CurrentYear": currentYear,
|
||||
"CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang),
|
||||
"AlternateEN": "https://juan.andres.morenorub.io/?lang=en",
|
||||
"AlternateES": "https://juan.andres.morenorub.io/?lang=es",
|
||||
"CVLengthClass": cvLengthClass,
|
||||
"ShowIcons": (cvIcons == "show"),
|
||||
"ThemeClean": (cvTheme == "clean"),
|
||||
}
|
||||
|
||||
// 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 := cvmodel.LoadCV(lang)
|
||||
if err != nil {
|
||||
HandleError(w, r, DataLoadError(err, "CV"))
|
||||
return
|
||||
}
|
||||
|
||||
// Load UI translations
|
||||
ui, err := uimodel.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,
|
||||
"CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang),
|
||||
"AlternateEN": "https://juan.andres.morenorub.io/?lang=en",
|
||||
"AlternateES": "https://juan.andres.morenorub.io/?lang=es",
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultCVShortcut handles shortcut URLs for default CV downloads
|
||||
// Pattern: /cv-jamr-{year}-{lang}.pdf (e.g., /cv-jamr-2025-en.pdf)
|
||||
// Validates year matches current year and redirects to default PDF export
|
||||
func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract path (e.g., "/cv-jamr-2025-en.pdf")
|
||||
path := r.URL.Path
|
||||
log.Printf("DefaultCVShortcut called with path: %s", path)
|
||||
|
||||
// Parse filename pattern: cv-jamr-{year}-{lang}.pdf
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/"), "-")
|
||||
if len(parts) != 4 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract year and language
|
||||
yearStr := parts[2]
|
||||
langWithExt := parts[3] // "en.pdf" or "es.pdf"
|
||||
lang := strings.TrimSuffix(langWithExt, ".pdf")
|
||||
|
||||
// Validate language
|
||||
if lang != "en" && lang != "es" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate year matches current year
|
||||
currentYear := fmt.Sprintf("%d", time.Now().Year())
|
||||
if yearStr != currentYear {
|
||||
// Return 404 if year doesn't match (old or future URLs)
|
||||
log.Printf("Invalid year in shortcut URL: %s (current: %s)", yearStr, currentYear)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate PDF directly instead of redirecting
|
||||
// This ensures the Content-Disposition filename is respected by browsers
|
||||
log.Printf("Shortcut URL: %s → generating PDF (short + with_skills)", path)
|
||||
|
||||
// Prepare cookies for PDF generation (short, with_skills, light mode)
|
||||
cookies := map[string]string{
|
||||
"cv-length": "short",
|
||||
"cv-icons": "show",
|
||||
"cv-language": lang,
|
||||
"cv-theme": "default", // with_skills = default theme
|
||||
"color-theme": "light", // Always light for PDFs
|
||||
}
|
||||
|
||||
// Construct URL for PDF generation
|
||||
targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang)
|
||||
|
||||
// Generate PDF with screen render mode (for sidebar layout)
|
||||
ctx := r.Context()
|
||||
pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, pdf.RenderModeScreen)
|
||||
if err != nil {
|
||||
log.Printf("PDF generation failed for shortcut URL: %v", err)
|
||||
HandleError(w, r, InternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Use the shortcut filename directly (simple, user-friendly)
|
||||
filename := filepath.Base(path) // cv-jamr-2025-en.pdf
|
||||
|
||||
// Set response headers with shortcut filename
|
||||
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("Error writing PDF response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData))
|
||||
}
|
||||
|
||||
// ExportPDF handles PDF export requests using chromedp
|
||||
func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract and validate query parameters
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
if lang != "en" && lang != "es" {
|
||||
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
|
||||
return
|
||||
}
|
||||
|
||||
length := r.URL.Query().Get("length")
|
||||
if length == "" {
|
||||
length = "short"
|
||||
}
|
||||
if length != "short" && length != "long" {
|
||||
HandleError(w, r, BadRequestError("Unsupported length. Use 'short' or 'long'"))
|
||||
return
|
||||
}
|
||||
|
||||
icons := r.URL.Query().Get("icons")
|
||||
if icons == "" {
|
||||
icons = "show"
|
||||
}
|
||||
if icons != "show" && icons != "hide" {
|
||||
HandleError(w, r, BadRequestError("Unsupported icons option. Use 'show' or 'hide'"))
|
||||
return
|
||||
}
|
||||
|
||||
version := r.URL.Query().Get("version")
|
||||
if version == "" {
|
||||
version = "with_skills"
|
||||
}
|
||||
if version != "with_skills" && version != "clean" {
|
||||
HandleError(w, r, BadRequestError("Unsupported version. Use 'with_skills' or 'clean'"))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("PDF export requested: lang=%s, length=%s, icons=%s, version=%s", lang, length, icons, version)
|
||||
|
||||
// Load CV data to get name for filename
|
||||
cv, err := cvmodel.LoadCV(lang)
|
||||
if err != nil {
|
||||
HandleError(w, r, DataLoadError(err, "CV"))
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare cookies to set preferences
|
||||
cookies := map[string]string{
|
||||
"cv-length": length,
|
||||
"cv-icons": icons,
|
||||
"cv-language": lang,
|
||||
}
|
||||
|
||||
// Set theme cookie based on version parameter
|
||||
if version == "clean" {
|
||||
cookies["cv-theme"] = "clean"
|
||||
} else {
|
||||
cookies["cv-theme"] = "default"
|
||||
}
|
||||
|
||||
// CRITICAL: ALWAYS force light mode for PDF generation (print-friendly)
|
||||
// This ensures PDFs are NEVER generated in dark mode, regardless of user's preference
|
||||
cookies["color-theme"] = "light"
|
||||
|
||||
// Construct URL for PDF generation (navigate to home page)
|
||||
targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang)
|
||||
|
||||
// Determine render mode based on version parameter
|
||||
// Clean version: use @media print CSS (print-friendly, no sidebars)
|
||||
// Extended version: use @media screen CSS (full layout with sidebars)
|
||||
var renderMode pdf.RenderMode
|
||||
if version == "clean" {
|
||||
renderMode = pdf.RenderModePrint
|
||||
} else {
|
||||
renderMode = pdf.RenderModeScreen
|
||||
}
|
||||
|
||||
// Generate PDF with cookies and appropriate render mode
|
||||
ctx := r.Context()
|
||||
pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, renderMode)
|
||||
if err != nil {
|
||||
log.Printf("PDF generation failed: %v", err)
|
||||
HandleError(w, r, InternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate filename based on parameters
|
||||
// Format: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf
|
||||
// Note: {version} is OMITTED when it's "clean"
|
||||
// Examples:
|
||||
// - cv-short-jamr-2025-es.pdf (clean version, no skills)
|
||||
// - cv-short-with_skills-jamr-2025-es.pdf (with skills sidebar)
|
||||
// - cv-long-jamr-2025-en.pdf (clean version, no skills)
|
||||
// - cv-long-with_skills-jamr-2025-en.pdf (with skills sidebar)
|
||||
|
||||
// Generate initials from name
|
||||
nameParts := strings.Fields(cv.Personal.Name)
|
||||
initials := ""
|
||||
for _, part := range nameParts {
|
||||
if len(part) > 0 {
|
||||
// Take first letter of each name part
|
||||
initials += string([]rune(part)[0])
|
||||
}
|
||||
}
|
||||
initials = strings.ToLower(initials)
|
||||
|
||||
// Get current year
|
||||
currentYear := time.Now().Year()
|
||||
|
||||
// Build filename: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf
|
||||
// Omit version if it's "clean"
|
||||
// Replace underscores with hyphens in version for filename (with_skills → with-skills)
|
||||
var filename string
|
||||
if version == "clean" {
|
||||
filename = fmt.Sprintf("cv-%s-%s-%d-%s.pdf", length, initials, currentYear, lang)
|
||||
} else {
|
||||
versionForFilename := strings.ReplaceAll(version, "_", "-")
|
||||
filename = fmt.Sprintf("cv-%s-%s-%s-%d-%s.pdf", length, versionForFilename, initials, currentYear, lang)
|
||||
}
|
||||
|
||||
// Set response headers
|
||||
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("Error writing PDF response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("PDF generated successfully: %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 []cvmodel.SkillCategory) (left, right []cvmodel.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 *cvmodel.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
|
||||
}
|
||||
}
|
||||
|
||||
// findProjectRoot finds the project root directory
|
||||
// It looks for .git directory walking up the directory tree
|
||||
func findProjectRoot() (string, error) {
|
||||
// Start from current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Walk up the directory tree looking for .git
|
||||
dir := cwd
|
||||
for {
|
||||
gitPath := filepath.Join(dir, ".git")
|
||||
if info, err := os.Stat(gitPath); err == nil && info.IsDir() {
|
||||
// Found .git directory - this is the project root
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// Move up one directory
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
// Reached root directory without finding .git
|
||||
// Fall back to current working directory
|
||||
return cwd, nil
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// validateRepoPath validates that a repository path is safe to use
|
||||
// Security: Prevents path traversal and command injection attacks
|
||||
// Only allows paths within the project directory
|
||||
func validateRepoPath(path string) error {
|
||||
// Resolve to absolute path to prevent path traversal
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Get project root directory - find the git repo root
|
||||
// This ensures the validation works regardless of where code runs from
|
||||
projectRoot, err := findProjectRoot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine project root: %w", err)
|
||||
}
|
||||
|
||||
// Security check: Only allow paths within project directory
|
||||
// This prevents malicious paths like "../../../etc/passwd"
|
||||
if !strings.HasPrefix(absPath, projectRoot) {
|
||||
return fmt.Errorf("repository path outside project directory: %s", path)
|
||||
}
|
||||
|
||||
// Verify path exists and is a directory
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("path does not exist: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path is not a directory: %s", path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getGitRepoFirstCommitDate fetches the first commit date from a git repository
|
||||
// Supports local git repository paths
|
||||
// Security: Validates path and uses timeout to prevent hanging
|
||||
func getGitRepoFirstCommitDate(repoPath string) string {
|
||||
// Security: Validate repository path before executing git command
|
||||
if err := validateRepoPath(repoPath); err != nil {
|
||||
log.Printf("Security: Rejected git operation for invalid path %s: %v", repoPath, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Security: Add timeout context to prevent hanging
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Execute git command with timeout protection
|
||||
// Using CommandContext for automatic cancellation on timeout
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m")
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Log error but don't expose details to prevent information disclosure
|
||||
log.Printf("Git command failed for path %s: %v", repoPath, err)
|
||||
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 ""
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// HTMX ENDPOINTS - Phase 2
|
||||
// ==============================================================================
|
||||
|
||||
// prepareTemplateData prepares common template data used across handlers
|
||||
func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, error) {
|
||||
// Load CV data
|
||||
cv, err := cvmodel.LoadCV(lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load UI translations
|
||||
ui, err := uimodel.LoadUI(lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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,
|
||||
"CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang),
|
||||
"AlternateEN": "https://juan.andres.morenorub.io/?lang=en",
|
||||
"AlternateES": "https://juan.andres.morenorub.io/?lang=es",
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// getPreferenceCookie gets a preference cookie value, returns default if not found
|
||||
func getPreferenceCookie(r *http.Request, name string, defaultValue string) string {
|
||||
cookie, err := r.Cookie(name)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
// setPreferenceCookie sets a preference cookie (1 year expiry)
|
||||
func setPreferenceCookie(w http.ResponseWriter, name string, value string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: 365 * 24 * 60 * 60, // 1 year
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: false, // Set to true in production with HTTPS
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleLength handles CV length toggle (short/long) using atomic out-of-band swaps
|
||||
func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current state
|
||||
currentLength := getPreferenceCookie(r, "cv-length", "short")
|
||||
|
||||
// Migrate old value if needed
|
||||
if currentLength == "extended" {
|
||||
currentLength = "long"
|
||||
}
|
||||
|
||||
// Toggle state
|
||||
newLength := "long"
|
||||
if currentLength == "long" {
|
||||
newLength = "short"
|
||||
}
|
||||
|
||||
// Save new state
|
||||
setPreferenceCookie(w, "cv-length", newLength)
|
||||
|
||||
// Get language
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
lang = getPreferenceCookie(r, "cv-language", "en")
|
||||
}
|
||||
|
||||
// Prepare template data with length state
|
||||
cvLengthClass := "cv-short"
|
||||
if newLength == "long" {
|
||||
cvLengthClass = "cv-long"
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Lang": lang,
|
||||
"CVLengthClass": cvLengthClass,
|
||||
}
|
||||
|
||||
// Render length-toggle template with out-of-band swaps
|
||||
tmpl, err := h.templates.Render("length-toggle.html")
|
||||
if err != nil {
|
||||
HandleError(w, r, TemplateError(err, "length-toggle.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, "length-toggle.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ToggleIcons handles icon visibility toggle using atomic out-of-band swaps
|
||||
func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current state
|
||||
currentIcons := getPreferenceCookie(r, "cv-icons", "show")
|
||||
|
||||
// Migrate old values if needed
|
||||
switch currentIcons {
|
||||
case "true":
|
||||
currentIcons = "show"
|
||||
case "false":
|
||||
currentIcons = "hide"
|
||||
}
|
||||
|
||||
// Toggle state
|
||||
newIcons := "hide"
|
||||
if currentIcons == "hide" {
|
||||
newIcons = "show"
|
||||
}
|
||||
|
||||
// Save new state
|
||||
setPreferenceCookie(w, "cv-icons", newIcons)
|
||||
|
||||
// Get language
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
lang = getPreferenceCookie(r, "cv-language", "en")
|
||||
}
|
||||
|
||||
// Prepare template data with logo state
|
||||
data := map[string]interface{}{
|
||||
"Lang": lang,
|
||||
"ShowIcons": (newIcons == "show"),
|
||||
}
|
||||
|
||||
// Render logo-toggle template with out-of-band swaps
|
||||
tmpl, err := h.templates.Render("logo-toggle.html")
|
||||
if err != nil {
|
||||
HandleError(w, r, TemplateError(err, "logo-toggle.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, "logo-toggle.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// SwitchLanguage handles language switching with atomic updates
|
||||
// Uses HTMX out-of-band swaps to update both the language selector buttons
|
||||
// and all CV content wrappers in a single response
|
||||
func (h *CVHandler) SwitchLanguage(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
|
||||
}
|
||||
|
||||
// Save language preference
|
||||
setPreferenceCookie(w, "cv-language", lang)
|
||||
|
||||
// Prepare template data
|
||||
data, err := h.prepareTemplateData(lang)
|
||||
if err != nil {
|
||||
HandleError(w, r, DataLoadError(err, "CV"))
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve current length and logo preferences
|
||||
cvLength := getPreferenceCookie(r, "cv-length", "short")
|
||||
cvIcons := getPreferenceCookie(r, "cv-icons", "show")
|
||||
cvTheme := getPreferenceCookie(r, "cv-theme", "default")
|
||||
|
||||
// Add preferences to data
|
||||
if cvLength == "long" {
|
||||
data["CVLengthClass"] = "cv-long"
|
||||
} else {
|
||||
data["CVLengthClass"] = "cv-short"
|
||||
}
|
||||
data["ShowIcons"] = (cvIcons == "show")
|
||||
data["ThemeClean"] = (cvTheme == "clean")
|
||||
|
||||
// Render language-switch template with out-of-band swaps
|
||||
tmpl, err := h.templates.Render("language-switch.html")
|
||||
if err != nil {
|
||||
HandleError(w, r, TemplateError(err, "language-switch.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, "language-switch.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ToggleTheme handles theme toggle (default/clean) using atomic out-of-band swaps
|
||||
func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current state
|
||||
currentTheme := getPreferenceCookie(r, "cv-theme", "default")
|
||||
|
||||
// Toggle state
|
||||
newTheme := "clean"
|
||||
if currentTheme == "clean" {
|
||||
newTheme = "default"
|
||||
}
|
||||
|
||||
// Save new state
|
||||
setPreferenceCookie(w, "cv-theme", newTheme)
|
||||
|
||||
// Get language
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
lang = getPreferenceCookie(r, "cv-language", "en")
|
||||
}
|
||||
|
||||
// Prepare template data with theme state
|
||||
data := map[string]interface{}{
|
||||
"Lang": lang,
|
||||
"ThemeClean": (newTheme == "clean"),
|
||||
}
|
||||
|
||||
// Render theme-toggle template with out-of-band swaps
|
||||
tmpl, err := h.templates.Render("theme-toggle.html")
|
||||
if err != nil {
|
||||
HandleError(w, r, TemplateError(err, "theme-toggle.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, "theme-toggle.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
|
||||
uimodel "github.com/juanatsap/cv-site/internal/models/ui"
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// SKILLS HELPERS
|
||||
// ==============================================================================
|
||||
|
||||
// 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 []cvmodel.SkillCategory) (left, right []cvmodel.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
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// DATE/DURATION HELPERS
|
||||
// ==============================================================================
|
||||
|
||||
// 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 *cvmodel.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
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// GIT HELPERS
|
||||
// ==============================================================================
|
||||
|
||||
// findProjectRoot finds the project root directory
|
||||
// It looks for .git directory walking up the directory tree
|
||||
func findProjectRoot() (string, error) {
|
||||
// Start from current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Walk up the directory tree looking for .git
|
||||
dir := cwd
|
||||
for {
|
||||
gitPath := filepath.Join(dir, ".git")
|
||||
if info, err := os.Stat(gitPath); err == nil && info.IsDir() {
|
||||
// Found .git directory - this is the project root
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// Move up one directory
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
// Reached root directory without finding .git
|
||||
// Fall back to current working directory
|
||||
return cwd, nil
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// validateRepoPath validates that a repository path is safe to use
|
||||
// Security: Prevents path traversal and command injection attacks
|
||||
// Only allows paths within the project directory
|
||||
func validateRepoPath(path string) error {
|
||||
// Resolve to absolute path to prevent path traversal
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Get project root directory - find the git repo root
|
||||
// This ensures the validation works regardless of where code runs from
|
||||
projectRoot, err := findProjectRoot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine project root: %w", err)
|
||||
}
|
||||
|
||||
// Security check: Only allow paths within project directory
|
||||
// This prevents malicious paths like "../../../etc/passwd"
|
||||
if !strings.HasPrefix(absPath, projectRoot) {
|
||||
return fmt.Errorf("repository path outside project directory: %s", path)
|
||||
}
|
||||
|
||||
// Verify path exists and is a directory
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("path does not exist: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path is not a directory: %s", path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getGitRepoFirstCommitDate fetches the first commit date from a git repository
|
||||
// Supports local git repository paths
|
||||
// Security: Validates path and uses timeout to prevent hanging
|
||||
func getGitRepoFirstCommitDate(repoPath string) string {
|
||||
// Security: Validate repository path before executing git command
|
||||
if err := validateRepoPath(repoPath); err != nil {
|
||||
log.Printf("Security: Rejected git operation for invalid path %s: %v", repoPath, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Security: Add timeout context to prevent hanging
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Execute git command with timeout protection
|
||||
// Using CommandContext for automatic cancellation on timeout
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m")
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Log error but don't expose details to prevent information disclosure
|
||||
log.Printf("Git command failed for path %s: %v", repoPath, err)
|
||||
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 ""
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// TEMPLATE DATA PREPARATION
|
||||
// ==============================================================================
|
||||
|
||||
// prepareTemplateData prepares common template data used across handlers
|
||||
func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, error) {
|
||||
// Load CV data
|
||||
cv, err := cvmodel.LoadCV(lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load UI translations
|
||||
ui, err := uimodel.LoadUI(lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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,
|
||||
"CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang),
|
||||
"AlternateEN": "https://juan.andres.morenorub.io/?lang=en",
|
||||
"AlternateES": "https://juan.andres.morenorub.io/?lang=es",
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// COOKIE HELPERS
|
||||
// ==============================================================================
|
||||
|
||||
// getPreferenceCookie gets a preference cookie value, returns default if not found
|
||||
func getPreferenceCookie(r *http.Request, name string, defaultValue string) string {
|
||||
cookie, err := r.Cookie(name)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
// setPreferenceCookie sets a preference cookie (1 year expiry)
|
||||
func setPreferenceCookie(w http.ResponseWriter, name string, value string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: 365 * 24 * 60 * 60, // 1 year
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: false, // Set to true in production with HTTPS
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// HTMX TOGGLE HANDLERS
|
||||
// These handlers manage user preferences (length, icons, language, theme)
|
||||
// using atomic out-of-band swaps for a smooth UX
|
||||
// ==============================================================================
|
||||
|
||||
// ToggleLength handles CV length toggle (short/long) using atomic out-of-band swaps
|
||||
func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current state
|
||||
currentLength := getPreferenceCookie(r, "cv-length", "short")
|
||||
|
||||
// Migrate old value if needed
|
||||
if currentLength == "extended" {
|
||||
currentLength = "long"
|
||||
}
|
||||
|
||||
// Toggle state
|
||||
newLength := "long"
|
||||
if currentLength == "long" {
|
||||
newLength = "short"
|
||||
}
|
||||
|
||||
// Save new state
|
||||
setPreferenceCookie(w, "cv-length", newLength)
|
||||
|
||||
// Get language
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
lang = getPreferenceCookie(r, "cv-language", "en")
|
||||
}
|
||||
|
||||
// Prepare template data with length state
|
||||
cvLengthClass := "cv-short"
|
||||
if newLength == "long" {
|
||||
cvLengthClass = "cv-long"
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Lang": lang,
|
||||
"CVLengthClass": cvLengthClass,
|
||||
}
|
||||
|
||||
// Render length-toggle template with out-of-band swaps
|
||||
tmpl, err := h.templates.Render("length-toggle.html")
|
||||
if err != nil {
|
||||
HandleError(w, r, TemplateError(err, "length-toggle.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, "length-toggle.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ToggleIcons handles icon visibility toggle using atomic out-of-band swaps
|
||||
func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current state
|
||||
currentIcons := getPreferenceCookie(r, "cv-icons", "show")
|
||||
|
||||
// Migrate old values if needed
|
||||
switch currentIcons {
|
||||
case "true":
|
||||
currentIcons = "show"
|
||||
case "false":
|
||||
currentIcons = "hide"
|
||||
}
|
||||
|
||||
// Toggle state
|
||||
newIcons := "hide"
|
||||
if currentIcons == "hide" {
|
||||
newIcons = "show"
|
||||
}
|
||||
|
||||
// Save new state
|
||||
setPreferenceCookie(w, "cv-icons", newIcons)
|
||||
|
||||
// Get language
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
lang = getPreferenceCookie(r, "cv-language", "en")
|
||||
}
|
||||
|
||||
// Prepare template data with logo state
|
||||
data := map[string]interface{}{
|
||||
"Lang": lang,
|
||||
"ShowIcons": (newIcons == "show"),
|
||||
}
|
||||
|
||||
// Render logo-toggle template with out-of-band swaps
|
||||
tmpl, err := h.templates.Render("logo-toggle.html")
|
||||
if err != nil {
|
||||
HandleError(w, r, TemplateError(err, "logo-toggle.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, "logo-toggle.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// SwitchLanguage handles language switching with atomic updates
|
||||
// Uses HTMX out-of-band swaps to update both the language selector buttons
|
||||
// and all CV content wrappers in a single response
|
||||
func (h *CVHandler) SwitchLanguage(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
|
||||
}
|
||||
|
||||
// Save language preference
|
||||
setPreferenceCookie(w, "cv-language", lang)
|
||||
|
||||
// Prepare template data
|
||||
data, err := h.prepareTemplateData(lang)
|
||||
if err != nil {
|
||||
HandleError(w, r, DataLoadError(err, "CV"))
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve current length and logo preferences
|
||||
cvLength := getPreferenceCookie(r, "cv-length", "short")
|
||||
cvIcons := getPreferenceCookie(r, "cv-icons", "show")
|
||||
cvTheme := getPreferenceCookie(r, "cv-theme", "default")
|
||||
|
||||
// Add preferences to data
|
||||
if cvLength == "long" {
|
||||
data["CVLengthClass"] = "cv-long"
|
||||
} else {
|
||||
data["CVLengthClass"] = "cv-short"
|
||||
}
|
||||
data["ShowIcons"] = (cvIcons == "show")
|
||||
data["ThemeClean"] = (cvTheme == "clean")
|
||||
|
||||
// Render language-switch template with out-of-band swaps
|
||||
tmpl, err := h.templates.Render("language-switch.html")
|
||||
if err != nil {
|
||||
HandleError(w, r, TemplateError(err, "language-switch.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, "language-switch.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ToggleTheme handles theme toggle (default/clean) using atomic out-of-band swaps
|
||||
func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current state
|
||||
currentTheme := getPreferenceCookie(r, "cv-theme", "default")
|
||||
|
||||
// Toggle state
|
||||
newTheme := "clean"
|
||||
if currentTheme == "clean" {
|
||||
newTheme = "default"
|
||||
}
|
||||
|
||||
// Save new state
|
||||
setPreferenceCookie(w, "cv-theme", newTheme)
|
||||
|
||||
// Get language
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
lang = getPreferenceCookie(r, "cv-language", "en")
|
||||
}
|
||||
|
||||
// Prepare template data with theme state
|
||||
data := map[string]interface{}{
|
||||
"Lang": lang,
|
||||
"ThemeClean": (newTheme == "clean"),
|
||||
}
|
||||
|
||||
// Render theme-toggle template with out-of-band swaps
|
||||
tmpl, err := h.templates.Render("theme-toggle.html")
|
||||
if err != nil {
|
||||
HandleError(w, r, TemplateError(err, "theme-toggle.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, "theme-toggle.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
|
||||
uimodel "github.com/juanatsap/cv-site/internal/models/ui"
|
||||
"github.com/juanatsap/cv-site/internal/pdf"
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// PAGE HANDLERS
|
||||
// These handlers render full HTML pages or page sections
|
||||
// ==============================================================================
|
||||
|
||||
// Home renders the full CV page
|
||||
func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if this is a shortcut URL request (cv-jamr-{year}-{lang}.pdf)
|
||||
if strings.HasPrefix(r.URL.Path, "/cv-jamr-") && strings.HasSuffix(r.URL.Path, ".pdf") {
|
||||
h.DefaultCVShortcut(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 := cvmodel.LoadCV(lang)
|
||||
if err != nil {
|
||||
HandleError(w, r, DataLoadError(err, "CV"))
|
||||
return
|
||||
}
|
||||
|
||||
// Load UI translations
|
||||
ui, err := uimodel.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()
|
||||
|
||||
// Read user preferences from cookies
|
||||
cvLength := getPreferenceCookie(r, "cv-length", "short")
|
||||
cvIcons := getPreferenceCookie(r, "cv-icons", "show")
|
||||
cvTheme := getPreferenceCookie(r, "cv-theme", "default")
|
||||
|
||||
// Migrate old preference values to new ones (one-time auto-migration)
|
||||
if cvLength == "extended" {
|
||||
cvLength = "long"
|
||||
setPreferenceCookie(w, "cv-length", "long")
|
||||
}
|
||||
switch cvIcons {
|
||||
case "true":
|
||||
cvIcons = "show"
|
||||
setPreferenceCookie(w, "cv-icons", "show")
|
||||
case "false":
|
||||
cvIcons = "hide"
|
||||
setPreferenceCookie(w, "cv-icons", "hide")
|
||||
}
|
||||
|
||||
// Prepare CV length class
|
||||
cvLengthClass := "cv-short"
|
||||
if cvLength == "long" {
|
||||
cvLengthClass = "cv-long"
|
||||
}
|
||||
|
||||
// Prepare template data
|
||||
data := map[string]interface{}{
|
||||
"CV": cv,
|
||||
"UI": ui,
|
||||
"Lang": lang,
|
||||
"SkillsLeft": skillsLeft,
|
||||
"SkillsRight": skillsRight,
|
||||
"YearsOfExperience": yearsOfExperience,
|
||||
"CurrentYear": currentYear,
|
||||
"CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang),
|
||||
"AlternateEN": "https://juan.andres.morenorub.io/?lang=en",
|
||||
"AlternateES": "https://juan.andres.morenorub.io/?lang=es",
|
||||
"CVLengthClass": cvLengthClass,
|
||||
"ShowIcons": (cvIcons == "show"),
|
||||
"ThemeClean": (cvTheme == "clean"),
|
||||
}
|
||||
|
||||
// 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 := cvmodel.LoadCV(lang)
|
||||
if err != nil {
|
||||
HandleError(w, r, DataLoadError(err, "CV"))
|
||||
return
|
||||
}
|
||||
|
||||
// Load UI translations
|
||||
ui, err := uimodel.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,
|
||||
"CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang),
|
||||
"AlternateEN": "https://juan.andres.morenorub.io/?lang=en",
|
||||
"AlternateES": "https://juan.andres.morenorub.io/?lang=es",
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultCVShortcut handles shortcut URLs for default CV downloads
|
||||
// Pattern: /cv-jamr-{year}-{lang}.pdf (e.g., /cv-jamr-2025-en.pdf)
|
||||
// Validates year matches current year and redirects to default PDF export
|
||||
func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract path (e.g., "/cv-jamr-2025-en.pdf")
|
||||
path := r.URL.Path
|
||||
log.Printf("DefaultCVShortcut called with path: %s", path)
|
||||
|
||||
// Parse filename pattern: cv-jamr-{year}-{lang}.pdf
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/"), "-")
|
||||
if len(parts) != 4 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract year and language
|
||||
yearStr := parts[2]
|
||||
langWithExt := parts[3] // "en.pdf" or "es.pdf"
|
||||
lang := strings.TrimSuffix(langWithExt, ".pdf")
|
||||
|
||||
// Validate language
|
||||
if lang != "en" && lang != "es" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate year matches current year
|
||||
currentYear := fmt.Sprintf("%d", time.Now().Year())
|
||||
if yearStr != currentYear {
|
||||
// Return 404 if year doesn't match (old or future URLs)
|
||||
log.Printf("Invalid year in shortcut URL: %s (current: %s)", yearStr, currentYear)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate PDF directly instead of redirecting
|
||||
// This ensures the Content-Disposition filename is respected by browsers
|
||||
log.Printf("Shortcut URL: %s → generating PDF (short + with_skills)", path)
|
||||
|
||||
// Prepare cookies for PDF generation (short, with_skills, light mode)
|
||||
cookies := map[string]string{
|
||||
"cv-length": "short",
|
||||
"cv-icons": "show",
|
||||
"cv-language": lang,
|
||||
"cv-theme": "default", // with_skills = default theme
|
||||
"color-theme": "light", // Always light for PDFs
|
||||
}
|
||||
|
||||
// Construct URL for PDF generation
|
||||
targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang)
|
||||
|
||||
// Generate PDF with screen render mode (for sidebar layout)
|
||||
ctx := r.Context()
|
||||
pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, pdf.RenderModeScreen)
|
||||
if err != nil {
|
||||
log.Printf("PDF generation failed for shortcut URL: %v", err)
|
||||
HandleError(w, r, InternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Use the shortcut filename directly (simple, user-friendly)
|
||||
filename := filepath.Base(path) // cv-jamr-2025-en.pdf
|
||||
|
||||
// Set response headers with shortcut filename
|
||||
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("Error writing PDF response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData))
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
|
||||
"github.com/juanatsap/cv-site/internal/pdf"
|
||||
)
|
||||
|
||||
// ==============================================================================
|
||||
// PDF EXPORT HANDLER
|
||||
// Handles PDF generation with customizable options (length, icons, version, theme)
|
||||
// ==============================================================================
|
||||
|
||||
// ExportPDF handles PDF export requests using chromedp
|
||||
func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract and validate query parameters
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
if lang != "en" && lang != "es" {
|
||||
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
|
||||
return
|
||||
}
|
||||
|
||||
length := r.URL.Query().Get("length")
|
||||
if length == "" {
|
||||
length = "short"
|
||||
}
|
||||
if length != "short" && length != "long" {
|
||||
HandleError(w, r, BadRequestError("Unsupported length. Use 'short' or 'long'"))
|
||||
return
|
||||
}
|
||||
|
||||
icons := r.URL.Query().Get("icons")
|
||||
if icons == "" {
|
||||
icons = "show"
|
||||
}
|
||||
if icons != "show" && icons != "hide" {
|
||||
HandleError(w, r, BadRequestError("Unsupported icons option. Use 'show' or 'hide'"))
|
||||
return
|
||||
}
|
||||
|
||||
version := r.URL.Query().Get("version")
|
||||
if version == "" {
|
||||
version = "with_skills"
|
||||
}
|
||||
if version != "with_skills" && version != "clean" {
|
||||
HandleError(w, r, BadRequestError("Unsupported version. Use 'with_skills' or 'clean'"))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("PDF export requested: lang=%s, length=%s, icons=%s, version=%s", lang, length, icons, version)
|
||||
|
||||
// Load CV data to get name for filename
|
||||
cv, err := cvmodel.LoadCV(lang)
|
||||
if err != nil {
|
||||
HandleError(w, r, DataLoadError(err, "CV"))
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare cookies to set preferences
|
||||
cookies := map[string]string{
|
||||
"cv-length": length,
|
||||
"cv-icons": icons,
|
||||
"cv-language": lang,
|
||||
}
|
||||
|
||||
// Set theme cookie based on version parameter
|
||||
if version == "clean" {
|
||||
cookies["cv-theme"] = "clean"
|
||||
} else {
|
||||
cookies["cv-theme"] = "default"
|
||||
}
|
||||
|
||||
// CRITICAL: ALWAYS force light mode for PDF generation (print-friendly)
|
||||
// This ensures PDFs are NEVER generated in dark mode, regardless of user's preference
|
||||
cookies["color-theme"] = "light"
|
||||
|
||||
// Construct URL for PDF generation (navigate to home page)
|
||||
targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang)
|
||||
|
||||
// Determine render mode based on version parameter
|
||||
// Clean version: use @media print CSS (print-friendly, no sidebars)
|
||||
// Extended version: use @media screen CSS (full layout with sidebars)
|
||||
var renderMode pdf.RenderMode
|
||||
if version == "clean" {
|
||||
renderMode = pdf.RenderModePrint
|
||||
} else {
|
||||
renderMode = pdf.RenderModeScreen
|
||||
}
|
||||
|
||||
// Generate PDF with cookies and appropriate render mode
|
||||
ctx := r.Context()
|
||||
pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, renderMode)
|
||||
if err != nil {
|
||||
log.Printf("PDF generation failed: %v", err)
|
||||
HandleError(w, r, InternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate filename based on parameters
|
||||
// Format: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf
|
||||
// Note: {version} is OMITTED when it's "clean"
|
||||
// Examples:
|
||||
// - cv-short-jamr-2025-es.pdf (clean version, no skills)
|
||||
// - cv-short-with_skills-jamr-2025-es.pdf (with skills sidebar)
|
||||
// - cv-long-jamr-2025-en.pdf (clean version, no skills)
|
||||
// - cv-long-with_skills-jamr-2025-en.pdf (with skills sidebar)
|
||||
|
||||
// Generate initials from name
|
||||
nameParts := strings.Fields(cv.Personal.Name)
|
||||
initials := ""
|
||||
for _, part := range nameParts {
|
||||
if len(part) > 0 {
|
||||
// Take first letter of each name part
|
||||
initials += string([]rune(part)[0])
|
||||
}
|
||||
}
|
||||
initials = strings.ToLower(initials)
|
||||
|
||||
// Get current year
|
||||
currentYear := time.Now().Year()
|
||||
|
||||
// Build filename: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf
|
||||
// Omit version if it's "clean"
|
||||
// Replace underscores with hyphens in version for filename (with_skills → with-skills)
|
||||
var filename string
|
||||
if version == "clean" {
|
||||
filename = fmt.Sprintf("cv-%s-%s-%d-%s.pdf", length, initials, currentYear, lang)
|
||||
} else {
|
||||
versionForFilename := strings.ReplaceAll(version, "_", "-")
|
||||
filename = fmt.Sprintf("cv-%s-%s-%s-%d-%s.pdf", length, versionForFilename, initials, currentYear, lang)
|
||||
}
|
||||
|
||||
// Set response headers
|
||||
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("Error writing PDF response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData))
|
||||
}
|
||||
Reference in New Issue
Block a user