Implement HTML writer #33
No files matched your search
@@ -27,7 +27,7 @@ cat statement.csv | any2anexoj-cli --platform=trading212
|
||||
|
||||
- `table` (default): pretty terminal table
|
||||
- `csv`: the Anexo J CSV schema
|
||||
- `html`: a complete, standalone Bootstrap-styled HTML report — a `<!DOCTYPE html>` document with the Anexo J table embedded and Bootstrap loaded via CDN
|
||||
- `html`: a complete, standalone Bootstrap-styled HTML report — a `<!DOCTYPE html>` document with the Anexo J table embedded and the official Bootstrap 5.3.3 stylesheet inlined via `go:embed`, so the report renders fully offline
|
||||
|
||||
The `table` and `html` formats are translated according to `--language`/`-l` (`pt` by default, `en` available). Other flags: `--selectors` filters which entries are processed (`code`, `assetCountry`), `--open-figi-api-key` raises OpenFIGI rate limits for faster report generation, and `--debug` enables debug logging.
|
||||
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// resetFlags puts every pflag-backed variable back to its default so each
|
||||
// test that calls runWithIO sees a clean slate regardless of test ordering
|
||||
// or arguments the previous test set via pflag.Set.
|
||||
func resetFlags(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := pflag.Set("platform", "trading212"); err != nil {
|
||||
t.Fatalf("reset platform flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("language", "en"); err != nil {
|
||||
t.Fatalf("reset language flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("debug", "false"); err != nil {
|
||||
t.Fatalf("reset debug flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("format", "table"); err != nil {
|
||||
t.Fatalf("reset format flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("open-figi-api-key", ""); err != nil {
|
||||
t.Fatalf("reset open-figi-api-key flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("selectors", ""); err != nil {
|
||||
t.Fatalf("reset selectors flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("state-file", ""); err != nil {
|
||||
t.Fatalf("reset state-file flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// trading212SampleCSV is a minimal Trading212 export with a header row and
|
||||
// one market buy + one matching market sell so BuildReport reaches EOF and
|
||||
// exercises the store.Save path. The line format mirrors the fixtures in
|
||||
// internal/trading212/record_test.go (20 columns).
|
||||
const trading212SampleCSV = `Action,Time,ISIN,Ticker,Name,Notes,Quantity,Price,Price currency,Exchange rate,Result,Result currency,Charges,Charges currency,Stamp duty,Stamp duty currency,Conversion fee,Conversion fee currency,French transaction tax,French transaction tax currency
|
||||
Market buy,2025-07-03 10:44:29,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,
|
||||
Market sell,2025-08-04 11:45:30,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"
|
||||
`
|
||||
|
||||
// runWithStdin runs runWithIO against the supplied stdin payload.
|
||||
func runWithStdin(t *testing.T, stdin string, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
if err := runWithIO(t.Context(), strings.NewReader(stdin), stdout); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestRunWithIO_StateFileCreated verifies that running the CLI with
|
||||
// --state-file produces a state file on disk after a successful EOF.
|
||||
func TestRunWithIO_StateFileCreated(t *testing.T) {
|
||||
resetFlags(t)
|
||||
t.Cleanup(func() { resetFlags(t) })
|
||||
|
||||
dir := t.TempDir()
|
||||
statePath := filepath.Join(dir, "state.json")
|
||||
|
||||
if err := pflag.Set("state-file", statePath); err != nil {
|
||||
t.Fatalf("set state-file flag: %v", err)
|
||||
}
|
||||
if err := pflag.Set("format", "csv"); 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())
|
||||
}
|
||||
|
||||
info, err := os.Stat(statePath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected state file at %s but stat returned error: %v", statePath, err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatalf("state file at %s is empty", statePath)
|
||||
}
|
||||
|
||||
// State file must look like JSON with the expected version field.
|
||||
body, err := os.ReadFile(statePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read state file: %v", err)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"version"`)) {
|
||||
t.Errorf("state file missing version field, got: %s", body)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"trading212"`)) {
|
||||
t.Errorf("state file missing trading212 platform, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWithIO_NoStateFileByDefault verifies that omitting --state-file
|
||||
// behaves exactly like the pre-persistence CLI: nothing is written to disk
|
||||
// and the report still renders.
|
||||
func TestRunWithIO_NoStateFileByDefault(t *testing.T) {
|
||||
resetFlags(t)
|
||||
t.Cleanup(func() { resetFlags(t) })
|
||||
|
||||
// Use a temp working directory so any accidental file write would
|
||||
// show up clearly via t.TempDir's cleanup listing.
|
||||
dir := t.TempDir()
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
|
||||
if err := pflag.Set("format", "csv"); 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())
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read tmp dir: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
t.Errorf("unexpected file written without --state-file: %s", e.Name())
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
t.Errorf("expected non-empty csv output on stdout")
|
||||
}
|
||||
}
|
||||
|
||||
// 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, "<h1>Tax report</h1>") {
|
||||
t.Errorf("expected output to contain the translated report title")
|
||||
}
|
||||
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.
|
||||
func TestRunWithIO_UnsupportedPlatformForPersistence(t *testing.T) {
|
||||
resetFlags(t)
|
||||
t.Cleanup(func() { resetFlags(t) })
|
||||
|
||||
if err := pflag.Set("state-file", filepath.Join(t.TempDir(), "state.json")); err != nil {
|
||||
t.Fatalf("set state-file flag: %v", err)
|
||||
}
|
||||
// Currently only trading212 is wired through buildStore, but the
|
||||
// reader switch also only supports trading212, so the reader error
|
||||
// fires first. Either error is acceptable; we just need a clear
|
||||
// failure message.
|
||||
if err := pflag.Set("platform", "unknown-broker"); err != nil {
|
||||
t.Fatalf("set platform flag: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := runWithStdin(t, trading212SampleCSV, &stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error for unsupported platform")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "platform") {
|
||||
t.Errorf("expected error to mention platform, got: %v", err)
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
@charset "UTF-8";/*!
|
||||
* Bootstrap v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
|
||||
/*# sourceMappingURL=bootstrap.min.css.map */
|
||||
@@ -4,7 +4,7 @@
|
||||
<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">
|
||||
<style>{{.CSS}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container-fluid py-4">
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// standalone HTML document: the report table fragment (built from the
|
||||
// shared table definition in internal/writing/internal/tabledef, so it can
|
||||
// never drift from the terminal layout) embedded in a Bootstrap-styled page
|
||||
// with a translated title and generation timestamp.
|
||||
// with a translated title and generation timestamp. The official Bootstrap
|
||||
// 5.3.3 stylesheet is inlined via go:embed, so the document renders fully
|
||||
// offline.
|
||||
package html
|
||||
|
||||
import (
|
||||
@@ -16,11 +18,18 @@ import (
|
||||
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
||||
)
|
||||
|
||||
// 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"
|
||||
// bootstrapMinCSS is the official Bootstrap 5.3.3 minified CSS distribution
|
||||
// file, embedded so every rendered document renders fully offline. After any
|
||||
// version bump, re-verify provenance with:
|
||||
//
|
||||
// openssl dgst -sha384 -binary bootstrap.min.css | base64
|
||||
//
|
||||
// It must print the official SRI hash published at
|
||||
// https://getbootstrap.com/docs/5.3/getting-started/download/
|
||||
// (sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH).
|
||||
//
|
||||
//go:embed bootstrap.min.css
|
||||
var bootstrapMinCSS string
|
||||
|
||||
// Writer writes a complete HTML document for an AggregatorWriter.
|
||||
type Writer struct {
|
||||
@@ -59,15 +68,13 @@ func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
|
||||
data := struct {
|
||||
Lang string
|
||||
Title string
|
||||
CSSURL string
|
||||
Integrity string
|
||||
CSS template.CSS
|
||||
TableFragment template.HTML
|
||||
GeneratedAt string
|
||||
}{
|
||||
Lang: hw.lang,
|
||||
Title: title,
|
||||
CSSURL: bootstrapCSSURL,
|
||||
Integrity: bootstrapIntegrity,
|
||||
CSS: template.CSS(bootstrapMinCSS),
|
||||
TableFragment: template.HTML(fragment),
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
@@ -77,9 +77,8 @@ func TestWriter_Render(t *testing.T) {
|
||||
`<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"`,
|
||||
`<style>`,
|
||||
`Bootstrap v5.3.3`,
|
||||
`<main class="container-fluid py-4">`,
|
||||
`<h1>Tax report</h1>`,
|
||||
`<div class="table-responsive">`,
|
||||
@@ -105,6 +104,15 @@ func TestWriter_Render(t *testing.T) {
|
||||
t.Errorf("expected raw <table> tag in output\nGot:\n%s", got)
|
||||
}
|
||||
|
||||
// The document must be fully self-contained: no external stylesheet
|
||||
// <link> and no CDN reference anywhere.
|
||||
if strings.Contains(got, "<link") {
|
||||
t.Errorf("expected no external stylesheet <link>; document must be self-contained\nGot:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "cdn.jsdelivr.net") {
|
||||
t.Errorf("expected no CDN references; document must be self-contained\nGot:\n%s", got)
|
||||
}
|
||||
|
||||
// Generated timestamp must be valid RFC3339 UTC.
|
||||
startIdx := strings.Index(got, "Generated at ")
|
||||
if startIdx == -1 {
|
||||
|
||||
Reference in new issue
Block a user