mirror of https://github.com/hay-kot/homebox.git
323 lines
7.3 KiB
Go
323 lines
7.3 KiB
Go
package reporting
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
|
"github.com/hay-kot/homebox/backend/internal/data/types"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// IOSheet is the representation of a CSV/TSV sheet that is used for importing/exporting
|
|
// items from homebox. It is used to read/write the data from/to a CSV/TSV file given
|
|
// the standard format of the file.
|
|
//
|
|
// See ExportTSVRow for the format of the data in the sheet.
|
|
type IOSheet struct {
|
|
headers []string
|
|
custom []int
|
|
index map[string]int
|
|
Rows []ExportTSVRow
|
|
}
|
|
|
|
func (s *IOSheet) indexHeaders() {
|
|
s.index = make(map[string]int)
|
|
|
|
for i, h := range s.headers {
|
|
if strings.HasPrefix(h, "HB.field") {
|
|
s.custom = append(s.custom, i)
|
|
}
|
|
|
|
if strings.HasPrefix(h, "HB.") {
|
|
s.index[h] = i
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *IOSheet) GetColumn(str string) (col int, ok bool) {
|
|
if s.index == nil {
|
|
s.indexHeaders()
|
|
}
|
|
|
|
col, ok = s.index[str]
|
|
return
|
|
}
|
|
|
|
// Read reads a CSV/TSV and populates the "Rows" field with the data from the sheet
|
|
// Custom Fields are supported via the `HB.field.*` headers. The `HB.field.*` the "Name"
|
|
// of the field is the part after the `HB.field.` prefix. Additionally, Custom Fields with
|
|
// no value are excluded from the row.Fields slice, this includes empty strings.
|
|
//
|
|
// Note That
|
|
// - the first row is assumed to be the header
|
|
// - at least 1 row of data is required
|
|
// - rows and columns must be rectangular (i.e. all rows must have the same number of columns)
|
|
func (s *IOSheet) Read(data io.Reader) error {
|
|
sheet, err := readRawCsv(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(sheet) < 2 {
|
|
return fmt.Errorf("sheet must have at least 1 row of data (header + 1)")
|
|
}
|
|
|
|
s.headers = sheet[0]
|
|
s.Rows = make([]ExportTSVRow, len(sheet)-1)
|
|
|
|
for i, row := range sheet[1:] {
|
|
if len(row) != len(s.headers) {
|
|
return fmt.Errorf("row has %d columns, expected %d", len(row), len(s.headers))
|
|
}
|
|
|
|
rowData := ExportTSVRow{}
|
|
|
|
st := reflect.TypeOf(ExportTSVRow{})
|
|
|
|
for i := 0; i < st.NumField(); i++ {
|
|
field := st.Field(i)
|
|
tag := field.Tag.Get("csv")
|
|
if tag == "" || tag == "-" {
|
|
continue
|
|
}
|
|
|
|
col, ok := s.GetColumn(tag)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
val := row[col]
|
|
|
|
var v interface{}
|
|
|
|
switch field.Type {
|
|
case reflect.TypeOf(""):
|
|
v = val
|
|
case reflect.TypeOf(int(0)):
|
|
v = parseInt(val)
|
|
case reflect.TypeOf(bool(false)):
|
|
v = parseBool(val)
|
|
case reflect.TypeOf(float64(0)):
|
|
v = parseFloat(val)
|
|
|
|
// Custom Types
|
|
case reflect.TypeOf(types.Date{}):
|
|
v = types.DateFromString(val)
|
|
case reflect.TypeOf(repo.AssetID(0)):
|
|
v, _ = repo.ParseAssetID(val)
|
|
case reflect.TypeOf(LocationString{}):
|
|
v = parseLocationString(val)
|
|
case reflect.TypeOf(LabelString{}):
|
|
v = parseLabelString(val)
|
|
}
|
|
|
|
log.Debug().
|
|
Str("tag", tag).
|
|
Interface("val", v).
|
|
Str("type", fmt.Sprintf("%T", v)).
|
|
Msg("parsed value")
|
|
|
|
// Nil values are not allowed at the moment. This may change.
|
|
if v == nil {
|
|
return fmt.Errorf("could not convert %q to %s", val, field.Type)
|
|
}
|
|
|
|
ptrField := reflect.ValueOf(&rowData).Elem().Field(i)
|
|
ptrField.Set(reflect.ValueOf(v))
|
|
}
|
|
|
|
for _, col := range s.custom {
|
|
colName := strings.TrimPrefix(s.headers[col], "HB.field.")
|
|
customVal := row[col]
|
|
if customVal == "" {
|
|
continue
|
|
}
|
|
|
|
rowData.Fields = append(rowData.Fields, ExportItemFields{
|
|
Name: colName,
|
|
Value: customVal,
|
|
})
|
|
}
|
|
|
|
s.Rows[i] = rowData
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ReadItems writes the sheet to a writer.
|
|
func (s *IOSheet) ReadItems(ctx context.Context, items []repo.ItemOut, GID uuid.UUID, repos *repo.AllRepos) error {
|
|
s.Rows = make([]ExportTSVRow, len(items))
|
|
|
|
extraHeaders := map[string]struct{}{}
|
|
|
|
for i := range items {
|
|
item := items[i]
|
|
|
|
// TODO: Support fetching nested locations
|
|
locID := item.Location.ID
|
|
|
|
locPaths, err := repos.Locations.PathForLoc(context.Background(), GID, locID)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("could not get location path")
|
|
return err
|
|
}
|
|
|
|
locString := fromPathSlice(locPaths)
|
|
|
|
labelString := make([]string, len(item.Labels))
|
|
|
|
for i, l := range item.Labels {
|
|
labelString[i] = l.Name
|
|
}
|
|
|
|
customFields := make([]ExportItemFields, len(item.Fields))
|
|
|
|
for i, f := range item.Fields {
|
|
extraHeaders[f.Name] = struct{}{}
|
|
|
|
customFields[i] = ExportItemFields{
|
|
Name: f.Name,
|
|
Value: f.TextValue,
|
|
}
|
|
}
|
|
|
|
s.Rows[i] = ExportTSVRow{
|
|
// fill struct
|
|
Location: locString,
|
|
LabelStr: labelString,
|
|
|
|
ImportRef: item.ImportRef,
|
|
AssetID: item.AssetID,
|
|
Name: item.Name,
|
|
Quantity: item.Quantity,
|
|
Description: item.Description,
|
|
Insured: item.Insured,
|
|
Archived: item.Archived,
|
|
|
|
PurchasePrice: item.PurchasePrice,
|
|
PurchaseFrom: item.PurchaseFrom,
|
|
PurchaseTime: item.PurchaseTime,
|
|
|
|
Manufacturer: item.Manufacturer,
|
|
ModelNumber: item.ModelNumber,
|
|
SerialNumber: item.SerialNumber,
|
|
|
|
LifetimeWarranty: item.LifetimeWarranty,
|
|
WarrantyExpires: item.WarrantyExpires,
|
|
WarrantyDetails: item.WarrantyDetails,
|
|
|
|
SoldTo: item.SoldTo,
|
|
SoldTime: item.SoldTime,
|
|
SoldPrice: item.SoldPrice,
|
|
SoldNotes: item.SoldNotes,
|
|
|
|
Fields: customFields,
|
|
}
|
|
}
|
|
|
|
// Extract and sort additional headers for deterministic output
|
|
customHeaders := make([]string, 0, len(extraHeaders))
|
|
|
|
for k := range extraHeaders {
|
|
customHeaders = append(customHeaders, k)
|
|
}
|
|
|
|
sort.Strings(customHeaders)
|
|
|
|
st := reflect.TypeOf(ExportTSVRow{})
|
|
|
|
// Write headers
|
|
for i := 0; i < st.NumField(); i++ {
|
|
field := st.Field(i)
|
|
tag := field.Tag.Get("csv")
|
|
if tag == "" || tag == "-" {
|
|
continue
|
|
}
|
|
|
|
s.headers = append(s.headers, tag)
|
|
}
|
|
|
|
for _, h := range customHeaders {
|
|
s.headers = append(s.headers, "HB.field."+h)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TSV writes the current sheet to a writer in TSV format.
|
|
func (s *IOSheet) TSV() ([][]string, error) {
|
|
memcsv := make([][]string, len(s.Rows)+1)
|
|
|
|
memcsv[0] = s.headers
|
|
|
|
// use struct tags in rows to dertmine column order
|
|
for i, row := range s.Rows {
|
|
rowIdx := i + 1
|
|
|
|
memcsv[rowIdx] = make([]string, len(s.headers))
|
|
|
|
st := reflect.TypeOf(row)
|
|
|
|
for i := 0; i < st.NumField(); i++ {
|
|
field := st.Field(i)
|
|
tag := field.Tag.Get("csv")
|
|
if tag == "" || tag == "-" {
|
|
continue
|
|
}
|
|
|
|
col, ok := s.GetColumn(tag)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
val := reflect.ValueOf(row).Field(i)
|
|
|
|
var v string
|
|
|
|
switch field.Type {
|
|
case reflect.TypeOf(""):
|
|
v = val.String()
|
|
case reflect.TypeOf(int(0)):
|
|
v = strconv.Itoa(int(val.Int()))
|
|
case reflect.TypeOf(bool(false)):
|
|
v = strconv.FormatBool(val.Bool())
|
|
case reflect.TypeOf(float64(0)):
|
|
v = strconv.FormatFloat(val.Float(), 'f', -1, 64)
|
|
|
|
// Custom Types
|
|
case reflect.TypeOf(types.Date{}):
|
|
v = val.Interface().(types.Date).String()
|
|
case reflect.TypeOf(repo.AssetID(0)):
|
|
v = val.Interface().(repo.AssetID).String()
|
|
case reflect.TypeOf(LocationString{}):
|
|
v = val.Interface().(LocationString).String()
|
|
case reflect.TypeOf(LabelString{}):
|
|
v = val.Interface().(LabelString).String()
|
|
default:
|
|
log.Debug().Str("type", field.Type.String()).Msg("unknown type")
|
|
}
|
|
|
|
memcsv[rowIdx][col] = v
|
|
}
|
|
|
|
for _, f := range row.Fields {
|
|
col, ok := s.GetColumn("HB.field." + f.Name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
memcsv[i+1][col] = f.Value
|
|
}
|
|
}
|
|
|
|
return memcsv, nil
|
|
}
|