print as html

This commit is contained in:
natercio committed 2026-10-05 15:08:00 +01:00
1 parent 62fe1a7076
commit 6703481dbb
9 files changed
+339 -20

No files matched your search

+2
View File
@@ -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 `<!DOCTYPE html>` 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 <path>` to keep the FIFO buy-queue between runs. The typical workflow is:
+13 -2
View File
@@ -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)
}
}
+31
View File
@@ -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, "<!DOCTYPE html>") {
t.Errorf("expected HTML output to start with <!DOCTYPE html>; 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, "</html>") {
t.Errorf("expected output to end with </html>")
}
}
// TestRunWithIO_UnsupportedPlatformForPersistence verifies that using
// --state-file with an unknown platform surfaces a clear error rather than
// silently falling back to EphemeralStore.
+96
View File
@@ -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/[email protected]/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 <table> 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 <table> markup.
var documentTemplate = template.Must(template.New("html").Parse(`<!DOCTYPE html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.CSSURL}}" integrity="{{.Integrity}}" crossorigin="anonymous">
</head>
<body>
<main class="container-fluid py-4">
<h1>{{.Title}}</h1>
<div class="table-responsive">
{{.TableFragment}}
</div>
<p class="text-muted small">Generated at {{.GeneratedAt}}</p>
</main>
</body>
</html>
`))
// 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 <tr> 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
}
+184
View File
@@ -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{
"<!DOCTYPE html>",
`<html lang="en">`,
`<meta charset="utf-8">`,
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
`<title>Tax report</title>`,
`https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css`,
`integrity="`,
`crossorigin="anonymous"`,
`<main class="container-fluid py-4">`,
`<h1>Tax report</h1>`,
`<div class="table-responsive">`,
`<table class="table table-striped table-hover table-bordered">`,
`</table>`,
`<tfoot>`,
`</tfoot>`,
`</html>`,
`<p class="text-muted small">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, "&lt;table") {
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
}
if !strings.Contains(got, "<table") {
t.Errorf("expected raw <table> 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, "</p>")
if endIdx == -1 {
t.Fatalf("missing </p> closing tag after 'Generated at' line")
}
timestampStr := strings.TrimPrefix(strings.SplitN(timestampLine, "</p>", 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 <tr> 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, "<tr")
secondRows := strings.Count(second, "<tr")
if firstRows == 0 {
t.Fatalf("expected at least one <tr> row in the first render; got:\n%s", first)
}
if firstRows != secondRows {
t.Errorf("expected second Render to produce the same number of <tr> 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, `<html lang="pt">`) {
t.Errorf("expected <html lang=\"pt\"> attribute; got:\n%s", got)
}
if !strings.Contains(got, `<h1>Relatório fiscal</h1>`) {
t.Errorf("expected Portuguese title in <h1>; got:\n%s", got)
}
}
@@ -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"
@@ -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)
+4
View File
@@ -46,5 +46,9 @@
"sum": {
"one": "Sum",
"other": "Sums"
},
"report_title": {
"one": "Tax report",
"other": "Tax reports"
}
}
+4
View File
@@ -46,5 +46,9 @@
"sum": {
"one": "Soma",
"other": "Somas"
},
"report_title": {
"one": "Relatório fiscal",
"other": "Relatórios fiscais"
}
}