feat: add xlsx export wherever csv export exists (#31690)

This commit is contained in:
andig 2026-07-12 11:58:02 +02:00 • committed by GitHub
parent 3158ff16ef
commit 3ce6600143
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 873 additions and 425 deletions

View file

@ -1,156 +0,0 @@
package csv
import (
"context"
"encoding/csv"
"fmt"
"io"
"reflect"
"strings"
"time"
"github.com/evcc-io/evcc/util/locale"
"github.com/fatih/structs"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
"golang.org/x/text/message"
"golang.org/x/text/number"
)
type Config struct {
I18nPrefix string // e.g., "sessions.csv" or "config.hems.csv"
}
func formatValue(mp *message.Printer, value any, digits int) string {
if rv := reflect.ValueOf(value); rv.Kind() == reflect.Pointer && rv.IsNil() {
return ""
}
switch v := value.(type) {
case float64:
return mp.Sprint(number.Decimal(v, number.NoSeparator(), number.MaxFractionDigits(digits)))
case *float64:
return mp.Sprint(number.Decimal(*v, number.NoSeparator(), number.MaxFractionDigits(digits)))
case time.Time:
if v.IsZero() {
return ""
}
return v.Local().Format("2006-01-02 15:04:05")
default:
return fmt.Sprintf("%v", value)
}
}
func writeHeader(ctx context.Context, ww *csv.Writer, structType any, i18nPrefix string) error {
localizer := locale.Localizer
if val, ok := ctx.Value(locale.Locale).(string); ok && val != "" {
localizer = i18n.NewLocalizer(locale.Bundle, val, locale.Language)
}
var row []string
for _, f := range structs.Fields(structType) {
csvTag := f.Tag("csv")
if csvTag == "-" {
continue
}
caption, err := localizer.Localize(&locale.Config{
MessageID: i18nPrefix + "." + strings.ToLower(f.Name()),
})
if err != nil {
if csvTag != "" {
caption = csvTag
} else {
caption = f.Name()
}
}
row = append(row, caption)
}
return ww.Write(row)
}
func writeRow(ww *csv.Writer, mp *message.Printer, structVal any) error {
var row []string
for _, f := range structs.Fields(structVal) {
if f.Tag("csv") == "-" {
continue
}
digits := 4
if format := f.Tag("format"); format == "int" {
digits = 0
}
val := formatValue(mp, f.Value(), digits)
row = append(row, val)
}
return ww.Write(row)
}
// NewLocalizedWriter writes a UTF-8 BOM and returns a locale-configured
// csv.Writer (German uses ';') and matching number printer.
func NewLocalizedWriter(ctx context.Context, w io.Writer) (*csv.Writer, *message.Printer, error) {
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return nil, nil, err
}
lang := locale.Language
if v, ok := ctx.Value(locale.Locale).(string); ok && v != "" {
lang = v
}
if lang == "" {
lang = "en"
}
tag, err := language.Parse(lang)
if err != nil {
return nil, nil, err
}
ww := csv.NewWriter(w)
if b, _ := tag.Base(); b.String() == language.German.String() {
ww.Comma = ';'
}
return ww, message.NewPrinter(tag), nil
}
// WriteStructSlice writes a slice of structs to CSV format with localized headers
func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg Config) error {
ww, mp, err := NewLocalizedWriter(ctx, w)
if err != nil {
return err
}
sliceVal := reflect.ValueOf(slice)
if sliceVal.Kind() == reflect.Pointer {
sliceVal = sliceVal.Elem()
}
if sliceVal.Kind() != reflect.Slice {
return fmt.Errorf("expected slice, got %T", slice)
}
var structType any
if sliceVal.Len() > 0 {
structType = sliceVal.Index(0).Interface()
} else {
structType = reflect.New(sliceVal.Type().Elem()).Elem().Interface()
}
if err := writeHeader(ctx, ww, structType, cfg.I18nPrefix); err != nil {
return err
}
for i := 0; i < sliceVal.Len(); i++ {
row := sliceVal.Index(i).Interface()
if err := writeRow(ww, mp, row); err != nil {
return err
}
}
ww.Flush()
return ww.Error()
}

22
util/export/api.go Normal file
View file

@ -0,0 +1,22 @@
package export
import (
"github.com/nicksnyder/go-i18n/v2/i18n"
)
type Config struct {
I18nPrefix string // e.g., "sessions.csv" or "config.hems.csv"
}
// RowWriter is a typed row sink shared by the csv and xlsx exporters
type RowWriter interface {
Write(record []any) error
Flush()
Error() error
Localizer() *i18n.Localizer
}
// Writer serializes itself to a RowWriter.
type Writer interface {
Write(RowWriter) error
}

56
util/export/csv/csv.go Normal file
View file

@ -0,0 +1,56 @@
package csv
import (
"context"
"encoding/csv"
"fmt"
"io"
"strconv"
"time"
"github.com/evcc-io/evcc/util/export"
"github.com/nicksnyder/go-i18n/v2/i18n"
)
// rowWriter renders typed cells as locale-independent strings
type rowWriter struct {
*csv.Writer
localizer *i18n.Localizer
}
func (w rowWriter) Localizer() *i18n.Localizer { return w.localizer }
func (w rowWriter) Write(record []any) error {
row := make([]string, len(record))
for i, v := range record {
switch v := v.(type) {
case nil:
row[i] = ""
case float64:
row[i] = strconv.FormatFloat(v, 'f', -1, 64)
case time.Time:
row[i] = v.Format("2006-01-02 15:04:05")
default:
row[i] = fmt.Sprintf("%v", v)
}
}
return w.Writer.Write(row)
}
// New writes a UTF-8 BOM and returns a csv row writer. The context locale only affects header captions.
func New(ctx context.Context, w io.Writer) (export.RowWriter, error) {
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return nil, err
}
return rowWriter{csv.NewWriter(w), export.Localizer(ctx)}, nil
}
// WriteStructSlice writes a slice of structs to CSV with localized headers.
func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg export.Config) error {
ww, err := New(ctx, w)
if err != nil {
return err
}
return export.WriteStructSlice(ww, slice, cfg)
}

View file

@ -9,11 +9,12 @@ import (
"time"
"github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/util/export"
"github.com/evcc-io/evcc/util/locale"
)
func init() {
assets.I18n = os.DirFS("../../i18n")
assets.I18n = os.DirFS("../../../i18n")
_ = locale.Init()
}
@ -32,17 +33,12 @@ func TestWriteStructSlice_Empty(t *testing.T) {
slice := TestStructs{}
ctx := context.WithValue(context.Background(), locale.Locale, "en")
err := WriteStructSlice(ctx, &buf, &slice, Config{
I18nPrefix: "test.csv",
})
if err != nil {
if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
t.Errorf("Expected no error, got %v", err)
}
output := buf.String()
if !strings.Contains(output, "Name") {
t.Errorf("Expected header with 'Name', got: %s", output)
if !strings.Contains(buf.String(), "Name") {
t.Errorf("Expected header with 'Name', got: %s", buf.String())
}
}
@ -60,16 +56,11 @@ func TestWriteStructSlice_WithData(t *testing.T) {
}
ctx := context.WithValue(context.Background(), locale.Locale, "en")
err := WriteStructSlice(ctx, &buf, &slice, Config{
I18nPrefix: "test.csv",
})
if err != nil {
if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
t.Errorf("Expected no error, got %v", err)
}
output := buf.String()
if !strings.HasPrefix(output, "\xEF\xBB\xBF") {
t.Error("Expected UTF-8 BOM at start")
}
@ -79,9 +70,8 @@ func TestWriteStructSlice_WithData(t *testing.T) {
t.Fatalf("Expected at least 2 lines (header + data), got: %d", len(lines))
}
expectedHeader := "Name,Value,OptValue,Created"
if lines[0] != expectedHeader {
t.Errorf("Expected header: %s\nGot: %s", expectedHeader, lines[0])
if lines[0] != "Name,Value,OptValue,Created" {
t.Errorf("Expected header: Name,Value,OptValue,Created\nGot: %s", lines[0])
}
if !strings.Contains(lines[1], "Test,123.456,42.5,2024-01-01") {
@ -89,39 +79,22 @@ func TestWriteStructSlice_WithData(t *testing.T) {
}
}
func TestWriteStructSlice_GermanLocale(t *testing.T) {
func TestWriteStructSlice_LocaleIndependentValues(t *testing.T) {
var buf bytes.Buffer
slice := TestStructs{
{Name: "Test", Value: 123.456},
}
ctx := context.WithValue(context.Background(), locale.Locale, "de")
err := WriteStructSlice(ctx, &buf, &slice, Config{
I18nPrefix: "test.csv",
})
if err != nil {
if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
t.Errorf("Expected no error, got %v", err)
}
output := buf.String()
lines := strings.Split(output, "\n")
if len(lines) > 1 && !strings.Contains(lines[1], ";") {
t.Errorf("Expected German CSV to use ';' separator, got: %s", lines[1])
}
}
func TestFormatValue_NilPointer(t *testing.T) {
var nilPtr *float64
result := formatValue(nil, nilPtr, 3)
if result != "" {
t.Errorf("Expected empty string for nil pointer, got: %s", result)
}
}
func TestFormatValue_ZeroTime(t *testing.T) {
result := formatValue(nil, time.Time{}, 3)
if result != "" {
t.Errorf("Expected empty string for zero time, got: %s", result)
lines := strings.Split(buf.String(), "\n")
if len(lines) < 2 {
t.Fatalf("Expected header + data row, got: %s", buf.String())
}
if !strings.Contains(lines[1], "Test,123.456") {
t.Errorf("Expected comma delimiter and dot decimal for German locale, got: %s", lines[1])
}
}

121
util/export/writer.go Normal file
View file

@ -0,0 +1,121 @@
package export
import (
"context"
"fmt"
"math"
"reflect"
"strings"
"time"
"github.com/evcc-io/evcc/util/locale"
"github.com/fatih/structs"
"github.com/nicksnyder/go-i18n/v2/i18n"
)
// normalizeValue converts a field into a typed cell value: nil for empty, floats rounded to digits, times localized
func normalizeValue(value any, digits int) any {
if rv := reflect.ValueOf(value); rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return nil
}
value = rv.Elem().Interface()
}
switch v := value.(type) {
case float64:
p := math.Pow10(digits)
return math.Round(v*p) / p
case time.Time:
if v.IsZero() {
return nil
}
return v.Local()
default:
return v
}
}
func writeHeader(ww RowWriter, structType any, i18nPrefix string) error {
localizer := ww.Localizer()
var row []any
for _, f := range structs.Fields(structType) {
csvTag := f.Tag("csv")
if csvTag == "-" {
continue
}
caption, err := localizer.Localize(&locale.Config{
MessageID: i18nPrefix + "." + strings.ToLower(f.Name()),
})
if err != nil {
if csvTag != "" {
caption = csvTag
} else {
caption = f.Name()
}
}
row = append(row, caption)
}
return ww.Write(row)
}
func writeRow(ww RowWriter, structVal any) error {
var row []any
for _, f := range structs.Fields(structVal) {
if f.Tag("csv") == "-" {
continue
}
digits := 4
if format := f.Tag("format"); format == "int" {
digits = 0
}
row = append(row, normalizeValue(f.Value(), digits))
}
return ww.Write(row)
}
// Localizer returns an i18n localizer for the context's locale.
func Localizer(ctx context.Context) *i18n.Localizer {
if val, ok := ctx.Value(locale.Locale).(string); ok && val != "" {
return i18n.NewLocalizer(locale.Bundle, val, locale.Language)
}
return locale.Localizer
}
// WriteStructSlice emits a slice of structs to ww with localized headers.
func WriteStructSlice(ww RowWriter, slice any, cfg Config) error {
sliceVal := reflect.ValueOf(slice)
if sliceVal.Kind() == reflect.Pointer {
sliceVal = sliceVal.Elem()
}
if sliceVal.Kind() != reflect.Slice {
return fmt.Errorf("expected slice, got %T", slice)
}
var structType any
if sliceVal.Len() > 0 {
structType = sliceVal.Index(0).Interface()
} else {
structType = reflect.New(sliceVal.Type().Elem()).Elem().Interface()
}
if err := writeHeader(ww, structType, cfg.I18nPrefix); err != nil {
return err
}
for i := 0; i < sliceVal.Len(); i++ {
if err := writeRow(ww, sliceVal.Index(i).Interface()); err != nil {
return err
}
}
ww.Flush()
return ww.Error()
}

View file

@ -0,0 +1,28 @@
package export
import (
"testing"
"time"
)
func TestNormalizeValue_NilPointer(t *testing.T) {
var nilPtr *float64
if got := normalizeValue(nilPtr, 3); got != nil {
t.Errorf("Expected nil for nil pointer, got: %v", got)
}
}
func TestNormalizeValue_ZeroTime(t *testing.T) {
if got := normalizeValue(time.Time{}, 3); got != nil {
t.Errorf("Expected nil for zero time, got: %v", got)
}
}
func TestNormalizeValue_Rounding(t *testing.T) {
if got := normalizeValue(1.23456789, 4); got != 1.2346 {
t.Errorf("Expected 1.2346, got: %v", got)
}
if got := normalizeValue(42.5, 0); got != 43.0 {
t.Errorf("Expected 43, got: %v", got)
}
}

131
util/export/xlsx/xlsx.go Normal file
View file

@ -0,0 +1,131 @@
package xlsx
import (
"context"
"io"
"time"
"github.com/evcc-io/evcc/util/export"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/xuri/excelize/v2"
)
// writer adapts excelize to export.RowWriter with native typed cells
type writer struct {
f *excelize.File
sheet string
dst io.Writer
row int
err error
localizer *i18n.Localizer
dateStyle int
durStyle int
}
// cellStyle returns the number format style for typed cells, 0 for default
func (x *writer) cellStyle(v any) int {
switch v.(type) {
case time.Time:
return x.dateStyle
case time.Duration:
return x.durStyle
default:
return 0
}
}
func (x *writer) Write(record []any) error {
for i, v := range record {
if v == nil {
continue
}
cell, err := excelize.CoordinatesToCellName(i+1, x.row)
if err != nil {
x.err = err
return err
}
if err := x.f.SetCellValue(x.sheet, cell, v); err != nil {
x.err = err
return err
}
if style := x.cellStyle(v); style != 0 {
if err := x.f.SetCellStyle(x.sheet, cell, cell, style); err != nil {
x.err = err
return err
}
}
}
if x.row == 1 {
if err := x.styleHeader(len(record)); err != nil {
x.err = err
return err
}
}
x.row++
return nil
}
// styleHeader makes the header row bold and freezes it below the viewport.
func (x *writer) styleHeader(cols int) error {
if cols == 0 {
return nil
}
style, err := x.f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
if err != nil {
return err
}
last, err := excelize.CoordinatesToCellName(cols, 1)
if err != nil {
return err
}
if err := x.f.SetCellStyle(x.sheet, "A1", last, style); err != nil {
return err
}
return x.f.SetPanes(x.sheet, &excelize.Panes{Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft"})
}
func (x *writer) Flush() {
if x.err != nil {
return
}
if err := x.f.Write(x.dst); err != nil {
x.err = err
}
}
func (x *writer) Error() error { return x.err }
func (x *writer) Localizer() *i18n.Localizer { return x.localizer }
// New returns an xlsx-backed export.RowWriter. The context locale only affects header captions.
func New(ctx context.Context, w io.Writer) (export.RowWriter, error) {
f := excelize.NewFile()
dateFmt, durFmt := "yyyy-mm-dd hh:mm:ss", "[h]:mm:ss"
dateStyle, err := f.NewStyle(&excelize.Style{CustomNumFmt: &dateFmt})
if err != nil {
return nil, err
}
durStyle, err := f.NewStyle(&excelize.Style{CustomNumFmt: &durFmt})
if err != nil {
return nil, err
}
return &writer{
f: f,
sheet: f.GetSheetName(0),
dst: w,
row: 1,
localizer: export.Localizer(ctx),
dateStyle: dateStyle,
durStyle: durStyle,
}, nil
}
// WriteStructSlice writes a slice of structs to xlsx with localized headers.
func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg export.Config) error {
ww, err := New(ctx, w)
if err != nil {
return err
}
return export.WriteStructSlice(ww, slice, cfg)
}

View file

@ -0,0 +1,112 @@
package xlsx
import (
"bytes"
"context"
"os"
"strings"
"testing"
"time"
"github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/util/export"
"github.com/evcc-io/evcc/util/locale"
"github.com/xuri/excelize/v2"
)
func init() {
assets.I18n = os.DirFS("../../../i18n")
_ = locale.Init()
}
type TestStruct struct {
ID int `json:"id" csv:"-"`
Name string `json:"name"`
Value float64 `json:"value"`
OptValue *float64 `json:"optValue,omitempty"`
Created time.Time `json:"created"`
}
type TestStructs []TestStruct
func TestWriteStructSlice_WithData(t *testing.T) {
var buf bytes.Buffer
val := 42.5
slice := TestStructs{
{
ID: 1,
Name: "Test",
Value: 123.456,
OptValue: &val,
Created: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
},
}
ctx := context.WithValue(context.Background(), locale.Locale, "en")
if err := WriteStructSlice(ctx, &buf, &slice, export.Config{I18nPrefix: "test.csv"}); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
f, err := excelize.OpenReader(&buf)
if err != nil {
t.Fatalf("output is not a valid xlsx: %v", err)
}
defer f.Close()
rows, err := f.GetRows(f.GetSheetName(0))
if err != nil {
t.Fatalf("GetRows: %v", err)
}
if len(rows) < 2 {
t.Fatalf("expected header + data row, got %d rows", len(rows))
}
if got := rows[0]; !equal(got, []string{"Name", "Value", "OptValue", "Created"}) {
t.Errorf("unexpected header: %v", got)
}
if got := rows[1]; !equal(got[:3], []string{"Test", "123.456", "42.5"}) {
t.Errorf("unexpected data row: %v", got)
}
sheet := f.GetSheetName(0)
// numeric cells carry no type attribute, strings are shared strings
if typ, err := f.GetCellType(sheet, "B2"); err != nil || typ == excelize.CellTypeSharedString {
t.Errorf("expected numeric cell for Value, got type %v, err %v", typ, err)
}
if v, err := f.GetCellValue(sheet, "D2", excelize.Options{RawCellValue: true}); err != nil || v == "" || strings.Contains(v, "-") {
t.Errorf("expected date serial number for Created, got %q, err %v", v, err)
}
styleID, err := f.GetCellStyle(sheet, "A1")
if err != nil {
t.Fatalf("GetCellStyle: %v", err)
}
style, err := f.GetStyle(styleID)
if err != nil {
t.Fatalf("GetStyle: %v", err)
}
if style.Font == nil || !style.Font.Bold {
t.Error("expected bold header")
}
panes, err := f.GetPanes(sheet)
if err != nil {
t.Fatalf("GetPanes: %v", err)
}
if !panes.Freeze {
t.Error("expected frozen header row")
}
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View file

@ -49,21 +49,31 @@ func Init() error {
Sessions struct {
CSV map[string]string `json:"csv"`
} `json:"sessions"`
Config struct {
Hems struct {
CSV map[string]string `json:"csv"`
} `json:"hems"`
} `json:"config"`
}
if err := json.Unmarshal(b, &s); err != nil {
return err
}
if len(s.Sessions.CSV) > 0 {
m := make([]*i18n.Message, 0, len(s.Sessions.CSV))
for k, v := range s.Sessions.CSV {
var m []*i18n.Message
for prefix, msgs := range map[string]map[string]string{
"sessions.csv": s.Sessions.CSV,
"config.hems.csv": s.Config.Hems.CSV,
} {
for k, v := range msgs {
m = append(m, &i18n.Message{
ID: "sessions.csv." + k,
ID: prefix + "." + k,
Other: v,
})
}
}
if len(m) > 0 {
languageTag := language.Make(strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())))
if err := Bundle.AddMessages(languageTag, m...); err != nil {
return fmt.Errorf("loading locales failed: %w", err)