From 6703481dbb3f9f382aaf7887a87a48f966832148 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 5 Oct 2026 10:33:02 +0100 Subject: [PATCH] print as html --- README.md | 2 + cmd/any2anexoj-cli/main.go | 15 +- cmd/any2anexoj-cli/main_test.go | 31 +++ internal/writing/html/writer.go | 96 +++++++++ internal/writing/html/writer_test.go | 184 ++++++++++++++++++ internal/writing/{pretty => table}/printer.go | 6 +- .../writing/{pretty => table}/printer_test.go | 17 +- internal/writing/translations/en.json | 4 + internal/writing/translations/pt.json | 4 + 9 files changed, 339 insertions(+), 20 deletions(-) create mode 100644 internal/writing/html/writer.go create mode 100644 internal/writing/html/writer_test.go rename internal/writing/{pretty => table}/printer.go (96%) rename internal/writing/{pretty => table}/printer_test.go (92%) diff --git a/README.md b/README.md index 8026a23..d7dfc0c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest cat statement.csv | any2anexoj-cli --platform=tranding212 ``` +Pass `--format html` to emit a complete, standalone Bootstrap-styled HTML report (a `` document with the Anexo J table embedded and Bootstrap loaded via CDN); the default `--format table` prints the pretty terminal table and `--format csv` writes the Anexo J CSV schema. + ### Incremental Reports For year over year reporting, pass `--state-file ` to keep the FIFO buy-queue between runs. The typical workflow is: diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 4de4204..84a744d 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -15,6 +15,7 @@ import ( "github.com/nmoniz/any2anexoj/internal/trading212" "github.com/nmoniz/any2anexoj/internal/writing" "github.com/nmoniz/any2anexoj/internal/writing/csv" + "github.com/nmoniz/any2anexoj/internal/writing/html" "github.com/nmoniz/any2anexoj/internal/writing/pretty" "github.com/spf13/pflag" "golang.org/x/text/language" @@ -26,7 +27,7 @@ var ( platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms") lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code") debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages") - format = pflag.StringP("format", "f", "table", "Output format: table or csv") + format = pflag.StringP("format", "f", "table", "Output format: table, csv or html") ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)") selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors: code, assetCountry") stateFile = pflag.String("state-file", "", "Path to a state file for incremental processing") @@ -109,8 +110,18 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error { } pretty.NewPrettyPrinter(stdout, loc).Render(writer) return nil + case "html": + loc, err := writing.NewLocalizer(*lang) + if err != nil { + return fmt.Errorf("create localizer: %w", err) + } + // RenderHTML never writes to the printer's output writer, so + // io.Discard is safe here. + printer := pretty.NewPrettyPrinter(io.Discard, loc) + title := loc.Translate("report_title", 1, nil) + return html.NewHTMLWriter(stdout, printer, title, *lang).Render(writer) default: - return fmt.Errorf("unsupported format %q: must be table or csv", *format) + return fmt.Errorf("unsupported format %q: must be table, csv or html", *format) } } diff --git a/cmd/any2anexoj-cli/main_test.go b/cmd/any2anexoj-cli/main_test.go index 81d6517..77a0c21 100644 --- a/cmd/any2anexoj-cli/main_test.go +++ b/cmd/any2anexoj-cli/main_test.go @@ -139,6 +139,37 @@ func TestRunWithIO_NoStateFileByDefault(t *testing.T) { } } +// TestRunWithIO_HTMLFormat verifies that --format html produces a complete +// HTML document on stdout starting with the DOCTYPE declaration. +func TestRunWithIO_HTMLFormat(t *testing.T) { + resetFlags(t) + t.Cleanup(func() { resetFlags(t) }) + + if err := pflag.Set("format", "html"); err != nil { + t.Fatalf("set format flag: %v", err) + } + + var stdout bytes.Buffer + if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil { + t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String()) + } + + got := stdout.String() + if !strings.HasPrefix(got, "") { + t.Errorf("expected HTML output to start with ; got prefix: %q", + got[:min(len(got), 64)]) + } + if !strings.Contains(got, "table-responsive") { + t.Errorf("expected output to contain Bootstrap table-responsive wrapper") + } + if !strings.Contains(got, "table-striped") { + t.Errorf("expected output to contain Bootstrap table-striped class") + } + if !strings.Contains(got, "") { + t.Errorf("expected output to end with ") + } +} + // TestRunWithIO_UnsupportedPlatformForPersistence verifies that using // --state-file with an unknown platform surfaces a clear error rather than // silently falling back to EphemeralStore. diff --git a/internal/writing/html/writer.go b/internal/writing/html/writer.go new file mode 100644 index 0000000..720e642 --- /dev/null +++ b/internal/writing/html/writer.go @@ -0,0 +1,96 @@ +// Package html renders an internal.AggregatorWriter as a complete, +// standalone HTML document built around the same table fragment produced by +// pretty.PrettyPrinter.RenderHTML. The document includes the Bootstrap 5.3 +// stylesheet via CDN and embeds the raw table fragment without HTML-escaping +// it so the result opens directly in a browser. +package html + +import ( + "fmt" + "html/template" + "io" + "time" + + "github.com/nmoniz/any2anexoj/internal" + "github.com/nmoniz/any2anexoj/internal/writing/pretty" +) + +// bootstrapCSSURL is the jsDelivr CDN URL for Bootstrap 5.3.3. +const bootstrapCSSURL = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" + +// bootstrapIntegrity is the official SRI hash for Bootstrap 5.3.3 css. +const bootstrapIntegrity = "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" + +// HTMLWriter writes a complete HTML document for an AggregatorWriter. +type HTMLWriter struct { + output io.Writer + printer *pretty.PrettyPrinter + title string + lang string +} + +// NewHTMLWriter builds an HTMLWriter that writes a complete HTML document to +// w. The printer is used purely as a source for the raw fragment via +// RenderHTML; this writer never invokes Render or any method that writes to +// printer's own io.Writer. +func NewHTMLWriter(w io.Writer, printer *pretty.PrettyPrinter, title, lang string) *HTMLWriter { + return &HTMLWriter{ + output: w, + printer: printer, + title: title, + lang: lang, + } +} + +// documentTemplate renders the full HTML page. The table fragment is typed as +// template.HTML so the template engine emits it verbatim without escaping +// the angle brackets in the
markup. +var documentTemplate = template.Must(template.New("html").Parse(` + + + + +{{.Title}} + + + +
+

{{.Title}}

+
+{{.TableFragment}} +
+

Generated at {{.GeneratedAt}}

+
+ + +`)) + +// Render writes a complete standalone HTML document for aw to the configured +// output. The same HTMLWriter may be reused; each call rebuilds the table +// fragment from aw so the rendered count reflects aw's current items and +// no rows are duplicated across invocations. +func (hw *HTMLWriter) Render(aw *internal.AggregatorWriter) error { + if hw.printer == nil { + return fmt.Errorf("html: nil printer") + } + fragment := hw.printer.RenderHTML(aw) + data := struct { + Lang string + Title string + CSSURL string + Integrity string + TableFragment template.HTML + GeneratedAt string + }{ + Lang: hw.lang, + Title: hw.title, + CSSURL: bootstrapCSSURL, + Integrity: bootstrapIntegrity, + TableFragment: template.HTML(fragment), + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + } + if err := documentTemplate.Execute(hw.output, data); err != nil { + return fmt.Errorf("render html document: %w", err) + } + return nil +} diff --git a/internal/writing/html/writer_test.go b/internal/writing/html/writer_test.go new file mode 100644 index 0000000..561bb0c --- /dev/null +++ b/internal/writing/html/writer_test.go @@ -0,0 +1,184 @@ +package html_test + +import ( + "bytes" + "strings" + "testing" + "time" + + htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html" + "github.com/nmoniz/any2anexoj/internal/writing/pretty" + "github.com/nmoniz/any2anexoj/internal/writing" + "github.com/nmoniz/any2anexoj/internal" + "github.com/shopspring/decimal" +) + +// buildTestAggregator returns an AggregatorWriter seeded with two +// deterministic ReportItems so every test can assert on a stable document +// shape. Mirrors the fixture pattern used in +// internal/writing/pretty/printer_test.go. +func buildTestAggregator(t *testing.T) *internal.AggregatorWriter { + t.Helper() + aw := internal.NewAggregatorWriter() + ctx := t.Context() + + err := aw.Write(ctx, internal.ReportItem{ + Symbol: "AAPL", + Nature: internal.NatureG01, + BrokerCountry: 826, + AssetCountry: 840, + BuyValue: decimal.NewFromFloat(100.50), + BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC), + SellValue: decimal.NewFromFloat(150.75), + SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC), + Fees: decimal.NewFromFloat(2.50), + Taxes: decimal.NewFromFloat(5.00), + }) + if err != nil { + t.Fatalf("failed to write first report item: %v", err) + } + + err = aw.Write(ctx, internal.ReportItem{ + Symbol: "GOOGL", + Nature: internal.NatureG20, + BrokerCountry: 826, + AssetCountry: 840, + BuyValue: decimal.NewFromFloat(200.00), + BuyTimestamp: time.Date(2023, 3, 10, 0, 0, 0, 0, time.UTC), + SellValue: decimal.NewFromFloat(225.50), + SellTimestamp: time.Date(2023, 9, 5, 0, 0, 0, 0, time.UTC), + Fees: decimal.NewFromFloat(3.00), + Taxes: decimal.NewFromFloat(7.50), + }) + if err != nil { + t.Fatalf("failed to write second report item: %v", err) + } + + return aw +} + +func TestHTMLWriter_Render(t *testing.T) { + loc, err := writing.NewLocalizer("en") + if err != nil { + t.Fatalf("create localizer: %v", err) + } + + var buf bytes.Buffer + // RenderHTML never writes to the printer's output, so Discard is safe. + pp := pretty.NewPrettyPrinter(&buf, loc) + hw := htmlwriter.NewHTMLWriter(&buf, pp, "Tax report", "en") + + if err := hw.Render(buildTestAggregator(t)); err != nil { + t.Fatalf("Render returned an error: %v", err) + } + + got := buf.String() + + wantSubstrings := []string{ + "", + ``, + ``, + ``, + `Tax report`, + `https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css`, + `integrity="`, + `crossorigin="anonymous"`, + `
`, + `

Tax report

`, + `
`, + `
`, + `
`, + ``, + ``, + ``, + `

Generated at `, + } + for _, s := range wantSubstrings { + if !strings.Contains(got, s) { + t.Errorf("rendered HTML missing expected substring %q\nGot:\n%s", s, got) + } + } + + // The table fragment must be embedded as raw HTML, not escaped. + if strings.Contains(got, "<table") { + t.Errorf("table fragment appears to be HTML-escaped; expected raw markup\nGot:\n%s", got) + } + if !strings.Contains(got, " tag in output\nGot:\n%s", got) + } + + // Generated timestamp must be valid RFC3339 UTC. + startIdx := strings.Index(got, "Generated at ") + if startIdx == -1 { + t.Fatalf("missing 'Generated at' prefix in output") + } + timestampLine := got[startIdx:] + endIdx := strings.Index(timestampLine, "

") + if endIdx == -1 { + t.Fatalf("missing

closing tag after 'Generated at' line") + } + timestampStr := strings.TrimPrefix(strings.SplitN(timestampLine, "

", 2)[0], "Generated at ") + if _, err := time.Parse(time.RFC3339, timestampStr); err != nil { + t.Errorf("generation timestamp is not valid RFC3339: %q (err: %v)", timestampStr, err) + } +} + +// TestHTMLWriter_NoRowDuplication verifies that calling Render twice on the +// same writer produces documents with the same number of
rows, just +// like pretty.PrettyPrinter's no-duplication guarantee. +func TestHTMLWriter_NoRowDuplication(t *testing.T) { + loc, err := writing.NewLocalizer("en") + if err != nil { + t.Fatalf("create localizer: %v", err) + } + + var buf bytes.Buffer + pp := pretty.NewPrettyPrinter(&buf, loc) + hw := htmlwriter.NewHTMLWriter(&buf, pp, "Tax report", "en") + + aw := buildTestAggregator(t) + + if err := hw.Render(aw); err != nil { + t.Fatalf("first Render returned an error: %v", err) + } + first := buf.String() + buf.Reset() + + if err := hw.Render(aw); err != nil { + t.Fatalf("second Render returned an error: %v", err) + } + second := buf.String() + + firstRows := strings.Count(first, " row in the first render; got:\n%s", first) + } + if firstRows != secondRows { + t.Errorf("expected second Render to produce the same number of rows as the first (no duplication)\nfirst rows=%d, second rows=%d\nFirst:\n%s\nSecond:\n%s", + firstRows, secondRows, first, second) + } +} + +func TestHTMLWriter_LangAttribute(t *testing.T) { + loc, err := writing.NewLocalizer("pt") + if err != nil { + t.Fatalf("create localizer: %v", err) + } + + var buf bytes.Buffer + pp := pretty.NewPrettyPrinter(&buf, loc) + hw := htmlwriter.NewHTMLWriter(&buf, pp, "Relatório fiscal", "pt") + + if err := hw.Render(buildTestAggregator(t)); err != nil { + t.Fatalf("Render returned an error: %v", err) + } + + got := buf.String() + if !strings.Contains(got, ``) { + t.Errorf("expected attribute; got:\n%s", got) + } + if !strings.Contains(got, `

Relatório fiscal

`) { + t.Errorf("expected Portuguese title in

; got:\n%s", got) + } +} diff --git a/internal/writing/pretty/printer.go b/internal/writing/table/printer.go similarity index 96% rename from internal/writing/pretty/printer.go rename to internal/writing/table/printer.go index 56bcf58..2406746 100644 --- a/internal/writing/pretty/printer.go +++ b/internal/writing/table/printer.go @@ -1,8 +1,4 @@ -<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer.go -package main -======== -package pretty ->>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer.go +package table import ( "fmt" diff --git a/internal/writing/pretty/printer_test.go b/internal/writing/table/printer_test.go similarity index 92% rename from internal/writing/pretty/printer_test.go rename to internal/writing/table/printer_test.go index e0201fa..795de64 100644 --- a/internal/writing/pretty/printer_test.go +++ b/internal/writing/table/printer_test.go @@ -1,8 +1,4 @@ -<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go -package main -======== -package pretty_test ->>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go +package table_test import ( "bytes" @@ -14,8 +10,7 @@ import ( <<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go ======== "github.com/nmoniz/any2anexoj/internal/writing" - "github.com/nmoniz/any2anexoj/internal/writing/pretty" ->>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go + "github.com/nmoniz/any2anexoj/internal/writing/table" "github.com/shopspring/decimal" ) @@ -69,11 +64,7 @@ func TestPrettyPrinter_Render(t *testing.T) { // Create writing printer with buffer var buf bytes.Buffer -<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go - pp := NewPrettyPrinter(&buf, localizer) -======== - pp := pretty.NewPrettyPrinter(&buf, localizer) ->>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go + pp := table.NewPrettyPrinter(&buf, localizer) // Render the table pp.Render(aw) @@ -130,7 +121,7 @@ func TestRenderHTML(t *testing.T) { // Output buffer used only to satisfy the constructor contract; RenderHTML // must not write to it. var buf bytes.Buffer - pp := pretty.NewPrettyPrinter(&buf, localizer) + pp := table.NewPrettyPrinter(&buf, localizer) html := pp.RenderHTML(aw) diff --git a/internal/writing/translations/en.json b/internal/writing/translations/en.json index 495e79f..18b425e 100644 --- a/internal/writing/translations/en.json +++ b/internal/writing/translations/en.json @@ -46,5 +46,9 @@ "sum": { "one": "Sum", "other": "Sums" + }, + "report_title": { + "one": "Tax report", + "other": "Tax reports" } } diff --git a/internal/writing/translations/pt.json b/internal/writing/translations/pt.json index 908013b..fbe62f1 100644 --- a/internal/writing/translations/pt.json +++ b/internal/writing/translations/pt.json @@ -46,5 +46,9 @@ "sum": { "one": "Soma", "other": "Somas" + }, + "report_title": { + "one": "Relatório fiscal", + "other": "Relatórios fiscais" } }