package handlers import ( "bytes" "fmt" "log" "net/http" "path/filepath" "regexp" "strings" "text/template" "time" c "github.com/juanatsap/cv-site/internal/constants" "github.com/juanatsap/cv-site/internal/httputil" ) // Plain text configuration const plainTextLineWidth = 80 // Text-based browsers and CLI tools that should get plain text var textBrowsers = []string{ "curl", "wget", "httpie", "lynx", "w3m", "links", "elinks", "browsh", "carbonyl", "netrik", "retawq", "surfraw", } // isTextBrowser detects if the request comes from a text-based browser or CLI tool func isTextBrowser(r *http.Request) bool { ua := strings.ToLower(r.Header.Get(c.HeaderUserAgent)) // Check for known text browsers for _, browser := range textBrowsers { if strings.Contains(ua, browser) { return true } } // Check Accept header - if client prefers text/plain accept := r.Header.Get(c.HeaderAccept) return strings.HasPrefix(accept, c.ContentTypePlainSimple) } // ============================================================================== // PLAIN TEXT HANDLER // Renders CV as clean plain text for terminal/AI consumption // ============================================================================== // PlainText renders the CV as plain text // Useful for: curl users, AI crawlers, accessibility, copy-paste func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) { langCode, ok := httputil.LangOrError(r) if !ok { http.Error(w, "Unsupported language. Use 'en' or 'es'", http.StatusBadRequest) return } // Check icons parameter (default: true) showIcons := r.URL.Query().Get("icons") != "false" // Prepare template data using shared helper (loads CV data) data, err := h.prepareTemplateData(langCode) if err != nil { log.Printf("PlainText: Failed to load CV data: %v", err) http.Error(w, "Failed to load CV data", http.StatusInternalServerError) return } // Add base URL, icons setting, and line width data["BaseURL"] = h.serverAddr data["Icons"] = showIcons data["LineWidth"] = plainTextLineWidth // Template functions for text formatting funcMap := template.FuncMap{ // center centers text within the line width "center": func(text string) string { runes := []rune(text) textLen := len(runes) if textLen >= plainTextLineWidth { return text } padding := (plainTextLineWidth - textLen) / 2 return strings.Repeat(" ", padding) + text }, // separator creates a centered separator line "separator": func(char string, length int) string { line := strings.Repeat(char, length) padding := (plainTextLineWidth - length) / 2 return strings.Repeat(" ", padding) + line }, // box creates a centered box frame with rounded corners around text "box": func(text string) string { // Calculate visual width (emojis display as 2 chars wide) visualWidth := 0 for _, r := range text { if r > 0x1F000 { // Emoji range visualWidth += 2 } else { visualWidth++ } } innerPadding := 3 // spaces on each side inside the box boxWidth := visualWidth + (innerPadding * 2) + 2 // padding both sides + 2 border chars topBottom := "╭" + strings.Repeat("─", boxWidth-2) + "╮" middle := "│" + strings.Repeat(" ", innerPadding) + text + strings.Repeat(" ", innerPadding) + "│" bottom := "╰" + strings.Repeat("─", boxWidth-2) + "╯" // Center each line (using visual width for proper alignment) centerLine := func(line string, lineVisualWidth int) string { if lineVisualWidth >= plainTextLineWidth { return line } padding := (plainTextLineWidth - lineVisualWidth) / 2 return strings.Repeat(" ", padding) + line } return centerLine(topBottom, boxWidth) + "\n" + centerLine(middle, boxWidth) + "\n" + centerLine(bottom, boxWidth) }, } // Load and parse the plain text template with custom functions tmplPath := filepath.Join(c.DirTemplates, "cv-text.txt") tmpl, err := template.New("cv-text.txt").Funcs(funcMap).ParseFiles(tmplPath) if err != nil { log.Printf("PlainText: Failed to load template: %v", err) http.Error(w, "Failed to load template", http.StatusInternalServerError) return } // Render to buffer var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { log.Printf("PlainText: Failed to execute template: %v", err) http.Error(w, "Failed to render template: "+err.Error(), http.StatusInternalServerError) return } // Clean up the output text := cleanPlainText(buf.String()) // Set response headers w.Header().Set(c.HeaderContentType, c.ContentTypePlainText) w.Header().Set(c.HeaderXContentTypeOpts, c.NoSniff) // Check if download is requested if r.URL.Query().Get("download") == "true" { year := time.Now().Year() filename := fmt.Sprintf("cv-jamr-%d-%s.txt", year, langCode) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) } // Write plain text response _, _ = w.Write([]byte(text)) } // cleanPlainText removes extra whitespace, HTML tags, and wraps long lines func cleanPlainText(text string) string { const maxLineLength = 80 // Remove HTML tags (from safeHTML fields) htmlTagRe := regexp.MustCompile(`<[^>]*>`) text = htmlTagRe.ReplaceAllString(text, "") // Replace multiple blank lines with double newline multipleNewlines := regexp.MustCompile(`\n{3,}`) text = multipleNewlines.ReplaceAllString(text, "\n\n") // Process each line: trim and wrap lines := strings.Split(text, "\n") var cleanedLines []string for _, line := range lines { line = strings.TrimRight(line, " \t") // Wrap long lines wrapped := wrapLine(line, maxLineLength) cleanedLines = append(cleanedLines, wrapped...) } text = strings.Join(cleanedLines, "\n") // Trim overall text = strings.TrimSpace(text) return text } // wrapLine wraps a single line to maxLength, preserving leading whitespace // Uses runes for proper Unicode/emoji handling func wrapLine(line string, maxLength int) []string { runes := []rune(line) if len(runes) <= maxLength { return []string{line} } // Don't wrap separator lines (===, ---) if strings.HasPrefix(strings.TrimSpace(line), "===") || strings.HasPrefix(strings.TrimSpace(line), "---") { return []string{line} } // Detect leading whitespace for continuation lines indent := "" for _, r := range runes { if r == ' ' || r == '\t' { indent += string(r) } else { break } } // For list items, add extra indent for continuation trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "- ") { indent += " " } var wrapped []string remaining := runes for len(remaining) > maxLength { // Find last space before maxLength breakPoint := maxLength for i := maxLength; i > 0; i-- { if remaining[i] == ' ' { breakPoint = i break } } // If no space found, force break at maxLength if breakPoint == maxLength { hasSpace := false for i := 0; i < maxLength; i++ { if remaining[i] == ' ' { hasSpace = true break } } if !hasSpace { breakPoint = maxLength } } wrapped = append(wrapped, string(remaining[:breakPoint])) remaining = []rune(indent + strings.TrimLeft(string(remaining[breakPoint:]), " ")) } if len(remaining) > 0 { wrapped = append(wrapped, string(remaining)) } return wrapped }