diff --git a/api/dto/dto.go b/api/dto/dto.go index af6e14a8..92220f73 100644 --- a/api/dto/dto.go +++ b/api/dto/dto.go @@ -2,6 +2,7 @@ package dto import ( "fmt" + "strings" "time" ) @@ -103,19 +104,20 @@ type Rate struct { // TimeEntry DTO type TimeEntry struct { - ID string `json:"id"` - Billable bool `json:"billable"` - Description string `json:"description"` - HourlyRate Rate `json:"hourlyRate"` - IsLocked bool `json:"isLocked"` - Project *Project `json:"project"` - ProjectID string `json:"projectId"` - Tags []Tag `json:"tags"` - Task *Task `json:"task"` - TimeInterval TimeInterval `json:"timeInterval"` - TotalBillable int64 `json:"totalBillable"` - User *User `json:"user"` - WorkspaceID string `json:"workspaceId"` + ID string `json:"id"` + Billable bool `json:"billable"` + Description string `json:"description"` + HourlyRate Rate `json:"hourlyRate"` + IsLocked bool `json:"isLocked"` + Project *Project `json:"project"` + CustomFields []CustomField `json:"customFieldValues"` + ProjectID string `json:"projectId"` + Tags []Tag `json:"tags"` + Task *Task `json:"task"` + TimeInterval TimeInterval `json:"timeInterval"` + TotalBillable int64 `json:"totalBillable"` + User *User `json:"user"` + WorkspaceID string `json:"workspaceId"` } // NewTimeInterval will create a TimeInterval from start and end times @@ -199,11 +201,36 @@ func (e Client) GetName() string { return e.Name } // CustomField DTO type CustomField struct { - CustomFieldID string `json:"customFieldId"` - Status string `json:"status"` - Name string `json:"name"` - Type string `json:"type"` - Value string `json:"value"` + CustomFieldID string `json:"customFieldId"` + TimeEntryId string `json:"timeEntryId"` + Name string `json:"name"` + Type string `json:"type"` + Value interface{} `json:"value"` +} + +// ValueAsString converter for CustomFieldDTO +/* + Custom field `Value` can be either a string or an array of strings. + This function is used to get the value always as string, using the `|` symbol + as separator between each individual string. +*/ +func (cf CustomField) ValueAsString() string { + switch v := cf.Value.(type) { + case string: + return v + case []interface{}: + parts := make([]string, len(v)) + for i, item := range v { + parts[i] = fmt.Sprint(item) + } + return strings.Join(parts, "|") + case []string: + return strings.Join(v, "|") + case nil: + return "" + default: + return fmt.Sprint(v) + } } // Project DTO diff --git a/pkg/cmd/config/init/init.go b/pkg/cmd/config/init/init.go index 15250988..e0367e6e 100644 --- a/pkg/cmd/config/init/init.go +++ b/pkg/cmd/config/init/init.go @@ -85,6 +85,9 @@ func NewCmdInit(f cmdutil.Factory) *cobra.Command { updateFlag(i, config, cmdutil.CONF_SHOW_TASKS, `Should show task on time entries as a separated column?`, ), + updateFlag(i, config, cmdutil.CONF_SHOW_CUSTOM_FIELDS, + `Should show custom fields on time entries as a separated column?`, + ), updateFlag(i, config, cmdutil.CONF_SHOW_CLIENT, `Should show client on time entries as a separated column?`, ), diff --git a/pkg/cmd/config/init/init_test.go b/pkg/cmd/config/init/init_test.go index ce80e964..cfc96a74 100644 --- a/pkg/cmd/config/init/init_test.go +++ b/pkg/cmd/config/init/init_test.go @@ -99,6 +99,7 @@ func TestInitCmd(t *testing.T) { setBoolFn(config, cmdutil.CONF_ALLOW_INCOMPLETE, false, false) setBoolFn(config, cmdutil.CONF_SHOW_TASKS, true, true) + setBoolFn(config, cmdutil.CONF_SHOW_CUSTOM_FIELDS, true, true) setBoolFn(config, cmdutil.CONF_SHOW_CLIENT, true, true) setBoolFn(config, cmdutil.CONF_SHOW_TOTAL_DURATION, true, true) setBoolFn(config, cmdutil.CONF_DESCR_AUTOCOMP, false, true) @@ -191,6 +192,10 @@ func TestInitCmd(t *testing.T) { c.SendLine("") c.ExpectString("Yes") + c.ExpectString("show custom fields") + c.SendLine("") + c.ExpectString("Yes") + c.ExpectString("show client on time entries") c.SendLine("") c.ExpectString("Yes") diff --git a/pkg/cmd/config/list/list.go b/pkg/cmd/config/list/list.go index 89684b78..a3673bbb 100644 --- a/pkg/cmd/config/list/list.go +++ b/pkg/cmd/config/list/list.go @@ -25,6 +25,7 @@ func NewCmdList(f cmdutil.Factory) *cobra.Command { interactive: true no-closing: false show-task: false + show-custom-fields: false show-total-duration: true token: Yamdas569 user: diff --git a/pkg/cmd/config/set/set.go b/pkg/cmd/config/set/set.go index d4fb6d68..75418ce8 100644 --- a/pkg/cmd/config/set/set.go +++ b/pkg/cmd/config/set/set.go @@ -30,6 +30,7 @@ func NewCmdSet( $ %[1]s token "Yamdas569" $ %[1]s workweek-days monday,tuesday,wednesday,thursday,friday $ %[1]s show-task true + $ %[1]s show-custom-fields true $ %[1]s user.id 4564d5a6s4d54a5s4dasd5 `, "clockify-cli config set"), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/cmd/time-entry/report/report.go b/pkg/cmd/time-entry/report/report.go index 7f8ba15a..e6be0608 100644 --- a/pkg/cmd/time-entry/report/report.go +++ b/pkg/cmd/time-entry/report/report.go @@ -124,9 +124,9 @@ func NewCmdReport(f cmdutil.Factory) *cobra.Command { # csv format output $ %[1]s --csv - id,description,project.id,project.name,task.id,task.name,start,end,duration,user.id,user.email,user.name,tags... - 62b87a9785815e619d7ce02e,Example for today,621948458cb9606d934ebb1c,Clockify Cli,62b87a7e984dba2c0669724d,Report Command,2022-06-26 12:25:56,2022-06-26 12:26:47,0:00:51,5c6bf21db079873a55facc08,joe@due.com,John Due,Development (62ae28b72518aa18da2acb49) - 62b87abb85815e619d7ce034,Example for today (second one),621948458cb9606d934ebb1c,Clockify Cli,62b87a7e984dba2c0669724d,Report Command,2022-06-26 12:26:47,2022-06-26 13:00:00,0:33:13,5c6bf21db079873a55facc08,joe@due.com,John Due,Development (62ae28b72518aa18da2acb49) + id,description,project.id,project.name,task.id,task.name,start,end,duration,user.id,user.email,user.name,tags...,customFields... + 62b87a9785815e619d7ce02e,Example for today,621948458cb9606d934ebb1c,Clockify Cli,62b87a7e984dba2c0669724d,Report Command,2022-06-26 12:25:56,2022-06-26 12:26:47,0:00:51,5c6bf21db079873a55facc08,joe@due.com,John Due,Development (62ae28b72518aa18da2acb49),Example custom field(5e1147fe8c526f38930d57b8)=value + 62b87abb85815e619d7ce034,Example for today (second one),621948458cb9606d934ebb1c,Clockify Cli,62b87a7e984dba2c0669724d,Report Command,2022-06-26 12:26:47,2022-06-26 13:00:00,0:33:13,5c6bf21db079873a55facc08,joe@due.com,John Due,Development (62ae28b72518aa18da2acb49), Example custom field(5e1147fe8c526f38930d57b8)=value `, "clockify-cli report", "`", timehlp.FullTimeFormat, timehlp.OnlyTimeFormat, diff --git a/pkg/cmd/time-entry/util/report.go b/pkg/cmd/time-entry/util/report.go index 0b0038ec..cfc25f39 100644 --- a/pkg/cmd/time-entry/util/report.go +++ b/pkg/cmd/time-entry/util/report.go @@ -149,6 +149,10 @@ func PrintTimeEntries( opts = opts.WithShowTasks() } + if config.GetBool(cmdutil.CONF_SHOW_CUSTOM_FIELDS) { + opts = opts.WithShowCustomFields() + } + if config.GetBool(cmdutil.CONF_SHOW_CLIENT) { opts = opts.WithShowClients() } diff --git a/pkg/cmdutil/config.go b/pkg/cmdutil/config.go index c2f06830..0f1d50a2 100644 --- a/pkg/cmdutil/config.go +++ b/pkg/cmdutil/config.go @@ -22,6 +22,7 @@ const ( CONF_TOKEN = "token" CONF_ALLOW_INCOMPLETE = "allow-incomplete" CONF_SHOW_TASKS = "show-task" + CONF_SHOW_CUSTOM_FIELDS = "show-custom-fields" CONF_SHOW_CLIENT = "show-client" CONF_DESCR_AUTOCOMP = "description-autocomplete" CONF_DESCR_AUTOCOMP_DAYS = "description-autocomplete-days" diff --git a/pkg/output/time-entry/csv.go b/pkg/output/time-entry/csv.go index bd708afd..99714720 100644 --- a/pkg/output/time-entry/csv.go +++ b/pkg/output/time-entry/csv.go @@ -3,6 +3,7 @@ package timeentry import ( "encoding/csv" "io" + "strings" "time" "github.com/lucassabreu/clockify-cli/api/dto" @@ -26,6 +27,7 @@ func TimeEntriesCSVPrint(timeEntries []dto.TimeEntry, out io.Writer) error { "user.email", "user.name", "tags...", + "customFields...", }); err != nil { return err } @@ -74,8 +76,10 @@ func TimeEntriesCSVPrint(timeEntries []dto.TimeEntry, out io.Writer) error { te.User.Name, } - if err := w.Write(append( - arr, tagsToStringSlice(te.Tags)...)); err != nil { + arr = append(arr, strings.Join(tagsToStringSlice(te.Tags), ";")) + arr = append(arr, strings.Join(customFieldsToStringSlice(te.CustomFields), ";")) + + if err := w.Write(arr); err != nil { return err } } diff --git a/pkg/output/time-entry/default.go b/pkg/output/time-entry/default.go index 9e042039..925ff8e7 100644 --- a/pkg/output/time-entry/default.go +++ b/pkg/output/time-entry/default.go @@ -36,6 +36,7 @@ const ( // entries type TimeEntryOutputOptions struct { ShowTasks bool + ShowCustomFields bool ShowClients bool ShowTotalDuration bool TimeFormat string @@ -46,6 +47,7 @@ func NewTimeEntryOutputOptions() TimeEntryOutputOptions { return TimeEntryOutputOptions{ TimeFormat: TimeFormatSimple, ShowTasks: false, + ShowCustomFields: false, ShowClients: false, ShowTotalDuration: false, } @@ -65,6 +67,12 @@ func (teo TimeEntryOutputOptions) WithShowTasks() TimeEntryOutputOptions { return teo } +// WithShowCustomFields shows a new column with the custom fields of the time entry +func (teo TimeEntryOutputOptions) WithShowCustomFields() TimeEntryOutputOptions { + teo.ShowCustomFields = true + return teo +} + // WithShowCliens shows a new column with the client of the time entry func (teo TimeEntryOutputOptions) WithShowClients() TimeEntryOutputOptions { teo.ShowClients = true @@ -96,6 +104,10 @@ func TimeEntriesPrint( header = append(header, "Description", "Tags") + if options.ShowCustomFields { + header = append(header, "Custom Fields") + } + tw.SetHeader(header) tw.SetRowLine(true) if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil { @@ -152,6 +164,13 @@ func TimeEntriesPrint( strings.Join(tagsToStringSlice(t.Tags), "\n"), ) + if options.ShowCustomFields { + line = append( + line, + strings.Join(customFieldsToStringSlice(t.CustomFields), "\n"), + ) + } + tw.Rich(line, colors) } @@ -181,3 +200,13 @@ func tagsToStringSlice(tags []dto.Tag) []string { func durationToString(d time.Duration) string { return dto.Duration{Duration: d}.HumanString() } + +func customFieldsToStringSlice(customFields []dto.CustomField) []string { + s := make([]string, len(customFields)) + + for i, cf := range customFields { + s[i] = fmt.Sprintf("%s(%s)=%s", cf.Name, cf.CustomFieldID, cf.ValueAsString()) + } + + return s +} diff --git a/pkg/output/time-entry/markdown.gotmpl.md b/pkg/output/time-entry/markdown.gotmpl.md index 5c63c502..f1196342 100644 --- a/pkg/output/time-entry/markdown.gotmpl.md +++ b/pkg/output/time-entry/markdown.gotmpl.md @@ -23,7 +23,20 @@ {{- $tags = "No Tags" -}} {{- end -}} -{{- $pad := maxLength .Description $project $tags $bil -}} +{{- $customFields := "" -}} +{{- $hasCustomFields := false -}} +{{- with .CustomFields -}} + {{- range $index, $element := . -}} + {{- $value := $element.ValueAsString -}} + {{- if ne $value "" -}} + {{- if ne $index 0 }}{{ $customFields = concat $customFields ", " }}{{ end -}} + {{- $customFields = concat $customFields $element.Name ": " $value -}} + {{- $hasCustomFields = true -}} + {{- end -}} + {{- end -}} +{{- end -}} + +{{- $pad := maxLength .Description $project $tags $customFields $bil -}} ## _Time Entry_: {{ .ID }} @@ -35,9 +48,12 @@ Start Time: _{{ formatTimeWS .TimeInterval.Start }}_ 🗓 Today {{- .TimeInterval.Start.Format " 01/02/2006" }} {{- end }} -| | {{ pad "" $pad }} | -|---------------|-{{ repeatString "-" $pad }}-| -| _Description_ | {{ pad .Description $pad }} | -| _Project_ | {{ pad $project $pad }} | -| _Tags_ | {{ pad $tags $pad }} | -| _Billable_ | {{ pad $bil $pad }} | +| | {{ pad "" $pad }} | +|-----------------|-{{ repeatString "-" $pad }}-| +| _Description_ | {{ pad .Description $pad }} | +| _Project_ | {{ pad $project $pad }} | +| _Tags_ | {{ pad $tags $pad }} | +| _Billable_ | {{ pad $bil $pad }} | +{{- if $hasCustomFields }} +| _Custom Fields_ | {{ pad $customFields $pad }} | +{{- end }} diff --git a/pkg/output/time-entry/markdown_test.go b/pkg/output/time-entry/markdown_test.go index 8e145c88..4b7df859 100644 --- a/pkg/output/time-entry/markdown_test.go +++ b/pkg/output/time-entry/markdown_test.go @@ -37,12 +37,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **1:05:01** | Start Time: _%s_ 🗓 Today - | | | - |---------------|--------------------------| - | _Description_ | Open and without project | - | _Project_ | No Project | - | _Tags_ | No Tags | - | _Billable_ | No | + | | | + |-----------------|--------------------------| + | _Description_ | Open and without project | + | _Project_ | No Project | + | _Tags_ | No Tags | + | _Billable_ | No | `, t65Min1SecAgo.UTC().Format(timehlp.SimplerOnlyTimeFormat)), }, { @@ -63,12 +63,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|----------------------------| - | _Description_ | Closed and without project | - | _Project_ | No Project | - | _Tags_ | No Tags | - | _Billable_ | No | + | | | + |-----------------|----------------------------| + | _Description_ | Closed and without project | + | _Project_ | No Project | + | _Tags_ | No Tags | + | _Billable_ | No | `), }, { @@ -92,12 +92,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|------------------| - | _Description_ | With project | - | _Project_ | **Project Name** | - | _Tags_ | No Tags | - | _Billable_ | No | + | | | + |-----------------|------------------| + | _Description_ | With project | + | _Project_ | **Project Name** | + | _Tags_ | No Tags | + | _Billable_ | No | `), }, { @@ -122,12 +122,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|--------------------------------| - | _Description_ | With project | - | _Project_ | **Project Name** - Client Name | - | _Tags_ | No Tags | - | _Billable_ | Yes | + | | | + |-----------------|--------------------------------| + | _Description_ | With project | + | _Project_ | **Project Name** - Client Name | + | _Tags_ | No Tags | + | _Billable_ | Yes | `), }, { @@ -155,12 +155,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|-----------------------------| - | _Description_ | With project | - | _Project_ | **Project Name**: Task Name | - | _Tags_ | No Tags | - | _Billable_ | Yes | + | | | + |-----------------|-----------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | No Tags | + | _Billable_ | Yes | `), }, { @@ -191,12 +191,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|-----------------------------| - | _Description_ | With project | - | _Project_ | **Project Name**: Task Name | - | _Tags_ | Stand-up Meeting | - | _Billable_ | Yes | + | | | + |-----------------|-----------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | Stand-up Meeting | + | _Billable_ | Yes | `), }, { @@ -228,12 +228,154 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|----------------------------------| - | _Description_ | With project | - | _Project_ | **Project Name**: Task Name | - | _Tags_ | A Tag with long name, Normal tag | - | _Billable_ | Yes | + | | | + |-----------------|----------------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | A Tag with long name, Normal tag | + | _Billable_ | Yes | + `), + }, + { + name: "Closed with project, client, task, tags and a custom field with non empty value", + tes: []dto.TimeEntry{{ + WorkspaceID: "w1", + ID: "te1", + Billable: true, + Description: "With project", + Project: &dto.Project{ + Name: "Project Name", + ClientName: "Client Name", + }, + Task: &dto.Task{ + Name: "Task Name", + }, + CustomFields: []dto.CustomField{{ + CustomFieldID: "abcdef123456", + Name: "A custom field name", + TimeEntryId: "te1", + Type: "DROPDOWN_SINGLE", + Value: "A custom field value", + }}, + Tags: []dto.Tag{ + {Name: "A Tag with long name"}, + {Name: "Normal tag"}, + }, + TimeInterval: dto.NewTimeInterval( + start, + &end, + ), + }}, + output: heredoc.Doc(` + ## _Time Entry_: te1 + + _Time and date_ + **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 + + | | | + |-----------------|-------------------------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | A Tag with long name, Normal tag | + | _Billable_ | Yes | + | _Custom Fields_ | A custom field name: A custom field value | + `), + }, + { + name: "Closed with project, client, task, tags and a custom field with an empty value", + tes: []dto.TimeEntry{{ + WorkspaceID: "w1", + ID: "te1", + Billable: true, + Description: "With project", + Project: &dto.Project{ + Name: "Project Name", + ClientName: "Client Name", + }, + Task: &dto.Task{ + Name: "Task Name", + }, + CustomFields: []dto.CustomField{{ + CustomFieldID: "abcdef123456", + Name: "A custom field name", + TimeEntryId: "te1", + Type: "DROPDOWN_SINGLE", + Value: "", + }}, + Tags: []dto.Tag{ + {Name: "A Tag with long name"}, + {Name: "Normal tag"}, + }, + TimeInterval: dto.NewTimeInterval( + start, + &end, + ), + }}, + output: heredoc.Doc(` + ## _Time Entry_: te1 + + _Time and date_ + **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 + + | | | + |-----------------|----------------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | A Tag with long name, Normal tag | + | _Billable_ | Yes | + `), + }, + { + name: "Closed with project, client, task, tags and a custom field non empty value and a custom field with multiple values", + tes: []dto.TimeEntry{{ + WorkspaceID: "w1", + ID: "te1", + Billable: true, + Description: "With project", + Project: &dto.Project{ + Name: "Project Name", + ClientName: "Client Name", + }, + Task: &dto.Task{ + Name: "Task Name", + }, + CustomFields: []dto.CustomField{ + { + CustomFieldID: "abcdef123456", + Name: "A custom field name", + TimeEntryId: "te1", + Type: "DROPDOWN_SINGLE", + Value: "A custom field value", + }, + { + CustomFieldID: "abcdef123457", + Name: "Another custom field name", + TimeEntryId: "te1", + Type: "DROPDOWN_MULTIPLE", + Value: []string{"Value 1", "Value 2"}, + }}, + Tags: []dto.Tag{ + {Name: "A Tag with long name"}, + {Name: "Normal tag"}, + }, + TimeInterval: dto.NewTimeInterval( + start, + &end, + ), + }}, + output: heredoc.Doc(` + ## _Time Entry_: te1 + + _Time and date_ + **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 + + | | | + |-----------------|---------------------------------------------------------------------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | A Tag with long name, Normal tag | + | _Billable_ | Yes | + | _Custom Fields_ | A custom field name: A custom field value, Another custom field name: Value 1|Value 2 | `), }, }