feat: add xlsx export wherever csv export exists (#31690)
This commit is contained in:
parent
3158ff16ef
commit
3ce6600143
29 changed files with 873 additions and 425 deletions
|
|
@ -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
22
util/export/api.go
Normal 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
56
util/export/csv/csv.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
121
util/export/writer.go
Normal 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()
|
||||
}
|
||||
28
util/export/writer_test.go
Normal file
28
util/export/writer_test.go
Normal 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
131
util/export/xlsx/xlsx.go
Normal 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)
|
||||
}
|
||||
112
util/export/xlsx/xlsx_test.go
Normal file
112
util/export/xlsx/xlsx_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue