diff --git a/api/compose/spec.json b/api/compose/spec.json index 26b8a53d6..70d2e2704 100644 --- a/api/compose/spec.json +++ b/api/compose/spec.json @@ -860,6 +860,12 @@ "type": "[]string", "required": true, "title": "Fields to export" + }, + { + "name": "timezone", + "type": "string", + "required": false, + "title": "Convert times to this timezone" } ] } diff --git a/api/compose/spec/record.json b/api/compose/spec/record.json index c700545c4..65f77f49a 100644 --- a/api/compose/spec/record.json +++ b/api/compose/spec/record.json @@ -190,6 +190,12 @@ "required": true, "title": "Fields to export", "type": "[]string" + }, + { + "name": "timezone", + "required": false, + "title": "Convert times to this timezone", + "type": "string" } ], "path": [ diff --git a/compose/encoder/encoder.go b/compose/encoder/encoder.go index 699e8162f..6aeee8e16 100644 --- a/compose/encoder/encoder.go +++ b/compose/encoder/encoder.go @@ -27,12 +27,14 @@ type ( w FlatWriter ff []field u userFinder + tz string } structuredEncoder struct { w StructuredEncoder ff []field u userFinder + tz string } ) @@ -49,15 +51,40 @@ func MakeFields(nn ...string) []field { return ff } +func preprocessHeader(hh []field, tz string) []field { + nhh := make([]field, 0) + + // We need to prepare additional header fields for exporting + if tz != "" && tz != "UTC" { + for _, f := range hh { + switch f.name { + case "createdAt", + "updatedAt", + "deletedAt": + + nhh = append(nhh, f, field{name: f.name + "_date"}, field{name: f.name + "_time"}) + break + default: + nhh = append(nhh, f) + break + } + } + } else { + return hh + } + return nhh +} + func MultiValueField(name string) field { return field{name: name, encodeAllMulti: true} } -func NewFlatWriter(w FlatWriter, header bool, u userFinder, ff ...field) *flatWriter { +func NewFlatWriter(w FlatWriter, header bool, u userFinder, tz string, ff ...field) *flatWriter { f := &flatWriter{ w: w, - ff: ff, + ff: preprocessHeader(ff, tz), u: u, + tz: tz, } if header { @@ -80,11 +107,13 @@ func (enc flatWriter) writeHeader() { _ = enc.w.Write(ss) } -func NewStructuredEncoder(w StructuredEncoder, u userFinder, ff ...field) *structuredEncoder { +func NewStructuredEncoder(w StructuredEncoder, u userFinder, tz string, ff ...field) *structuredEncoder { return &structuredEncoder{ - w: w, + w: w, + // No need for additional timezone headers, since the output is structured ff: ff, u: u, + tz: tz, } } diff --git a/compose/encoder/encoder_xlsx.go b/compose/encoder/encoder_xlsx.go index 16b048767..fc4fe48c0 100644 --- a/compose/encoder/encoder_xlsx.go +++ b/compose/encoder/encoder_xlsx.go @@ -13,15 +13,17 @@ type ( w io.Writer ff []field u userFinder + tz string } ) -func NewExcelizeEncoder(w io.Writer, header bool, u userFinder, ff ...field) *excelizeEncoder { +func NewExcelizeEncoder(w io.Writer, header bool, u userFinder, tz string, ff ...field) *excelizeEncoder { enc := &excelizeEncoder{ f: excelize.NewFile(), w: w, - ff: ff, + ff: preprocessHeader(ff, tz), u: u, + tz: tz, } if header { diff --git a/compose/encoder/record.go b/compose/encoder/record.go index 1c58e61c7..3d629ce1d 100644 --- a/compose/encoder/record.go +++ b/compose/encoder/record.go @@ -8,15 +8,44 @@ import ( "github.com/cortezaproject/corteza-server/compose/types" ) +type ( + parsedTime struct { + field string + value string + } +) + // Time formatter // // Takes ptr to time.Time so we can conver both cases (value + ptr) -func fmtTime(tp *time.Time) string { +// The function also generates additional fields with included timezone. +func fmtTime(field string, tp *time.Time, tz string) (pt []*parsedTime, err error) { if tp == nil { - return "" + return pt, nil } - return tp.UTC().Format(time.RFC3339) + pt = append(pt, &parsedTime{ + field: field, + value: tp.UTC().Format(time.RFC3339), + }) + if tz == "" || tz == "UTC" { + return + } + + loc, err := time.LoadLocation(tz) + if err != nil { + return pt, err + } + tt := tp.In(loc) + pt = append(pt, + &parsedTime{ + field: field + "_date", + value: tt.Format("2006-01-02"), + }, &parsedTime{ + field: field + "_time", + value: tt.Format("15:04:05"), + }) + return } func fmtUint64(u uint64) string { @@ -49,6 +78,12 @@ func fmtSysUser(u uint64, finder userFinder) (string, error) { func (enc flatWriter) Record(r *types.Record) (err error) { var out = make([]string, len(enc.ff)) + procTime := func(d []string, pts []*parsedTime, base int) { + for i, p := range pts { + d[base+i] = p.value + } + } + for f, field := range enc.ff { switch field.name { case "recordID", "ID": @@ -68,21 +103,33 @@ func (enc flatWriter) Record(r *types.Record) (err error) { return err } case "createdAt": - out[f] = fmtTime(&r.CreatedAt) + tt, err := fmtTime("createdAt", &r.CreatedAt, enc.tz) + if err != nil { + return err + } + procTime(out, tt, f) case "updatedBy": out[f], err = fmtSysUser(r.UpdatedBy, enc.u) if err != nil { return err } case "updatedAt": - out[f] = fmtTime(r.UpdatedAt) + tt, err := fmtTime("updatedAt", r.UpdatedAt, enc.tz) + if err != nil { + return err + } + procTime(out, tt, f) case "deletedBy": out[f], err = fmtSysUser(r.DeletedBy, enc.u) if err != nil { return err } case "deletedAt": - out[f] = fmtTime(r.DeletedAt) + tt, err := fmtTime("deletedAt", r.DeletedAt, enc.tz) + if err != nil { + return err + } + procTime(out, tt, f) default: vv := r.Values.FilterByName(field.name) // @todo support for field.encodeAllMulti @@ -106,6 +153,12 @@ func (enc structuredEncoder) Record(r *types.Record) (err error) { c int ) + procTime := func(d map[string]interface{}, pts []*parsedTime) { + for _, p := range pts { + d[p.field] = p.value + } + } + for _, f := range enc.ff { switch f.name { case "recordID", "ID": @@ -128,7 +181,11 @@ func (enc structuredEncoder) Record(r *types.Record) (err error) { return err } case "createdAt": - out[f.name] = fmtTime(&r.CreatedAt) + tt, err := fmtTime("createdAt", &r.CreatedAt, enc.tz) + if err != nil { + return err + } + procTime(out, tt) case "updatedBy": out[f.name], err = fmtSysUser(r.UpdatedBy, enc.u) if err != nil { @@ -138,7 +195,11 @@ func (enc structuredEncoder) Record(r *types.Record) (err error) { if r.UpdatedAt == nil { out[f.name] = nil } else { - out[f.name] = fmtTime(r.UpdatedAt) + tt, err := fmtTime("updatedAt", r.UpdatedAt, enc.tz) + if err != nil { + return err + } + procTime(out, tt) } case "deletedBy": @@ -150,7 +211,11 @@ func (enc structuredEncoder) Record(r *types.Record) (err error) { if r.DeletedAt == nil { out[f.name] = nil } else { - out[f.name] = fmtTime(r.DeletedAt) + tt, err := fmtTime("deletedAt", r.DeletedAt, enc.tz) + if err != nil { + return err + } + procTime(out, tt) } default: @@ -182,6 +247,12 @@ func (enc *excelizeEncoder) Record(r *types.Record) (err error) { enc.row++ var u string + procTime := func(pts []*parsedTime, base int) { + for i, p := range pts { + _ = enc.f.SetCellStr(enc.sheet(), enc.pos(base+i), p.value) + } + } + for p, f := range enc.ff { p++ switch f.name { @@ -204,7 +275,11 @@ func (enc *excelizeEncoder) Record(r *types.Record) (err error) { } _ = enc.f.SetCellStr(enc.sheet(), enc.pos(p), u) case "createdAt": - _ = enc.f.SetCellStr(enc.sheet(), enc.pos(p), fmtTime(&r.CreatedAt)) + tt, err := fmtTime("createdAt", &r.CreatedAt, enc.tz) + if err != nil { + return err + } + procTime(tt, p) case "updatedBy": u, err = fmtSysUser(r.UpdatedBy, enc.u) if err != nil { @@ -212,7 +287,11 @@ func (enc *excelizeEncoder) Record(r *types.Record) (err error) { } _ = enc.f.SetCellStr(enc.sheet(), enc.pos(p), u) case "updatedAt": - _ = enc.f.SetCellStr(enc.sheet(), enc.pos(p), fmtTime(r.UpdatedAt)) + tt, err := fmtTime("updatedAt", r.UpdatedAt, enc.tz) + if err != nil { + return err + } + procTime(tt, p) case "deletedBy": u, err = fmtSysUser(r.DeletedBy, enc.u) if err != nil { @@ -220,7 +299,11 @@ func (enc *excelizeEncoder) Record(r *types.Record) (err error) { } _ = enc.f.SetCellStr(enc.sheet(), enc.pos(p), u) case "deletedAt": - _ = enc.f.SetCellStr(enc.sheet(), enc.pos(p), fmtTime(r.DeletedAt)) + tt, err := fmtTime("deletedAt", r.DeletedAt, enc.tz) + if err != nil { + return err + } + procTime(tt, p) default: vv := r.Values.FilterByName(f.name) if len(vv) > 0 { diff --git a/compose/rest/record.go b/compose/rest/record.go index 9977b0848..f4a65a447 100644 --- a/compose/rest/record.go +++ b/compose/rest/record.go @@ -441,15 +441,15 @@ func (ctrl *Record) Export(ctx context.Context, r *request.RecordExport) (interf switch strings.ToLower(r.Ext) { case "json", "jsonl", "ldjson", "ndjson": contentType = "application/jsonl" - recordEncoder = encoder.NewStructuredEncoder(json.NewEncoder(w), uf, ff...) + recordEncoder = encoder.NewStructuredEncoder(json.NewEncoder(w), uf, r.Timezone, ff...) case "csv": contentType = "text/csv" - recordEncoder = encoder.NewFlatWriter(csv.NewWriter(w), true, uf, ff...) + recordEncoder = encoder.NewFlatWriter(csv.NewWriter(w), true, uf, r.Timezone, ff...) case "xlsx": contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - recordEncoder = encoder.NewExcelizeEncoder(w, true, uf, ff...) + recordEncoder = encoder.NewExcelizeEncoder(w, true, uf, r.Timezone, ff...) default: http.Error(w, "unsupported format ("+r.Ext+")", http.StatusBadRequest) diff --git a/compose/rest/request/record.go b/compose/rest/request/record.go index 35b7434e6..bef20044b 100644 --- a/compose/rest/request/record.go +++ b/compose/rest/request/record.go @@ -529,6 +529,10 @@ type RecordExport struct { rawFields []string Fields []string + hasTimezone bool + rawTimezone string + Timezone string + hasFilename bool rawFilename string Filename string @@ -557,6 +561,7 @@ func (r RecordExport) Auditable() map[string]interface{} { out["filter"] = r.Filter out["fields"] = r.Fields + out["timezone"] = r.Timezone out["filename"] = r.Filename out["ext"] = r.Ext out["namespaceID"] = r.NamespaceID @@ -609,6 +614,11 @@ func (r *RecordExport) Fill(req *http.Request) (err error) { r.Fields = parseStrings(val) } + if val, ok := get["timezone"]; ok { + r.hasTimezone = true + r.rawTimezone = val + r.Timezone = val + } r.hasFilename = true r.rawFilename = chi.URLParam(req, "filename") r.Filename = chi.URLParam(req, "filename") @@ -1783,6 +1793,21 @@ func (r *RecordExport) GetFields() []string { return r.Fields } +// HasTimezone returns true if timezone was set +func (r *RecordExport) HasTimezone() bool { + return r.hasTimezone +} + +// RawTimezone returns raw value of timezone parameter +func (r *RecordExport) RawTimezone() string { + return r.rawTimezone +} + +// GetTimezone returns casted value of timezone parameter +func (r *RecordExport) GetTimezone() string { + return r.Timezone +} + // HasFilename returns true if filename was set func (r *RecordExport) HasFilename() bool { return r.hasFilename