print as html
This commit is contained in:
1 parent
bb00676452
commit
3b971cda61
9 files changed
+339
-7
No files matched your search
@@ -23,6 +23,8 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
|
|||||||
cat statement.csv | any2anexoj-cli --platform=tranding212
|
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
|
### Incremental Reports
|
||||||
|
|
||||||
For year over year reporting, pass `--state-file <path>` to keep the FIFO buy-queue between runs. The typical workflow is:
|
For year over year reporting, pass `--state-file <path>` to keep the FIFO buy-queue between runs. The typical workflow is:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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"
|
||||||
"github.com/nmoniz/any2anexoj/internal/writing/pretty"
|
"github.com/nmoniz/any2anexoj/internal/writing/pretty"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
@@ -26,7 +27,7 @@ var (
|
|||||||
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
|
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
|
||||||
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
|
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
|
||||||
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
|
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)")
|
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")
|
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")
|
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)
|
pretty.NewPrettyPrinter(stdout, loc).Render(writer)
|
||||||
return nil
|
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:
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// TestRunWithIO_UnsupportedPlatformForPersistence verifies that using
|
||||||
// --state-file with an unknown platform surfaces a clear error rather than
|
// --state-file with an unknown platform surfaces a clear error rather than
|
||||||
// silently falling back to EphemeralStore.
|
// silently falling back to EphemeralStore.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "<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,4 +1,4 @@
|
|||||||
package pretty
|
package table
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package pretty_test
|
package table_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
"github.com/nmoniz/any2anexoj/internal/writing"
|
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||||
"github.com/nmoniz/any2anexoj/internal/writing/pretty"
|
"github.com/nmoniz/any2anexoj/internal/writing/table"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,7 +58,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 := pretty.NewPrettyPrinter(&buf, localizer)
|
pp := table.NewPrettyPrinter(&buf, localizer)
|
||||||
|
|
||||||
// Render the table
|
// Render the table
|
||||||
pp.Render(aw)
|
pp.Render(aw)
|
||||||
@@ -115,7 +115,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 := pretty.NewPrettyPrinter(&buf, localizer)
|
pp := table.NewPrettyPrinter(&buf, localizer)
|
||||||
|
|
||||||
html := pp.RenderHTML(aw)
|
html := pp.RenderHTML(aw)
|
||||||
|
|
||||||
@@ -46,5 +46,9 @@
|
|||||||
"sum": {
|
"sum": {
|
||||||
"one": "Sum",
|
"one": "Sum",
|
||||||
"other": "Sums"
|
"other": "Sums"
|
||||||
|
},
|
||||||
|
"report_title": {
|
||||||
|
"one": "Tax report",
|
||||||
|
"other": "Tax reports"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,5 +46,9 @@
|
|||||||
"sum": {
|
"sum": {
|
||||||
"one": "Soma",
|
"one": "Soma",
|
||||||
"other": "Somas"
|
"other": "Somas"
|
||||||
|
},
|
||||||
|
"report_title": {
|
||||||
|
"one": "Relatório fiscal",
|
||||||
|
"other": "Relatórios fiscais"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user