diff --git a/go.mod b/go.mod index 14e000a..62e33e6 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-pdf/fpdf v0.9.0 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect diff --git a/go.sum b/go.sum index 4a8cd63..7a0e404 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/go-openapi/spec v0.20.14 h1:7CBlRnw+mtjFGlPDRZmAMnq35cRzI91xj03HVyUi/ github.com/go-openapi/spec v0.20.14/go.mod h1:8EOhTpBoFiask8rrgwbLC3zmJfz4zsCUueRuPM6GNkw= github.com/go-openapi/swag v0.22.9 h1:XX2DssF+mQKM2DHsbgZK74y/zj4mo9I99+89xUmuZCE= github.com/go-openapi/swag v0.22.9/go.mod h1:3/OXnFfnMAwBD099SwYRk7GD3xOrr1iL7d/XNLXVVwE= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= diff --git a/internal/server/server.go b/internal/server/server.go index be7672a..eb6ef21 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -715,8 +715,9 @@ func (ct *Controller) voteAnswer(c *gin.Context) { // @Accept json // @Produce json // @Produce text/markdown +// @Produce application/pdf // @Param export body types.RetrospectiveExportRequest true "Export Retrospective" -// @Success 200 {object} types.Retrospective "Retrospective Object (JSON) or Markdown file" +// @Success 200 {object} types.Retrospective "Retrospective Object (JSON), Markdown file, or PDF file" // @Failure 400 {string} string "Invalid input" // @Failure 404 {string} string "Not Found" // @Failure 500 {string} string "Internal error" @@ -751,11 +752,20 @@ func (ct *Controller) exportRetrospective(c *gin.Context) { filename := fmt.Sprintf("retrospective-%s", strings.ReplaceAll(retro.Name, " ", "_")) switch input.ExportType { case types.ExportTypeMarkdown: - markdown := ct.service.ConvertRetrospectiveToMarkdown(c, retro) c.Header("Content-Type", "text/markdown") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.md\"", filename)) c.String(http.StatusOK, markdown) + case types.ExportTypePDF: + pdfBytes, err := ct.service.ConvertRetrospectiveToPDF(c, retro) + if err != nil { + ct.logger.Error("error converting retrospective to PDF", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "error generating PDF"}) + return + } + c.Header("Content-Type", "application/pdf") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", filename)) + c.Data(http.StatusOK, "application/pdf", pdfBytes) default: c.Header("Content-Type", "application/json") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.json\"", filename)) diff --git a/internal/service/convert.go b/internal/service/convert.go index f7e3a40..06bca48 100644 --- a/internal/service/convert.go +++ b/internal/service/convert.go @@ -1,11 +1,15 @@ package service import ( - "api/types" + "bytes" "context" "fmt" "sort" "strings" + + "api/types" + + "github.com/go-pdf/fpdf" ) func (s *Service) ConvertRetrospectiveToMarkdown(ctx context.Context, retro *types.Retrospective) string { @@ -44,3 +48,73 @@ func (s *Service) ConvertRetrospectiveToMarkdown(ctx context.Context, retro *typ return sb.String() } + +func (s *Service) ConvertRetrospectiveToPDF(ctx context.Context, retro *types.Retrospective) ([]byte, error) { + pdf := fpdf.New("P", "mm", "A4", "") + pdf.SetMargins(20, 20, 20) + pdf.AddPage() + + // Title + pdf.SetFont("Arial", "B", 24) + pdf.Cell(0, 12, "Simple Retro") + pdf.Ln(16) + + // Subtitle (retrospective name) + pdf.SetFont("Arial", "B", 18) + pdf.Cell(0, 10, retro.Name) + pdf.Ln(12) + + // Description + if retro.Description != "" { + pdf.SetFont("Arial", "", 12) + pdf.MultiCell(0, 6, retro.Description, "", "", false) + pdf.Ln(4) + } + + // Created at + pdf.SetFont("Arial", "I", 10) + pdf.SetTextColor(128, 128, 128) + pdf.Cell(0, 6, "Created on "+retro.CreatedAt.Format("January 2, 2006 at 3:04 PM")) + pdf.Ln(10) + pdf.SetTextColor(0, 0, 0) + + // Separator line + pdf.Line(20, pdf.GetY(), 190, pdf.GetY()) + pdf.Ln(8) + + // Questions and answers + for _, question := range retro.Questions { + // Question title + pdf.SetFont("Arial", "B", 14) + pdf.MultiCell(0, 8, question.Text, "", "", false) + pdf.Ln(4) + + // Sort answers by position + answers := make([]types.Answer, len(question.Answers)) + copy(answers, question.Answers) + sort.Slice(answers, func(i, j int) bool { + return answers[i].Position < answers[j].Position + }) + + pdf.SetFont("Arial", "", 11) + for _, answer := range answers { + var text string + if answer.Votes > 0 { + text = fmt.Sprintf(" - %s (%d votes)", answer.Text, answer.Votes) + } else { + text = fmt.Sprintf(" - %s", answer.Text) + } + pdf.MultiCell(0, 6, text, "", "", false) + pdf.Ln(1) + } + pdf.Ln(6) + } + + var buf bytes.Buffer + err := pdf.Output(&buf) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/types/retrospective.go b/types/retrospective.go index e260b14..8dfb3d8 100644 --- a/types/retrospective.go +++ b/types/retrospective.go @@ -15,6 +15,7 @@ const ( ExportTypeJSON ExportType = "JSON" ExportTypeMarkdown ExportType = "MARKDOWN" + ExportTypePDF ExportType = "PDF" ) type Retrospective struct { diff --git a/types/validations.go b/types/validations.go index acc891e..f5da7e7 100644 --- a/types/validations.go +++ b/types/validations.go @@ -121,7 +121,7 @@ func (r RetrospectiveExportRequest) Validate() error { } switch r.ExportType { - case ExportTypeJSON, ExportTypeMarkdown: + case ExportTypeJSON, ExportTypeMarkdown, ExportTypePDF: return nil default: return fmt.Errorf("invalid export type")