Files
bamort/backend/pdfrender/handlers.go
T
Frank 59fe69d35d refactor: Unify PDF pagination system and rename templates
BREAKING CHANGE: Template names changed from page1_stats.html to page_1.html

## Phase 1: Unified Pagination Function
- Implemented PaginateMultiList() to replace PaginateSkills(), PaginateSpells(), and PaginatePage2PlayLists()
- Single metadata-driven function handles all list types (skills, weapons, spells, equipment)
- Properly handles filters (learned/unlearned/language) via template metadata
- Shares list trackers by ListType+Filter combination to avoid duplication
- Added comprehensive tests for all edge cases

## Phase 2: Template Naming Convention
- Renamed templates to be data-agnostic:
  - page1_stats.html -> page_1.html
  - page1.2_stats.html -> page_1.2.html
  - page2_play.html -> page_2.html
  - page2.2_play.html -> page_2.2.html
  - page3_spell.html -> page_3.html
  - page3.2_spell.html -> page_3.2.html
  - page4_equip.html -> page_4.html
- Updated GenerateContinuationTemplateName() for new naming (page_1.html -> page_1.2.html)
- Updated ExtractBaseTemplateName() to handle new format
- Updated all test files and source files with new template names

## Phase 3: Simplified RenderPageWithContinuations
- Removed hardcoded switch statements based on template names
- Replaced with generic dataMap and unified pagination call
- Extracted populatePageDataFromDistribution() to handle data mapping
- Template type detection now driven by metadata, not hardcoded names

## Benefits
-  Extensibility: Add new templates without code changes
-  Maintainability: One pagination algorithm instead of three
-  Clarity: Template names reflect page numbers, not content types
-  Flexibility: Templates can mix any data types
-  All 40+ tests passing

## Technical Details
- Added SkillsColumn3 and SkillsColumn4 fields to PageData for continuation pages
- Template metadata loaded from HTML comments drives pagination behavior
- Backward compatibility maintained for old template references in comments
2025-12-21 22:07:46 +01:00

232 lines
6.7 KiB
Go

package pdfrender
import (
"bamort/config"
"bamort/models"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/pdfcpu/pdfcpu/pkg/api"
)
// TemplateInfo represents information about an available export template
type TemplateInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// ListTemplates returns a list of available export templates
func ListTemplates(c *gin.Context) {
templatesDir := config.Cfg.TemplatesDir
// Read template directories
entries, err := os.ReadDir(templatesDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read templates directory"})
return
}
var templates []TemplateInfo
for _, entry := range entries {
if entry.IsDir() {
templates = append(templates, TemplateInfo{
ID: entry.Name(),
Name: entry.Name(),
Description: "PDF Export Template: " + entry.Name(),
})
}
}
c.JSON(http.StatusOK, templates)
}
// ExportCharacterToPDF exports a character to PDF and saves it to xporttemp directory
// Query params:
// - template: template ID to use (default: "Default_A4_Quer")
// - showUserName: whether to show user name (default: false)
//
// Returns JSON with filename: {"filename": "CharacterName_20231225_143045.pdf"}
func ExportCharacterToPDF(c *gin.Context) {
// Get character ID
charID := c.Param("id")
if charID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Character ID is required"})
return
}
// Load character
char := &models.Char{}
if err := char.FirstID(charID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Character not found"})
return
}
// Get template parameter (default to Default_A4_Quer)
templateID := c.DefaultQuery("template", "Default_A4_Quer")
// Map character to view model
viewModel, err := MapCharacterToViewModel(char)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to map character: " + err.Error()})
return
}
// Load templates
templateDir := filepath.Join(config.Cfg.TemplatesDir, templateID)
loader := NewTemplateLoader(templateDir)
if err := loader.LoadTemplates(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load templates: " + err.Error()})
return
}
renderer := NewPDFRenderer()
currentDate := time.Now().Format("02.01.2006")
// Render all 4 pages with continuations
var allPDFs [][]byte
// Page 1: Stats
page1PDFs, err := RenderPageWithContinuations(viewModel, "page_1.html", 1, currentDate, loader, renderer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render page 1: " + err.Error()})
return
}
allPDFs = append(allPDFs, page1PDFs...)
// Page 2: Play
page2PDFs, err := RenderPageWithContinuations(viewModel, "page_2.html", 2, currentDate, loader, renderer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render page 2: " + err.Error()})
return
}
allPDFs = append(allPDFs, page2PDFs...)
// Page 3: Spells
page3PDFs, err := RenderPageWithContinuations(viewModel, "page_3.html", 3, currentDate, loader, renderer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render page 3: " + err.Error()})
return
}
allPDFs = append(allPDFs, page3PDFs...)
// Page 4: Equipment
page4PDFs, err := RenderPageWithContinuations(viewModel, "page_4.html", 4, currentDate, loader, renderer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render page 4: " + err.Error()})
return
}
allPDFs = append(allPDFs, page4PDFs...)
// Merge PDFs if needed
var finalPDF []byte
if len(allPDFs) == 1 {
finalPDF = allPDFs[0]
} else {
// Merge multiple PDFs
tmpDir := "/tmp/bamort_pdf_export"
if err := os.MkdirAll(tmpDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp directory"})
return
}
defer os.RemoveAll(tmpDir)
// Save individual PDFs
var filePaths []string
for i, pdf := range allPDFs {
filename := fmt.Sprintf("%s/page_%d.pdf", tmpDir, i)
if err := os.WriteFile(filename, pdf, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write temporary PDF"})
return
}
filePaths = append(filePaths, filename)
}
// Merge PDFs
combinedPath := fmt.Sprintf("%s/combined.pdf", tmpDir)
if err := api.MergeCreateFile(filePaths, combinedPath, false, nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to merge PDFs: " + err.Error()})
return
}
// Read combined PDF
finalPDF, err = os.ReadFile(combinedPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read combined PDF"})
return
}
}
// Ensure export temp directory exists
if err := EnsureExportTempDir(config.Cfg.ExportTempDir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export directory"})
return
}
// Generate filename
filename := GenerateExportFilename(char.Name, time.Now())
filePath := filepath.Join(config.Cfg.ExportTempDir, filename)
// Save PDF to file
if err := os.WriteFile(filePath, finalPDF, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save PDF file: " + err.Error()})
return
}
// Return filename
c.JSON(http.StatusOK, gin.H{"filename": filename})
}
// GetPDFFile serves a PDF file from the xporttemp directory
func GetPDFFile(c *gin.Context) {
filename := c.Param("filename")
if filename == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Filename is required"})
return
}
// Prevent path traversal attacks - only allow base filename
if filepath.Base(filename) != filename {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filename"})
return
}
// Only allow .pdf files
if filepath.Ext(filename) != ".pdf" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only PDF files are allowed"})
return
}
// Construct full path
filePath := filepath.Join(config.Cfg.ExportTempDir, filename)
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
return
}
// Serve the file
c.File(filePath)
}
// CleanupExportTemp removes PDF files older than 7 days from xporttemp directory
func CleanupExportTemp(c *gin.Context) {
// Clean up files older than 7 days
maxAge := 7 * 24 * time.Hour
count, err := CleanupOldFiles(config.Cfg.ExportTempDir, maxAge)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to cleanup files: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"deleted": count,
"message": fmt.Sprintf("Deleted %d files older than 7 days", count),
})
}