addressed consistency

This commit is contained in:
natercio committed 2026-10-05 15:08:19 +01:00
1 parent 6703481dbb
commit e167627ac3
8 files changed
+99 -83

No files matched your search

+6 -7
View File
@@ -15,8 +15,8 @@ import (
"github.com/nmoniz/any2anexoj/internal/trading212" "github.com/nmoniz/any2anexoj/internal/trading212"
"github.com/nmoniz/any2anexoj/internal/writing" "github.com/nmoniz/any2anexoj/internal/writing"
"github.com/nmoniz/any2anexoj/internal/writing/csv" "github.com/nmoniz/any2anexoj/internal/writing/csv"
"github.com/nmoniz/any2anexoj/internal/writing/html" htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
"github.com/nmoniz/any2anexoj/internal/writing/pretty" "github.com/nmoniz/any2anexoj/internal/writing/table"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"golang.org/x/text/language" "golang.org/x/text/language"
) )
@@ -102,13 +102,13 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
switch *format { switch *format {
case "csv": case "csv":
return csv.NewCSVWriter(stdout).Render(writer) return csv.NewWriter(stdout).Render(writer)
case "table": case "table":
loc, err := writing.NewLocalizer(*lang) loc, err := writing.NewLocalizer(*lang)
if err != nil { if err != nil {
return fmt.Errorf("create localizer: %w", err) return fmt.Errorf("create localizer: %w", err)
} }
pretty.NewPrettyPrinter(stdout, loc).Render(writer) table.NewPrinter(stdout, loc).Render(writer)
return nil return nil
case "html": case "html":
loc, err := writing.NewLocalizer(*lang) loc, err := writing.NewLocalizer(*lang)
@@ -117,9 +117,8 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
} }
// RenderHTML never writes to the printer's output writer, so // RenderHTML never writes to the printer's output writer, so
// io.Discard is safe here. // io.Discard is safe here.
printer := pretty.NewPrettyPrinter(io.Discard, loc) printer := table.NewPrinter(io.Discard, loc)
title := loc.Translate("report_title", 1, nil) return htmlwriter.NewWriter(stdout, printer, loc, *lang).Render(writer)
return html.NewHTMLWriter(stdout, printer, title, *lang).Render(writer)
default: default:
return fmt.Errorf("unsupported format %q: must be table, csv or html", *format) return fmt.Errorf("unsupported format %q: must be table, csv or html", *format)
} }
+4 -4
View File
@@ -10,15 +10,15 @@ import (
"github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal"
) )
type CSVWriter struct { type Writer struct {
w *csv.Writer w *csv.Writer
} }
func NewCSVWriter(w io.Writer) *CSVWriter { func NewWriter(w io.Writer) *Writer {
return &CSVWriter{w: csv.NewWriter(w)} return &Writer{w: csv.NewWriter(w)}
} }
func (cw *CSVWriter) Render(aw *internal.AggregatorWriter) error { func (cw *Writer) Render(aw *internal.AggregatorWriter) error {
err := cw.w.Write([]string{ err := cw.w.Write([]string{
"source_country", "code", "source_country", "code",
"realization_year", "realization_month", "realization_day", "realization_value", "realization_year", "realization_month", "realization_day", "realization_value",
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
func TestCSVWriter_Render(t *testing.T) { func TestWriter_Render(t *testing.T) {
aw := internal.NewAggregatorWriter() aw := internal.NewAggregatorWriter()
ctx := t.Context() ctx := t.Context()
@@ -49,7 +49,7 @@ func TestCSVWriter_Render(t *testing.T) {
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := csv.NewCSVWriter(&buf).Render(aw); err != nil { if err := csv.NewWriter(&buf).Render(aw); err != nil {
t.Fatalf("render: %v", err) t.Fatalf("render: %v", err)
} }
+18
View File
@@ -0,0 +1,18 @@
<!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>
+30 -36
View File
@@ -1,79 +1,73 @@
// Package html renders an internal.AggregatorWriter as a complete, // Package html renders an internal.AggregatorWriter as a complete,
// standalone HTML document built around the same table fragment produced by // standalone HTML document built around the same table fragment produced by
// pretty.PrettyPrinter.RenderHTML. The document includes the Bootstrap 5.3 // table.Printer.RenderHTML. The document includes the Bootstrap 5.3
// stylesheet via CDN and embeds the raw table fragment without HTML-escaping // stylesheet via CDN and embeds the raw table fragment without HTML-escaping
// it so the result opens directly in a browser. // it so the result opens directly in a browser.
package html package html
import ( import (
_ "embed"
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
"time" "time"
"github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/writing/pretty" "github.com/nmoniz/any2anexoj/internal/writing/table"
) )
// translator is the contract the HTML writer relies on for localized
// document text such as the page title. *writing.Localizer satisfies it.
type translator interface {
Translate(key string, count int, values map[string]any) string
}
// bootstrapCSSURL is the jsDelivr CDN URL for Bootstrap 5.3.3. // 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" 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. // bootstrapIntegrity is the official SRI hash for Bootstrap 5.3.3 css.
const bootstrapIntegrity = "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" const bootstrapIntegrity = "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
// HTMLWriter writes a complete HTML document for an AggregatorWriter. // Writer writes a complete HTML document for an AggregatorWriter.
type HTMLWriter struct { type Writer struct {
output io.Writer output io.Writer
printer *pretty.PrettyPrinter printer *table.Printer
title string translator translator
lang string lang string
} }
// NewHTMLWriter builds an HTMLWriter that writes a complete HTML document to // NewWriter builds a Writer that writes a complete HTML document to
// w. The printer is used purely as a source for the raw <table> fragment via // 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 // RenderHTML; this writer never invokes Render or any method that writes to
// printer's own io.Writer. // printer's own io.Writer. The document title is looked up from the
func NewHTMLWriter(w io.Writer, printer *pretty.PrettyPrinter, title, lang string) *HTMLWriter { // "report_title" translation via tr, so callers never pass a pre-baked title.
return &HTMLWriter{ func NewWriter(w io.Writer, printer *table.Printer, tr translator, lang string) *Writer {
return &Writer{
output: w, output: w,
printer: printer, printer: printer,
title: title, translator: tr,
lang: lang, lang: lang,
} }
} }
// documentTemplate renders the full HTML page. The table fragment is typed as //go:embed document.tmpl
// template.HTML so the template engine emits it verbatim without escaping var documentTemplateBody string
// the angle brackets in the <table> markup.
var documentTemplate = template.Must(template.New("html").Parse(`<!DOCTYPE html> // documentTemplate renders the full HTML page from document.tmpl. The table
<html lang="{{.Lang}}"> // fragment is typed as template.HTML so the template engine emits it
<head> // verbatim without escaping the angle brackets in the <table> markup.
<meta charset="utf-8"> var documentTemplate = template.Must(template.New("html").Parse(documentTemplateBody))
<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 // Render writes a complete standalone HTML document for aw to the configured
// output. The same HTMLWriter may be reused; each call rebuilds the table // output. The same Writer may be reused; each call rebuilds the table
// fragment from aw so the rendered <tr> count reflects aw's current items and // fragment from aw so the rendered <tr> count reflects aw's current items and
// no rows are duplicated across invocations. // no rows are duplicated across invocations.
func (hw *HTMLWriter) Render(aw *internal.AggregatorWriter) error { func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
if hw.printer == nil { if hw.printer == nil {
return fmt.Errorf("html: nil printer") return fmt.Errorf("html: nil printer")
} }
fragment := hw.printer.RenderHTML(aw) fragment := hw.printer.RenderHTML(aw)
title := hw.translator.Translate("report_title", 1, nil)
data := struct { data := struct {
Lang string Lang string
Title string Title string
@@ -83,7 +77,7 @@ func (hw *HTMLWriter) Render(aw *internal.AggregatorWriter) error {
GeneratedAt string GeneratedAt string
}{ }{
Lang: hw.lang, Lang: hw.lang,
Title: hw.title, Title: title,
CSSURL: bootstrapCSSURL, CSSURL: bootstrapCSSURL,
Integrity: bootstrapIntegrity, Integrity: bootstrapIntegrity,
TableFragment: template.HTML(fragment), TableFragment: template.HTML(fragment),
+13 -13
View File
@@ -7,7 +7,7 @@ import (
"time" "time"
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html" htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
"github.com/nmoniz/any2anexoj/internal/writing/pretty" "github.com/nmoniz/any2anexoj/internal/writing/table"
"github.com/nmoniz/any2anexoj/internal/writing" "github.com/nmoniz/any2anexoj/internal/writing"
"github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
@@ -16,7 +16,7 @@ import (
// buildTestAggregator returns an AggregatorWriter seeded with two // buildTestAggregator returns an AggregatorWriter seeded with two
// deterministic ReportItems so every test can assert on a stable document // deterministic ReportItems so every test can assert on a stable document
// shape. Mirrors the fixture pattern used in // shape. Mirrors the fixture pattern used in
// internal/writing/pretty/printer_test.go. // internal/writing/table/printer_test.go.
func buildTestAggregator(t *testing.T) *internal.AggregatorWriter { func buildTestAggregator(t *testing.T) *internal.AggregatorWriter {
t.Helper() t.Helper()
aw := internal.NewAggregatorWriter() aw := internal.NewAggregatorWriter()
@@ -57,7 +57,7 @@ func buildTestAggregator(t *testing.T) *internal.AggregatorWriter {
return aw return aw
} }
func TestHTMLWriter_Render(t *testing.T) { func TestWriter_Render(t *testing.T) {
loc, err := writing.NewLocalizer("en") loc, err := writing.NewLocalizer("en")
if err != nil { if err != nil {
t.Fatalf("create localizer: %v", err) t.Fatalf("create localizer: %v", err)
@@ -65,8 +65,8 @@ func TestHTMLWriter_Render(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
// RenderHTML never writes to the printer's output, so Discard is safe. // RenderHTML never writes to the printer's output, so Discard is safe.
pp := pretty.NewPrettyPrinter(&buf, loc) pp := table.NewPrinter(&buf, loc)
hw := htmlwriter.NewHTMLWriter(&buf, pp, "Tax report", "en") hw := htmlwriter.NewWriter(&buf, pp, loc, "en")
if err := hw.Render(buildTestAggregator(t)); err != nil { if err := hw.Render(buildTestAggregator(t)); err != nil {
t.Fatalf("Render returned an error: %v", err) t.Fatalf("Render returned an error: %v", err)
@@ -123,18 +123,18 @@ func TestHTMLWriter_Render(t *testing.T) {
} }
} }
// TestHTMLWriter_NoRowDuplication verifies that calling Render twice on the // TestWriter_NoRowDuplication verifies that calling Render twice on the
// same writer produces documents with the same number of <tr> rows, just // same writer produces documents with the same number of <tr> rows, just
// like pretty.PrettyPrinter's no-duplication guarantee. // like table.Printer's no-duplication guarantee.
func TestHTMLWriter_NoRowDuplication(t *testing.T) { func TestWriter_NoRowDuplication(t *testing.T) {
loc, err := writing.NewLocalizer("en") loc, err := writing.NewLocalizer("en")
if err != nil { if err != nil {
t.Fatalf("create localizer: %v", err) t.Fatalf("create localizer: %v", err)
} }
var buf bytes.Buffer var buf bytes.Buffer
pp := pretty.NewPrettyPrinter(&buf, loc) pp := table.NewPrinter(&buf, loc)
hw := htmlwriter.NewHTMLWriter(&buf, pp, "Tax report", "en") hw := htmlwriter.NewWriter(&buf, pp, loc, "en")
aw := buildTestAggregator(t) aw := buildTestAggregator(t)
@@ -160,15 +160,15 @@ func TestHTMLWriter_NoRowDuplication(t *testing.T) {
} }
} }
func TestHTMLWriter_LangAttribute(t *testing.T) { func TestWriter_LangAttribute(t *testing.T) {
loc, err := writing.NewLocalizer("pt") loc, err := writing.NewLocalizer("pt")
if err != nil { if err != nil {
t.Fatalf("create localizer: %v", err) t.Fatalf("create localizer: %v", err)
} }
var buf bytes.Buffer var buf bytes.Buffer
pp := pretty.NewPrettyPrinter(&buf, loc) pp := table.NewPrinter(&buf, loc)
hw := htmlwriter.NewHTMLWriter(&buf, pp, "Relatório fiscal", "pt") hw := htmlwriter.NewWriter(&buf, pp, loc, "pt")
if err := hw.Render(buildTestAggregator(t)); err != nil { if err := hw.Render(buildTestAggregator(t)); err != nil {
t.Fatalf("Render returned an error: %v", err) t.Fatalf("Render returned an error: %v", err)
+21 -16
View File
@@ -1,3 +1,6 @@
// Package table renders an internal.AggregatorWriter as the human-readable
// Anexo J report table, for the terminal (Render) and for embedding in HTML
// documents (RenderHTML).
package table package table
import ( import (
@@ -10,23 +13,22 @@ import (
"github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal"
) )
// PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each // translator is the contract the table printer relies on for human-readable
// column labels. *Localizer satisfies it.
type translator interface {
Translate(key string, count int, values map[string]any) string
}
// Printer writes a simple, human readable, table row to the provided io.Writer for each
// ReportItem received. Render and RenderHTML each build a fresh table.Writer so that // ReportItem received. Render and RenderHTML each build a fresh table.Writer so that
// invoking them (in any order or repeatedly) on the same instance never duplicates rows. // invoking them (in any order or repeatedly) on the same instance never duplicates rows.
type PrettyPrinter struct { type Printer struct {
output io.Writer output io.Writer
translator Translator translator Translator
} }
<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer.go func NewPrinter(w io.Writer, tr translator) *Printer {
type Translator interface { return &Printer{
Translate(key string, count int, values map[string]any) string
}
func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter {
========
func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter {
return &PrettyPrinter{
output: w, output: w,
translator: tr, translator: tr,
} }
@@ -37,8 +39,7 @@ func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter {
// multiple times without duplicating rows. The returned table is not wired to // multiple times without duplicating rows. The returned table is not wired to
// pp.output via an output mirror; callers that want the rendered output // pp.output via an output mirror; callers that want the rendered output
// written to pp.output must set the mirror themselves before calling .Render(). // written to pp.output must set the mirror themselves before calling .Render().
func (pp *PrettyPrinter) buildTable(aw *internal.AggregatorWriter) table.Writer { func (pp *Printer) buildTable(aw *internal.AggregatorWriter) table.Writer {
>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer.go
tw := table.NewWriter() tw := table.NewWriter()
tw.SetAutoIndex(true) tw.SetAutoIndex(true)
tw.SetStyle(table.StyleLight) tw.SetStyle(table.StyleLight)
@@ -83,12 +84,16 @@ func (pp *PrettyPrinter) buildTable(aw *internal.AggregatorWriter) table.Writer
} }
sumTxt := pp.translator.Translate("sum", 1, nil) sumTxt := pp.translator.Translate("sum", 1, nil)
tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight}) // The footer lists one cell per column (13) so it never relies on
// go-pretty's implicit short-row padding; the trailing empty cell is the
// counter-country column, which has no total. Totals are formatted with
// StringFixed(2) so they match the two-decimal data rows.
tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned().StringFixed(2), "", "", "", aw.TotalSpent().StringFixed(2), aw.TotalFees().StringFixed(2), aw.TotalTaxes().StringFixed(2), ""}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
return tw return tw
} }
func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { func (pp *Printer) Render(aw *internal.AggregatorWriter) {
t := pp.buildTable(aw) t := pp.buildTable(aw)
t.SetOutputMirror(pp.output) t.SetOutputMirror(pp.output)
t.Render() t.Render()
@@ -96,7 +101,7 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) {
// RenderHTML builds a fresh table from aw and renders it as an HTML string // RenderHTML builds a fresh table from aw and renders it as an HTML string
// using go-pretty's HTMLOptions. // using go-pretty's HTMLOptions.
func (pp *PrettyPrinter) RenderHTML(aw *internal.AggregatorWriter) string { func (pp *Printer) RenderHTML(aw *internal.AggregatorWriter) string {
t := pp.buildTable(aw) t := pp.buildTable(aw)
t.Style().HTML = table.HTMLOptions{ t.Style().HTML = table.HTMLOptions{
CSSClass: "table table-striped table-hover table-bordered", CSSClass: "table table-striped table-hover table-bordered",
+5 -5
View File
@@ -14,7 +14,7 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
func TestPrettyPrinter_Render(t *testing.T) { func TestPrinter_Render(t *testing.T) {
// Create test data // Create test data
aw := internal.NewAggregatorWriter() aw := internal.NewAggregatorWriter()
ctx := t.Context() ctx := t.Context()
@@ -64,7 +64,7 @@ func TestPrettyPrinter_Render(t *testing.T) {
// Create writing printer with buffer // Create writing printer with buffer
var buf bytes.Buffer var buf bytes.Buffer
pp := table.NewPrettyPrinter(&buf, localizer) pp := table.NewPrinter(&buf, localizer)
// Render the table // Render the table
pp.Render(aw) pp.Render(aw)
@@ -81,13 +81,13 @@ func TestPrettyPrinter_Render(t *testing.T) {
│ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │ │ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │
│ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │ │ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │
├───┼─────────────────────┴──────┴──────┴───────┴─────┼──────────────┼──────┴───────┴─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤ ├───┼─────────────────────┴──────┴──────┴───────┴─────┼──────────────┼──────┴───────┴─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
│ │ SUM │ 376.25 € │ │ 300.5 € │ 5.5 € │ 12.5 € │ │ │ │ SUM │ 376.25 € │ │ 300.50 € │ 5.50 € │ 12.50 € │ │
└───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘ └───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘
` `
// Compare output // Compare output
if got != want { if got != want {
t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want) t.Errorf("Printer.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
} }
} }
@@ -121,7 +121,7 @@ func TestRenderHTML(t *testing.T) {
// Output buffer used only to satisfy the constructor contract; RenderHTML // Output buffer used only to satisfy the constructor contract; RenderHTML
// must not write to it. // must not write to it.
var buf bytes.Buffer var buf bytes.Buffer
pp := table.NewPrettyPrinter(&buf, localizer) pp := table.NewPrinter(&buf, localizer)
html := pp.RenderHTML(aw) html := pp.RenderHTML(aw)