├── site ├── .gitignore ├── content │ ├── changelog │ │ └── _index.md │ └── _index.md ├── static │ └── css │ │ └── custom.css ├── archetypes │ └── default.md ├── config.toml └── layouts │ └── partials │ ├── menu-footer.html │ └── logo.html ├── .gitignore ├── netlify.toml ├── .gitmodules ├── pkg ├── cmdutil │ ├── version.go │ ├── errors.go │ ├── project.go │ ├── args.go │ ├── flags.go │ ├── args_test.go │ └── flags_test.go ├── output │ ├── task │ │ ├── json.go │ │ ├── quiet.go │ │ ├── template.go │ │ ├── csv.go │ │ └── default.go │ ├── user │ │ ├── json.go │ │ ├── quiet.go │ │ ├── template.go │ │ └── default.go │ ├── tag │ │ ├── quiet.go │ │ ├── default.go │ │ └── template.go │ ├── client │ │ ├── quiet.go │ │ ├── json.go │ │ ├── template.go │ │ ├── csv.go │ │ └── default.go │ ├── project │ │ ├── quiet.go │ │ ├── json.go │ │ ├── template.go │ │ ├── csv.go │ │ └── default.go │ ├── workspace │ │ ├── quiet.go │ │ ├── template.go │ │ └── default.go │ ├── time-entry │ │ ├── quiet.go │ │ ├── markdown.go │ │ ├── json.go │ │ ├── template.go │ │ ├── duration.go │ │ ├── duration_test.go │ │ ├── csv.go │ │ └── markdown.gotmpl.md │ └── util │ │ ├── color.go │ │ └── template.go ├── cmdcomplutil │ ├── factory.go │ ├── workspace.go │ ├── tag.go │ ├── user.go │ ├── client.go │ ├── task.go │ └── project.go ├── timehlp │ ├── range.go │ ├── util.go │ ├── time_test.go │ ├── relative.go │ └── time.go ├── cmd │ ├── version │ │ ├── version.go │ │ └── version_test.go │ ├── client │ │ ├── client.go │ │ ├── util │ │ │ └── util.go │ │ ├── add │ │ │ └── add.go │ │ └── list │ │ │ └── list.go │ ├── time-entry │ │ ├── util │ │ │ ├── create.go │ │ │ ├── out-in-progress.go │ │ │ ├── validate-closing.go │ │ │ ├── validate.go │ │ │ ├── name-for-id.go │ │ │ ├── fill-with-flags.go │ │ │ ├── flags.go │ │ │ ├── util.go │ │ │ ├── help.go │ │ │ └── description-completer.go │ │ ├── report │ │ │ ├── today │ │ │ │ └── today.go │ │ │ ├── yesterday │ │ │ │ └── yesterday.go │ │ │ ├── this-week │ │ │ │ └── this-week.go │ │ │ ├── this-month │ │ │ │ └── this-month.go │ │ │ ├── last-month │ │ │ │ └── last-month.go │ │ │ ├── last-week │ │ │ │ └── last-week.go │ │ │ ├── last-day │ │ │ │ └── last-day.go │ │ │ ├── util │ │ │ │ └── report_flag_test.go │ │ │ └── last-week-day │ │ │ │ └── last-week-day.go │ │ ├── timeentry.go │ │ ├── show │ │ │ └── show.go │ │ ├── delete │ │ │ └── delete.go │ │ └── manual │ │ │ └── manual.go │ ├── project │ │ ├── project.go │ │ ├── util │ │ │ └── util.go │ │ └── get │ │ │ └── get.go │ ├── config │ │ ├── util │ │ │ └── util.go │ │ ├── get │ │ │ └── get.go │ │ ├── list │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── set │ │ │ └── set.go │ │ └── config.go │ ├── task │ │ ├── task.go │ │ ├── util │ │ │ ├── report.go │ │ │ └── read-flags.go │ │ ├── list │ │ │ └── list.go │ │ ├── quick-add │ │ │ └── quick-add.go │ │ ├── add │ │ │ └── add.go │ │ └── delete │ │ │ └── delete.go │ ├── user │ │ ├── util │ │ │ └── util.go │ │ ├── me │ │ │ └── me.go │ │ └── user.go │ ├── workspace │ │ └── workspace.go │ ├── completion │ │ └── completion.go │ ├── tag │ │ └── tag.go │ └── root.go ├── ui │ └── color.go ├── search │ ├── errors.go │ ├── find.go │ ├── tag.go │ ├── user.go │ ├── task.go │ ├── find_test.go │ └── client.go ├── cmdcompl │ ├── valid-args.go │ └── flags.go └── timeentryhlp │ └── timeentry.go ├── .deepsource.toml ├── internal ├── testhlp │ └── helper.go ├── mocks │ └── gen.go └── consoletest │ └── test.go ├── .nvimrc ├── .mockery.yaml ├── scripts └── site-build ├── .github └── workflows │ ├── golangci-lint.yml │ ├── test-unit.yaml │ └── release.yml ├── api ├── logger.go └── tag_test.go ├── cmd └── gendocs │ └── main.go ├── go.mod ├── Makefile ├── .goreleaser.yml ├── docs └── project-layout.md └── strhlp └── strhlp.go /site/.gitignore: -------------------------------------------------------------------------------- 1 | .hugo_build.lock 2 | -------------------------------------------------------------------------------- /site/content/changelog/_index.md: -------------------------------------------------------------------------------- 1 | ../../../CHANGELOG.md -------------------------------------------------------------------------------- /site/static/css/custom.css: -------------------------------------------------------------------------------- 1 | #body img.badges { 2 | display: inline; 3 | margin: 0; 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | snap.login 4 | /clockify-cli 5 | site/content/commands/ 6 | site/public/ 7 | site/content/license 8 | -------------------------------------------------------------------------------- /site/archetypes/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "{{ replace .Name "-" " " | title }}" 3 | date: {{ .Date }} 4 | draft: true 5 | --- 6 | 7 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "make site-build" 3 | publish = "site/public" 4 | environment = { GO_VERSION = "1.19" } 5 | 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "site/themes/hugo-theme-learn"] 2 | path = site/themes/hugo-theme-learn 3 | url = https://github.com/matcornic/hugo-theme-learn.git 4 | -------------------------------------------------------------------------------- /pkg/cmdutil/version.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | // Version register which is the CLI tag, commit and build date 4 | type Version struct { 5 | Tag string 6 | Commit string 7 | Date string 8 | } 9 | -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "go" 5 | enabled = true 6 | 7 | [analyzers.meta] 8 | import_root = "github.com/lucassabreu/clockify-cli" 9 | dependencies_vendored = false 10 | 11 | [[analyzers]] 12 | name = "test-coverage" 13 | enabled = true 14 | -------------------------------------------------------------------------------- /internal/testhlp/helper.go: -------------------------------------------------------------------------------- 1 | package testhlp 2 | 3 | import "time" 4 | 5 | // MustParseTime will parse a string as time.Time or panic 6 | func MustParseTime(l, v string) time.Time { 7 | t, err := time.Parse(l, v) 8 | if err == nil { 9 | return t 10 | } 11 | panic(err) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/output/task/json.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // TasksJSONPrint will print as JSON 11 | func TasksJSONPrint(t []dto.Task, w io.Writer) error { 12 | return json.NewEncoder(w).Encode(t) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/output/user/json.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // UserJSONPrint will print the user as a JSON 11 | func UserJSONPrint(u dto.User, w io.Writer) error { 12 | return json.NewEncoder(w).Encode(u) 13 | } 14 | -------------------------------------------------------------------------------- /.nvimrc: -------------------------------------------------------------------------------- 1 | set spell 2 | set spelllang=en 3 | set textwidth=79 4 | set colorcolumn=80 5 | let g:goyo_width = 103 6 | 7 | autocmd FileType markdown setlocal ts=2 sts=2 sw=2 expandtab textwidth=99 colorcolumn=100 8 | autocmd FileType markdown setlocal nofoldenable 9 | autocmd BufRead,BufNewFile *.md setlocal spell wrap 10 | -------------------------------------------------------------------------------- /pkg/cmdcomplutil/factory.go: -------------------------------------------------------------------------------- 1 | package cmdcomplutil 2 | 3 | import "github.com/lucassabreu/clockify-cli/api" 4 | 5 | type config interface { 6 | IsAllowNameForID() bool 7 | IsSearchProjectWithClientsName() bool 8 | } 9 | 10 | type factory interface { 11 | Client() (api.Client, error) 12 | GetWorkspaceID() (string, error) 13 | } 14 | -------------------------------------------------------------------------------- /internal/mocks/gen.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/api" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 6 | ) 7 | 8 | type Factory interface { 9 | cmdutil.Factory 10 | } 11 | 12 | type Config interface { 13 | cmdutil.Config 14 | } 15 | 16 | type Client interface { 17 | api.Client 18 | } 19 | -------------------------------------------------------------------------------- /pkg/output/tag/quiet.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // TagPrintQuietly will only print the IDs 11 | func TagPrintQuietly(ts []dto.Tag, w io.Writer) error { 12 | for i := 0; i < len(ts); i++ { 13 | fmt.Fprintln(w, ts[i].ID) 14 | } 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/output/task/quiet.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // TaskPrintQuietly will only print the IDs 11 | func TaskPrintQuietly(ts []dto.Task, w io.Writer) error { 12 | for i := 0; i < len(ts); i++ { 13 | fmt.Fprintln(w, ts[i].ID) 14 | } 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/output/client/quiet.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // ClientPrintQuietly will only print the IDs 11 | func ClientPrintQuietly(cs []dto.Client, w io.Writer) error { 12 | for i := 0; i < len(cs); i++ { 13 | fmt.Fprintln(w, cs[i].ID) 14 | } 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/output/user/quiet.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // UserPrintQuietly will only print the IDs 11 | func UserPrintQuietly(users []dto.User, w io.Writer) error { 12 | for i := 0; i < len(users); i++ { 13 | fmt.Fprintln(w, users[i].ID) 14 | } 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/output/project/quiet.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // ProjectPrintQuietly will only print the IDs 11 | func ProjectPrintQuietly(ps []dto.Project, w io.Writer) error { 12 | for i := 0; i < len(ps); i++ { 13 | fmt.Fprintln(w, ps[i].ID) 14 | } 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/output/workspace/quiet.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // WorkspacePrintQuietly will only print the IDs 11 | func WorkspacePrintQuietly(ws []dto.Workspace, w io.Writer) error { 12 | for i := 0; i < len(ws); i++ { 13 | fmt.Fprintln(w, ws[i].ID) 14 | } 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/cmdutil/errors.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | // FlagError happens when a non-cobra validation fails 4 | type FlagError struct { 5 | err error 6 | } 7 | 8 | func (fe *FlagError) Error() string { 9 | return fe.err.Error() 10 | } 11 | 12 | func (fe *FlagError) Unwrap() error { 13 | return fe.err 14 | } 15 | 16 | func FlagErrorWrap(err error) *FlagError { 17 | return &FlagError{err: err} 18 | } 19 | -------------------------------------------------------------------------------- /pkg/output/time-entry/quiet.go: -------------------------------------------------------------------------------- 1 | package timeentry 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // TimeEntriesPrintQuietly will only print the IDs 11 | func TimeEntriesPrintQuietly(timeEntries []dto.TimeEntry, w io.Writer) error { 12 | for i := 0; i < len(timeEntries); i++ { 13 | fmt.Fprintln(w, timeEntries[i].ID) 14 | } 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /.mockery.yaml: -------------------------------------------------------------------------------- 1 | dir: internal/mocks 2 | template: testify 3 | template-data: 4 | unroll-variadic: true 5 | packages: 6 | github.com/lucassabreu/clockify-cli/internal/mocks: 7 | interfaces: 8 | Client: 9 | configs: 10 | - filename: "mock_Client.go" 11 | Config: 12 | configs: 13 | - filename: "mock_Config.go" 14 | Factory: 15 | configs: 16 | - filename: "mock_Factory.go" 17 | -------------------------------------------------------------------------------- /pkg/output/time-entry/markdown.go: -------------------------------------------------------------------------------- 1 | package timeentry 2 | 3 | import ( 4 | _ "embed" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | //go:embed markdown.gotmpl.md 11 | var mdTemplate string 12 | 13 | // TimeEntriesMarkdownPrint will print time entries in "markdown blocks" 14 | func TimeEntriesMarkdownPrint(tes []dto.TimeEntry, w io.Writer) error { 15 | return TimeEntriesPrintWithTemplate(mdTemplate)(tes, w) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/output/client/json.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // ClientJSONPrint will print as JSON 11 | func ClientJSONPrint(t dto.Client, w io.Writer) error { 12 | return json.NewEncoder(w).Encode(t) 13 | } 14 | 15 | // ClientsJSONPrint will print as JSON 16 | func ClientsJSONPrint(t []dto.Client, w io.Writer) error { 17 | return json.NewEncoder(w).Encode(t) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/output/project/json.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // ProjectsJSONPrint will print as JSON 11 | func ProjectsJSONPrint(t []dto.Project, w io.Writer) error { 12 | return json.NewEncoder(w).Encode(t) 13 | } 14 | 15 | // ProjectJSONPrint will print as JSON 16 | func ProjectJSONPrint(t dto.Project, w io.Writer) error { 17 | return json.NewEncoder(w).Encode(t) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/output/time-entry/json.go: -------------------------------------------------------------------------------- 1 | package timeentry 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // TimeEntryJSONPrint will print as JSON 11 | func TimeEntryJSONPrint(t dto.TimeEntry, w io.Writer) error { 12 | return json.NewEncoder(w).Encode(t) 13 | } 14 | 15 | // TimeEntriesJSONPrint will print as JSON 16 | func TimeEntriesJSONPrint(t []dto.TimeEntry, w io.Writer) error { 17 | return json.NewEncoder(w).Encode(t) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/output/util/color.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/lucassabreu/clockify-cli/pkg/ui" 7 | ) 8 | 9 | // ColorToTermColor coverts HEX color to term colors 10 | func ColorToTermColor(hex string) []int { 11 | if hex == "" { 12 | return []int{} 13 | } 14 | 15 | fi, _ := os.Stdout.Stat() 16 | if fi.Mode()&os.ModeCharDevice == 0 { 17 | return []int{} 18 | } 19 | 20 | if c, err := ui.HEX(hex[1:]); err == nil { 21 | return append( 22 | []int{38, 2}, 23 | c.Values()..., 24 | ) 25 | } 26 | 27 | return []int{} 28 | } 29 | -------------------------------------------------------------------------------- /pkg/timehlp/range.go: -------------------------------------------------------------------------------- 1 | package timehlp 2 | 3 | import "time" 4 | 5 | // GetMonthRange given a time it returns the first and last date of a month 6 | func GetMonthRange(ref time.Time) (first, last time.Time) { 7 | first = ref.AddDate(0, 0, ref.Day()*-1+1) 8 | last = first.AddDate(0, 1, -1) 9 | 10 | return 11 | } 12 | 13 | // GetWeekRange given a time it returns the first and last date of a week 14 | func GetWeekRange(ref time.Time) (first, last time.Time) { 15 | first = ref.AddDate(0, 0, int(ref.Weekday())*-1) 16 | last = first.AddDate(0, 0, 7) 17 | 18 | return 19 | } 20 | -------------------------------------------------------------------------------- /pkg/output/tag/default.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/olekukonko/tablewriter" 8 | ) 9 | 10 | // TagPrint will print more details 11 | func TagPrint(ts []dto.Tag, w io.Writer) error { 12 | tw := tablewriter.NewWriter(w) 13 | tw.SetHeader([]string{"ID", "Name"}) 14 | 15 | lines := make([][]string, len(ts)) 16 | for i := 0; i < len(ts); i++ { 17 | lines[i] = []string{ 18 | ts[i].ID, 19 | ts[i].Name, 20 | } 21 | } 22 | 23 | tw.AppendBulk(lines) 24 | tw.Render() 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/cmd/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // NewCmdVersion represents the version command 11 | func NewCmdVersion(f cmdutil.Factory) *cobra.Command { 12 | return &cobra.Command{ 13 | Use: "version", 14 | Short: "Shows the CLI version", 15 | Run: func(cmd *cobra.Command, _ []string) { 16 | v := f.Version() 17 | fmt.Fprintln(cmd.OutOrStdout(), 18 | "Version: "+v.Tag+", Commit: "+v.Commit+", Build At: "+v.Date, 19 | ) 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /site/config.toml: -------------------------------------------------------------------------------- 1 | baseURL = "http://example.org/" 2 | languageCode = "en-us" 3 | title = "Clockify CLI" 4 | 5 | theme = "hugo-theme-learn" 6 | 7 | pygmentsCodeFences = true 8 | pygmentsUseClasses = true 9 | pygmentsOptions = "hl_lines=3,startinline=1" 10 | 11 | [outputs] 12 | home = ["HTML", "RSS", "JSON"] 13 | 14 | [params] 15 | description = "Clockify CLI" 16 | author = "Lucas dos Santos Abreu" 17 | showVisitedLinks = false 18 | custom_css = ["css/custom.css"] 19 | editURL = "https://github.com/lucassabreu/clockify-cli/tree/main/site/" 20 | disableNextPrev = true 21 | -------------------------------------------------------------------------------- /pkg/cmdutil/project.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdcomplutil" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // AddProjectFlags creates a project flag with autocomplete configured 10 | func AddProjectFlags(cmd *cobra.Command, f Factory) { 11 | cmd.Flags().StringP("project", "p", "", 12 | "the name/id of the project to work on") 13 | _ = cmd.MarkFlagRequired("project") 14 | 15 | _ = cmdcompl.AddSuggestionsToFlag(cmd, "project", 16 | cmdcomplutil.NewProjectAutoComplete(f, f.Config())) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/cmd/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/client/add" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmd/client/list" 6 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // NewCmdClient represents the client command 11 | func NewCmdClient(f cmdutil.Factory) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "client", 14 | Aliases: []string{"clients"}, 15 | Short: "Work with Clockify clients", 16 | } 17 | 18 | cmd.AddCommand(list.NewCmdList(f, nil)) 19 | cmd.AddCommand(add.NewCmdAdd(f, nil)) 20 | 21 | return cmd 22 | } 23 | -------------------------------------------------------------------------------- /pkg/output/tag/template.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/lucassabreu/clockify-cli/pkg/output/util" 8 | ) 9 | 10 | // TagPrintWithTemplate will print each worspace using the format string 11 | func TagPrintWithTemplate(format string) func([]dto.Tag, io.Writer) error { 12 | return func(ts []dto.Tag, w io.Writer) error { 13 | t, err := util.NewTemplate(format) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | for i := 0; i < len(ts); i++ { 19 | if err := t.Execute(w, ts[i]); err != nil { 20 | return err 21 | } 22 | } 23 | return nil 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/output/task/template.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/lucassabreu/clockify-cli/pkg/output/util" 8 | ) 9 | 10 | // TaskPrintWithTemplate will print each client using the format string 11 | func TaskPrintWithTemplate(format string) func([]dto.Task, io.Writer) error { 12 | return func(ts []dto.Task, w io.Writer) error { 13 | t, err := util.NewTemplate(format) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | for i := 0; i < len(ts); i++ { 19 | if err := t.Execute(w, ts[i]); err != nil { 20 | return err 21 | } 22 | } 23 | return nil 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/output/task/csv.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "encoding/csv" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // TasksCSVPrint will print as CSV 11 | func TasksCSVPrint(ts []dto.Task, out io.Writer) error { 12 | w := csv.NewWriter(out) 13 | 14 | if err := w.Write([]string{ 15 | "id", 16 | "name", 17 | "status", 18 | }); err != nil { 19 | return err 20 | } 21 | 22 | for i := 0; i < len(ts); i++ { 23 | if err := w.Write([]string{ 24 | ts[i].ID, 25 | ts[i].Name, 26 | string(ts[i].Status), 27 | }); err != nil { 28 | return err 29 | } 30 | } 31 | 32 | w.Flush() 33 | return w.Error() 34 | } 35 | -------------------------------------------------------------------------------- /pkg/output/client/template.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/lucassabreu/clockify-cli/pkg/output/util" 8 | ) 9 | 10 | // ClientPrintWithTemplate will print each client using the format string 11 | func ClientPrintWithTemplate(format string) func([]dto.Client, io.Writer) error { 12 | return func(cs []dto.Client, w io.Writer) error { 13 | t, err := util.NewTemplate(format) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | for i := 0; i < len(cs); i++ { 19 | if err := t.Execute(w, cs[i]); err != nil { 20 | return err 21 | } 22 | } 23 | return nil 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/output/user/template.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/lucassabreu/clockify-cli/pkg/output/util" 8 | ) 9 | 10 | // UserPrintWithTemplate will print each worspace using the format string 11 | func UserPrintWithTemplate(format string) func([]dto.User, io.Writer) error { 12 | return func(users []dto.User, w io.Writer) error { 13 | t, err := util.NewTemplate(format) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | for i := 0; i < len(users); i++ { 19 | if err := t.Execute(w, users[i]); err != nil { 20 | return err 21 | } 22 | } 23 | return nil 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/output/project/template.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/lucassabreu/clockify-cli/pkg/output/util" 8 | ) 9 | 10 | // ProjectPrintWithTemplate will print each worspace using the format string 11 | func ProjectPrintWithTemplate(format string) func([]dto.Project, io.Writer) error { 12 | return func(ps []dto.Project, w io.Writer) error { 13 | t, err := util.NewTemplate(format) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | for i := 0; i < len(ps); i++ { 19 | if err := t.Execute(w, ps[i]); err != nil { 20 | return err 21 | } 22 | } 23 | return nil 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/output/workspace/template.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/lucassabreu/clockify-cli/pkg/output/util" 8 | ) 9 | 10 | // WorkspacePrintWithTemplate will print each worspace using the format string 11 | func WorkspacePrintWithTemplate( 12 | format string) func([]dto.Workspace, io.Writer) error { 13 | return func(ws []dto.Workspace, w io.Writer) error { 14 | t, err := util.NewTemplate(format) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | for i := 0; i < len(ws); i++ { 20 | if err := t.Execute(w, ws[i]); err != nil { 21 | return err 22 | } 23 | } 24 | return nil 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /scripts/site-build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | 4 | set -e 5 | 6 | rm -rf site/content/commands/* || true 7 | 8 | echo "generating documentation for commands..." 9 | go run ./cmd/gendocs/main.go site/content/commands 10 | 11 | echo "doing last touches..." 12 | # fix root command to be "chapter root" 13 | mv site/content/commands/clockify-cli.md site/content/commands/_index.md 14 | 15 | # add license information 16 | mkdir -p site/content/license 17 | echo '--- 18 | title: License 19 | chapter: true 20 | --- 21 | ```txt' > site/content/license/_index.md 22 | cat LICENSE >> site/content/license/_index.md 23 | echo '```' >> site/content/license/_index.md 24 | 25 | echo "building site :tada:" 26 | cd site && hugo 27 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - master 8 | - main 9 | pull_request: 10 | permissions: 11 | contents: read 12 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 13 | # pull-requests: read 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-go@v5 21 | with: 22 | go-version: 1.24 23 | cache: false 24 | - name: golangci-lint 25 | uses: golangci/golangci-lint-action@v6 26 | with: 27 | version: latest 28 | -------------------------------------------------------------------------------- /pkg/output/client/csv.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | ) 10 | 11 | // ClientsCSVPrint will print as CSV 12 | func ClientsCSVPrint(clients []dto.Client, out io.Writer) error { 13 | w := csv.NewWriter(out) 14 | 15 | if err := w.Write([]string{ 16 | "id", 17 | "name", 18 | "archived", 19 | }); err != nil { 20 | return err 21 | } 22 | 23 | for i := 0; i < len(clients); i++ { 24 | c := clients[i] 25 | if err := w.Write([]string{ 26 | c.ID, 27 | c.Name, 28 | fmt.Sprintf("%v", c.Archived), 29 | }); err != nil { 30 | return err 31 | } 32 | } 33 | 34 | w.Flush() 35 | return w.Error() 36 | } 37 | -------------------------------------------------------------------------------- /pkg/output/user/default.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/olekukonko/tablewriter" 8 | ) 9 | 10 | // UserPrint will print more details 11 | func UserPrint(users []dto.User, w io.Writer) error { 12 | tw := tablewriter.NewWriter(w) 13 | tw.SetHeader([]string{"ID", "Name", "Email", "Status", "TimeZone"}) 14 | 15 | lines := make([][]string, len(users)) 16 | for i := 0; i < len(users); i++ { 17 | lines[i] = []string{ 18 | users[i].ID, 19 | users[i].Name, 20 | users[i].Email, 21 | string(users[i].Status), 22 | users[i].Settings.TimeZone, 23 | } 24 | } 25 | 26 | tw.AppendBulk(lines) 27 | tw.Render() 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/util/create.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/api" 5 | ) 6 | 7 | // CreateTimeEntryFn will create a time entry 8 | func CreateTimeEntryFn(c api.Client) Step { 9 | return func(dto TimeEntryDTO) (TimeEntryDTO, error) { 10 | te, err := c.CreateTimeEntry(api.CreateTimeEntryParam{ 11 | Workspace: dto.Workspace, 12 | Billable: dto.Billable, 13 | Start: dto.Start, 14 | End: dto.End, 15 | ProjectID: dto.ProjectID, 16 | Description: dto.Description, 17 | TagIDs: dto.TagIDs, 18 | TaskID: dto.TaskID, 19 | }) 20 | 21 | if err != nil { 22 | return dto, err 23 | } 24 | 25 | return TimeEntryImplToDTO(te), nil 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/output/project/csv.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "encoding/csv" 5 | "io" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | // ProjectsCSVPrint will print each time entry using the format string 11 | func ProjectsCSVPrint(ps []dto.Project, out io.Writer) error { 12 | w := csv.NewWriter(out) 13 | 14 | if err := w.Write([]string{ 15 | "id", 16 | "name", 17 | "client.id", 18 | "client.name", 19 | }); err != nil { 20 | return err 21 | } 22 | 23 | for i := 0; i < len(ps); i++ { 24 | p := ps[i] 25 | if err := w.Write([]string{ 26 | p.ID, 27 | p.Name, 28 | p.ClientID, 29 | p.ClientName, 30 | }); err != nil { 31 | return err 32 | } 33 | } 34 | 35 | w.Flush() 36 | return w.Error() 37 | } 38 | -------------------------------------------------------------------------------- /pkg/ui/color.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | ) 7 | 8 | func HEX(hex string) (RGB, error) { 9 | if len(hex) != 6 { 10 | return RGB{}, errors.New("HEX must be 6 characters") 11 | } 12 | 13 | values, err := strconv.ParseUint(string(hex), 16, 32) 14 | 15 | if err != nil { 16 | return RGB{}, err 17 | } 18 | 19 | return RGB{ 20 | int(values >> 16), 21 | int((values >> 8) & 0xFF), 22 | int(values & 0xFF), 23 | }, nil 24 | } 25 | 26 | type RGB [3]int 27 | 28 | func (c RGB) R() int { 29 | return c[0] 30 | } 31 | 32 | func (c RGB) G() int { 33 | return c[1] 34 | } 35 | 36 | func (c RGB) B() int { 37 | return c[2] 38 | } 39 | 40 | func (c RGB) Values() []int { 41 | return []int{c.R(), c.G(), c.B()} 42 | } 43 | -------------------------------------------------------------------------------- /pkg/output/task/default.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | "github.com/olekukonko/tablewriter" 9 | "golang.org/x/term" 10 | ) 11 | 12 | // TaskPrint will print more details 13 | func TaskPrint(ts []dto.Task, w io.Writer) error { 14 | tw := tablewriter.NewWriter(w) 15 | tw.SetHeader([]string{"ID", "Name", "Status"}) 16 | 17 | lines := make([][]string, len(ts)) 18 | for i := 0; i < len(ts); i++ { 19 | lines[i] = []string{ 20 | ts[i].ID, 21 | ts[i].Name, 22 | string(ts[i].Status), 23 | } 24 | } 25 | 26 | if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil { 27 | tw.SetColWidth(width / 3) 28 | } 29 | tw.AppendBulk(lines) 30 | tw.Render() 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cmdutil/args.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/strhlp" 5 | "github.com/pkg/errors" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // RequiredNamedArgs will fail if the number of arguments received is less than 10 | // the length of the parameter `names`, and will show the help explaining 11 | // required named arguments 12 | func RequiredNamedArgs(names ...string) cobra.PositionalArgs { 13 | return func(cmd *cobra.Command, args []string) error { 14 | if len(args) >= len(names) { 15 | return nil 16 | } 17 | 18 | if len(names) == 1 { 19 | return FlagErrorWrap(errors.New("requires arg " + names[0])) 20 | } 21 | 22 | return FlagErrorWrap(errors.Errorf( 23 | "requires args %s; %d of those received", 24 | strhlp.ListForHumans(names), len(args), 25 | )) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /api/logger.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | // Logger for the Client 4 | type Logger interface { 5 | Print(v ...interface{}) 6 | Printf(format string, v ...interface{}) 7 | Println(v ...interface{}) 8 | } 9 | 10 | // SetDebugLogger debug logger 11 | func (c *client) SetDebugLogger(logger Logger) Client { 12 | c.debugLogger = logger 13 | return c 14 | } 15 | 16 | func (c *client) debugf(format string, v ...interface{}) { 17 | if c.debugLogger == nil { 18 | return 19 | } 20 | 21 | c.debugLogger.Printf(format, v...) 22 | } 23 | 24 | // SetInfoLogger info logger 25 | func (c *client) SetInfoLogger(logger Logger) Client { 26 | c.infoLogger = logger 27 | return c 28 | } 29 | 30 | func (c *client) infof(format string, v ...interface{}) { 31 | if c.infoLogger == nil { 32 | return 33 | } 34 | 35 | c.infoLogger.Printf(format, v...) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/output/workspace/default.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/olekukonko/tablewriter" 8 | ) 9 | 10 | // WorkspacePrint will print more details 11 | func WorkspacePrint( 12 | wDefault string) func(ws []dto.Workspace, w io.Writer) error { 13 | return func(ws []dto.Workspace, w io.Writer) error { 14 | tw := tablewriter.NewWriter(w) 15 | tw.SetHeader([]string{"ID", "Name", "Image"}) 16 | 17 | lines := make([][]string, len(ws)) 18 | for i := 0; i < len(ws); i++ { 19 | lines[i] = []string{ 20 | ws[i].ID, 21 | ws[i].Name, 22 | ws[i].ImageURL, 23 | } 24 | if wDefault == ws[i].ID { 25 | lines[i][1] = lines[i][1] + " (default)" 26 | } 27 | } 28 | 29 | tw.AppendBulk(lines) 30 | tw.Render() 31 | 32 | return nil 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pkg/cmd/project/project.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/project/add" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmd/project/edit" 6 | "github.com/lucassabreu/clockify-cli/pkg/cmd/project/get" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmd/project/list" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // NewCmdProject represents the project command 13 | func NewCmdProject(f cmdutil.Factory) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "project", 16 | Aliases: []string{"projects"}, 17 | Short: "Work with Clockify projects", 18 | } 19 | 20 | cmd.AddCommand(list.NewCmdList(f, nil)) 21 | cmd.AddCommand(get.NewCmdGet(f, nil)) 22 | cmd.AddCommand(add.NewCmdAdd(f, nil)) 23 | cmd.AddCommand(edit.NewCmdEdit(f, nil)) 24 | 25 | return cmd 26 | } 27 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/util/out-in-progress.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | ) 10 | 11 | // OutInProgressFn will stop the in progress time entry, if it exists 12 | func OutInProgressFn(c api.Client) Step { 13 | return func(tei TimeEntryDTO) (TimeEntryDTO, error) { 14 | return tei, out(c, tei.Workspace, tei.UserID, tei.Start) 15 | } 16 | } 17 | 18 | func out(c api.Client, w, u string, end time.Time) error { 19 | if err := c.Out(api.OutParam{ 20 | Workspace: w, 21 | UserID: u, 22 | End: end, 23 | }); getErrorCode(err) != 404 { 24 | return err 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func getErrorCode(err error) int { 31 | var e dto.Error 32 | if errors.As(err, &e) { 33 | return e.Code 34 | } 35 | 36 | return 0 37 | } 38 | -------------------------------------------------------------------------------- /pkg/output/client/default.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | "github.com/olekukonko/tablewriter" 9 | "golang.org/x/term" 10 | ) 11 | 12 | // ClientPrint will print more details 13 | func ClientPrint(cs []dto.Client, w io.Writer) error { 14 | tw := tablewriter.NewWriter(w) 15 | tw.SetHeader([]string{"ID", "Name", "Archived"}) 16 | 17 | yesNo := map[bool]string{ 18 | true: "YES", 19 | false: "NO", 20 | } 21 | 22 | lines := make([][]string, len(cs)) 23 | for i := 0; i < len(cs); i++ { 24 | c := cs[i] 25 | lines[i] = []string{ 26 | c.ID, 27 | c.Name, 28 | yesNo[c.Archived], 29 | } 30 | } 31 | 32 | if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil { 33 | tw.SetColWidth(width / 3) 34 | } 35 | tw.AppendBulk(lines) 36 | tw.Render() 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/search/errors.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/lucassabreu/clockify-cli/strhlp" 7 | ) 8 | 9 | // ErrNotFound represents a fail to identify a entity by its name or id 10 | type ErrNotFound struct { 11 | EntityName string 12 | Reference string 13 | Filters map[string]string 14 | } 15 | 16 | func (e ErrNotFound) Error() string { 17 | sufix := "" 18 | if len(e.Filters) > 0 { 19 | sufix = " for " 20 | keys := make([]string, len(e.Filters)) 21 | i := 0 22 | for k := range e.Filters { 23 | keys[i] = k 24 | i++ 25 | } 26 | 27 | sort.Strings(keys) 28 | for i := range keys { 29 | keys[i] = keys[i] + " '" + e.Filters[keys[i]] + "'" 30 | } 31 | 32 | sufix = sufix + strhlp.ListForHumans(keys) 33 | } 34 | 35 | return "No " + e.EntityName + " with id or name containing '" + 36 | e.Reference + "' was found" + sufix 37 | } 38 | -------------------------------------------------------------------------------- /pkg/timehlp/util.go: -------------------------------------------------------------------------------- 1 | package timehlp 2 | 3 | import "time" 4 | 5 | // TruncateDate clears the hours, minutes and seconds of a time.Time for UTC 6 | func TruncateDate(t time.Time) time.Time { 7 | return TruncateDateWithTimezone(t, time.UTC) 8 | } 9 | 10 | // TruncateDateWithTimezone clears the hours, minutes and seconds of a 11 | // time.Time for a time.Location 12 | func TruncateDateWithTimezone(t time.Time, l *time.Location) time.Time { 13 | return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, l). 14 | Truncate(time.Second) 15 | } 16 | 17 | // Today will return a UTC time.Time for the same day as time.Now() in Local 18 | // time, but at 0:00:00.000 19 | func Today() time.Time { 20 | n := Now() 21 | return TruncateDateWithTimezone(n, n.Location()) 22 | } 23 | 24 | // Now returns a time.Time using the local timezone 25 | func Now() time.Time { 26 | return time.Now().In(time.Local).Truncate(time.Second) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/output/time-entry/template.go: -------------------------------------------------------------------------------- 1 | package timeentry 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/lucassabreu/clockify-cli/pkg/output/util" 8 | ) 9 | 10 | // TimeEntriesPrintWithTemplate will print each time entry using the format 11 | // string 12 | func TimeEntriesPrintWithTemplate( 13 | format string, 14 | ) func([]dto.TimeEntry, io.Writer) error { 15 | return func(timeEntries []dto.TimeEntry, w io.Writer) error { 16 | t, err := util.NewTemplate(format) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | l := len(timeEntries) 22 | for i := 0; i < l; i++ { 23 | if err := t.Execute(w, struct { 24 | dto.TimeEntry 25 | First bool 26 | Last bool 27 | }{ 28 | TimeEntry: timeEntries[i], 29 | First: i == 0, 30 | Last: i == (l - 1), 31 | }); err != nil { 32 | return err 33 | } 34 | } 35 | return nil 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/search/find.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/lucassabreu/clockify-cli/strhlp" 8 | ) 9 | 10 | type named interface { 11 | GetID() string 12 | GetName() string 13 | } 14 | 15 | var ErrEmptyReference = errors.New("no reference informed") 16 | 17 | func findByName( 18 | r, entityName string, fn func() ([]named, error)) (string, error) { 19 | name := strhlp.Normalize(strings.TrimSpace(r)) 20 | if name == "" { 21 | return r, ErrEmptyReference 22 | } 23 | 24 | l, err := fn() 25 | if err != nil { 26 | return r, err 27 | } 28 | 29 | isSimilar := strhlp.IsSimilar(name) 30 | for _, e := range l { 31 | if strings.ToLower(e.GetID()) == name { 32 | return e.GetID(), nil 33 | } 34 | 35 | if isSimilar(e.GetName()) { 36 | return e.GetID(), nil 37 | } 38 | } 39 | 40 | return r, ErrNotFound{ 41 | EntityName: entityName, 42 | Reference: r, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/util/validate-closing.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lucassabreu/clockify-cli/api" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 8 | ) 9 | 10 | // ValidateClosingTimeEntry checks if the current time entry will fail to be 11 | // stopped 12 | func ValidateClosingTimeEntry(f cmdutil.Factory) Step { 13 | return func(dto TimeEntryDTO) (TimeEntryDTO, error) { 14 | c, err := f.Client() 15 | if err != nil { 16 | return dto, err 17 | } 18 | 19 | te, err := c.GetTimeEntryInProgress(api.GetTimeEntryInProgressParam{ 20 | Workspace: dto.Workspace, 21 | UserID: dto.UserID, 22 | }) 23 | 24 | if te == nil || err != nil { 25 | return dto, err 26 | } 27 | 28 | if err = validateTimeEntry(TimeEntryImplToDTO(*te), f); err != nil { 29 | return dto, fmt.Errorf( 30 | "running time entry can't be ended: %w", err) 31 | } 32 | 33 | return dto, nil 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/report/today/today.go: -------------------------------------------------------------------------------- 1 | package today 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/report/util" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 6 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // NewCmdToday represents report today command 11 | func NewCmdToday(f cmdutil.Factory) *cobra.Command { 12 | of := util.NewReportFlags() 13 | cmd := &cobra.Command{ 14 | Use: "today", 15 | Short: "List all time entries created today", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | if err := of.Check(); err != nil { 18 | return err 19 | } 20 | 21 | today := timehlp.Today() 22 | return util.ReportWithRange(f, today, today, cmd.OutOrStdout(), of) 23 | }, 24 | } 25 | 26 | cmd.Long = cmd.Short + "\n\n" + 27 | util.HelpNamesForIds + "\n" + 28 | util.HelpMoreInfoAboutPrinting 29 | 30 | util.AddReportFlags(f, cmd, &of) 31 | 32 | return cmd 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/report/yesterday/yesterday.go: -------------------------------------------------------------------------------- 1 | package yesterday 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/report/util" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 6 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // NewCmdYesterday represents report today command 11 | func NewCmdYesterday(f cmdutil.Factory) *cobra.Command { 12 | of := util.NewReportFlags() 13 | cmd := &cobra.Command{ 14 | Use: "yesterday", 15 | Short: "List all time entries created yesterday", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | if err := of.Check(); err != nil { 18 | return err 19 | } 20 | 21 | day := timehlp.Today().Add(-1) 22 | return util.ReportWithRange(f, day, day, cmd.OutOrStdout(), of) 23 | }, 24 | } 25 | 26 | cmd.Long = cmd.Short + "\n\n" + 27 | util.HelpNamesForIds + "\n" + 28 | util.HelpMoreInfoAboutPrinting 29 | 30 | util.AddReportFlags(f, cmd, &of) 31 | 32 | return cmd 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cmdcomplutil/workspace.go: -------------------------------------------------------------------------------- 1 | package cmdcomplutil 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/lucassabreu/clockify-cli/api" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // NewWorspaceAutoComplete will provice auto-completion for flags or args 12 | func NewWorspaceAutoComplete(f factory) cmdcompl.SuggestFn { 13 | return func( 14 | cmd *cobra.Command, args []string, toComplete string, 15 | ) (cmdcompl.ValidArgs, error) { 16 | c, err := f.Client() 17 | if err != nil { 18 | return cmdcompl.EmptyValidArgs(), err 19 | } 20 | 21 | ws, err := c.GetWorkspaces(api.GetWorkspaces{}) 22 | 23 | if err != nil { 24 | return cmdcompl.EmptyValidArgs(), err 25 | } 26 | 27 | va := make(cmdcompl.ValidArgsMap) 28 | toComplete = strings.ToLower(toComplete) 29 | for i := range ws { 30 | if toComplete != "" && !strings.Contains(ws[i].ID, toComplete) { 31 | continue 32 | } 33 | va.Set(ws[i].ID, ws[i].Name) 34 | } 35 | 36 | return va, nil 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/report/this-week/this-week.go: -------------------------------------------------------------------------------- 1 | package thisweek 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/report/util" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 6 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // NewCmdThisWeek represents the report this-week command 11 | func NewCmdThisWeek(f cmdutil.Factory) *cobra.Command { 12 | of := util.NewReportFlags() 13 | cmd := &cobra.Command{ 14 | Use: "this-week", 15 | Short: "List all time entries in this week", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | if err := of.Check(); err != nil { 18 | return err 19 | } 20 | 21 | first, last := timehlp.GetWeekRange(timehlp.Today()) 22 | return util.ReportWithRange(f, first, last, cmd.OutOrStdout(), of) 23 | }, 24 | } 25 | 26 | cmd.Long = cmd.Short + "\n\n" + 27 | util.HelpNamesForIds + "\n" + 28 | util.HelpMoreInfoAboutPrinting 29 | 30 | util.AddReportFlags(f, cmd, &of) 31 | 32 | return cmd 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cmd/config/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "strings" 8 | 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 10 | "github.com/spf13/cobra" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | const FormatYAML = "yaml" 15 | const FormatJSON = "json" 16 | 17 | // AddReportFlags adds the format flag 18 | func AddReportFlags(cmd *cobra.Command, format *string) error { 19 | cmd.Flags().StringVarP(format, "format", "f", FormatYAML, "output format") 20 | return cmdcompl.AddFixedSuggestionsToFlag(cmd, "format", 21 | cmdcompl.ValidArgsSlide{FormatYAML, FormatJSON}) 22 | } 23 | 24 | // Report prints the value as YAML or JSON 25 | func Report(out io.Writer, format string, v interface{}) error { 26 | format = strings.ToLower(format) 27 | var b []byte 28 | switch format { 29 | case FormatJSON: 30 | b, _ = json.Marshal(v) 31 | case FormatYAML: 32 | b, _ = yaml.Marshal(v) 33 | default: 34 | return errors.New("invalid format") 35 | } 36 | 37 | _, err := out.Write(b) 38 | return err 39 | } 40 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/report/this-month/this-month.go: -------------------------------------------------------------------------------- 1 | package thismonth 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/report/util" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 6 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // NewCmdThisMonth represents the reports this-month command 11 | func NewCmdThisMonth(f cmdutil.Factory) *cobra.Command { 12 | of := util.NewReportFlags() 13 | cmd := &cobra.Command{ 14 | Use: "this-month", 15 | Short: "List all time entries in this month", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | if err := of.Check(); err != nil { 18 | return err 19 | } 20 | 21 | first, last := timehlp.GetMonthRange(timehlp.Today()) 22 | return util.ReportWithRange(f, first, last, cmd.OutOrStdout(), of) 23 | }, 24 | } 25 | 26 | cmd.Long = cmd.Short + "\n\n" + 27 | util.HelpNamesForIds + "\n" + 28 | util.HelpMoreInfoAboutPrinting 29 | 30 | util.AddReportFlags(f, cmd, &of) 31 | 32 | return cmd 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cmdutil/flags.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/lucassabreu/clockify-cli/strhlp" 7 | "github.com/pkg/errors" 8 | "github.com/spf13/pflag" 9 | ) 10 | 11 | // XorFlag will fail if 2 or more entries are true 12 | func XorFlag(exclusiveFlags map[string]bool) error { 13 | fs := make([]string, 0) 14 | for n := range exclusiveFlags { 15 | if exclusiveFlags[n] { 16 | fs = append(fs, n) 17 | } 18 | } 19 | 20 | if len(fs) < 2 { 21 | return nil 22 | } 23 | 24 | sort.Strings(fs) 25 | fs = strhlp.Map(func(s string) string { return "`" + s + "`" }, fs) 26 | return FlagErrorWrap(errors.New( 27 | "the following flags can't be used together: " + 28 | strhlp.ListForHumans(fs), 29 | )) 30 | } 31 | 32 | // XorFlagSet works like XorFlag, but will read if the flag was changed from 33 | // the pflag.FlagSet 34 | func XorFlagSet(f *pflag.FlagSet, exclusiveFlags ...string) error { 35 | fs := map[string]bool{} 36 | for _, ef := range exclusiveFlags { 37 | fs[ef] = f.Changed(ef) 38 | } 39 | 40 | return XorFlag(fs) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/search/tag.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/api" 5 | "golang.org/x/sync/errgroup" 6 | ) 7 | 8 | // GetTagsByName receives a list of id or names of tags and returns their ids 9 | func GetTagsByName( 10 | c api.Client, 11 | workspace string, 12 | tags []string, 13 | ) ([]string, error) { 14 | if len(tags) == 0 { 15 | return tags, nil 16 | } 17 | 18 | ts, err := c.GetTags(api.GetTagsParam{ 19 | Workspace: workspace, 20 | PaginationParam: api.AllPages(), 21 | }) 22 | if err != nil { 23 | return tags, err 24 | } 25 | 26 | ns := make([]named, len(ts)) 27 | for i := 0; i < len(ns); i++ { 28 | ns[i] = ts[i] 29 | } 30 | 31 | var g errgroup.Group 32 | for i := 0; i < len(tags); i++ { 33 | j := i 34 | g.Go(func() error { 35 | id, err := findByName( 36 | tags[j], 37 | "tag", func() ([]named, error) { return ns, nil }, 38 | ) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | tags[j] = id 44 | return nil 45 | }) 46 | } 47 | 48 | return tags, g.Wait() 49 | } 50 | -------------------------------------------------------------------------------- /pkg/cmd/task/task.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/task/add" 5 | del "github.com/lucassabreu/clockify-cli/pkg/cmd/task/delete" 6 | "github.com/lucassabreu/clockify-cli/pkg/cmd/task/done" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmd/task/edit" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmd/task/list" 9 | quickadd "github.com/lucassabreu/clockify-cli/pkg/cmd/task/quick-add" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // NewCmdTask represents the client command 15 | func NewCmdTask(f cmdutil.Factory) *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "task", 18 | Aliases: []string{"tasks"}, 19 | Short: "Work with Clockify tasks", 20 | } 21 | 22 | cmd.AddCommand(list.NewCmdList(f, nil)) 23 | cmd.AddCommand(add.NewCmdAdd(f, nil)) 24 | cmd.AddCommand(quickadd.NewCmdQuickAdd(f, nil)) 25 | cmd.AddCommand(edit.NewCmdEdit(f, nil)) 26 | cmd.AddCommand(del.NewCmdDelete(f, nil)) 27 | cmd.AddCommand(done.NewCmdDone(f, nil)) 28 | 29 | return cmd 30 | } 31 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/report/last-month/last-month.go: -------------------------------------------------------------------------------- 1 | package lastmonth 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/report/util" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 6 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // NewCmdLastMonth represents the report last-month command 11 | func NewCmdLastMonth(f cmdutil.Factory) *cobra.Command { 12 | of := util.NewReportFlags() 13 | cmd := &cobra.Command{ 14 | Use: "last-month", 15 | Short: "List all time entries in last month", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | if err := of.Check(); err != nil { 18 | return err 19 | } 20 | 21 | first, last := timehlp.GetMonthRange( 22 | timehlp.Today().AddDate(0, -1, 0)) 23 | return util.ReportWithRange(f, first, last, cmd.OutOrStdout(), of) 24 | }, 25 | } 26 | 27 | cmd.Long = cmd.Short + "\n\n" + 28 | util.HelpNamesForIds + "\n" + 29 | util.HelpMoreInfoAboutPrinting 30 | 31 | util.AddReportFlags(f, cmd, &of) 32 | 33 | return cmd 34 | 35 | } 36 | -------------------------------------------------------------------------------- /pkg/output/project/default.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | "github.com/lucassabreu/clockify-cli/pkg/output/util" 10 | "github.com/olekukonko/tablewriter" 11 | "golang.org/x/term" 12 | ) 13 | 14 | // ProjectPrint will print more details 15 | func ProjectPrint(ps []dto.Project, w io.Writer) error { 16 | tw := tablewriter.NewWriter(w) 17 | tw.SetHeader([]string{"ID", "Name", "Client"}) 18 | 19 | if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil { 20 | tw.SetColWidth(width / 3) 21 | } 22 | 23 | colors := make([]tablewriter.Colors, 3) 24 | for i := 0; i < len(ps); i++ { 25 | w := ps[i] 26 | client := "" 27 | if w.ClientID != "" { 28 | client = fmt.Sprintf("%s (%s)", w.ClientName, w.ClientID) 29 | } 30 | colors[1] = []int{} 31 | if w.Color != "" { 32 | colors[1] = util.ColorToTermColor(w.Color) 33 | } 34 | 35 | tw.Rich([]string{ 36 | w.ID, 37 | w.Name, 38 | client, 39 | }, colors) 40 | } 41 | 42 | tw.Render() 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/report/last-week/last-week.go: -------------------------------------------------------------------------------- 1 | package lastweek 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/report/util" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 6 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // NewCmdLastWeek represents the report last-week command 11 | func NewCmdLastWeek(f cmdutil.Factory) *cobra.Command { 12 | of := util.NewReportFlags() 13 | cmd := &cobra.Command{ 14 | Use: "last-week", 15 | Short: "List all time entries in last week", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | if err := of.Check(); err != nil { 18 | return err 19 | } 20 | 21 | first, last := timehlp.GetWeekRange( 22 | timehlp.TruncateDate(timehlp.Today()).AddDate(0, 0, -7)) 23 | return util.ReportWithRange(f, first, last, cmd.OutOrStdout(), of) 24 | }, 25 | } 26 | 27 | cmd.Long = cmd.Short + "\n\n" + 28 | util.HelpNamesForIds + "\n" + 29 | util.HelpMoreInfoAboutPrinting 30 | 31 | util.AddReportFlags(f, cmd, &of) 32 | 33 | return cmd 34 | } 35 | -------------------------------------------------------------------------------- /pkg/search/user.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/api" 5 | "golang.org/x/sync/errgroup" 6 | ) 7 | 8 | // GetUsersByName receives a list of id or names of clients and returns their 9 | // ids 10 | func GetUsersByName( 11 | c api.Client, 12 | workspace string, 13 | users []string, 14 | ) ([]string, error) { 15 | if len(users) == 0 { 16 | return users, nil 17 | } 18 | 19 | us, err := c.WorkspaceUsers(api.WorkspaceUsersParam{ 20 | Workspace: workspace, 21 | PaginationParam: api.AllPages(), 22 | }) 23 | if err != nil { 24 | return users, err 25 | } 26 | 27 | ns := make([]named, len(us)) 28 | for i := 0; i < len(ns); i++ { 29 | ns[i] = us[i] 30 | } 31 | 32 | var g errgroup.Group 33 | for i := 0; i < len(users); i++ { 34 | j := i 35 | g.Go(func() error { 36 | id, err := findByName( 37 | users[j], "user", 38 | func() ([]named, error) { return ns, nil }, 39 | ) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | users[j] = id 45 | return nil 46 | }) 47 | } 48 | 49 | return users, g.Wait() 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/test-unit.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: [push] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Check out code into the Go module directory 9 | uses: actions/checkout@v4 10 | 11 | - uses: actions/setup-go@v5 12 | with: 13 | go-version: 1.24 14 | 15 | - name: Get dependencies 16 | run: | 17 | go mod download 18 | go install gotest.tools/gotestsum@latest 19 | 20 | - name: Generate coverage report 21 | run: | 22 | gotestsum --format dots -- \ 23 | -coverprofile=coverage.txt \ 24 | -covermode=atomic \ 25 | ./... 26 | 27 | - name: Upload coverage report 28 | uses: codecov/codecov-action@v4 29 | with: 30 | token: ${{ secrets.CODECOV_TOKEN }} 31 | file: ./coverage.txt 32 | flags: unittests 33 | 34 | - name: Report test coverage to DeepSource 35 | uses: deepsourcelabs/test-coverage-action@master 36 | with: 37 | key: go 38 | coverage-file: ./coverage.txt 39 | dsn: ${{ secrets.DEEPSOURCE_DSN }} 40 | -------------------------------------------------------------------------------- /pkg/cmd/config/get/get.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmd/config/util" 6 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func NewCmdGet( 12 | f cmdutil.Factory, 13 | validParameters cmdcompl.ValidArgsMap, 14 | ) *cobra.Command { 15 | var format string 16 | cmd := &cobra.Command{ 17 | Use: "get ", 18 | Short: "Retrieves one parameter set by the user", 19 | Example: heredoc.Docf(` 20 | $ %[1]s token 21 | Yamdas569 22 | 23 | $ %[1]s workweek-days --format=json 24 | ["monday","tuesday","wednesday","thursday","friday"] 25 | `, "clockify-cli config get"), 26 | Args: cobra.MatchAll( 27 | cmdutil.RequiredNamedArgs("param"), 28 | cobra.ExactArgs(1), 29 | ), 30 | ValidArgs: validParameters.IntoValidArgs(), 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | return util.Report( 33 | cmd.OutOrStdout(), format, 34 | f.Config().Get(args[0])) 35 | }, 36 | } 37 | 38 | _ = util.AddReportFlags(cmd, &format) 39 | 40 | return cmd 41 | } 42 | -------------------------------------------------------------------------------- /pkg/cmdcomplutil/tag.go: -------------------------------------------------------------------------------- 1 | package cmdcomplutil 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/lucassabreu/clockify-cli/api" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // NewTagAutoComplete will provide auto-completion to flags or args 12 | func NewTagAutoComplete(f factory) cmdcompl.SuggestFn { 13 | return func( 14 | cmd *cobra.Command, args []string, toComplete string, 15 | ) (cmdcompl.ValidArgs, error) { 16 | w, err := f.GetWorkspaceID() 17 | if err != nil { 18 | return cmdcompl.EmptyValidArgs(), err 19 | } 20 | 21 | c, err := f.Client() 22 | if err != nil { 23 | return cmdcompl.EmptyValidArgs(), err 24 | } 25 | 26 | b := false 27 | projects, err := c.GetTags(api.GetTagsParam{ 28 | Workspace: w, 29 | Archived: &b, 30 | PaginationParam: api.AllPages(), 31 | }) 32 | if err != nil { 33 | return cmdcompl.EmptyValidArgs(), err 34 | } 35 | 36 | va := make(cmdcompl.ValidArgsMap) 37 | toComplete = strings.ToLower(toComplete) 38 | for _, e := range projects { 39 | if toComplete != "" && !strings.Contains(e.ID, toComplete) { 40 | continue 41 | } 42 | va.Set(e.ID, e.Name) 43 | } 44 | 45 | return va, nil 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/output/time-entry/duration.go: -------------------------------------------------------------------------------- 1 | package timeentry 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | "golang.org/x/text/language" 10 | "golang.org/x/text/message" 11 | "golang.org/x/text/number" 12 | ) 13 | 14 | func timeEntriesTotalDurationOnly( 15 | f func(time.Duration) string, 16 | timeEntries []dto.TimeEntry, 17 | w io.Writer, 18 | ) error { 19 | _, err := fmt.Fprintln(w, f(sumTimeEntriesDuration(timeEntries))) 20 | return err 21 | } 22 | 23 | // TimeEntriesTotalDurationOnlyAsFloat will only print the total duration as 24 | // float 25 | func TimeEntriesTotalDurationOnlyAsFloat( 26 | timeEntries []dto.TimeEntry, w io.Writer, 27 | l language.Tag) error { 28 | p := message.NewPrinter(l) 29 | return timeEntriesTotalDurationOnly( 30 | func(d time.Duration) string { 31 | return p.Sprintf("%f", number.Decimal(d.Hours())) 32 | }, 33 | timeEntries, 34 | w, 35 | ) 36 | } 37 | 38 | // TimeEntriesTotalDurationOnlyFormatted will only print the total duration as 39 | // float 40 | func TimeEntriesTotalDurationOnlyFormatted( 41 | timeEntries []dto.TimeEntry, w io.Writer) error { 42 | return timeEntriesTotalDurationOnly( 43 | durationToString, 44 | timeEntries, 45 | w, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/cmdcomplutil/user.go: -------------------------------------------------------------------------------- 1 | package cmdcomplutil 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // NewUserAutoComplete will provice auto-completion for flags or args 13 | func NewUserAutoComplete(f factory) cmdcompl.SuggestFn { 14 | return func( 15 | cmd *cobra.Command, args []string, toComplete string, 16 | ) (cmdcompl.ValidArgs, error) { 17 | w, err := f.GetWorkspaceID() 18 | if err != nil { 19 | return cmdcompl.EmptyValidArgs(), err 20 | } 21 | 22 | c, err := f.Client() 23 | if err != nil { 24 | return cmdcompl.EmptyValidArgs(), err 25 | } 26 | 27 | us, err := c.WorkspaceUsers(api.WorkspaceUsersParam{ 28 | Workspace: w, 29 | PaginationParam: api.AllPages(), 30 | }) 31 | 32 | if err != nil { 33 | return cmdcompl.EmptyValidArgs(), err 34 | } 35 | 36 | va := make(cmdcompl.ValidArgsMap) 37 | toComplete = strings.ToLower(toComplete) 38 | for i := range us { 39 | if toComplete != "" && !strings.Contains(us[i].ID, toComplete) { 40 | continue 41 | } 42 | va.Set(us[i].ID, fmt.Sprintf("%s (%s)", us[i].Name, us[i].Email)) 43 | } 44 | 45 | return va, nil 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /site/layouts/partials/menu-footer.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | Download 6 | 7 | 8 | Star 9 | 10 | 11 | Fork 12 | 13 |

Built with from Grav and Hugo

14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /pkg/cmdcomplutil/client.go: -------------------------------------------------------------------------------- 1 | package cmdcomplutil 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/lucassabreu/clockify-cli/api" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // NewClientAutoComplete will provide auto-completion to flags or args 12 | func NewClientAutoComplete(f factory) cmdcompl.SuggestFn { 13 | return func( 14 | cmd *cobra.Command, args []string, toComplete string, 15 | ) (cmdcompl.ValidArgs, error) { 16 | w, err := f.GetWorkspaceID() 17 | if err != nil { 18 | return cmdcompl.EmptyValidArgs(), err 19 | } 20 | 21 | c, err := f.Client() 22 | if err != nil { 23 | return cmdcompl.EmptyValidArgs(), err 24 | } 25 | 26 | b := false 27 | clients, err := c.GetClients(api.GetClientsParam{ 28 | Workspace: w, 29 | Archived: &b, 30 | PaginationParam: api.AllPages(), 31 | }) 32 | if err != nil { 33 | return cmdcompl.EmptyValidArgs(), err 34 | } 35 | 36 | va := make(cmdcompl.ValidArgsMap) 37 | toComplete = strings.ToLower(toComplete) 38 | for _, client := range clients { 39 | if toComplete != "" && !strings.Contains(client.ID, toComplete) { 40 | continue 41 | } 42 | va.Set(client.ID, client.Name) 43 | } 44 | 45 | return va, nil 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/cmd/user/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 8 | "github.com/lucassabreu/clockify-cli/pkg/output/user" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // OutputFlags sets how to print out a list of users 13 | type OutputFlags struct { 14 | Format string 15 | JSON bool 16 | Quiet bool 17 | } 18 | 19 | func (of OutputFlags) Check() error { 20 | return cmdutil.XorFlag(map[string]bool{ 21 | "format": of.Format != "", 22 | "json": of.JSON, 23 | "quiet": of.Quiet, 24 | }) 25 | } 26 | 27 | // AddReportFlags adds the default output flags for users 28 | func AddReportFlags(cmd *cobra.Command, of *OutputFlags) { 29 | cmd.Flags().StringVarP(&of.Format, "format", "f", "", 30 | "golang text/template format to be applied on each workspace") 31 | cmd.Flags().BoolVarP(&of.Quiet, "quiet", "q", false, "only display ids") 32 | cmd.Flags().BoolVarP(&of.JSON, "json", "j", false, "print as json") 33 | } 34 | 35 | // Report prints out the users 36 | func Report(u []dto.User, out io.Writer, of OutputFlags) error { 37 | switch { 38 | case of.Format != "": 39 | return user.UserPrintWithTemplate(of.Format)(u, out) 40 | case of.Quiet: 41 | return user.UserPrintQuietly(u, out) 42 | default: 43 | return user.UserPrint(u, out) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/cmdcomplutil/task.go: -------------------------------------------------------------------------------- 1 | package cmdcomplutil 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/lucassabreu/clockify-cli/api" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // NewTaskAutoComplete will provide auto-completion to flags or args 12 | func NewTaskAutoComplete(f factory, onlyActive bool) cmdcompl.SuggestFn { 13 | return func( 14 | cmd *cobra.Command, args []string, toComplete string, 15 | ) (cmdcompl.ValidArgs, error) { 16 | project, err := cmd.Flags().GetString("project") 17 | if err != nil { 18 | return cmdcompl.EmptyValidArgs(), err 19 | } 20 | 21 | w, err := f.GetWorkspaceID() 22 | if err != nil { 23 | return cmdcompl.EmptyValidArgs(), err 24 | } 25 | 26 | c, err := f.Client() 27 | if err != nil { 28 | return cmdcompl.EmptyValidArgs(), err 29 | } 30 | 31 | ts, err := c.GetTasks(api.GetTasksParam{ 32 | Workspace: w, 33 | ProjectID: project, 34 | Active: onlyActive, 35 | }) 36 | if err != nil { 37 | return cmdcompl.EmptyValidArgs(), err 38 | } 39 | 40 | va := make(cmdcompl.ValidArgsMap) 41 | toComplete = strings.ToLower(toComplete) 42 | for i := range ts { 43 | if toComplete != "" && !strings.Contains(ts[i].ID, toComplete) { 44 | continue 45 | } 46 | va.Set(ts[i].ID, ts[i].Name) 47 | } 48 | 49 | return va, nil 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/cmd/config/list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmd/config/util" 6 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // NewCmdList creates the config list command 11 | func NewCmdList(f cmdutil.Factory) *cobra.Command { 12 | var format string 13 | cmd := &cobra.Command{ 14 | Use: "list", 15 | Aliases: []string{"ls"}, 16 | Short: "List all parameters set by the user", 17 | Example: heredoc.Doc(` 18 | $ clockify-cli config list 19 | allow-incomplete: false 20 | allow-name-for-id: true 21 | allow-project-name: true 22 | debug: false 23 | description-autocomplete: true 24 | description-autocomplete-days: 15 25 | interactive: true 26 | no-closing: false 27 | show-task: false 28 | show-custom-fields: false 29 | show-total-duration: true 30 | token: Yamdas569 31 | user: 32 | id: ffffffffffffffffffffffff 33 | workspace: eeeeeeeeeeeeeeeeeeeeeeee 34 | workweek-days: 35 | - monday 36 | - tuesday 37 | - wednesday 38 | - thursday 39 | - friday 40 | `), 41 | Args: cobra.NoArgs, 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | return util.Report(cmd.OutOrStdout(), format, f.Config().All()) 44 | }, 45 | } 46 | 47 | _ = util.AddReportFlags(cmd, &format) 48 | 49 | return cmd 50 | } 51 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/report/last-day/last-day.go: -------------------------------------------------------------------------------- 1 | package lastday 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/report/util" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 6 | "github.com/lucassabreu/clockify-cli/pkg/timeentryhlp" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // NewCmdLastDay represents the report last-day command 11 | func NewCmdLastDay(f cmdutil.Factory) *cobra.Command { 12 | of := util.NewReportFlags() 13 | cmd := &cobra.Command{ 14 | Use: "last-day", 15 | Short: "List time entries from last day were a time entry was created", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | if err := of.Check(); err != nil { 18 | return err 19 | } 20 | 21 | w, err := f.GetWorkspaceID() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | u, err := f.GetUserID() 27 | if err != nil { 28 | return err 29 | } 30 | 31 | c, err := f.Client() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | te, err := timeentryhlp.GetLatestEntryEntry(c, w, u) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | return util.ReportWithRange( 42 | f, te.TimeInterval.Start, te.TimeInterval.Start, 43 | cmd.OutOrStdout(), of) 44 | }, 45 | } 46 | 47 | cmd.Long = cmd.Short + "\n\n" + 48 | util.HelpNamesForIds + "\n" + 49 | util.HelpMoreInfoAboutPrinting 50 | 51 | util.AddReportFlags(f, cmd, &of) 52 | 53 | return cmd 54 | } 55 | -------------------------------------------------------------------------------- /pkg/output/time-entry/duration_test.go: -------------------------------------------------------------------------------- 1 | package timeentry_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | timeentry "github.com/lucassabreu/clockify-cli/pkg/output/time-entry" 10 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 11 | "github.com/stretchr/testify/assert" 12 | "golang.org/x/text/language" 13 | ) 14 | 15 | func TestTimeEntriesTotalDurationOnlyAsFloat_ShouldUseUserLanguage( 16 | t *testing.T) { 17 | 18 | start, _ := timehlp.ConvertToTime("2024-01-01 00:00") 19 | end := start.Add(time.Hour*1000 + (time.Minute * 31)) 20 | 21 | tes := []dto.TimeEntry{ 22 | {TimeInterval: dto.NewTimeInterval(start, &end)}, 23 | } 24 | 25 | tts := []struct { 26 | name string 27 | language language.Tag 28 | output string 29 | }{ 30 | {language: language.English, output: "1,000.517"}, 31 | {language: language.German, output: "1.000,517"}, 32 | {language: language.MustParse("pt-br"), output: "1.000,517"}, 33 | {language: language.Spanish, output: "1.000,517"}, 34 | {language: language.Afrikaans, output: "1\u00a0000,517"}, 35 | } 36 | 37 | for _, tt := range tts { 38 | t.Run(tt.language.String(), func(t *testing.T) { 39 | buffer := strings.Builder{} 40 | 41 | err := timeentry.TimeEntriesTotalDurationOnlyAsFloat( 42 | tes, &buffer, tt.language) 43 | 44 | if !assert.NoError(t, err) { 45 | return 46 | } 47 | 48 | assert.Equal(t, tt.output+"\n", buffer.String()) 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/cmd/client/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 8 | output "github.com/lucassabreu/clockify-cli/pkg/output/client" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // OutputFlags sets how to print out a list of clients 13 | type OutputFlags struct { 14 | Format string 15 | CSV bool 16 | JSON bool 17 | Quiet bool 18 | } 19 | 20 | func (of OutputFlags) Check() error { 21 | return cmdutil.XorFlag(map[string]bool{ 22 | "format": of.Format != "", 23 | "json": of.JSON, 24 | "csv": of.CSV, 25 | "quiet": of.Quiet, 26 | }) 27 | } 28 | 29 | // AddReportFlags adds the default output flags for clients 30 | func AddReportFlags(cmd *cobra.Command, of *OutputFlags) { 31 | cmd.Flags().StringVarP(&of.Format, "format", "f", "", 32 | "golang text/template format to be applied on each Client") 33 | cmd.Flags().BoolVarP(&of.JSON, "json", "j", false, "print as JSON") 34 | cmd.Flags().BoolVarP(&of.CSV, "csv", "v", false, "print as CSV") 35 | cmd.Flags().BoolVarP(&of.Quiet, "quiet", "q", false, "only display ids") 36 | } 37 | 38 | // Report prints out the clients 39 | func Report(cs []dto.Client, out io.Writer, of OutputFlags) error { 40 | switch { 41 | case of.JSON: 42 | return output.ClientsJSONPrint(cs, out) 43 | case of.CSV: 44 | return output.ClientsCSVPrint(cs, out) 45 | case of.Format != "": 46 | return output.ClientPrintWithTemplate(of.Format)(cs, out) 47 | case of.Quiet: 48 | return output.ClientPrintQuietly(cs, out) 49 | default: 50 | return output.ClientPrint(cs, out) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/search/task.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/api" 5 | "golang.org/x/sync/errgroup" 6 | ) 7 | 8 | // GetTaskByName will try to find the first task containing the string on its 9 | // name or id that matches the value 10 | func GetTaskByName( 11 | c api.Client, 12 | f api.GetTasksParam, 13 | task string, 14 | ) (string, error) { 15 | return findByName(task, "task", func() ([]named, error) { 16 | f.PaginationParam = api.AllPages() 17 | ts, err := c.GetTasks(f) 18 | if err != nil { 19 | return []named{}, err 20 | } 21 | 22 | ns := make([]named, len(ts)) 23 | for i := 0; i < len(ns); i++ { 24 | ns[i] = ts[i] 25 | } 26 | 27 | return ns, nil 28 | }) 29 | } 30 | 31 | // GetTasksByName will try to find tasks containing the string on its name or 32 | // id that matches the value 33 | func GetTasksByName( 34 | c api.Client, 35 | f api.GetTasksParam, 36 | tasks []string, 37 | ) ([]string, error) { 38 | if len(tasks) == 0 { 39 | return tasks, nil 40 | } 41 | 42 | f.PaginationParam = api.AllPages() 43 | ts, err := c.GetTasks(f) 44 | if err != nil { 45 | return tasks, err 46 | } 47 | 48 | ns := make([]named, len(ts)) 49 | for i := 0; i < len(ns); i++ { 50 | ns[i] = ts[i] 51 | } 52 | 53 | var g errgroup.Group 54 | for i := 0; i < len(tasks); i++ { 55 | j := i 56 | g.Go(func() error { 57 | id, err := findByName( 58 | tasks[j], 59 | "task", func() ([]named, error) { return ns, nil }, 60 | ) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | tasks[j] = id 66 | return nil 67 | }) 68 | } 69 | 70 | return tasks, g.Wait() 71 | } 72 | -------------------------------------------------------------------------------- /cmd/gendocs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/lucassabreu/clockify-cli/pkg/cmd" 12 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 13 | "github.com/spf13/cobra/doc" 14 | ) 15 | 16 | const gendocFrontmatterTemplate = `--- 17 | date: %s 18 | title: "%s" 19 | slug: %s 20 | url: %s 21 | weight: 40 22 | --- 23 | ` 24 | 25 | func main() { 26 | if err := execute(); err != nil { 27 | fmt.Fprintln(os.Stderr, err) 28 | os.Exit(1) 29 | } 30 | } 31 | 32 | func execute() error { 33 | docdir := "site/content/commands" 34 | if len(os.Args) > 1 { 35 | docdir = os.Args[1] 36 | } 37 | 38 | if err := os.MkdirAll(docdir, os.ModePerm); err != nil { 39 | return err 40 | } 41 | 42 | now := time.Now().Format("2006-01-02") 43 | prepender := func(filename string) string { 44 | name := filepath.Base(filename) 45 | base := strings.TrimSuffix(name, path.Ext(name)) 46 | url := "/en/commands/" + strings.ToLower(base) + "/" 47 | return fmt.Sprintf(gendocFrontmatterTemplate, 48 | now, strings.ReplaceAll(base, "_", " "), base, url) 49 | } 50 | 51 | linkHandler := func(name string) string { 52 | base := strings.TrimSuffix(name, path.Ext(name)) 53 | return "/en/commands/" + strings.ToLower(base) + "/" 54 | } 55 | 56 | cmd := cmd.NewCmdRoot(cmdutil.NewFactory(cmdutil.Version{})) 57 | 58 | fmt.Println("Generating Hugo command-line documentation in", docdir, "...") 59 | err := doc.GenMarkdownTreeCustom(cmd, docdir, prepender, linkHandler) 60 | if err != nil { 61 | return err 62 | } 63 | fmt.Println("Done.") 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/cmd/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version_test 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/lucassabreu/clockify-cli/pkg/cmd/version" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 10 | "github.com/lucassabreu/clockify-cli/internal/mocks" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestVersion(t *testing.T) { 15 | type c struct { 16 | name string 17 | version cmdutil.Version 18 | expected string 19 | } 20 | cases := []c{ 21 | c{ 22 | name: "when no version", 23 | version: cmdutil.Version{}, 24 | expected: "Version: , Commit: , Build At:", 25 | }, 26 | c{ 27 | name: "when default version", 28 | version: cmdutil.Version{ 29 | Tag: "dev", Commit: "none", Date: "unknown"}, 30 | expected: "Version: dev, Commit: none, Build At: unknown", 31 | }, 32 | c{ 33 | name: "with valid version", 34 | version: cmdutil.Version{ 35 | Tag: "1.0.0", 36 | Commit: "63595df3d0dd6e4eef2e973631013ca9e06928ef", 37 | Date: "2022-07-01T04:10:47Z"}, 38 | expected: "Version: 1.0.0, " + 39 | "Commit: 63595df3d0dd6e4eef2e973631013ca9e06928ef, " + 40 | "Build At: 2022-07-01T04:10:47Z", 41 | }, 42 | } 43 | 44 | for _, tt := range cases { 45 | t.Run(tt.name, func(t *testing.T) { 46 | f := mocks.NewMockFactory(t) 47 | f.On("Version").Return(tt.version) 48 | 49 | cmd := version.NewCmdVersion(f) 50 | cmd.SetArgs([]string{}) 51 | 52 | b := bytes.NewBufferString("") 53 | cmd.SetOut(b) 54 | cmd.SetErr(b) 55 | 56 | _, err := cmd.ExecuteC() 57 | 58 | assert.NoError(t, err) 59 | assert.Equal(t, tt.expected, strings.TrimSpace(b.String())) 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: 7 | - "*" 8 | 9 | jobs: 10 | goreleaser: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout 14 | uses: actions/checkout@v4 15 | - name: go-setup 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: 1.24 19 | - name: install snapcraft 20 | uses: samuelmeuli/action-snapcraft@v3 21 | - name: install nix 22 | uses: cachix/install-nix-action@v31 23 | - name: goreleaser-setup 24 | uses: goreleaser/goreleaser-action@v5 25 | with: 26 | distribution: goreleaser 27 | version: latest 28 | install-only: true 29 | - if: startsWith(github.ref, 'refs/tags/') 30 | name: release a new version 31 | run: | 32 | make release "tag=${GITHUB_REF#refs/tags/}" 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN_GORELEASER }} 35 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} 36 | - if: startsWith(github.ref, 'refs/tags/') == false 37 | name: test releasing a snapshot version 38 | run: make release SNAPSHOT=1 tag=Unreleased 39 | - if: startsWith(github.ref, 'refs/tags/') 40 | name: trigger Netlify deploy with new release 41 | run: | 42 | curl -vs -X POST "https://api.netlify.com/build_hooks/${NETLIFY_HOOK}" \ 43 | --data-urlencode "trigger_title=triggered+by github actions (tag: ${GITHUB_REF#refs/tags/})" \ 44 | --data-urlencode "trigger_branch=main" 45 | env: 46 | NETLIFY_HOOK: ${{ secrets.NETLIFY_HOOK }} 47 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/util/validate.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/lucassabreu/clockify-cli/api" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 10 | ) 11 | 12 | // GetValidateTimeEntryFn will check if the time entry is valid given the 13 | // workspace parameters 14 | func GetValidateTimeEntryFn(f cmdutil.Factory) Step { 15 | if f.Config().GetBool(cmdutil.CONF_ALLOW_INCOMPLETE) { 16 | return skip 17 | } 18 | 19 | return func(tei TimeEntryDTO) (TimeEntryDTO, error) { 20 | return tei, validateTimeEntry(tei, f) 21 | } 22 | } 23 | 24 | func validateTimeEntry(te TimeEntryDTO, f cmdutil.Factory) error { 25 | w, err := f.GetWorkspace() 26 | if err != nil { 27 | return err 28 | } 29 | 30 | if w.Settings.ForceProjects && te.ProjectID == "" { 31 | return errors.New("workspace requires project") 32 | } 33 | 34 | if w.Settings.ForceTasks && te.TaskID == "" { 35 | return errors.New("workspace requires task") 36 | } 37 | 38 | if w.Settings.ForceDescription && strings.TrimSpace(te.Description) == "" { 39 | return errors.New("workspace requires description") 40 | } 41 | 42 | if w.Settings.ForceTags && len(te.TagIDs) == 0 { 43 | return errors.New("workspace requires at least one tag") 44 | } 45 | 46 | if te.ProjectID == "" { 47 | return nil 48 | } 49 | 50 | c, err := f.Client() 51 | if err != nil { 52 | return err 53 | } 54 | 55 | p, err := c.GetProject(api.GetProjectParam{ 56 | Workspace: te.Workspace, 57 | ProjectID: te.ProjectID, 58 | }) 59 | 60 | if err != nil { 61 | return err 62 | } 63 | 64 | if p.Archived { 65 | return fmt.Errorf("project %s - %s is archived", p.ID, p.Name) 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/cmd/task/util/report.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/api/dto" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 6 | "github.com/lucassabreu/clockify-cli/pkg/output/task" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // OutputFlags sets how the tasks will be printed 11 | type OutputFlags struct { 12 | Format string 13 | JSON bool 14 | CSV bool 15 | Quiet bool 16 | } 17 | 18 | // Check guaranties that only one type of output is chosen 19 | func (of OutputFlags) Check() error { 20 | return cmdutil.XorFlag(map[string]bool{ 21 | "format": of.Format != "", 22 | "json": of.JSON, 23 | "csv": of.CSV, 24 | "quiet": of.Quiet, 25 | }) 26 | } 27 | 28 | // TaskAddReportFlags will add common format flags used for tasks 29 | func TaskAddReportFlags(cmd *cobra.Command, of *OutputFlags) { 30 | cmd.Flags().StringVarP(&of.Format, "format", "f", "", 31 | "golang text/template format to be applied on each Client") 32 | cmd.Flags().BoolVarP(&of.JSON, "json", "j", false, "print as JSON") 33 | cmd.Flags().BoolVarP(&of.CSV, "csv", "v", false, "print as CSV") 34 | cmd.Flags().BoolVarP(&of.Quiet, "quiet", "q", false, "only display ids") 35 | } 36 | 37 | // TaskReport will output the task as set by the flags 38 | func TaskReport(cmd *cobra.Command, of OutputFlags, tasks ...dto.Task) error { 39 | out := cmd.OutOrStdout() 40 | 41 | switch { 42 | case of.JSON: 43 | return task.TasksJSONPrint(tasks, out) 44 | case of.CSV: 45 | return task.TasksCSVPrint(tasks, out) 46 | case of.Quiet: 47 | return task.TaskPrintQuietly(tasks, out) 48 | case of.Format != "": 49 | return task.TaskPrintWithTemplate(of.Format)(tasks, out) 50 | default: 51 | return task.TaskPrint(tasks, out) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/search/find_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSearchOnList(t *testing.T) { 10 | entities := []named{ 11 | namedStruct{ 12 | ID: "1", 13 | Name: "entity one", 14 | }, 15 | namedStruct{ 16 | ID: "2", 17 | Name: "entity two", 18 | }, 19 | namedStruct{ 20 | ID: "3", 21 | Name: "entity three", 22 | }, 23 | namedStruct{ 24 | ID: "4", 25 | Name: "more complex name", 26 | }, 27 | namedStruct{ 28 | ID: "id", 29 | Name: "by id", 30 | }, 31 | namedStruct{ 32 | ID: "bra", 33 | Name: "with [bracket]", 34 | }, 35 | } 36 | 37 | tts := []struct { 38 | name string 39 | search string 40 | entities []named 41 | result string 42 | }{ 43 | { 44 | name: "one term", 45 | search: "two", 46 | entities: entities, 47 | result: "2", 48 | }, 49 | { 50 | name: "two terms", 51 | search: "complex name", 52 | entities: entities, 53 | result: "4", 54 | }, 55 | { 56 | name: "sections of the name", 57 | search: "mo nam", 58 | entities: entities, 59 | result: "4", 60 | }, 61 | { 62 | name: "with brackets", 63 | search: "[bracket]", 64 | entities: entities, 65 | result: "bra", 66 | }, 67 | { 68 | name: "using id", 69 | search: "by id", 70 | entities: entities, 71 | result: "id", 72 | }, 73 | } 74 | 75 | for i := range tts { 76 | tt := tts[i] 77 | t.Run(tt.name, func(t *testing.T) { 78 | id, err := findByName(tt.search, "element", func() ([]named, error) { 79 | return tt.entities, nil 80 | }) 81 | 82 | if assert.NoError(t, err) { 83 | assert.Equal(t, tt.result, id) 84 | } 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pkg/search/client.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/api" 5 | "golang.org/x/sync/errgroup" 6 | ) 7 | 8 | // GetClientsByName receives a list of id or names of clients and returns their 9 | // ids 10 | func GetClientsByName( 11 | c api.Client, 12 | workspace string, 13 | clients []string, 14 | ) ([]string, error) { 15 | if len(clients) == 0 { 16 | return clients, nil 17 | } 18 | 19 | cs, err := c.GetClients(api.GetClientsParam{ 20 | Workspace: workspace, 21 | PaginationParam: api.AllPages(), 22 | }) 23 | if err != nil { 24 | return clients, err 25 | } 26 | 27 | ns := make([]named, len(cs)) 28 | for i := 0; i < len(ns); i++ { 29 | ns[i] = cs[i] 30 | } 31 | 32 | var g errgroup.Group 33 | for i := 0; i < len(clients); i++ { 34 | j := i 35 | g.Go(func() error { 36 | id, err := findByName( 37 | clients[j], 38 | "client", func() ([]named, error) { return ns, nil }, 39 | ) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | clients[j] = id 45 | return nil 46 | }) 47 | } 48 | 49 | return clients, g.Wait() 50 | } 51 | 52 | // GetClientByName will look for a client that the id or name Contains the 53 | // string on client parameter 54 | func GetClientByName( 55 | c api.Client, 56 | workspace string, 57 | client string, 58 | ) (string, error) { 59 | return findByName( 60 | client, 61 | "client", func() ([]named, error) { 62 | 63 | cs, err := c.GetClients(api.GetClientsParam{ 64 | Workspace: workspace, 65 | PaginationParam: api.AllPages(), 66 | }) 67 | if err != nil { 68 | return []named{}, err 69 | } 70 | 71 | ns := make([]named, len(cs)) 72 | for i := 0; i < len(ns); i++ { 73 | ns[i] = cs[i] 74 | } 75 | return ns, nil 76 | }, 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /pkg/cmd/workspace/workspace.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 5 | output "github.com/lucassabreu/clockify-cli/pkg/output/workspace" 6 | 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // NewCmdWorkspace represents the workspaces command 12 | func NewCmdWorkspace(f cmdutil.Factory) *cobra.Command { 13 | fl := struct { 14 | name string 15 | format string 16 | quiet bool 17 | }{} 18 | cmd := &cobra.Command{ 19 | Use: "workspace", 20 | Aliases: []string{"workspaces"}, 21 | Short: "List your available workspaces", 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | if err := cmdutil.XorFlag(map[string]bool{ 24 | "format": fl.format != "", 25 | "quiet": fl.quiet, 26 | }); err != nil { 27 | return err 28 | } 29 | 30 | c, err := f.Client() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | list, err := c.GetWorkspaces(api.GetWorkspaces{ 36 | Name: fl.name, 37 | }) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | if fl.quiet { 43 | return output.WorkspacePrintQuietly(list, cmd.OutOrStdout()) 44 | } 45 | 46 | if fl.format != "" { 47 | return output.WorkspacePrintWithTemplate(fl.format)( 48 | list, cmd.OutOrStdout()) 49 | } 50 | 51 | w, _ := f.GetWorkspaceID() 52 | return output.WorkspacePrint(w)(list, cmd.OutOrStdout()) 53 | }, 54 | } 55 | 56 | cmd.Flags().StringVarP(&fl.name, "name", "n", "", 57 | "will be used to filter the workspaces by name") 58 | cmd.Flags().StringVarP(&fl.format, "format", "f", "", 59 | "golang text/template format to be applied on each workspace") 60 | cmd.Flags().BoolVarP(&fl.quiet, "quiet", "q", false, "only display ids") 61 | 62 | return cmd 63 | } 64 | -------------------------------------------------------------------------------- /pkg/timehlp/time_test.go: -------------------------------------------------------------------------------- 1 | package timehlp_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestParseTime(t *testing.T) { 12 | now := timehlp.Today() 13 | nowStr := now.Format("2006-01-02") 14 | 15 | t.Run("now", func(t *testing.T) { 16 | // this case is special because it is not deterministic 17 | parsed, err := timehlp.ConvertToTime("now") 18 | 19 | assert.Nil(t, err) 20 | assert.Equal(t, nowStr, parsed.Format("2006-01-02")) 21 | 22 | }) 23 | 24 | tts := []struct { 25 | name string 26 | expected string 27 | toParse string 28 | }{ 29 | {name: "FullTimeFormat", expected: "09:59:01", toParse: fmt.Sprintf("%s %s", nowStr, "09:59:01")}, 30 | {name: "SimplerTimeFormat", expected: "09:59:00", toParse: fmt.Sprintf("%s %s", nowStr, "09:59")}, 31 | {name: "OnlyTimeFormat", expected: "16:03:02", toParse: "16:03:02"}, 32 | {name: "SimplerOnlyTimeFormat", expected: "16:03:00", toParse: "16:03"}, 33 | {name: "SimplerOnlyTimeFormat", expected: "06:03:00", toParse: "06:03"}, 34 | {name: "SimplerOnlyTimeFormatWL", expected: "06:03:00", toParse: "6:03"}, 35 | {name: "SimplestOnlyTimeFormat", expected: "16:03:00", toParse: "1603"}, 36 | {name: "SimplestOnlyTimeFormatWL", expected: "06:03:00", toParse: "603"}, 37 | } 38 | 39 | for _, tt := range tts { 40 | t.Run(tt.name, func(t *testing.T) { 41 | 42 | parsed, err := timehlp.ConvertToTime(tt.toParse) 43 | 44 | assert.Nil(t, err) 45 | assert.Equal(t, fmt.Sprintf("%s %s", nowStr, tt.expected), parsed.Format("2006-01-02 15:04:05")) 46 | }) 47 | } 48 | } 49 | 50 | func TestFailParseTime(t *testing.T) { 51 | _, err := timehlp.ConvertToTime("2024-05-25 25:61") 52 | assert.Error(t, err, 53 | "parsing time \"2024-05-25 25:61:00\": hour out of range") 54 | } 55 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/timeentry.go: -------------------------------------------------------------------------------- 1 | package timeentry 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lucassabreu/clockify-cli/api/dto" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/clone" 8 | del "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/delete" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/edit" 10 | em "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/edit-multipple" 11 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/in" 12 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/invoiced" 13 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/manual" 14 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/out" 15 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/report" 16 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/show" 17 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/split" 18 | teutil "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util" 19 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | func NewCmdTimeEntry(f cmdutil.Factory) (cmds []*cobra.Command) { 24 | rmFn := func( 25 | tes []dto.TimeEntry, out io.Writer, of teutil.OutputFlags) error { 26 | return teutil.PrintTimeEntries(tes, out, f.Config(), of) 27 | } 28 | 29 | rFn := func( 30 | tei dto.TimeEntryImpl, out io.Writer, of teutil.OutputFlags, 31 | ) error { 32 | return teutil.PrintTimeEntryImpl(tei, f, out, of) 33 | } 34 | 35 | cmds = append( 36 | cmds, 37 | 38 | in.NewCmdIn(f, rFn), 39 | manual.NewCmdManual(f), 40 | clone.NewCmdClone(f), 41 | 42 | edit.NewCmdEdit(f, rFn), 43 | em.NewCmdEditMultiple(f), 44 | 45 | split.NewCmdSplit(f, rmFn), 46 | 47 | out.NewCmdOut(f), 48 | 49 | del.NewCmdDelete(f), 50 | 51 | show.NewCmdShow(f), 52 | report.NewCmdReport(f), 53 | ) 54 | 55 | cmds = append(cmds, invoiced.NewCmdInvoiced(f)...) 56 | 57 | return 58 | } 59 | -------------------------------------------------------------------------------- /pkg/output/time-entry/csv.go: -------------------------------------------------------------------------------- 1 | package timeentry 2 | 3 | import ( 4 | "encoding/csv" 5 | "io" 6 | "strings" 7 | "time" 8 | 9 | "github.com/lucassabreu/clockify-cli/api/dto" 10 | ) 11 | 12 | // TimeEntriesCSVPrint will print each time entry using the format string 13 | func TimeEntriesCSVPrint(timeEntries []dto.TimeEntry, out io.Writer) error { 14 | w := csv.NewWriter(out) 15 | 16 | if err := w.Write([]string{ 17 | "id", 18 | "description", 19 | "project.id", 20 | "project.name", 21 | "task.id", 22 | "task.name", 23 | "start", 24 | "end", 25 | "duration", 26 | "user.id", 27 | "user.email", 28 | "user.name", 29 | "tags...", 30 | "customFields...", 31 | }); err != nil { 32 | return err 33 | } 34 | 35 | format := func(t *time.Time) string { 36 | if t == nil { 37 | return "" 38 | } 39 | return t.In(time.Local).Format(TimeFormatFull) 40 | } 41 | 42 | for i := 0; i < len(timeEntries); i++ { 43 | te := timeEntries[i] 44 | var p dto.Project 45 | if te.Project != nil { 46 | p = *te.Project 47 | } 48 | 49 | end := time.Now() 50 | if te.TimeInterval.End != nil { 51 | end = *te.TimeInterval.End 52 | } 53 | 54 | if te.User == nil { 55 | u := dto.User{} 56 | te.User = &u 57 | } 58 | 59 | if te.Task == nil { 60 | t := dto.Task{} 61 | te.Task = &t 62 | } 63 | 64 | arr := []string{ 65 | te.ID, 66 | te.Description, 67 | p.ID, 68 | p.Name, 69 | te.Task.ID, 70 | te.Task.Name, 71 | format(&te.TimeInterval.Start), 72 | format(te.TimeInterval.End), 73 | durationToString(end.Sub(te.TimeInterval.Start)), 74 | te.User.ID, 75 | te.User.Email, 76 | te.User.Name, 77 | } 78 | 79 | arr = append(arr, strings.Join(tagsToStringSlice(te.Tags), ";")) 80 | arr = append(arr, strings.Join(customFieldsToStringSlice(te.CustomFields), ";")) 81 | 82 | if err := w.Write(arr); err != nil { 83 | return err 84 | } 85 | } 86 | 87 | w.Flush() 88 | return w.Error() 89 | } 90 | -------------------------------------------------------------------------------- /pkg/cmd/project/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 9 | "github.com/lucassabreu/clockify-cli/pkg/output/project" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // OutputFlags defines how to print the project 14 | type OutputFlags struct { 15 | JSON bool 16 | CSV bool 17 | Quiet bool 18 | Format string 19 | } 20 | 21 | func (of OutputFlags) Check() error { 22 | return cmdutil.XorFlag(map[string]bool{ 23 | "format": of.Format != "", 24 | "json": of.JSON, 25 | "csv": of.CSV, 26 | "quiet": of.Quiet, 27 | }) 28 | } 29 | 30 | // AddReportFlags adds the common flags to print projects 31 | func AddReportFlags(cmd *cobra.Command, of *OutputFlags) { 32 | cmd.Flags().StringVarP(&of.Format, "format", "f", "", 33 | "golang text/template format to be applied on each Project") 34 | cmd.Flags().BoolVarP(&of.JSON, "json", "j", false, "print as JSON") 35 | cmd.Flags().BoolVarP(&of.CSV, "csv", "v", false, "print as CSV") 36 | cmd.Flags().BoolVarP(&of.Quiet, "quiet", "q", false, "only display ids") 37 | } 38 | 39 | // Report will print the projects as set by the flags 40 | func Report(list []dto.Project, out io.Writer, f OutputFlags) error { 41 | switch { 42 | case f.JSON: 43 | return project.ProjectsJSONPrint(list, out) 44 | case f.CSV: 45 | return project.ProjectsCSVPrint(list, out) 46 | case f.Quiet: 47 | return project.ProjectPrintQuietly(list, out) 48 | case f.Format != "": 49 | return project.ProjectPrintWithTemplate(f.Format)(list, out) 50 | default: 51 | return project.ProjectPrint(list, os.Stdout) 52 | } 53 | } 54 | 55 | // ReportOne will print a project as set by the flags 56 | func ReportOne(p dto.Project, out io.Writer, f OutputFlags) error { 57 | switch { 58 | case f.JSON: 59 | return project.ProjectJSONPrint(p, out) 60 | default: 61 | return Report([]dto.Project{p}, os.Stdout, f) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/timehlp/relative.go: -------------------------------------------------------------------------------- 1 | package timehlp 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | var ErrInvalidReliveTime = errors.New( 12 | "supported relative time formats are: " + 13 | "+15:04:05, +15:04 or unit descriptive +1d15h4m5s, " + 14 | "+15h5s, 120m", 15 | ) 16 | 17 | func relativeToTime(timeString string) (t time.Time, err error) { 18 | var d time.Duration 19 | timeString = strings.ReplaceAll(timeString, " ", "") 20 | 21 | if c := strings.Count(timeString, ":"); c > 0 { 22 | d, err = relativeColonTimeToDuration(timeString[1:]) 23 | } else { 24 | d, err = relativeUnitDescriptiveTimeToDuration(timeString[1:]) 25 | } 26 | 27 | if timeString[0] == '-' { 28 | d = d * -1 29 | } 30 | 31 | t = Now().Add(d) 32 | return 33 | } 34 | 35 | func relativeColonTimeToDuration(s string) (d time.Duration, err error) { 36 | parts := strings.Split(s, ":") 37 | c := len(parts) 38 | if c > 2 || c == 0 { 39 | return d, ErrInvalidReliveTime 40 | } 41 | 42 | u := time.Second 43 | for i := c - 1; i >= 0; i-- { 44 | p := strings.TrimPrefix(parts[i], "0") 45 | v, err := strconv.Atoi(p) 46 | if err != nil && p != "" { 47 | return d, ErrInvalidReliveTime 48 | } 49 | d = d + time.Duration(v)*u 50 | u = u * 60 51 | } 52 | 53 | return 54 | } 55 | 56 | func relativeUnitDescriptiveTimeToDuration(s string) ( 57 | d time.Duration, err error) { 58 | var u time.Duration 59 | var i, j int 60 | for ; i < len(s); i++ { 61 | switch s[i] { 62 | case 'd': 63 | u = time.Hour * 24 64 | case 'h': 65 | u = time.Hour 66 | case 'm': 67 | u = time.Minute 68 | case 's': 69 | u = time.Second 70 | default: 71 | continue 72 | } 73 | 74 | v, err := strconv.Atoi(s[j:i]) 75 | if err != nil { 76 | return d, ErrInvalidReliveTime 77 | } 78 | 79 | d = d + time.Duration(v)*u 80 | j = i + 1 81 | } 82 | 83 | if i != j { 84 | return d, ErrInvalidReliveTime 85 | } 86 | 87 | return d, nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/util/name-for-id.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/api" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 6 | "github.com/lucassabreu/clockify-cli/pkg/search" 7 | ) 8 | 9 | // GetAllowNameForIDsFn will try to find project/task/tags by their names if 10 | // the value provided was not a ID 11 | func GetAllowNameForIDsFn(config cmdutil.Config, c api.Client) Step { 12 | if !config.GetBool(cmdutil.CONF_ALLOW_NAME_FOR_ID) { 13 | return skip 14 | } 15 | 16 | cbs := []Step{ 17 | lookupProject(c, config), 18 | lookupTask(c), 19 | lookupTags(c), 20 | } 21 | 22 | if config.IsInteractive() { 23 | cbs = disableErrorReporting(cbs) 24 | } 25 | 26 | return compose(cbs...) 27 | } 28 | 29 | func lookupProject(c api.Client, cnf cmdutil.Config) Step { 30 | return func(te TimeEntryDTO) (TimeEntryDTO, error) { 31 | if te.ProjectID == "" { 32 | return te, nil 33 | } 34 | 35 | var err error 36 | te.ProjectID, err = search.GetProjectByName( 37 | c, cnf, te.Workspace, te.ProjectID, te.Client) 38 | return te, err 39 | } 40 | 41 | } 42 | 43 | func lookupTask(c api.Client) Step { 44 | return func(te TimeEntryDTO) (TimeEntryDTO, error) { 45 | if te.TaskID == "" { 46 | return te, nil 47 | } 48 | 49 | var err error 50 | te.TaskID, err = search.GetTaskByName( 51 | c, 52 | api.GetTasksParam{ 53 | Workspace: te.Workspace, 54 | ProjectID: te.ProjectID, 55 | Active: true, 56 | }, 57 | te.TaskID) 58 | return te, err 59 | } 60 | } 61 | 62 | func lookupTags(c api.Client) Step { 63 | return func(te TimeEntryDTO) (TimeEntryDTO, error) { 64 | if len(te.TagIDs) == 0 { 65 | return te, nil 66 | } 67 | 68 | var err error 69 | te.TagIDs, err = search.GetTagsByName(c, te.Workspace, te.TagIDs) 70 | return te, err 71 | } 72 | 73 | } 74 | 75 | func disableErrorReporting(cbs []Step) []Step { 76 | for i := range cbs { 77 | cb := cbs[i] 78 | cbs[i] = func(tei TimeEntryDTO) (TimeEntryDTO, error) { 79 | tei, _ = cb(tei) 80 | return tei, nil 81 | } 82 | } 83 | return cbs 84 | } 85 | -------------------------------------------------------------------------------- /pkg/cmdcompl/valid-args.go: -------------------------------------------------------------------------------- 1 | package cmdcompl 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | type ValidArgs interface { 9 | // IntoUse will return a string with a complete arg use 10 | // Example: "{ arg1 | arg2 | arg3 }" 11 | IntoUse() string 12 | // IntoUse will return a string with the joined options 13 | // Example: "arg1 | arg2 | arg3" 14 | IntoUseOptions() string 15 | // OnlyArgs will return a []string to be used on cobra.Command.ValidArgs 16 | OnlyArgs() []string 17 | // OnlyArgs will return a []string to be used as result for auto-complete 18 | IntoValidArgs() []string 19 | } 20 | 21 | // EmptyValidArgs returns a ValidArgs with no options 22 | func EmptyValidArgs() ValidArgs { 23 | return new(ValidArgsSlide) 24 | } 25 | 26 | type ValidArgsMap map[string]string 27 | 28 | func (va ValidArgsMap) Set(k, v string) ValidArgsMap { 29 | va[k] = v 30 | return va 31 | } 32 | 33 | func (va ValidArgsMap) OnlyArgs() []string { 34 | keys := make([]string, len(va)) 35 | i := 0 36 | for k := range va { 37 | keys[i] = k 38 | i++ 39 | } 40 | 41 | sort.Strings(keys) 42 | return keys 43 | } 44 | 45 | func (va ValidArgsMap) IntoUseOptions() string { 46 | return strings.Join(va.OnlyArgs(), " | ") 47 | } 48 | 49 | func (va ValidArgsMap) IntoUse() string { 50 | return "{ " + va.IntoUseOptions() + " }" 51 | } 52 | 53 | func (va ValidArgsMap) IntoValidArgs() []string { 54 | var args []string 55 | for k, v := range va { 56 | args = append(args, k+"\t"+v) 57 | } 58 | return args 59 | } 60 | 61 | func (va ValidArgsMap) Long() string { 62 | str := "" 63 | for _, k := range va.OnlyArgs() { 64 | str = str + " - " + k + ": " + va[k] + "\n" 65 | } 66 | 67 | return str 68 | } 69 | 70 | type ValidArgsSlide []string 71 | 72 | func (va ValidArgsSlide) IntoUseOptions() string { 73 | return strings.Join(va, " | ") 74 | } 75 | 76 | func (va ValidArgsSlide) IntoUse() string { 77 | return "{ " + va.IntoUseOptions() + " }" 78 | } 79 | 80 | func (va ValidArgsSlide) IntoValidArgs() []string { 81 | return va 82 | } 83 | 84 | func (va ValidArgsSlide) OnlyArgs() []string { 85 | return va 86 | } 87 | -------------------------------------------------------------------------------- /pkg/output/time-entry/markdown.gotmpl.md: -------------------------------------------------------------------------------- 1 | {{- $project := "" -}} 2 | {{- if eq .Project nil }} 3 | {{- $project = "No Project" -}} 4 | {{- else -}} 5 | {{- $project = concat "**" .Project.Name "**" -}} 6 | {{- if ne .Task nil -}} 7 | {{- $project = concat $project ": " .Task.Name -}} 8 | {{- else if ne .Project.ClientName "" -}} 9 | {{- $project = concat $project " - " .Project.ClientName -}} 10 | {{- end -}} 11 | {{- end -}} 12 | 13 | {{- $bil := "No" -}} 14 | {{- if .Billable -}}{{ $bil = "Yes" }}{{- end -}} 15 | 16 | {{- $tags := "" -}} 17 | {{- with .Tags -}} 18 | {{- range $index, $element := . -}} 19 | {{- if ne $index 0 }}{{ $tags = concat $tags ", " }}{{ end -}} 20 | {{- $tags = concat $tags $element.Name -}} 21 | {{- end -}} 22 | {{- else -}} 23 | {{- $tags = "No Tags" -}} 24 | {{- end -}} 25 | 26 | {{- $customFields := "" -}} 27 | {{- $hasCustomFields := false -}} 28 | {{- with .CustomFields -}} 29 | {{- range $index, $element := . -}} 30 | {{- $value := $element.ValueAsString -}} 31 | {{- if ne $value "" -}} 32 | {{- if ne $index 0 }}{{ $customFields = concat $customFields ", " }}{{ end -}} 33 | {{- $customFields = concat $customFields $element.Name ": " $value -}} 34 | {{- $hasCustomFields = true -}} 35 | {{- end -}} 36 | {{- end -}} 37 | {{- end -}} 38 | 39 | {{- $pad := maxLength .Description $project $tags $customFields $bil -}} 40 | 41 | ## _Time Entry_: {{ .ID }} 42 | 43 | _Time and date_ 44 | **{{ dsf .TimeInterval.Duration }}** | {{ if eq .TimeInterval.End nil -}} 45 | Start Time: _{{ formatTimeWS .TimeInterval.Start }}_ 🗓 Today 46 | {{- else -}} 47 | {{ formatTimeWS .TimeInterval.Start }} - {{ formatTimeWS .TimeInterval.End }} 🗓 48 | {{- .TimeInterval.Start.Format " 01/02/2006" }} 49 | {{- end }} 50 | 51 | | | {{ pad "" $pad }} | 52 | |-----------------|-{{ repeatString "-" $pad }}-| 53 | | _Description_ | {{ pad .Description $pad }} | 54 | | _Project_ | {{ pad $project $pad }} | 55 | | _Tags_ | {{ pad $tags $pad }} | 56 | | _Billable_ | {{ pad $bil $pad }} | 57 | {{- if $hasCustomFields }} 58 | | _Custom Fields_ | {{ pad $customFields $pad }} | 59 | {{- end }} 60 | -------------------------------------------------------------------------------- /pkg/cmd/config/set/set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/MakeNowJust/heredoc" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 11 | "github.com/lucassabreu/clockify-cli/strhlp" 12 | "github.com/spf13/cobra" 13 | "golang.org/x/text/language" 14 | ) 15 | 16 | // NewCmdSet will update the value of one parameter 17 | func NewCmdSet( 18 | f cmdutil.Factory, 19 | validParameters cmdcompl.ValidArgsMap, 20 | ) *cobra.Command { 21 | cmd := &cobra.Command{ 22 | Use: "set ", 23 | Args: cobra.MatchAll( 24 | cmdutil.RequiredNamedArgs("param", "value"), 25 | cobra.ExactArgs(2), 26 | ), 27 | ValidArgs: validParameters.IntoValidArgs(), 28 | Short: "Changes the value of one parameter", 29 | Example: heredoc.Docf(` 30 | $ %[1]s token "Yamdas569" 31 | $ %[1]s workweek-days monday,tuesday,wednesday,thursday,friday 32 | $ %[1]s show-task true 33 | $ %[1]s show-custom-fields true 34 | $ %[1]s user.id 4564d5a6s4d54a5s4dasd5 35 | `, "clockify-cli config set"), 36 | RunE: func(cmd *cobra.Command, args []string) error { 37 | param := args[0] 38 | value := args[1] 39 | config := f.Config() 40 | 41 | switch param { 42 | case cmdutil.CONF_WORKWEEK_DAYS: 43 | ws := strings.Split(strings.ToLower(value), ",") 44 | ws = strhlp.Filter( 45 | func(s string) bool { 46 | return strhlp.Search(s, cmdutil.GetWeekdays()) != -1 47 | }, 48 | ws, 49 | ) 50 | config.SetStringSlice(param, ws) 51 | case cmdutil.CONF_LANGUAGE: 52 | lang, err := language.Parse(value) 53 | if err != nil { 54 | return fmt.Errorf( 55 | "%s is not a valid language: %w", value, err) 56 | } 57 | 58 | config.SetLanguage(lang) 59 | case cmdutil.CONF_TIMEZONE: 60 | tz, err := time.LoadLocation(value) 61 | if err != nil { 62 | return fmt.Errorf( 63 | "%s is not a valid timezone: %w", value, err) 64 | } 65 | 66 | config.SetTimeZone(tz) 67 | default: 68 | config.SetString(param, value) 69 | } 70 | 71 | return config.Save() 72 | }, 73 | } 74 | 75 | return cmd 76 | } 77 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/report/util/report_flag_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/report/util" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestReportFlags_Check(t *testing.T) { 11 | tts := map[string]struct { 12 | rf util.ReportFlags 13 | err string 14 | }{ 15 | "just billable": { 16 | rf: util.ReportFlags{ 17 | Billable: true, 18 | NotBillable: false, 19 | }, 20 | }, 21 | "just not-billable": { 22 | rf: util.ReportFlags{ 23 | Billable: false, 24 | NotBillable: true, 25 | }, 26 | }, 27 | "only one billable": { 28 | rf: util.ReportFlags{ 29 | Billable: true, 30 | NotBillable: true, 31 | }, 32 | err: "can't be used together.*billable.*not-billable", 33 | }, 34 | "just client": { 35 | rf: util.ReportFlags{ 36 | Client: "me", 37 | }, 38 | }, 39 | "just projects": { 40 | rf: util.ReportFlags{ 41 | Projects: []string{"mine"}, 42 | }, 43 | }, 44 | "client and project": { 45 | rf: util.ReportFlags{ 46 | Client: "me", 47 | Projects: []string{"mine"}, 48 | }, 49 | }, 50 | "fill missing dates": { 51 | rf: util.ReportFlags{ 52 | FillMissingDates: true, 53 | }, 54 | }, 55 | "limit": { 56 | rf: util.ReportFlags{ 57 | Limit: 10, 58 | }, 59 | }, 60 | "only limit or fill missing": { 61 | rf: util.ReportFlags{ 62 | Limit: 10, 63 | FillMissingDates: true, 64 | }, 65 | err: "can't be used together.*fill-missing-dates.*limit", 66 | }, 67 | "limit and page": { 68 | rf: util.ReportFlags{ 69 | Limit: 10, 70 | Page: 10, 71 | }, 72 | }, 73 | "page needs limit": { 74 | rf: util.ReportFlags{ 75 | Page: 10, 76 | }, 77 | err: "page can't be used without limit", 78 | }, 79 | } 80 | 81 | for name, tt := range tts { 82 | t.Run(name, func(t *testing.T) { 83 | err := tt.rf.Check() 84 | 85 | if tt.err == "" { 86 | assert.NoError(t, err) 87 | return 88 | } 89 | 90 | if !assert.Error(t, err) { 91 | return 92 | } 93 | 94 | assert.Regexp(t, tt.err, err.Error()) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/cmdcompl/flags.go: -------------------------------------------------------------------------------- 1 | package cmdcompl 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // AddFixedSuggestionsToFlag add fixed suggestions to a flag 8 | func AddFixedSuggestionsToFlag(cmd *cobra.Command, flagName string, va ValidArgs) error { 9 | f := cmd.Flags().Lookup(flagName) 10 | if f == nil { 11 | f = cmd.PersistentFlags().Lookup(flagName) 12 | } 13 | 14 | f.Usage = va.IntoUse() + " " + f.Usage 15 | return cmd.RegisterFlagCompletionFunc( 16 | f.Name, 17 | func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 18 | return va.IntoValidArgs(), cobra.ShellCompDirectiveDefault 19 | }, 20 | ) 21 | } 22 | 23 | // SuggestFn is a function to search valid suggestions for a flag/argument 24 | type SuggestFn func(cmd *cobra.Command, args []string, toComplete string) (ValidArgs, error) 25 | 26 | func process(va ValidArgs, err error) ([]string, cobra.ShellCompDirective) { 27 | if err != nil { 28 | cobra.CompError(err.Error()) 29 | return []string{}, cobra.ShellCompDirectiveError 30 | } 31 | 32 | return va.IntoValidArgs(), cobra.ShellCompDirectiveDefault 33 | } 34 | 35 | // AddSuggestionsToFlag add fixed suggestions to a flag 36 | func AddSuggestionsToFlag(cmd *cobra.Command, flagName string, suggestFn SuggestFn) error { 37 | return cmd.RegisterFlagCompletionFunc( 38 | flagName, 39 | func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 40 | return process(suggestFn(cmd, args, toComplete)) 41 | }, 42 | ) 43 | } 44 | 45 | func EmptySuggestionFuncion(_ *cobra.Command, _ []string, _ string) (ValidArgs, error) { 46 | return EmptyValidArgs(), nil 47 | } 48 | 49 | // CombineSuggestionsToArgs combine one or more suggestion resolver functions and call then accordingly with arg count 50 | func CombineSuggestionsToArgs(fns ...SuggestFn) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 51 | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 52 | if len(args) > len(fns) { 53 | return []string{}, cobra.ShellCompDirectiveDefault 54 | } 55 | 56 | return process(fns[len(args)](cmd, args, toComplete)) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/cmd/user/me/me.go: -------------------------------------------------------------------------------- 1 | package me 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmd/user/util" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 10 | "github.com/lucassabreu/clockify-cli/pkg/output/user" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // NewCmdMe represents the me command 15 | func NewCmdMe( 16 | f cmdutil.Factory, 17 | report func(io.Writer, *util.OutputFlags, dto.User) error, 18 | ) *cobra.Command { 19 | of := util.OutputFlags{} 20 | cmd := &cobra.Command{ 21 | Use: "me", 22 | Short: "Show details about the user who owns the token", 23 | Long: heredoc.Doc(` 24 | Shows details about the user who owns the token used by the CLI. 25 | 26 | This user may be different from the one set at "user.id", but if the parameter is not set the CLI will defaults to this one. 27 | `), 28 | Example: heredoc.Docf(` 29 | $ %[1]s 30 | +--------------------------+-------------+--------------+--------+-------------------+ 31 | | ID | NAME | EMAIL | STATUS | TIMEZONE | 32 | +--------------------------+-------------+--------------+--------+-------------------+ 33 | | ffffffffffffffffffffffff | John JD Due | due@john.net | ACTIVE | America/Sao_Paulo | 34 | +--------------------------+-------------+--------------+--------+-------------------+ 35 | 36 | $ %[1]s --quiet 37 | ffffffffffffffffffffffff 38 | 39 | $ %[1]s --format "{{ .Name }} ({{ .Email }})" 40 | John JD Due (due@john.net) 41 | `, "clockify-cli user me"), 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | if err := of.Check(); err != nil { 44 | return err 45 | } 46 | 47 | c, err := f.Client() 48 | if err != nil { 49 | return err 50 | } 51 | 52 | u, err := c.GetMe() 53 | if err != nil { 54 | return err 55 | } 56 | 57 | out := cmd.OutOrStdout() 58 | if report != nil { 59 | return report(out, &of, u) 60 | } 61 | 62 | if of.JSON { 63 | return user.UserJSONPrint(u, out) 64 | } 65 | 66 | return util.Report([]dto.User{u}, out, of) 67 | }, 68 | } 69 | 70 | util.AddReportFlags(cmd, &of) 71 | 72 | return cmd 73 | } 74 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/util/fill-with-flags.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 7 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 8 | ) 9 | 10 | type flagSet interface { 11 | Changed(string) bool 12 | GetString(string) (string, error) 13 | GetStringSlice(string) ([]string, error) 14 | } 15 | 16 | // FillTimeEntryWithFlags will read the flags and fill the time entry with they 17 | func FillTimeEntryWithFlags(flags flagSet) Step { 18 | return func(dto TimeEntryDTO) (TimeEntryDTO, error) { 19 | if err := cmdutil.XorFlag(map[string]bool{ 20 | "billable": flags.Changed("billable"), 21 | "not-billable": flags.Changed("not-billable"), 22 | }); err != nil { 23 | return dto, err 24 | } 25 | 26 | if flags.Changed("project") { 27 | p, _ := flags.GetString("project") 28 | if p != dto.ProjectID { 29 | dto.TaskID = "" 30 | } 31 | dto.ProjectID = p 32 | 33 | if flags.Changed("client") { 34 | c, _ := flags.GetString("client") 35 | if c != dto.Client { 36 | dto.TaskID = "" 37 | } 38 | dto.Client = c 39 | } 40 | } 41 | 42 | if flags.Changed("description") { 43 | dto.Description, _ = flags.GetString("description") 44 | } 45 | 46 | if flags.Changed("task") { 47 | dto.TaskID, _ = flags.GetString("task") 48 | } 49 | 50 | if flags.Changed("tag") { 51 | dto.TagIDs, _ = flags.GetStringSlice("tag") 52 | } 53 | 54 | if flags.Changed("tags") { 55 | dto.TagIDs, _ = flags.GetStringSlice("tags") 56 | } 57 | 58 | if flags.Changed("billable") { 59 | b := true 60 | dto.Billable = &b 61 | } 62 | 63 | if flags.Changed("not-billable") { 64 | b := false 65 | dto.Billable = &b 66 | } 67 | 68 | var err error 69 | if flags.Changed("when") { 70 | whenString, _ := flags.GetString("when") 71 | var v time.Time 72 | if v, err = timehlp.ConvertToTime(whenString); err != nil { 73 | return dto, err 74 | } 75 | dto.Start = v 76 | } 77 | 78 | if flags.Changed("when-to-close") { 79 | whenString, _ := flags.GetString("when-to-close") 80 | var v time.Time 81 | if v, err = timehlp.ConvertToTime(whenString); err != nil { 82 | return dto, err 83 | } 84 | dto.End = &v 85 | } 86 | 87 | return dto, nil 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /site/content/_index.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Clockify CLI is a command line tool to help manage time entries from [Clockify][clockify] and 4 | related resources. 5 | 6 | - [Available commands][commands] 7 | - [Usage examples][usage] 8 | - [Installation][install] 9 | 10 | ## Configuration 11 | 12 | - Generate a API key by visiting your user [settings on Clockify.me][settings], in the "API" 13 | section generate with you don't have one and copy your API key. 14 | - Run the command `clockify-cli config init`, it will ask for the API key you copied. 15 | - The CLI will also ask for your preferences and default settings. You can change these 16 | preferences any time, see [clockify-cli config][cli-config] about the options. 17 | - After this run the command `clockify-cli in` to start a time entry. 18 | - (optional) To add auto completion follow the instructions [here][auto-complete] 19 | 20 | ## Usage 21 | 22 | Almost every command has examples on its help, just run [`clockify-cli in --help`][cli-in-examples] 23 | to see some of them. 24 | 25 | For a more step by step scenarios look at [Usage document][usage]. 26 | 27 | ## How to Contact 28 | 29 | - Questions about how to use the CLI? 30 | - Wanna provide feedback on some feature? 31 | - Report a bug or ask for a feature? 32 | 33 | All these can be done opening a [issue on Github][issues]. 34 | 35 | ## Contributing 36 | 37 | Wants to help improve the CLI or the project, check out our [contributing page][contributing] 38 | 39 | #### Disclaimer 40 | 41 | The maintainers of this CLI are just users of Clockify and have no inside view from it, all actions 42 | performed by it are possible using the [API][api] provided by Clockify. 43 | 44 | [clockify]: https://clockify.me/ 45 | [api]: https://clockify.me/developers-api 46 | [install]: https://github.com/lucassabreu/clockify-cli#how-to-install- 47 | [usage]: /en/usage/ 48 | [commands]: /en/commands/clockify-cli/ 49 | [settings]: https://app.clockify.me/user/settings 50 | [auto-complete]: /en/commands/clockify-cli_completion/#synopsis 51 | [issues]: https://github.com/lucassabreu/clockify-cli/issues 52 | [contributing]: https://github.com/lucassabreu/clockify-cli/blob/main/CONTRIBUTING.md 53 | [cli-config]: /en/commands/clockify-cli_config/ 54 | [cli-in-examples]: /en/commands/clockify-cli_in/#examples 55 | -------------------------------------------------------------------------------- /pkg/cmdutil/args_test.go: -------------------------------------------------------------------------------- 1 | package cmdutil_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 8 | "github.com/spf13/cobra" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestRequiredNamedArgs(t *testing.T) { 13 | tt := []struct { 14 | name string 15 | posArgs cobra.PositionalArgs 16 | args []string 17 | err error 18 | }{ 19 | { 20 | name: "req one and none sent", 21 | posArgs: cmdutil.RequiredNamedArgs("param1"), 22 | args: []string{}, 23 | err: errors.New("requires arg param1"), 24 | }, 25 | { 26 | name: "req two and none sent", 27 | posArgs: cmdutil.RequiredNamedArgs("param1", "param2"), 28 | args: []string{}, 29 | err: errors.New( 30 | "requires args param1 and param2; 0 of those received"), 31 | }, 32 | { 33 | name: "req three and one sent", 34 | posArgs: cmdutil.RequiredNamedArgs("param1", "param2", "param3"), 35 | args: []string{"param1"}, 36 | err: errors.New( 37 | "requires args param1, param2 and param3; 1 of those received", 38 | ), 39 | }, 40 | { 41 | name: "req one and one sent", 42 | posArgs: cmdutil.RequiredNamedArgs("param1"), 43 | args: []string{"param1"}, 44 | err: nil, 45 | }, 46 | { 47 | name: "req two and two sent", 48 | posArgs: cmdutil.RequiredNamedArgs("param1", "param2"), 49 | args: []string{"param1", "param2"}, 50 | err: nil, 51 | }, 52 | { 53 | name: "req one and two sent", 54 | posArgs: cmdutil.RequiredNamedArgs("param1"), 55 | args: []string{"param1", "param2"}, 56 | err: nil, 57 | }, 58 | } 59 | 60 | for _, tc := range tt { 61 | t.Run(tc.name, func(t *testing.T) { 62 | cmd := cobra.Command{ 63 | Args: tc.posArgs, 64 | RunE: func(cmd *cobra.Command, args []string) error { 65 | if tc.err != nil { 66 | t.Fatal("should not get here") 67 | } 68 | return nil 69 | }, 70 | } 71 | 72 | cmd.SetArgs(tc.args) 73 | 74 | _, err := cmd.ExecuteC() 75 | 76 | if tc.err == nil { 77 | assert.NoError(t, err) 78 | return 79 | } 80 | 81 | var flagErr cmdutil.FlagError 82 | if !assert.Error(t, err) && 83 | assert.ErrorAs(t, err, &flagErr) { 84 | assert.Equal(t, flagErr.Error(), tc.err.Error()) 85 | } 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/cmd/client/add/add.go: -------------------------------------------------------------------------------- 1 | package add 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmd/client/util" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 11 | "github.com/lucassabreu/clockify-cli/pkg/output/client" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // NewCmdAdd represents the add command 16 | func NewCmdAdd( 17 | f cmdutil.Factory, 18 | report func(io.Writer, *util.OutputFlags, dto.Client) error, 19 | ) *cobra.Command { 20 | of := util.OutputFlags{} 21 | cmd := &cobra.Command{ 22 | Use: "add", 23 | Aliases: []string{"new", "create"}, 24 | Short: "Adds a new client to the Clockify workspace", 25 | Example: heredoc.Docf(` 26 | $ %[1]s --name Special 27 | +--------------------------+---------+----------+ 28 | | ID | NAME | ARCHIVED | 29 | +--------------------------+---------+----------+ 30 | | eeeeeeeeeeeeeeeeeeeeeeee | Special | NO | 31 | +--------------------------+---------+----------+ 32 | 33 | $ %[1]s --name "Very Special" --quiet 34 | aaaaaaaaaaaaaaaaaaaaaaaa 35 | 36 | $ %[1]s --name "Special" # same name as existing one 37 | add client: Client with name 'Special' already exists (code: 501) 38 | `, "clockify-cli client add"), 39 | RunE: func(cmd *cobra.Command, args []string) error { 40 | if err := of.Check(); err != nil { 41 | return err 42 | } 43 | 44 | w, err := f.GetWorkspaceID() 45 | if err != nil { 46 | return err 47 | } 48 | 49 | c, err := f.Client() 50 | if err != nil { 51 | return err 52 | } 53 | 54 | name, _ := cmd.Flags().GetString("name") 55 | cl, err := c.AddClient(api.AddClientParam{ 56 | Workspace: w, 57 | Name: name, 58 | }) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | out := cmd.OutOrStdout() 64 | 65 | if report != nil { 66 | return report(out, &of, cl) 67 | } 68 | 69 | if of.JSON { 70 | client.ClientJSONPrint(cl, out) 71 | } 72 | 73 | return util.Report([]dto.Client{cl}, out, of) 74 | }, 75 | } 76 | 77 | cmd.Flags().StringP("name", "n", "", "the name of the new client") 78 | _ = cmd.MarkFlagRequired("name") 79 | 80 | util.AddReportFlags(cmd, &of) 81 | 82 | return cmd 83 | } 84 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/report/last-week-day/last-week-day.go: -------------------------------------------------------------------------------- 1 | package lastweekday 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "time" 7 | 8 | "github.com/MakeNowJust/heredoc" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/report/util" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 11 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 12 | "github.com/lucassabreu/clockify-cli/strhlp" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | // NewCmdLastWeekDay represents the report last working week day command 17 | func NewCmdLastWeekDay(f cmdutil.Factory) *cobra.Command { 18 | of := util.NewReportFlags() 19 | cmd := &cobra.Command{ 20 | Use: "last-week-day", 21 | Short: "List time entries from last week day", 22 | Long: heredoc.Docf(` 23 | List time entries from last week day 24 | 25 | For the CLI to know which days of the week you are expected to work, you will need to set them. 26 | This can be done using: 27 | $ clockify-cli config init 28 | 29 | Or more directly by running the set command as follows: 30 | $ clockify-cli config set workweek-days monday,tuesday,wednesday,thursday,friday 31 | 32 | %s 33 | %s 34 | `, 35 | util.HelpNamesForIds, 36 | util.HelpMoreInfoAboutPrinting, 37 | ), 38 | 39 | RunE: func(cmd *cobra.Command, args []string) error { 40 | if err := of.Check(); err != nil { 41 | return err 42 | } 43 | 44 | workweek := f.Config().GetWorkWeekdays() 45 | if len(workweek) == 0 { 46 | return errors.New("no workweek days were set") 47 | } 48 | 49 | day := timehlp.Today().Add(-1) 50 | if strhlp.Search( 51 | strings.ToLower(day.Weekday().String()), workweek) != -1 { 52 | return util.ReportWithRange(f, day, day, cmd.OutOrStdout(), of) 53 | } 54 | 55 | dayWeekday := int(day.Weekday()) 56 | if dayWeekday == int(time.Sunday) { 57 | dayWeekday = int(time.Saturday + 1) 58 | } 59 | 60 | lastWeekDay := int(time.Sunday) 61 | for _, w := range workweek { 62 | i := strhlp.Search(w, cmdutil.GetWeekdays()) 63 | if i > lastWeekDay && i < dayWeekday { 64 | lastWeekDay = i 65 | } 66 | } 67 | 68 | day = day.Add( 69 | time.Duration(-24*(dayWeekday-lastWeekDay)) * time.Hour) 70 | return util.ReportWithRange(f, day, day, cmd.OutOrStdout(), of) 71 | }, 72 | } 73 | 74 | util.AddReportFlags(f, cmd, &of) 75 | 76 | return cmd 77 | } 78 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lucassabreu/clockify-cli 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/AlecAivazis/survey/v2 v2.3.7 7 | github.com/MakeNowJust/heredoc v1.0.0 8 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 9 | github.com/creack/pty v1.1.17 10 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec 11 | github.com/mitchellh/go-homedir v1.1.0 12 | github.com/olekukonko/tablewriter v0.0.5 13 | github.com/pkg/errors v0.9.1 14 | github.com/spf13/cobra v1.8.0 15 | github.com/spf13/pflag v1.0.5 16 | github.com/spf13/viper v1.18.2 17 | github.com/stretchr/testify v1.9.0 18 | golang.org/x/sync v0.7.0 19 | golang.org/x/term v0.20.0 20 | golang.org/x/text v0.15.0 21 | gopkg.in/yaml.v3 v3.0.1 22 | ) 23 | 24 | require ( 25 | github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 27 | github.com/fsnotify/fsnotify v1.7.0 // indirect 28 | github.com/hashicorp/hcl v1.0.0 // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 31 | github.com/magiconair/properties v1.8.7 // indirect 32 | github.com/mattn/go-colorable v0.1.13 // indirect 33 | github.com/mattn/go-isatty v0.0.20 // indirect 34 | github.com/mattn/go-runewidth v0.0.15 // indirect 35 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 36 | github.com/mitchellh/mapstructure v1.5.0 // indirect 37 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 38 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 39 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 40 | github.com/rivo/uniseg v0.4.7 // indirect 41 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 42 | github.com/sagikazarmark/locafero v0.4.0 // indirect 43 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 44 | github.com/sourcegraph/conc v0.3.0 // indirect 45 | github.com/spf13/afero v1.11.0 // indirect 46 | github.com/spf13/cast v1.6.0 // indirect 47 | github.com/stretchr/objx v0.5.2 // indirect 48 | github.com/subosito/gotenv v1.6.0 // indirect 49 | go.uber.org/multierr v1.11.0 // indirect 50 | golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect 51 | golang.org/x/sys v0.20.0 // indirect 52 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 53 | gopkg.in/ini.v1 v1.67.0 // indirect 54 | ) 55 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/util/flags.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdcomplutil" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 9 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // AddTimeEntryFlags will add the common flags needed to add/edit a time entry 14 | func AddTimeEntryFlags( 15 | cmd *cobra.Command, f cmdutil.Factory, of *OutputFlags, 16 | ) { 17 | cmd.Flags().BoolP("billable", "b", false, 18 | "this time entry is billable") 19 | cmd.Flags().BoolP("not-billable", "n", false, 20 | "this time entry is not billable") 21 | cmd.Flags().String("task", "", "add a task to the entry") 22 | _ = cmdcompl.AddSuggestionsToFlag(cmd, "task", 23 | cmdcomplutil.NewTaskAutoComplete(f, true)) 24 | 25 | cmd.Flags().StringSliceP("tag", "T", []string{}, "add tags to the entry (can be used multiple times)") 26 | _ = cmdcompl.AddSuggestionsToFlag(cmd, "tag", 27 | cmdcomplutil.NewTagAutoComplete(f)) 28 | 29 | cmd.Flags().BoolP("allow-incomplete", "A", false, 30 | "allow creation of incomplete time entries to be edited later") 31 | 32 | cmd.Flags().StringP("client", "c", "", "client of the project to use for time entry") 33 | _ = cmdcompl.AddSuggestionsToFlag(cmd, "client", 34 | cmdcomplutil.NewClientAutoComplete(f)) 35 | 36 | cmd.Flags().StringP("project", "p", "", "project to use for time entry") 37 | _ = cmdcompl.AddSuggestionsToFlag(cmd, "project", 38 | cmdcomplutil.NewProjectAutoComplete(f, f.Config())) 39 | 40 | cmd.Flags().StringP("description", "d", "", "time entry description") 41 | _ = cmdcompl.AddSuggestionsToFlag(cmd, "description", 42 | newDescriptionAutoComplete(f), 43 | ) 44 | 45 | AddPrintTimeEntriesFlags(cmd, of) 46 | 47 | // deprecations 48 | cmd.Flags().StringSlice("tags", []string{}, "add tags to the entry") 49 | _ = cmdcompl.AddSuggestionsToFlag(cmd, "tags", 50 | cmdcomplutil.NewTagAutoComplete(f)) 51 | _ = cmd.Flags().MarkDeprecated("tags", "use tag instead") 52 | } 53 | 54 | // AddTimeEntryDateFlags adds the default start and end flags 55 | func AddTimeEntryDateFlags(cmd *cobra.Command) { 56 | cmd.Flags().StringP("when", "s", time.Now().Format(timehlp.FullTimeFormat), 57 | "when the entry should be started, "+ 58 | "if not informed will use current time") 59 | cmd.Flags().StringP("when-to-close", "e", "", 60 | "when the entry should be closed, if not informed will let it open "+ 61 | "(same formats as when)") 62 | } 63 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/show/show.go: -------------------------------------------------------------------------------- 1 | package show 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util" 6 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 8 | "github.com/lucassabreu/clockify-cli/pkg/timeentryhlp" 9 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // NewCmdShow represents the show command 14 | func NewCmdShow(f cmdutil.Factory) *cobra.Command { 15 | of := util.OutputFlags{TimeFormat: timehlp.FullTimeFormat} 16 | va := cmdcompl.ValidArgsSlide{ 17 | timeentryhlp.AliasCurrent, timeentryhlp.AliasLast} 18 | cmd := &cobra.Command{ 19 | Use: "show [ | " + va.IntoUseOptions() + 20 | " | ^n ]", 21 | ValidArgs: va.IntoValidArgs(), 22 | Args: cobra.MaximumNArgs(1), 23 | Short: "Show information about one time entry.", 24 | Long: heredoc.Docf(` 25 | Show information about one time entry. 26 | 27 | If no time entry ID is informed it shows the running it exists. 28 | 29 | To show the last ended time entry you can use "%s" for it, for the one before that you can use "^2", for the previous "^3" and so on. 30 | 31 | %s 32 | `, 33 | timeentryhlp.AliasLast, 34 | util.HelpMoreInfoAboutPrinting, 35 | ), 36 | Example: heredoc.Docf(` 37 | # trying to show running time entry, when there is none 38 | $ %[1]s 39 | looking for running time entry: time entry was not found 40 | 41 | # show the last time entry (ended) 42 | $ %[1]s last -q 43 | 62af70d849445270d7c09fbd 44 | 45 | # show the time entry before the last one 46 | $ %[1]s ^2 -q 47 | 62af668b49445270d7c092e4 48 | `, "clockify-cli show"), 49 | RunE: func(cmd *cobra.Command, args []string) error { 50 | if err := of.Check(); err != nil { 51 | return err 52 | } 53 | 54 | userID, err := f.GetUserID() 55 | if err != nil { 56 | return err 57 | } 58 | 59 | w, err := f.GetWorkspaceID() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | id := timeentryhlp.AliasCurrent 65 | if len(args) > 0 { 66 | id = args[0] 67 | } 68 | 69 | c, err := f.Client() 70 | if err != nil { 71 | return err 72 | } 73 | 74 | tei, err := timeentryhlp.GetTimeEntry(c, w, userID, id) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | return util.PrintTimeEntryImpl(tei, f, cmd.OutOrStdout(), of) 80 | }, 81 | } 82 | 83 | util.AddPrintTimeEntriesFlags(cmd, &of) 84 | _ = cmd.MarkFlagRequired("workspace") 85 | _ = cmd.MarkFlagRequired("user-id") 86 | 87 | return cmd 88 | } 89 | -------------------------------------------------------------------------------- /pkg/cmd/config/list/list_test.go: -------------------------------------------------------------------------------- 1 | package list_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/MakeNowJust/heredoc" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmd/config/list" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 11 | "github.com/lucassabreu/clockify-cli/internal/mocks" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestListCmd(t *testing.T) { 16 | tts := []struct { 17 | name string 18 | args []string 19 | config func(t *testing.T) cmdutil.Config 20 | expectedOutput string 21 | err error 22 | }{ 23 | { 24 | name: "no args", 25 | args: []string{"param"}, 26 | err: errors.New(`unknown command "param" for "list"`), 27 | config: func(t *testing.T) cmdutil.Config { return nil }, 28 | }, 29 | { 30 | name: "default format", 31 | args: []string{}, 32 | config: func(t *testing.T) cmdutil.Config { 33 | c := mocks.NewMockConfig(t) 34 | c.On("All").Once().Return(map[string]interface{}{ 35 | "token": "value", 36 | "user": map[string]string{"id": "user.id"}, 37 | }) 38 | return c 39 | }, 40 | expectedOutput: heredoc.Doc(` 41 | token: value 42 | user: 43 | id: user.id 44 | `), 45 | }, 46 | { 47 | name: "json format", 48 | args: []string{"--format=json"}, 49 | config: func(t *testing.T) cmdutil.Config { 50 | c := mocks.NewMockConfig(t) 51 | c.On("All").Once().Return(map[string]interface{}{ 52 | "token": "value", 53 | "user": map[string]string{"id": "user.id"}, 54 | }) 55 | return c 56 | }, 57 | expectedOutput: `{"token":"value","user":{"id":"user.id"}}`, 58 | }, 59 | { 60 | name: "invalid format", 61 | args: []string{"--format=tmol"}, 62 | config: func(t *testing.T) cmdutil.Config { 63 | c := mocks.NewMockConfig(t) 64 | c.On("All").Once().Return(map[string]interface{}{}) 65 | return c 66 | }, 67 | err: errors.New("invalid format"), 68 | }, 69 | } 70 | 71 | for _, tt := range tts { 72 | t.Run(tt.name, func(t *testing.T) { 73 | f := mocks.NewMockFactory(t) 74 | if c := tt.config(t); c != nil { 75 | f.On("Config").Return(c) 76 | } 77 | 78 | cmd := list.NewCmdList(f) 79 | cmd.SilenceErrors = true 80 | cmd.SilenceUsage = true 81 | 82 | b := bytes.NewBufferString("") 83 | cmd.SetOut(b) 84 | cmd.SetErr(b) 85 | 86 | cmd.SetArgs(tt.args) 87 | _, err := cmd.ExecuteC() 88 | if tt.err != nil && assert.Error(t, err) { 89 | assert.EqualError(t, err, tt.err.Error()) 90 | return 91 | } 92 | 93 | assert.NoError(t, err) 94 | assert.Equal(t, tt.expectedOutput, b.String()) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export GO111MODULE=on 2 | MAIN_PKG=./cmd/clockify-cli 3 | 4 | # Absolutely awesome: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 5 | help: ## show this help 6 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' 7 | 8 | clean: ## clean all buildable files 9 | rm -rf dist 10 | 11 | deps-install: ## install golang dependencies 12 | go mod download 13 | 14 | deps-upgrade: ## upgrade go dependencies 15 | go get -u -v $(MAIN_PKG) 16 | go mod tidy 17 | 18 | build: dist 19 | 20 | dist: deps-install dist/darwin dist/linux dist/windows ## build all cli versions (default) 21 | 22 | dist-internal: 23 | mkdir -p dist/$(goos) 24 | GOOS=$(goos) GOARCH=$(goarch) go build -o dist/$(goos)/clockify-cli $(MAIN_PKG) 25 | 26 | dist/darwin: 27 | make dist-internal goos=darwin goarch=amd64 28 | 29 | dist/linux: 30 | make dist-internal goos=linux goarch=amd64 31 | 32 | dist/windows: 33 | make dist-internal goos=windows goarch=amd64 34 | 35 | go-install: deps-install ## install dev version 36 | go install $(MAIN_PKG) 37 | 38 | go-generate: deps-install ## recreates generate files 39 | go install github.com/vektra/mockery/v3@v3.4.0 40 | mockery 41 | 42 | test-install: deps-install go-generate 43 | go install gotest.tools/gotestsum@latest 44 | 45 | test: test-install ## runs all tests 46 | gotestsum --format dots-v2 47 | 48 | test_coverprofile=coverage.txt 49 | test_covermode=atomic 50 | test-coverage: test-install ## runs all tests and output coverage 51 | gotestsum --format dots-v2 -- \ 52 | -coverprofile=$(coverprofile) \ 53 | -covermode=$(covermode) \ 54 | ./... 55 | 56 | test-watch: test-install ## runs all tests and watch changes 57 | gotestsum --format testname --watch -- -failfast 58 | 59 | goreleaser-test: tag=Unreleased 60 | goreleaser-test: release 61 | 62 | ifeq ($(tag),Unreleased) 63 | SNAPSHOT=1 64 | endif 65 | tag= 66 | release: ## releases a tagged version 67 | sed "/^## \[$(tag)/, /^## \[/!d" CHANGELOG.md | tail -n +2 | head -n -2 > /tmp/rn.md 68 | curl -sL https://git.io/goreleaser | bash /dev/stdin --release-notes /tmp/rn.md \ 69 | --clean $(if $(SNAPSHOT),--snapshot --skip=publish,) 70 | ifneq ($(SNAPSHOT),1) 71 | curl -X POST -d '{"trigger_branch":"$(tag)","trigger_title":"Releasing $(tag)"}' https://api.netlify.com/build_hooks/5eef4f99028bddbb4093e4c6 -v 72 | endif 73 | 74 | site/themes/hugo-theme-learn/.git: 75 | git submodule update --init 76 | 77 | site-build: site/themes/hugo-theme-learn/.git ## generates command documents and builds the site 78 | ./scripts/site-build 79 | 80 | site-serve: site-build ## builds the site, and serves it locally 81 | cd site && hugo serve 82 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | builds: 4 | - env: 5 | - CGO_ENABLED=0 6 | goos: 7 | - windows 8 | - linux 9 | - darwin 10 | hooks: 11 | pre: 12 | - go mod download 13 | main: ./cmd/clockify-cli 14 | 15 | archives: 16 | - id: default 17 | name_template: >- 18 | {{- .ProjectName }}_ 19 | {{- title .Os }}_ 20 | {{- if eq .Arch "amd64" }}x86_64 21 | {{- else if eq .Arch "386" }}i386 22 | {{- else }}{{ .Arch }}{{ end }} 23 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 24 | format_overrides: 25 | - goos: windows 26 | formats: [zip] 27 | files: 28 | - LICENSE 29 | 30 | checksum: 31 | name_template: "checksums.txt" 32 | 33 | snapshot: 34 | version_template: "{{ .Tag }}-next" 35 | 36 | changelog: 37 | sort: asc 38 | filters: 39 | exclude: 40 | - "^docs:" 41 | - "^test:" 42 | 43 | snapcrafts: 44 | - name: clockify-cli 45 | summary: Helps to interact with Clockfy's API 46 | description: Helps to interact with Clockfy's API 47 | 48 | grade: stable 49 | publish: true 50 | confinement: strict 51 | 52 | apps: 53 | clockify-cli: 54 | plugs: ["network"] 55 | 56 | homebrew_casks: 57 | - name: clockify-cli 58 | repository: 59 | owner: lucassabreu 60 | name: homebrew-tap 61 | homepage: https://github.com/lucassabreu/clockify-cli 62 | description: Helps to interact with Clockfy's API 63 | 64 | nix: 65 | - name: clockify-cli 66 | 67 | goamd64: v1 68 | 69 | # The project name and current git tag are used in the format string. 70 | # 71 | # Templates: allowed. 72 | commit_msg_template: "{{ .ProjectName }}: {{ .Tag }}" 73 | 74 | # Your app's homepage. 75 | # 76 | # Templates: allowed. 77 | # Default: inferred from global metadata. 78 | homepage: "https://clockify-cli.netlify.app/" 79 | 80 | # Your app's description. 81 | # 82 | # Templates: allowed. 83 | # Default: inferred from global metadata. 84 | description: "A simple cli to manage your time entries on Clockify from terminal" 85 | 86 | license: "asl20" 87 | 88 | # Repository to push the generated files to. 89 | repository: 90 | # Repository owner. 91 | # 92 | # Templates: allowed. 93 | owner: lucassabreu 94 | 95 | # Repository name. 96 | # 97 | # Templates: allowed. 98 | name: nur-packages 99 | 100 | # Optionally a branch can be provided. 101 | # 102 | # Default: default repository branch. 103 | # Templates: allowed. 104 | branch: main 105 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/util/util.go: -------------------------------------------------------------------------------- 1 | // util package provides reusable functionality to the commands under 2 | // pkg/cmd/time-entry, be it editing, creating, or rendering time entries 3 | package util 4 | 5 | import ( 6 | "time" 7 | 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | ) 10 | 11 | // TimeEntryDTO is used to keep and update the data of a time entry before 12 | // changing it, taking into account optional values (nil) 13 | type TimeEntryDTO struct { 14 | ID string 15 | Workspace string 16 | UserID string 17 | ProjectID string 18 | Client string 19 | TaskID string 20 | Description string 21 | Start time.Time 22 | End *time.Time 23 | TagIDs []string 24 | Billable *bool 25 | Locked *bool 26 | } 27 | 28 | // Step is used to stack multiple actions to be executed over a TimeEntryDTO 29 | type Step func(TimeEntryDTO) (TimeEntryDTO, error) 30 | 31 | func skip(te TimeEntryDTO) (TimeEntryDTO, error) { 32 | return te, nil 33 | } 34 | 35 | // Do will runs all callback functions over the time entry, keeping 36 | // the changes and returning it after 37 | func Do(te TimeEntryDTO, cbs ...Step) (TimeEntryDTO, error) { 38 | return compose(cbs...)(te) 39 | } 40 | 41 | func compose(cbs ...Step) Step { 42 | return func(dto TimeEntryDTO) (TimeEntryDTO, error) { 43 | var err error 44 | for _, cb := range cbs { 45 | if dto, err = cb(dto); err != nil { 46 | return dto, err 47 | } 48 | } 49 | 50 | return dto, err 51 | } 52 | } 53 | 54 | // TimeEntryImplToDTO returns a TimeEntryDTO using the information from a 55 | // TimeEntryImpl 56 | func TimeEntryImplToDTO(t dto.TimeEntryImpl) TimeEntryDTO { 57 | return TimeEntryDTO{ 58 | Workspace: t.WorkspaceID, 59 | UserID: t.UserID, 60 | ID: t.ID, 61 | ProjectID: t.ProjectID, 62 | TaskID: t.TaskID, 63 | Description: t.Description, 64 | Start: t.TimeInterval.Start, 65 | End: t.TimeInterval.End, 66 | TagIDs: t.TagIDs, 67 | Billable: &t.Billable, 68 | Locked: &t.IsLocked, 69 | } 70 | } 71 | 72 | // TimeEntryDTOToImpl returns a TimeEntryImpl using the information from a 73 | // TimeEntryDTO 74 | func TimeEntryDTOToImpl(t TimeEntryDTO) dto.TimeEntryImpl { 75 | if t.Billable == nil { 76 | b := false 77 | t.Billable = &b 78 | } 79 | 80 | if t.Locked == nil { 81 | b := false 82 | t.Locked = &b 83 | } 84 | 85 | return dto.TimeEntryImpl{ 86 | WorkspaceID: t.Workspace, 87 | UserID: t.UserID, 88 | Description: t.Description, 89 | ID: t.ID, 90 | ProjectID: t.ProjectID, 91 | TagIDs: t.TagIDs, 92 | TaskID: t.TaskID, 93 | TimeInterval: dto.NewTimeInterval(t.Start, t.End), 94 | Billable: *t.Billable, 95 | IsLocked: *t.Locked, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/cmd/client/list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmd/client/util" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // NewCmdList represents the list command 15 | func NewCmdList( 16 | f cmdutil.Factory, 17 | report func(io.Writer, *util.OutputFlags, []dto.Client) error, 18 | ) *cobra.Command { 19 | of := util.OutputFlags{} 20 | var archived, notArchived bool 21 | cmd := &cobra.Command{ 22 | Use: "list", 23 | Aliases: []string{"ls"}, 24 | Short: "List clients from a Clockify workspace", 25 | Example: heredoc.Docf(` 26 | $ %[1]s 27 | +--------------------------+----------+----------+ 28 | | ID | NAME | ARCHIVED | 29 | +--------------------------+----------+----------+ 30 | | 6202634a28782767054eec26 | Client 1 | NO | 31 | | 62964b36bb48532a70730dbe | Client 2 | YES | 32 | +--------------------------+----------+----------+ 33 | 34 | $ %[1]s --archived --csv 35 | 62964b36bb48532a70730dbe,Client 2,true 36 | 37 | $ %[1]s --not-archived --format "<{{ .Name }}>" 38 | 39 | 40 | $ %[1]s --name "1" --quiet 41 | 6202634a28782767054eec26 42 | `, "clockify-cli client list"), 43 | RunE: func(cmd *cobra.Command, args []string) error { 44 | if err := of.Check(); err != nil { 45 | return err 46 | } 47 | 48 | if err := cmdutil.XorFlag(map[string]bool{ 49 | "archived": archived, 50 | "not-archived": notArchived, 51 | }); err != nil { 52 | return err 53 | } 54 | 55 | p := api.GetClientsParam{ 56 | PaginationParam: api.AllPages(), 57 | } 58 | 59 | var err error 60 | if p.Workspace, err = f.GetWorkspaceID(); err != nil { 61 | return err 62 | } 63 | 64 | c, err := f.Client() 65 | if err != nil { 66 | return err 67 | } 68 | 69 | p.Name, _ = cmd.Flags().GetString("name") 70 | if archived || notArchived { 71 | p.Archived = &archived 72 | } 73 | 74 | clients, err := c.GetClients(p) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | if report != nil { 80 | return report(cmd.OutOrStdout(), &of, clients) 81 | } 82 | 83 | return util.Report(clients, cmd.OutOrStdout(), of) 84 | }, 85 | } 86 | 87 | cmd.Flags().StringP("name", "n", "", 88 | "will be used to filter the tag by name") 89 | cmd.Flags().BoolVarP( 90 | ¬Archived, "not-archived", "", false, "list only active projects") 91 | cmd.Flags().BoolVarP( 92 | &archived, "archived", "", false, "list only archived projects") 93 | 94 | util.AddReportFlags(cmd, &of) 95 | 96 | return cmd 97 | } 98 | -------------------------------------------------------------------------------- /pkg/cmd/task/list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmd/task/util" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 11 | "github.com/lucassabreu/clockify-cli/pkg/search" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // NewCmdList represents the list command 16 | func NewCmdList( 17 | f cmdutil.Factory, 18 | report func(io.Writer, *util.OutputFlags, []dto.Task) error, 19 | ) *cobra.Command { 20 | of := util.OutputFlags{} 21 | cmd := &cobra.Command{ 22 | Use: "list", 23 | Short: "List tasks in a Clockify project", 24 | Example: heredoc.Docf(` 25 | $ %[1]s --project special 26 | +--------------------------+----------+--------+ 27 | | ID | NAME | STATUS | 28 | +--------------------------+----------+--------+ 29 | | 62aa4eed49445270d7b9666c | Inactive | DONE | 30 | | 62aa4ee64ebb4f143c8d5225 | Second | ACTIVE | 31 | | 62aa4ea2c22de9759e6e3a0e | First | ACTIVE | 32 | +--------------------------+----------+--------+ 33 | 34 | $ %[1]s --project special --active --quiet 35 | 62aa4ee64ebb4f143c8d5225 36 | 62aa4ea2c22de9759e6e3a0e 37 | 38 | $ %[1]s --project special --name inact --csv 39 | id,name,status 40 | 62aa4eed49445270d7b9666c,Inactive,DONE 41 | `, "clockify-cli task list"), 42 | Aliases: []string{"ls"}, 43 | RunE: func(cmd *cobra.Command, args []string) error { 44 | workspace, err := f.GetWorkspaceID() 45 | if err != nil { 46 | return err 47 | } 48 | 49 | c, err := f.Client() 50 | if err != nil { 51 | return err 52 | } 53 | 54 | p := api.GetTasksParam{ 55 | Workspace: workspace, 56 | PaginationParam: api.AllPages(), 57 | } 58 | 59 | p.Active, _ = cmd.Flags().GetBool("active") 60 | p.Name, _ = cmd.Flags().GetString("name") 61 | p.ProjectID, _ = cmd.Flags().GetString("project") 62 | 63 | if f.Config().IsAllowNameForID() && 64 | p.ProjectID != "" { 65 | if p.ProjectID, err = search.GetProjectByName( 66 | c, f.Config(), workspace, p.ProjectID, ""); err != nil { 67 | return err 68 | } 69 | } 70 | 71 | tasks, err := c.GetTasks(p) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | if report == nil { 77 | return util.TaskReport(cmd, of, tasks...) 78 | } 79 | 80 | return report(cmd.OutOrStdout(), &of, tasks) 81 | }, 82 | } 83 | 84 | cmd.Flags().StringP("name", "n", "", 85 | "will be used to filter the tag by name") 86 | cmd.Flags().BoolP("active", "a", false, "display only active tasks") 87 | 88 | util.TaskAddReportFlags(cmd, &of) 89 | cmdutil.AddProjectFlags(cmd, f) 90 | 91 | return cmd 92 | } 93 | -------------------------------------------------------------------------------- /pkg/cmd/completion/completion.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/MakeNowJust/heredoc" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | const ( 14 | bash = "bash" 15 | zsh = "zsh" 16 | fish = "fish" 17 | powershell = "powershell" 18 | ) 19 | 20 | // NewCmdCompletion represents the completion command 21 | func NewCmdCompletion() *cobra.Command { 22 | args := cmdcompl.ValidArgsSlide{bash, zsh, fish, powershell} 23 | 24 | cmd := &cobra.Command{ 25 | Use: "completion " + args.IntoUse(), 26 | Short: "Generate completion script", 27 | DisableFlagsInUseLine: true, 28 | ValidArgs: args.OnlyArgs(), 29 | Args: cobra.MatchAll( 30 | cobra.OnlyValidArgs, 31 | cobra.ExactArgs(1), 32 | ), 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | out := cmd.OutOrStdout() 35 | switch strings.ToLower(args[0]) { 36 | case bash: 37 | return cmd.Root().GenBashCompletion(out) 38 | case zsh: 39 | return genZshCompletion(cmd, out) 40 | case fish: 41 | return cmd.Root().GenFishCompletion(out, false) 42 | case powershell: 43 | return cmd.Root().GenPowerShellCompletion(out) 44 | default: 45 | return nil 46 | } 47 | }, 48 | } 49 | 50 | cmd.Long = heredoc.Docf(` 51 | To load completions for every session, execute once: 52 | 53 | #### Linux (Bash): 54 | 55 | %[1]s 56 | $ clockify-cli completion %[2]s > /etc/bash_cmdcompl.d/clockify-cli 57 | %[1]s 58 | 59 | #### Linux (Shell): 60 | 61 | %[1]s 62 | $ clockify-cli completion %[2]s > /etc/bash_cmdcompl.d/clockify-cli 63 | %[1]s 64 | 65 | #### MacOS: 66 | 67 | %[1]s 68 | $ clockify-cli completion %[2]s > /usr/local/etc/bash_cmdcompl.d/clockify-cli 69 | %[1]s 70 | 71 | #### Zsh: 72 | 73 | To load completions for each session, add this line to your ~/.zshrc: 74 | %[1]s 75 | source <(clockify-cli completion %[3]s) 76 | %[1]s 77 | 78 | You will need to start a new shell for this setup to take effect. 79 | 80 | #### Fish: 81 | To load completions for each session, execute once: 82 | %[1]s 83 | $ clockify-cli completion %[4]s > ~/.config/fish/completions/clockify-cli.fish 84 | %[1]s`, "```", bash, zsh, fish) 85 | 86 | return cmd 87 | } 88 | 89 | func genZshCompletion(cmd *cobra.Command, w io.Writer) error { 90 | if _, err := fmt.Fprintln(w, 91 | "autoload -U compinit; compinit"); err != nil { 92 | return err 93 | } 94 | 95 | if err := cmd.Root().GenZshCompletion(w); err != nil { 96 | return err 97 | } 98 | 99 | _, err := fmt.Fprintln(w, "compdef _clockify-cli clockify-cli") 100 | return err 101 | } 102 | -------------------------------------------------------------------------------- /pkg/cmdutil/flags_test.go: -------------------------------------------------------------------------------- 1 | package cmdutil_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 8 | "github.com/spf13/pflag" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | type testcase struct { 13 | name string 14 | param map[string]bool 15 | err error 16 | } 17 | 18 | func testcases() []testcase { 19 | return []testcase{ 20 | { 21 | name: "all false", 22 | param: map[string]bool{ 23 | "pos1": false, 24 | "pos2": false, 25 | "pos3": false, 26 | }, 27 | }, 28 | { 29 | name: "empty", 30 | param: map[string]bool{}, 31 | }, 32 | { 33 | name: "pos1 and pos2 are true", 34 | param: map[string]bool{ 35 | "pos1": true, 36 | "pos2": true, 37 | "pos3": false, 38 | }, 39 | err: errors.New( 40 | "the following flags can't be used together: " + 41 | "`pos1` and `pos2`"), 42 | }, 43 | { 44 | name: "pos1, pos2 and pos3 are true", 45 | param: map[string]bool{ 46 | "pos1": true, 47 | "pos2": true, 48 | "pos4": false, 49 | "pos3": true, 50 | }, 51 | err: errors.New( 52 | "the following flags can't be used together: " + 53 | "`pos1`, `pos2` and `pos3`"), 54 | }, 55 | { 56 | name: "pos1 and pos4 are true", 57 | param: map[string]bool{ 58 | "pos1": true, 59 | "pos2": false, 60 | "pos3": false, 61 | "pos4": true, 62 | }, 63 | err: errors.New( 64 | "the following flags can't be used together: " + 65 | "`pos1` and `pos4`"), 66 | }, 67 | } 68 | } 69 | 70 | func TestXorFlag(t *testing.T) { 71 | for _, tc := range testcases() { 72 | t.Run(tc.name, func(t *testing.T) { 73 | err := cmdutil.XorFlag(tc.param) 74 | if tc.err == nil && assert.NoError(t, err) { 75 | return 76 | } 77 | 78 | assert.Error(t, err) 79 | var fErr *cmdutil.FlagError 80 | assert.ErrorAs(t, err, &fErr) 81 | assert.EqualError(t, tc.err, err.Error()) 82 | }) 83 | } 84 | } 85 | 86 | func TestXorFlagSet(t *testing.T) { 87 | for _, tc := range testcases() { 88 | t.Run(tc.name, func(t *testing.T) { 89 | fs := pflag.NewFlagSet("test", pflag.ContinueOnError) 90 | flags := make([]string, len(tc.param)) 91 | var args []string 92 | 93 | for fl := range tc.param { 94 | flags = append(flags, fl) 95 | fs.Bool(fl, false, "help") 96 | if tc.param[fl] { 97 | args = append(args, "--"+fl) 98 | } 99 | } 100 | _ = fs.Parse(args) 101 | 102 | err := cmdutil.XorFlagSet(fs, flags...) 103 | if tc.err == nil && assert.NoError(t, err) { 104 | return 105 | } 106 | 107 | assert.Error(t, err) 108 | var fErr *cmdutil.FlagError 109 | assert.ErrorAs(t, err, &fErr) 110 | assert.EqualError(t, tc.err, err.Error()) 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /pkg/cmdcomplutil/project.go: -------------------------------------------------------------------------------- 1 | package cmdcomplutil 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 10 | "github.com/lucassabreu/clockify-cli/strhlp" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // NewProjectAutoComplete will provide auto-completion to flags or args 15 | func NewProjectAutoComplete(f factory, config config) cmdcompl.SuggestFn { 16 | return func( 17 | cmd *cobra.Command, args []string, toComplete string, 18 | ) (cmdcompl.ValidArgs, error) { 19 | w, err := f.GetWorkspaceID() 20 | if err != nil { 21 | return cmdcompl.EmptyValidArgs(), err 22 | } 23 | 24 | c, err := f.Client() 25 | if err != nil { 26 | return cmdcompl.EmptyValidArgs(), err 27 | } 28 | 29 | b := false 30 | ps, err := c.GetProjects(api.GetProjectsParam{ 31 | Workspace: w, 32 | Archived: &b, 33 | PaginationParam: api.AllPages(), 34 | }) 35 | if err != nil { 36 | return cmdcompl.EmptyValidArgs(), err 37 | } 38 | 39 | filter := makeFilter(toComplete, config) 40 | 41 | psf := make([]dto.Project, 0) 42 | padding := 0 43 | for i := range ps { 44 | if !filter(ps[i]) { 45 | continue 46 | } 47 | 48 | if padding < len(ps[i].Name) { 49 | padding = len(ps[i].Name) 50 | } 51 | 52 | psf = append(psf, ps[i]) 53 | } 54 | 55 | format := func(p dto.Project) string { return p.Name } 56 | if config.IsSearchProjectWithClientsName() { 57 | f := fmt.Sprintf("%%-%ds", padding) 58 | format = func(p dto.Project) string { 59 | client := "Without Client" 60 | if p.ClientID != "" { 61 | client = p.ClientID + " -- " + p.ClientName 62 | } 63 | return fmt.Sprintf(f, p.Name) + " | " + client 64 | } 65 | } 66 | 67 | va := make(cmdcompl.ValidArgsMap) 68 | for i := range psf { 69 | va.Set(psf[i].ID, format(psf[i])) 70 | } 71 | 72 | return va, nil 73 | } 74 | } 75 | 76 | func makeFilter(toComplete string, config config) func(dto.Project) bool { 77 | if toComplete == "" { 78 | return func(_ dto.Project) bool { return true } 79 | } 80 | 81 | if config.IsAllowNameForID() && 82 | config.IsSearchProjectWithClientsName() { 83 | s := strhlp.IsSimilar(toComplete) 84 | return func(p dto.Project) bool { 85 | return strings.Contains(p.ID, toComplete) || s(p.Name) || 86 | strings.Contains(p.ClientID, toComplete) || s(p.ClientName) 87 | } 88 | } 89 | 90 | if config.IsAllowNameForID() { 91 | s := strhlp.IsSimilar(toComplete) 92 | return func(p dto.Project) bool { 93 | return strings.Contains(p.ID, toComplete) || s(p.Name) 94 | } 95 | } 96 | 97 | return func(p dto.Project) bool { 98 | return strings.Contains(p.ID, toComplete) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /pkg/cmd/tag/tag.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 10 | output "github.com/lucassabreu/clockify-cli/pkg/output/tag" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // NewCmdTag represents the tags command 15 | func NewCmdTag(f cmdutil.Factory) *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "tag", 18 | Aliases: []string{"tags"}, 19 | Short: "List tags on Clockify", 20 | Example: heredoc.Docf(` 21 | $ %[1]s 22 | +--------------------------+------------------+ 23 | | ID | NAME | 24 | +--------------------------+------------------+ 25 | | 62194867edaba27d0a45b464 | Code Review | 26 | | 6219485e8cb9606d934ebb5f | Meeting | 27 | | 621948708cb9606d934ebba7 | Pair Programming | 28 | | 6143b768195e5c503960a775 | Special Tag | 29 | +--------------------------+------------------+ 30 | 31 | $ %[1]s --name code -q 32 | 62194867edaba27d0a45b464 33 | 34 | $ %[1]s --format "{{.Name}}" -archived 35 | Archived Tag 36 | `, "clockify-cli tag"), 37 | RunE: func(cmd *cobra.Command, args []string) error { 38 | format, _ := cmd.Flags().GetString("format") 39 | quiet, _ := cmd.Flags().GetBool("quiet") 40 | if err := cmdutil.XorFlag(map[string]bool{ 41 | "format": format != "", 42 | "quiet": quiet, 43 | }); err != nil { 44 | return err 45 | } 46 | 47 | archived, _ := cmd.Flags().GetBool("archived") 48 | name, _ := cmd.Flags().GetString("name") 49 | 50 | tags, err := getTags(f, name, archived) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | out := cmd.OutOrStdout() 56 | if format != "" { 57 | return output.TagPrintWithTemplate(format)(tags, out) 58 | } 59 | 60 | if quiet { 61 | return output.TagPrintQuietly(tags, out) 62 | } 63 | 64 | return output.TagPrint(tags, os.Stdout) 65 | }, 66 | } 67 | 68 | cmd.Flags().StringP("name", "n", "", 69 | "will be used to filter the tag by name") 70 | cmd.Flags().StringP("format", "f", "", 71 | "golang text/template format to be applied on each Tag") 72 | cmd.Flags().BoolP("quiet", "q", false, "only display ids") 73 | cmd.Flags().BoolP("archived", "", false, "only display archived tags") 74 | 75 | return cmd 76 | } 77 | 78 | func getTags(f cmdutil.Factory, name string, archived bool) ([]dto.Tag, error) { 79 | c, err := f.Client() 80 | if err != nil { 81 | return []dto.Tag{}, err 82 | } 83 | 84 | w, err := f.GetWorkspaceID() 85 | if err != nil { 86 | return []dto.Tag{}, err 87 | } 88 | 89 | return c.GetTags(api.GetTagsParam{ 90 | Workspace: w, 91 | Name: name, 92 | Archived: &archived, 93 | PaginationParam: api.AllPages(), 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/output/util/template.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "strings" 7 | "text/template" 8 | "time" 9 | 10 | "github.com/lucassabreu/clockify-cli/api/dto" 11 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 12 | "github.com/lucassabreu/clockify-cli/strhlp" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | func formatTime(f string) func(time.Time) string { 17 | return func(t time.Time) string { 18 | return t.Format(f) 19 | } 20 | } 21 | 22 | var funcMap = template.FuncMap{ 23 | "formatDateTime": formatTime(timehlp.FullTimeFormat), 24 | "fdt": formatTime(timehlp.FullTimeFormat), 25 | "formatTime": formatTime(timehlp.OnlyTimeFormat), 26 | "formatTimeWS": formatTime(timehlp.SimplerOnlyTimeFormat), 27 | "ft": formatTime(timehlp.OnlyTimeFormat), 28 | "now": func(t *time.Time) time.Time { 29 | if t == nil { 30 | return timehlp.Now().UTC() 31 | } 32 | 33 | return *t 34 | }, 35 | "json": func(j interface{}) string { 36 | w := bytes.NewBufferString("") 37 | if err := json.NewEncoder(w).Encode(j); err != nil { 38 | return "" 39 | } 40 | 41 | return w.String() 42 | }, 43 | "yaml": func(j interface{}) string { 44 | w := bytes.NewBufferString("") 45 | if err := yaml.NewEncoder(w).Encode(j); err != nil { 46 | return "" 47 | } 48 | 49 | return w.String() 50 | }, 51 | "pad": strhlp.PadSpace, 52 | "ident": func(s, prefix string) string { 53 | return prefix + strings.ReplaceAll(s, "\n", "\n"+prefix) 54 | }, 55 | "since": func(s time.Time, e ...time.Time) dto.Duration { 56 | return diff(s, firstOrNow(e)) 57 | }, 58 | "until": func(s time.Time, e ...time.Time) dto.Duration { 59 | return diff(firstOrNow(e), s) 60 | }, 61 | "repeatString": strings.Repeat, 62 | "maxLength": func(s ...string) int { 63 | length := 0 64 | for i := range s { 65 | l := len(s[i]) 66 | if l > length { 67 | length = l 68 | } 69 | } 70 | 71 | return length 72 | }, 73 | "concat": func(ss ...string) string { 74 | b := &strings.Builder{} 75 | for _, s := range ss { 76 | b.WriteString(s) 77 | } 78 | 79 | return b.String() 80 | }, 81 | "dsf": func(ds string) string { 82 | d, err := dto.StringToDuration(ds) 83 | if err != nil { 84 | panic(err) 85 | } 86 | 87 | return dto.Duration{Duration: d}.HumanString() 88 | }, 89 | } 90 | 91 | func firstOrNow(ts []time.Time) time.Time { 92 | if len(ts) == 0 { 93 | return timehlp.Now().UTC() 94 | } 95 | return ts[0] 96 | } 97 | 98 | func diff(s, e time.Time) dto.Duration { 99 | return dto.Duration{Duration: e.Sub(s)} 100 | } 101 | 102 | func NewTemplate(format string) (*template.Template, error) { 103 | format = strings.ReplaceAll(format, "\\n", "\n") 104 | format = strings.ReplaceAll(format, "\\t", "\t") 105 | return template.New("tmpl").Funcs(funcMap).Parse(format + "\n") 106 | } 107 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/util/help.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "github.com/lucassabreu/clockify-cli/pkg/timeentryhlp" 4 | 5 | const ( 6 | HelpTimeEntryNowIfNotSet = "If no start time (`--when`) is set then the " + 7 | "current time will be used.\n" 8 | 9 | HelpInteractiveByDefault = "By default, the CLI will ask the " + 10 | "information interactively; use `--interactive=0` to disable it.\n" + 11 | "\n" + 12 | "If you prefer that it never don't do that by default, " + 13 | "run the bellow command, and use `--interactive` when you want " + 14 | "to be asked:\n" + 15 | "```\n" + 16 | "$ clockify-cli config set interactive false\n" + 17 | "```\n" 18 | 19 | HelpDateTimeFormats = "" + 20 | ` - Full Date and Time: "2016-02-01 15:04:05"` + "\n" + 21 | ` - Date and Time (assumes 0 seconds): "2016-02-01 15:04"` + "\n" + 22 | ` - Yesterday with Time: "yesterday 15:04:05"` + "\n" + 23 | ` - Yesterday with Time (0 seconds): "yesterday 15:04"` + "\n" + 24 | ` - Today at Time: "15:04:05"` + "\n" + 25 | ` - Today at Time (assumes 0 seconds): "15:04"` + "\n" + 26 | ` - 10mins in the future: +10m` + "\n" + 27 | ` - 1min and 30s ago: -90s` + "\n" + 28 | ` - 1hour and 10min ago: -1:10s` + "\n" + 29 | ` - 1day, 10min and 30s ago: -1d10m30s` + "\n" 30 | 31 | HelpTimeInputOnTimeEntry = "When setting a date/time input " + 32 | "(`--when` and `--when-to-close`) you can use any of the following " + 33 | "formats to set then:\n" + 34 | HelpDateTimeFormats 35 | 36 | HelpNamesForIds = "To be able to use names of resources instead of its " + 37 | "IDs you must enable the feature 'allow-name-for-id', to do that " + 38 | "run the command (the commands may take longer to look for the " + 39 | "resource id):\n" + 40 | "```\n" + 41 | "$ clockify-cli config set allow-name-for-id true\n" + 42 | "```\n\n" 43 | 44 | HelpValidateIncomplete = "By default, the CLI (and Clockify API) only " + 45 | "validates if the workspace and project rules are respected when a " + 46 | "time entry is stopped, if you prefer to validate when " + 47 | "starting/inserting it run the following command:\n" + 48 | "```\n" + 49 | "$ clockify-cli config set allow-incomplete false\n" + 50 | "```\n\n" 51 | 52 | HelpMoreInfoAboutStarting = "Use `clockify-cli in --help` for more " + 53 | "information about creating new time entries." 54 | 55 | HelpMoreInfoAboutPrinting = "Use `clockify-cli report --help` for more " + 56 | "information about printing time entries." 57 | 58 | HelpTimeEntriesAliasForEdit = "" + 59 | `If you want to edit the current (running) time entry you can ` + 60 | `use "` + timeentryhlp.AliasCurrent + `" instead of its ID.` + "\n" + 61 | `To edit the last ended time entry you can use "` + 62 | timeentryhlp.AliasLast + `" for it, for the one before that you ` + 63 | `can use "^2", for the previous "^3" and so on.` + "\n" 64 | ) 65 | -------------------------------------------------------------------------------- /pkg/timeentryhlp/timeentry.go: -------------------------------------------------------------------------------- 1 | package timeentryhlp 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/lucassabreu/clockify-cli/api" 9 | "github.com/lucassabreu/clockify-cli/api/dto" 10 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | const ( 15 | AliasCurrent = "current" 16 | AliasLast = "last" 17 | AliasLatest = "latest" 18 | ) 19 | 20 | // GetLatestEntryEntry will return the last time entry of a user, if it exists 21 | func GetLatestEntryEntry( 22 | c api.Client, workspace, userID string) (dto.TimeEntryImpl, error) { 23 | return GetTimeEntry(c, workspace, userID, AliasLatest) 24 | } 25 | 26 | var ErrNoTimeEntry = errors.New("time entry was not found") 27 | 28 | func mayNotFound(tei *dto.TimeEntryImpl, err error) ( 29 | dto.TimeEntryImpl, error) { 30 | if err != nil { 31 | return dto.TimeEntryImpl{}, err 32 | } 33 | 34 | if tei == nil { 35 | return dto.TimeEntryImpl{}, ErrNoTimeEntry 36 | } 37 | 38 | return *tei, nil 39 | } 40 | 41 | // GetTimeEntry will look for the time entry of a user for the id or alias 42 | // provided 43 | func GetTimeEntry( 44 | c api.Client, 45 | workspace, 46 | userID, 47 | id string, 48 | ) (dto.TimeEntryImpl, error) { 49 | id = strings.TrimSpace(strings.ToLower(id)) 50 | 51 | var onlyInProgress *bool 52 | switch id { 53 | case "^0", AliasCurrent: 54 | tei, err := mayNotFound(c.GetTimeEntryInProgress( 55 | api.GetTimeEntryInProgressParam{ 56 | Workspace: workspace, 57 | UserID: userID, 58 | })) 59 | if err == ErrNoTimeEntry { 60 | return tei, errors.Wrap(err, "looking for running time entry") 61 | } 62 | 63 | return tei, err 64 | case "^1", AliasLast: 65 | id = AliasLast 66 | b := false 67 | onlyInProgress = &b 68 | case AliasLatest: 69 | id = AliasLatest 70 | onlyInProgress = nil 71 | } 72 | 73 | if id != AliasLast && id != AliasLatest && !strings.HasPrefix(id, "^") { 74 | return mayNotFound(c.GetTimeEntry(api.GetTimeEntryParam{ 75 | Workspace: workspace, 76 | TimeEntryID: id, 77 | })) 78 | } 79 | 80 | page := 1 81 | if strings.HasPrefix(id, "^") { 82 | var err error 83 | if page, err = strconv.Atoi(id[1:]); err != nil { 84 | return dto.TimeEntryImpl{}, fmt.Errorf( 85 | `n on "^n" must be a unsigned integer, you sent: %s`, 86 | id[1:], 87 | ) 88 | } 89 | } 90 | 91 | now := timehlp.Now() 92 | list, err := c.GetUserTimeEntries(api.GetUserTimeEntriesParam{ 93 | Workspace: workspace, 94 | UserID: userID, 95 | OnlyInProgress: onlyInProgress, 96 | End: &now, 97 | PaginationParam: api.PaginationParam{ 98 | PageSize: 1, 99 | Page: page, 100 | }, 101 | }) 102 | 103 | if err != nil { 104 | return dto.TimeEntryImpl{}, err 105 | } 106 | 107 | if len(list) == 0 { 108 | return dto.TimeEntryImpl{}, ErrNoTimeEntry 109 | } 110 | 111 | return list[0], err 112 | } 113 | -------------------------------------------------------------------------------- /docs/project-layout.md: -------------------------------------------------------------------------------- 1 | # Clockify CLI Project Layout 2 | 3 | The project is organized in the following folders and important files: 4 | 5 | - [`cmd/`](../cmd) - `main` packages to build executable binaries. 6 | - [`docs/`](.) - documentation of the project for maintainers and contributors. 7 | - [`scripts/`](../scripts) - build and release scripts. 8 | - [`api/`](../api) - golang implementation of the Clockify API. 9 | - [`pkg/`](../pkg) - other packages that support the `api` or commands. 10 | - [`internal/`](../internal) - Go packages that are highly specific to this project 11 | - [`go.mod`](../go.mod) - external Go dependencies for this project. 12 | - [`Makefile`](../Makefile) - most of setup and maintenance actions for this project. 13 | 14 | ## Command line organization 15 | 16 | All CLI commands will be under [`pkg/cmd/`](../pkg/cmd) and the file naming convention is this: 17 | 18 | ``` 19 | pkg/cmd///.go 20 | ``` 21 | 22 | Following the same structure as the command path, so `clockify-cli client add` is at 23 | `pkg/cmd/client/add.go`, all command packages will have a function named `NewCmd` that 24 | will receive a `cmdutil.Factory` type and return a `*cobra.Command`. 25 | 26 | Specific logic for that command must be kept at the same package as it, and all subcommands must be 27 | registered on its parent package. So all subcommands of `client` will registered on the function 28 | `client.NewCmdClient()`. 29 | 30 | Output formatters must stay under the package [`pkg/output/`](../pkg/output) using the following 31 | file convention: 32 | 33 | ``` 34 | pkg/output//.go 35 | ``` 36 | 37 | Shared functionality for printing entities must be at the package 38 | [`pkg/outpututil/`](../pkg/outpututil). 39 | 40 | ### Steps do create a new command 41 | 42 | Say you will create a new command `delete` under `client`. 43 | 44 | 1. Create the package `pkg/cmd/client/delete/` 45 | 2. Create a function called `NewCmdDelete` on a file `delete.go` 46 | 1. This function must receive a [`cmdutil.Factory`][] struct and 47 | return a [`*cobra.Command`][] fully set. 48 | 3. Edit the entity root command at `pkg/cmd/client/client.go` to register it as a subcommand using 49 | the factory function previously created. If the entity root does not exist yet, then: 50 | 1. Create the file, and in it a function `NewCmdClient` that should receive `cmdutil.Factory` 51 | and return a [`*cobra.Command`][] with all its subcommands. 52 | 4. If is the first command of a entity: 53 | 1. Create a package called `pkg/output/client` 54 | 2. Implement output the five basic output formats `table` (default), `json`, `quiet` (only the 55 | ID), `template` ([Go template](https://pkg.go.dev/text/template)) and `csv`. Each one on a 56 | file by itself. 57 | 58 | ## Credits 59 | 60 | This document is based on the [project-layout.md from github/cli/cli][credit]. 61 | 62 | [credit]: https://github.com/cli/cli/blob/trunk/docs/project-layout.md 63 | [`*cobra.Command`]: https://pkg.go.dev/github.com/spf13/cobra#Command 64 | [`cmdutil.Factory`]: ../pkg/cmdutil/factory.go 65 | -------------------------------------------------------------------------------- /pkg/cmd/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmd/config/get" 6 | initialize "github.com/lucassabreu/clockify-cli/pkg/cmd/config/init" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmd/config/list" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmd/config/set" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 11 | 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var validParameters = cmdcompl.ValidArgsMap{ 16 | cmdutil.CONF_TOKEN: "clockify's token", 17 | cmdutil.CONF_WORKSPACE: "workspace to be used", 18 | cmdutil.CONF_USER_ID: "user id from the token", 19 | cmdutil.CONF_ALLOW_NAME_FOR_ID: "allow to input the name of the entity " + 20 | "instead of its ID (projects, clients, tasks, users and tags)", 21 | cmdutil.CONF_INTERACTIVE: "show interactive mode", 22 | cmdutil.CONF_WORKWEEK_DAYS: "days of the week were your expected to " + 23 | "work (use comma to set multiple)", 24 | cmdutil.CONF_ALLOW_INCOMPLETE: "should allow starting time entries with " + 25 | "missing required values", 26 | cmdutil.CONF_SHOW_TASKS: "should show an extra column with the task " + 27 | "description", 28 | cmdutil.CONF_SHOW_CLIENT: "should show an extra column with the client " + 29 | "description", 30 | cmdutil.CONF_DESCR_AUTOCOMP: "autocomplete description looking at " + 31 | "recent time entries", 32 | cmdutil.CONF_DESCR_AUTOCOMP_DAYS: "how many days should be considered " + 33 | "for the description autocomplete", 34 | cmdutil.CONF_SHOW_TOTAL_DURATION: "adds a totals line on time entry " + 35 | "reports with the sum of the time entries duration", 36 | cmdutil.CONF_LOG_LEVEL: "how much logs should be shown values: " + 37 | "none , error , info and debug", 38 | cmdutil.CONF_ALLOW_ARCHIVED_TAGS: "should allow and suggest archived tags", 39 | cmdutil.CONF_LANGUAGE: "which language to use for number " + 40 | "formatting", 41 | cmdutil.CONF_TIMEZONE: "which timezone to use to input/output time", 42 | } 43 | 44 | // NewCmdConfig represents the config command 45 | func NewCmdConfig(f cmdutil.Factory) *cobra.Command { 46 | cmd := &cobra.Command{ 47 | Use: "config", 48 | Short: "Manages CLI configuration", 49 | Args: cobra.MaximumNArgs(0), 50 | Example: heredoc.Doc(` 51 | # cli will guide you to configure the CLI 52 | $ clockify-cli config init 53 | 54 | # token is the minimum information required for the CLI to work 55 | $ clockify-cli set token 56 | 57 | # you can see your current parameters using: 58 | $ clockify-cli get 59 | 60 | # if you wanna see the value of token parameter: 61 | $ clockify-cli get token 62 | `), 63 | Long: heredoc.Doc(` 64 | Changes or shows configuration settings for clockify-cli 65 | 66 | These are the parameters manageable: 67 | `) + validParameters.Long(), 68 | } 69 | 70 | cmd.AddCommand(initialize.NewCmdInit(f)) 71 | cmd.AddCommand(set.NewCmdSet(f, validParameters)) 72 | cmd.AddCommand(get.NewCmdGet(f, validParameters)) 73 | cmd.AddCommand(list.NewCmdList(f)) 74 | 75 | return cmd 76 | } 77 | -------------------------------------------------------------------------------- /pkg/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/lucassabreu/clockify-cli/pkg/cmd/client" 5 | "github.com/lucassabreu/clockify-cli/pkg/cmd/completion" 6 | "github.com/lucassabreu/clockify-cli/pkg/cmd/config" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmd/project" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmd/tag" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmd/task" 10 | timeentry "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry" 11 | "github.com/lucassabreu/clockify-cli/pkg/cmd/user" 12 | "github.com/lucassabreu/clockify-cli/pkg/cmd/user/me" 13 | "github.com/lucassabreu/clockify-cli/pkg/cmd/version" 14 | "github.com/lucassabreu/clockify-cli/pkg/cmd/workspace" 15 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 16 | "github.com/lucassabreu/clockify-cli/pkg/cmdcomplutil" 17 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | // NewCmdRoot creates the base command when called without any subcommands 22 | func NewCmdRoot(f cmdutil.Factory) *cobra.Command { 23 | cmd := &cobra.Command{ 24 | Use: "clockify-cli", 25 | Short: "Allow to integrate with Clockify through terminal", 26 | SilenceErrors: true, 27 | SilenceUsage: true, 28 | } 29 | 30 | cmd.PersistentFlags().StringP("token", "t", "", 31 | "clockify's token\nCan be generated here: "+ 32 | "https://clockify.me/user/settings#generateApiKeyBtn") 33 | 34 | cmd.PersistentFlags().StringP("workspace", "w", "", "workspace to be used") 35 | _ = cmdcompl.AddSuggestionsToFlag(cmd, "workspace", 36 | cmdcomplutil.NewWorspaceAutoComplete(f)) 37 | 38 | cmd.PersistentFlags().StringP("user-id", "u", "", "user id from the token") 39 | _ = cmdcompl.AddSuggestionsToFlag(cmd, "user-id", 40 | cmdcomplutil.NewUserAutoComplete(f)) 41 | 42 | cmd.PersistentFlags().BoolP("interactive", "i", false, 43 | "will prompt you to confirm/complement commands input before "+ 44 | "executing the action ") 45 | 46 | cmd.PersistentFlags().IntP("interactive-page-size", "L", 7, 47 | "will set how many items will be shown on interactive mode") 48 | 49 | cmd.PersistentFlags().BoolP("allow-name-for-id", "", false, 50 | "allow use of project/client/tag's name when id is asked") 51 | 52 | cmd.PersistentFlags().String( 53 | "log-level", cmdutil.LOG_LEVEL_NONE, "set log level") 54 | _ = cmdcompl.AddFixedSuggestionsToFlag(cmd, "log-level", 55 | cmdcompl.ValidArgsSlide{ 56 | cmdutil.LOG_LEVEL_NONE, 57 | cmdutil.LOG_LEVEL_DEBUG, 58 | cmdutil.LOG_LEVEL_INFO, 59 | }) 60 | 61 | _ = cmd.MarkFlagRequired("token") 62 | 63 | cmd.AddCommand(version.NewCmdVersion(f)) 64 | 65 | cmd.AddCommand(config.NewCmdConfig(f)) 66 | 67 | cmd.AddCommand(workspace.NewCmdWorkspace(f)) 68 | 69 | cmd.AddCommand(user.NewCmdUser(f, nil)) 70 | cmd.AddCommand(me.NewCmdMe(f, nil)) 71 | 72 | cmd.AddCommand(client.NewCmdClient(f)) 73 | cmd.AddCommand(project.NewCmdProject(f)) 74 | cmd.AddCommand(task.NewCmdTask(f)) 75 | 76 | cmd.AddCommand(tag.NewCmdTag(f)) 77 | 78 | cmd.AddCommand(timeentry.NewCmdTimeEntry(f)...) 79 | 80 | cmd.AddCommand(completion.NewCmdCompletion()) 81 | 82 | return cmd 83 | } 84 | -------------------------------------------------------------------------------- /pkg/cmd/task/quick-add/quick-add.go: -------------------------------------------------------------------------------- 1 | package quickadd 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmd/task/util" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 11 | "github.com/lucassabreu/clockify-cli/pkg/search" 12 | "github.com/lucassabreu/clockify-cli/strhlp" 13 | "github.com/spf13/cobra" 14 | "golang.org/x/sync/errgroup" 15 | ) 16 | 17 | // NewCmdQuickAdd will add multiple tasks to a project, but only setting its 18 | // name 19 | func NewCmdQuickAdd( 20 | f cmdutil.Factory, 21 | report func(io.Writer, *util.OutputFlags, []dto.Task) error, 22 | ) *cobra.Command { 23 | of := util.OutputFlags{} 24 | cmd := &cobra.Command{ 25 | Use: "quick-add ...", 26 | Aliases: []string{"quick"}, 27 | Short: "Adds tasks to a project on Clockify", 28 | Args: cmdutil.RequiredNamedArgs("name"), 29 | Long: "Adds a new active tasks to a project on Clockify, " + 30 | "but only allow setting their names.", 31 | Example: heredoc.Docf(` 32 | $ %[1]s -p special "Very Important" 33 | +--------------------------+----------------+--------+ 34 | | ID | NAME | STATUS | 35 | +--------------------------+----------------+--------+ 36 | | 62aa5d7049445270d7b979d6 | Very Important | ACTIVE | 37 | +--------------------------+----------------+--------+ 38 | 39 | $ %[1]s -p special "Very Cool" -q 40 | dddddddddddddddddddddddd 41 | 42 | $ %[1]s -p special Billable "Not Billable" --csv 43 | id,name,status 44 | 62ab145ec22de9759e6f6e35,Billable,ACTIVE 45 | 62ab145ec22de9759e6f6e36,Not Billable,ACTIVE 46 | `, "clockify-cli task quick-add"), 47 | RunE: func(cmd *cobra.Command, args []string) error { 48 | if err := of.Check(); err != nil { 49 | return err 50 | } 51 | 52 | c, err := f.Client() 53 | if err != nil { 54 | return err 55 | } 56 | 57 | w, err := f.GetWorkspaceID() 58 | if err != nil { 59 | return err 60 | } 61 | 62 | p, _ := cmd.Flags().GetString("project") 63 | if f.Config().IsAllowNameForID() { 64 | if p, err = search.GetProjectByName( 65 | c, f.Config(), w, p, ""); err != nil { 66 | return err 67 | } 68 | } 69 | 70 | names := strhlp.Unique(args) 71 | tasks := make([]dto.Task, len(names)) 72 | g := errgroup.Group{} 73 | for j := range names { 74 | i := j 75 | g.Go(func() error { 76 | tasks[i], err = c.AddTask(api.AddTaskParam{ 77 | Workspace: w, 78 | ProjectID: p, 79 | Name: names[i], 80 | }) 81 | return err 82 | }) 83 | } 84 | 85 | if err := g.Wait(); err != nil { 86 | return err 87 | } 88 | 89 | if report != nil { 90 | return report(cmd.OutOrStdout(), &of, tasks) 91 | } 92 | 93 | return util.TaskReport(cmd, of, tasks...) 94 | }, 95 | } 96 | 97 | cmdutil.AddProjectFlags(cmd, f) 98 | util.TaskAddReportFlags(cmd, &of) 99 | return cmd 100 | } 101 | -------------------------------------------------------------------------------- /pkg/cmd/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmd/user/me" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmd/user/util" 11 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // NewCmdUser represents the users command 16 | func NewCmdUser( 17 | f cmdutil.Factory, 18 | report func(io.Writer, *util.OutputFlags, []dto.User) error, 19 | ) *cobra.Command { 20 | of := util.OutputFlags{} 21 | cmd := &cobra.Command{ 22 | Use: "user", 23 | Aliases: []string{"users"}, 24 | Short: "List users of a workspace", 25 | Example: heredoc.Docf(` 26 | $ %[1]s 27 | +--------------------------+-------------+--------------+--------+-------------------+ 28 | | ID | NAME | EMAIL | STATUS | TIMEZONE | 29 | +--------------------------+-------------+--------------+--------+-------------------+ 30 | | eeeeeeeeeeeeeeeeeeeeeeee | John Due | john@due.net | ACTIVE | America/Sao_Paulo | 31 | | ffffffffffffffffffffffff | John JD Due | due@john.net | ACTIVE | America/Sao_Paulo | 32 | +--------------------------+-------------+--------------+--------+-------------------+ 33 | 34 | $ %[1]s --quiet 35 | eeeeeeeeeeeeeeeeeeeeeeee 36 | ffffffffffffffffffffffff 37 | 38 | $ %[1]s --email due@john.net 39 | +--------------------------+-------------+--------------+--------+-------------------+ 40 | | ID | NAME | EMAIL | STATUS | TIMEZONE | 41 | +--------------------------+-------------+--------------+--------+-------------------+ 42 | | ffffffffffffffffffffffff | John JD Due | due@john.net | ACTIVE | America/Sao_Paulo | 43 | +--------------------------+-------------+--------------+--------+-------------------+ 44 | 45 | $ %[1]s me --format "{{ .Name }} ({{ .Email }})" --email due@john.net 46 | John JD Due (due@john.net) 47 | `, "clockify-cli user"), 48 | RunE: func(cmd *cobra.Command, args []string) error { 49 | email, _ := cmd.Flags().GetString("email") 50 | if err := of.Check(); err != nil { 51 | return err 52 | } 53 | 54 | c, err := f.Client() 55 | if err != nil { 56 | return err 57 | } 58 | 59 | w, err := f.GetWorkspaceID() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | users, err := c.WorkspaceUsers(api.WorkspaceUsersParam{ 65 | Workspace: w, 66 | Email: email, 67 | PaginationParam: api.AllPages(), 68 | }) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | if report != nil { 74 | return report(cmd.OutOrStderr(), &of, users) 75 | } 76 | 77 | return util.Report(users, cmd.OutOrStderr(), of) 78 | }, 79 | } 80 | 81 | cmd.Flags().StringP("email", "e", "", 82 | "will be used to filter the workspaces by email") 83 | 84 | util.AddReportFlags(cmd, &of) 85 | 86 | _ = cmd.MarkFlagRequired("workspace") 87 | 88 | cmd.AddCommand(me.NewCmdMe(f, nil)) 89 | 90 | return cmd 91 | } 92 | -------------------------------------------------------------------------------- /pkg/cmd/task/add/add.go: -------------------------------------------------------------------------------- 1 | package add 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/api/dto" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmd/task/util" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // NewCmdAdd represents the add command 15 | func NewCmdAdd( 16 | f cmdutil.Factory, 17 | report func(io.Writer, *util.OutputFlags, dto.Task) error, 18 | ) *cobra.Command { 19 | of := util.OutputFlags{} 20 | cmd := &cobra.Command{ 21 | Use: "add", 22 | Short: "Adds a new task to a project on Clockify", 23 | Long: heredoc.Doc(` 24 | Adds a new active task to a project on Clockify, also allows to assign users to it at the same time 25 | 26 | Tasks will be created as billable or not depending on the project settings. 27 | If you set a estimate for the task, but the project is set as manual estimation, then it will have no effect on Clockify. 28 | `), 29 | Example: heredoc.Docf(` 30 | $ %[1]s -p special --name="Very Important" 31 | +--------------------------+----------------+--------+ 32 | | ID | NAME | STATUS | 33 | +--------------------------+----------------+--------+ 34 | | 62aa5d7049445270d7b979d6 | Very Important | ACTIVE | 35 | +--------------------------+----------------+--------+ 36 | 37 | $ %[1]s -p special --name="Very Cool" --assign john@example.com | \ 38 | jq '.[] |.assigneeIds' --compact-output 39 | ["dddddddddddddddddddddddd"] 40 | 41 | $ %[1]s -p special --name Billable --billable --quiet 42 | 62ab129e4ebb4f143c8e8622 43 | 44 | $ %[1]s -p special --name "Not Billable" --not-billable --csv 45 | id,name,status 46 | 62ab145ec22de9759e6f6e36,Not Billable,ACTIVE 47 | 48 | $ %[1]s -p special --name 'With 1H to Make' --estimate 1 49 | +--------------------------+-----------------+--------+ 50 | | ID | NAME | STATUS | 51 | +--------------------------+-----------------+--------+ 52 | | 62aa5d7049445270d7b979d6 | With 1H to Make | ACTIVE | 53 | +--------------------------+-----------------+--------+ 54 | `, "clockify-cli task add"), 55 | RunE: func(cmd *cobra.Command, args []string) error { 56 | if err := of.Check(); err != nil { 57 | return err 58 | } 59 | 60 | fl, err := util.TaskReadFlags(cmd, f) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | c, err := f.Client() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | task, err := c.AddTask(api.AddTaskParam{ 71 | Workspace: fl.Workspace, 72 | ProjectID: fl.ProjectID, 73 | Name: fl.Name, 74 | Estimate: fl.Estimate, 75 | AssigneeIDs: fl.AssigneeIDs, 76 | Billable: fl.Billable, 77 | }) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | if report != nil { 83 | return report(cmd.OutOrStdout(), &of, task) 84 | } 85 | 86 | return util.TaskReport(cmd, of, task) 87 | }, 88 | } 89 | 90 | util.TaskAddReportFlags(cmd, &of) 91 | 92 | util.TaskAddPropFlags(cmd, f) 93 | _ = cmd.MarkFlagRequired("name") 94 | 95 | return cmd 96 | } 97 | -------------------------------------------------------------------------------- /internal/consoletest/test.go: -------------------------------------------------------------------------------- 1 | package consoletest 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | "time" 8 | 9 | "github.com/Netflix/go-expect" 10 | "github.com/hinshun/vt10x" 11 | 12 | pseudotty "github.com/creack/pty" 13 | ) 14 | 15 | // ExpectConsole is a helper to interact if the pseudo terminal on tests 16 | type ExpectConsole interface { 17 | ExpectEOF() 18 | ExpectString(string) 19 | Send(string) 20 | SendLine(string) 21 | } 22 | 23 | type console struct { 24 | t *testing.T 25 | c *expect.Console 26 | } 27 | 28 | func (c *console) ExpectEOF() { 29 | if _, err := c.c.ExpectEOF(); err != nil { 30 | c.t.Errorf("failed to ExpectEOF %v", err) 31 | } 32 | } 33 | 34 | func (c *console) ExpectString(s string) { 35 | if _, err := c.c.ExpectString(s); err != nil { 36 | c.t.Errorf("failed to ExpectString (%s) %v", s, err) 37 | } 38 | } 39 | 40 | func (c *console) Send(s string) { 41 | if _, err := c.c.Send(s); err != nil { 42 | c.t.Errorf("failed to Send %v", err) 43 | } 44 | } 45 | 46 | func (c *console) SendLine(s string) { 47 | if _, err := c.c.SendLine(s); err != nil { 48 | c.t.Errorf("failed to SendLine %v", err) 49 | } 50 | } 51 | 52 | // FileWriter is a simplification of the io.Stdout struct 53 | type FileWriter interface { 54 | io.Writer 55 | Fd() uintptr 56 | } 57 | 58 | // FileReader is a simplification of the io.Stdin struct 59 | type FileReader interface { 60 | io.Reader 61 | Fd() uintptr 62 | } 63 | 64 | // RunTestConsole simulates a terminal for interactive tests 65 | // This is mostly a adaptation of the RunTest function at 66 | // [survey_test.go](https://github.com/AlecAivazis/survey/blob/e47352f914346a910cc7e1ca9f65a7ac0674449a/survey_posix_test.go#L15), 67 | // but with interfaces exported to easy re-use on other packages. 68 | func RunTestConsole( 69 | t *testing.T, 70 | setup func(out FileWriter, in FileReader) error, 71 | procedure func(c ExpectConsole), 72 | ) { 73 | t.Parallel() 74 | 75 | pty, tty, err := pseudotty.Open() 76 | if err != nil { 77 | t.Fatalf("failed to open pseudotty: %v", err) 78 | } 79 | 80 | b := bytes.NewBufferString("") 81 | term := vt10x.New(vt10x.WithWriter(tty)) 82 | c, err := expect.NewConsole( 83 | expect.WithStdin(pty), 84 | expect.WithStdout(term), 85 | expect.WithStdout(b), 86 | expect.WithCloser(pty, tty), 87 | ) 88 | 89 | if err != nil { 90 | t.Fatalf("failed to create console: %v", err) 91 | } 92 | defer c.Close() 93 | 94 | finished := false 95 | t.Cleanup(func() { 96 | if finished { 97 | return 98 | } 99 | t.Error( 100 | "console test failed\n" + 101 | "current output:\n" + 102 | b.String() + "\n") 103 | }) 104 | 105 | donec := make(chan struct{}) 106 | go func() { 107 | defer close(donec) 108 | procedure(&console{c: c, t: t}) 109 | }() 110 | 111 | go func() { 112 | defer c.Tty().Close() 113 | if err = setup(c.Tty(), c.Tty()); err != nil { 114 | t.Error(err) 115 | return 116 | } 117 | }() 118 | 119 | select { 120 | case <-time.After(time.Second * 10): 121 | t.Error("console test timeout exceeded") 122 | case <-donec: 123 | finished = true 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /pkg/cmd/project/get/get.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "strings" 7 | 8 | "github.com/MakeNowJust/heredoc" 9 | "github.com/lucassabreu/clockify-cli/api" 10 | "github.com/lucassabreu/clockify-cli/api/dto" 11 | "github.com/lucassabreu/clockify-cli/pkg/cmd/project/util" 12 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 13 | "github.com/lucassabreu/clockify-cli/pkg/cmdcomplutil" 14 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 15 | "github.com/lucassabreu/clockify-cli/pkg/search" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | // NewCmdGet looks for a project with the informed ID 20 | func NewCmdGet( 21 | f cmdutil.Factory, 22 | report func(io.Writer, *util.OutputFlags, dto.Project) error, 23 | ) *cobra.Command { 24 | of := util.OutputFlags{} 25 | p := api.GetProjectParam{} 26 | cmd := &cobra.Command{ 27 | Use: "get", 28 | Args: cmdutil.RequiredNamedArgs("project"), 29 | ValidArgsFunction: cmdcompl.CombineSuggestionsToArgs( 30 | cmdcomplutil.NewProjectAutoComplete(f, f.Config())), 31 | Short: "Get a project on a Clockify workspace", 32 | Example: heredoc.Docf(` 33 | $ %[1]s 621948458cb9606d934ebb1c 34 | +--------------------------+-------------------+-----------------------------------------+ 35 | | ID | NAME | CLIENT | 36 | +--------------------------+-------------------+-----------------------------------------+ 37 | | 621948458cb9606d934ebb1c | Clockify Cli | Special (6202634a28782767054eec26) | 38 | +--------------------------+-------------------+-----------------------------------------+ 39 | 40 | $ %[1]s cli -q 41 | 621948458cb9606d934ebb1c 42 | 43 | $ %[1]s other --format '{{.Name}} - {{ .Color }} | {{ .ClientID }}' 44 | Other - #03A9F4 | 6202634a28782767054eec26 45 | `, "clockify-cli project get"), 46 | RunE: func(cmd *cobra.Command, args []string) (err error) { 47 | if p.ProjectID = strings.TrimSpace(args[0]); p.ProjectID == "" { 48 | return errors.New("project id should not be empty") 49 | } 50 | 51 | if err := of.Check(); err != nil { 52 | return err 53 | } 54 | 55 | if p.Workspace, err = f.GetWorkspaceID(); err != nil { 56 | return err 57 | } 58 | 59 | c, err := f.Client() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | if f.Config().IsAllowNameForID() { 65 | if p.ProjectID, err = search.GetProjectByName( 66 | c, f.Config(), p.Workspace, p.ProjectID, ""); err != nil { 67 | return err 68 | } 69 | } 70 | 71 | project, err := c.GetProject(p) 72 | if err != nil { 73 | return err 74 | } 75 | if project == nil { 76 | return api.EntityNotFound{ 77 | EntityName: "project", 78 | ID: args[0], 79 | } 80 | } 81 | 82 | if report != nil { 83 | return report(cmd.OutOrStdout(), &of, *project) 84 | } 85 | 86 | return util.ReportOne(*project, cmd.OutOrStdout(), of) 87 | }, 88 | } 89 | 90 | cmd.Flags().BoolVarP( 91 | &p.Hydrate, "hydrated", "H", false, 92 | "projects will have custom fields, tasks and memberships "+ 93 | "filled for json and format outputs") 94 | 95 | util.AddReportFlags(cmd, &of) 96 | 97 | return cmd 98 | } 99 | -------------------------------------------------------------------------------- /api/tag_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lucassabreu/clockify-cli/api" 7 | "github.com/lucassabreu/clockify-cli/api/dto" 8 | ) 9 | 10 | func TestGetTags(t *testing.T) { 11 | errPrefix := `get tags.*: ` 12 | uri := "/v1/workspaces/" + exampleID + 13 | "/tags" 14 | var l []dto.Tag 15 | 16 | tts := []testCase{ 17 | &simpleTestCase{ 18 | name: "requires workspace", 19 | param: api.GetTagsParam{}, 20 | err: errPrefix + "workspace is required", 21 | }, 22 | &simpleTestCase{ 23 | name: "valid workspace", 24 | param: api.GetTagsParam{Workspace: "w"}, 25 | err: errPrefix + "workspace .* is not valid ID", 26 | }, 27 | (&multiRequestTestCase{ 28 | name: "get all pages, but find none", 29 | param: api.GetTagsParam{ 30 | Workspace: exampleID, 31 | PaginationParam: api.AllPages(), 32 | }, 33 | 34 | result: l, 35 | }). 36 | addHttpCall(&httpRequest{ 37 | method: "get", 38 | url: uri + "?page=1&page-size=50", 39 | status: 200, 40 | response: "[]", 41 | }), 42 | (&multiRequestTestCase{ 43 | name: "get all pages, find five", 44 | param: api.GetTagsParam{ 45 | Workspace: exampleID, 46 | PaginationParam: api.PaginationParam{ 47 | PageSize: 2, 48 | AllPages: true, 49 | }, 50 | }, 51 | 52 | result: []dto.Tag{ 53 | {ID: "p1"}, 54 | {ID: "p2"}, 55 | {ID: "p3"}, 56 | {ID: "p4"}, 57 | {ID: "p5"}, 58 | }, 59 | }). 60 | addHttpCall(&httpRequest{ 61 | method: "get", 62 | url: uri + "?page=1&page-size=2", 63 | status: 200, 64 | response: `[{"id":"p1"},{"id":"p2"}]`, 65 | }). 66 | addHttpCall(&httpRequest{ 67 | method: "get", 68 | url: uri + "?page=2&page-size=2", 69 | status: 200, 70 | response: `[{"id":"p3"},{"id":"p4"}]`, 71 | }). 72 | addHttpCall(&httpRequest{ 73 | method: "get", 74 | url: uri + "?page=3&page-size=2", 75 | status: 200, 76 | response: `[{"id":"p5"}]`, 77 | }), 78 | &simpleTestCase{ 79 | name: "all parameters", 80 | param: api.GetTagsParam{ 81 | Workspace: exampleID, 82 | Name: "tag", 83 | PaginationParam: api.AllPages(), 84 | }, 85 | 86 | result: []dto.Tag{{ID: "p1", Name: "tag 1"}}, 87 | 88 | requestMethod: "get", 89 | requestUrl: uri + 90 | "?name=tag&page=1&page-size=50", 91 | 92 | responseStatus: 200, 93 | responseBody: `[{"id":"p1", "name": "tag 1"}]`, 94 | }, 95 | &simpleTestCase{ 96 | name: "error response", 97 | param: api.GetTagsParam{ 98 | Workspace: exampleID, 99 | PaginationParam: api.PaginationParam{Page: 2}, 100 | }, 101 | 102 | requestMethod: "get", 103 | requestUrl: uri + "?page=2&page-size=50", 104 | 105 | responseStatus: 400, 106 | responseBody: `{"code": 10, "message":"error"}`, 107 | 108 | err: errPrefix + `error \(code: 10\)`, 109 | }, 110 | } 111 | 112 | for _, tt := range tts { 113 | runClient(t, tt, 114 | func(c api.Client, p interface{}) (interface{}, error) { 115 | return c.GetTags( 116 | p.(api.GetTagsParam)) 117 | }) 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/delete/delete.go: -------------------------------------------------------------------------------- 1 | package del 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 10 | "github.com/lucassabreu/clockify-cli/pkg/timeentryhlp" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // NewCmdDelete represents the delete command 15 | func NewCmdDelete(f cmdutil.Factory) *cobra.Command { 16 | va := cmdcompl.ValidArgsSlide{timeentryhlp.AliasCurrent, timeentryhlp.AliasLast} 17 | cmd := &cobra.Command{ 18 | Use: "delete { | " + 19 | va.IntoUseOptions() + " }...", 20 | Aliases: []string{"del", "rm", "remove"}, 21 | Args: cmdutil.RequiredNamedArgs("time entry id"), 22 | ValidArgs: va.IntoValidArgs(), 23 | Short: `Delete time entry(ies), use id "` + 24 | timeentryhlp.AliasCurrent + `" to apply to time entry in progress`, 25 | Long: heredoc.Docf(` 26 | Delete time entries 27 | 28 | If you want to delete the current (running) time entry you can use "%s" instead of its ID. 29 | 30 | **Important**: this action can't be reverted, once the time entry is deleted its ID is lost. 31 | `, 32 | timeentryhlp.AliasCurrent, 33 | ), 34 | Example: heredoc.Docf(` 35 | # trying to delete a time entry that does not exist, or from other workspace 36 | $ %[1]s 62af70d849445270d7c09fbc 37 | delete time entry "62af70d849445270d7c09fbc": TIMEENTRY with id 62af70d849445270d7c09fbc doesn't belong to WORKSPACE with id cccccccccccccccccccccccc (code: 501) 38 | 39 | # deleting the running time entry 40 | $ %[1]s current 41 | # no output 42 | 43 | # deleting the last time entry 44 | $ %[1]s last 45 | # no output 46 | 47 | # deleting multiple time entries 48 | $ %[1]s 62b5b51085815e619d7ae18d 62b5d55185815e619d7af928 49 | # no output 50 | `, "clockify-cli delete"), 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | var err error 53 | var w, u string 54 | 55 | if w, err = f.GetWorkspaceID(); err != nil { 56 | return err 57 | } 58 | 59 | if u, err = f.GetUserID(); err != nil { 60 | return err 61 | } 62 | 63 | c, err := f.Client() 64 | if err != nil { 65 | return err 66 | } 67 | 68 | for i := range args { 69 | p := api.DeleteTimeEntryParam{ 70 | Workspace: w, 71 | TimeEntryID: args[i], 72 | } 73 | 74 | if p.TimeEntryID == timeentryhlp.AliasCurrent { 75 | te, err := c.GetTimeEntryInProgress( 76 | api.GetTimeEntryInProgressParam{ 77 | Workspace: p.Workspace, 78 | UserID: u, 79 | }) 80 | 81 | if err != nil { 82 | return err 83 | } 84 | 85 | if te == nil { 86 | return errors.New("there is no time entry in progress") 87 | } 88 | 89 | p.TimeEntryID = te.ID 90 | } 91 | 92 | if p.TimeEntryID == timeentryhlp.AliasLast { 93 | te, err := timeentryhlp.GetLatestEntryEntry(c, p.Workspace, u) 94 | 95 | if err != nil { 96 | return err 97 | } 98 | 99 | p.TimeEntryID = te.ID 100 | } 101 | 102 | if err := c.DeleteTimeEntry(p); err != nil { 103 | return err 104 | } 105 | } 106 | 107 | return nil 108 | }, 109 | } 110 | 111 | return cmd 112 | } 113 | -------------------------------------------------------------------------------- /pkg/cmd/task/util/read-flags.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 7 | "github.com/lucassabreu/clockify-cli/pkg/cmdcomplutil" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 9 | "github.com/lucassabreu/clockify-cli/pkg/search" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // TaskAddPropFlags add common flags expected on editing a task 14 | func TaskAddPropFlags(cmd *cobra.Command, f cmdutil.Factory) { 15 | cmd.Flags().StringP("name", "n", "", "new name of the task") 16 | cmd.Flags().Int32P("estimate", "E", 0, "estimation on hours") 17 | cmd.Flags().Bool("billable", false, "sets the task as billable") 18 | cmd.Flags().Bool("not-billable", false, "sets the task as not billable") 19 | 20 | cmd.Flags().StringSliceP("assignee", "A", []string{}, 21 | "list of users that are assigned to this task") 22 | _ = cmdcompl.AddSuggestionsToFlag(cmd, "assignee", 23 | cmdcomplutil.NewUserAutoComplete(f)) 24 | 25 | cmd.Flags().Bool("no-assignee", false, 26 | "cleans the assignee list") 27 | 28 | cmdutil.AddProjectFlags(cmd, f) 29 | } 30 | 31 | // FlagsDTO holds data about editing or creating a Task 32 | type FlagsDTO struct { 33 | Workspace string 34 | ProjectID string 35 | Name string 36 | Estimate *time.Duration 37 | AssigneeIDs *[]string 38 | Billable *bool 39 | } 40 | 41 | // TaskReadFlags read the common flags expected when editing a task 42 | func TaskReadFlags(cmd *cobra.Command, f cmdutil.Factory) (p FlagsDTO, err error) { 43 | if err := cmdutil.XorFlag(map[string]bool{ 44 | "assignee": cmd.Flags().Changed("assignee"), 45 | "no-assignee": cmd.Flags().Changed("no-assignee"), 46 | }); err != nil { 47 | return p, err 48 | } 49 | 50 | if err := cmdutil.XorFlag(map[string]bool{ 51 | "billable": cmd.Flags().Changed("billable"), 52 | "not-billable": cmd.Flags().Changed("not-billable"), 53 | }); err != nil { 54 | return p, err 55 | } 56 | 57 | if p.Workspace, err = f.GetWorkspaceID(); err != nil { 58 | return 59 | } 60 | 61 | p.ProjectID, _ = cmd.Flags().GetString("project") 62 | p.Name, _ = cmd.Flags().GetString("name") 63 | 64 | if cmd.Flags().Changed("estimate") { 65 | e, _ := cmd.Flags().GetInt32("estimate") 66 | d := time.Duration(e) * time.Hour 67 | p.Estimate = &d 68 | } 69 | 70 | if cmd.Flags().Changed("assignee") { 71 | assignees, _ := cmd.Flags().GetStringSlice("assignee") 72 | p.AssigneeIDs = &assignees 73 | } 74 | 75 | if f.Config().IsAllowNameForID() { 76 | c, err := f.Client() 77 | if err != nil { 78 | return p, err 79 | } 80 | 81 | if p.ProjectID, err = search.GetProjectByName( 82 | c, f.Config(), p.Workspace, p.ProjectID, ""); err != nil { 83 | return p, err 84 | } 85 | 86 | if p.AssigneeIDs != nil { 87 | as := *p.AssigneeIDs 88 | if as, err = search.GetUsersByName( 89 | c, p.Workspace, as); err != nil { 90 | return p, err 91 | } 92 | p.AssigneeIDs = &as 93 | } 94 | } 95 | 96 | if cmd.Flags().Changed("no-assignee") { 97 | var a []string 98 | 99 | p.AssigneeIDs = &a 100 | } 101 | 102 | switch { 103 | case cmd.Flags().Changed("billable"): 104 | b := true 105 | p.Billable = &b 106 | case cmd.Flags().Changed("not-billable"): 107 | b := false 108 | p.Billable = &b 109 | } 110 | 111 | return 112 | } 113 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/util/description-completer.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/lucassabreu/clockify-cli/api" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 10 | "github.com/lucassabreu/clockify-cli/strhlp" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // DescriptionSuggestFn provides suggestions to user when setting the description of a 15 | // time entry 16 | type DescriptionSuggestFn func(string) []string 17 | 18 | // descriptionCompleter looks for similar descriptions for auto-compliance 19 | type descriptionCompleter struct { 20 | client api.Client 21 | loaded bool 22 | param api.GetUserTimeEntriesParam 23 | descriptions []string 24 | } 25 | 26 | // NewDescriptionCompleter create or not a descriptionCompleter based on params 27 | func NewDescriptionCompleter(f cmdutil.Factory) DescriptionSuggestFn { 28 | if !f.Config().GetBool(cmdutil.CONF_DESCR_AUTOCOMP) { 29 | return func(s string) []string { return []string{} } 30 | } 31 | 32 | workspaceID, err := f.GetWorkspaceID() 33 | if err != nil { 34 | return func(s string) []string { return []string{} } 35 | } 36 | 37 | userID, err := f.GetUserID() 38 | if err != nil { 39 | return func(s string) []string { return []string{} } 40 | } 41 | 42 | c, err := f.Client() 43 | if err != nil { 44 | return func(s string) []string { return []string{} } 45 | } 46 | 47 | end := time.Now().UTC() 48 | start := end.Add(time.Hour * 49 | time.Duration(-24*f.Config().GetInt(cmdutil.CONF_DESCR_AUTOCOMP_DAYS))) 50 | 51 | d := &descriptionCompleter{ 52 | client: c, 53 | param: api.GetUserTimeEntriesParam{ 54 | Workspace: workspaceID, 55 | UserID: userID, 56 | End: &end, 57 | Start: &start, 58 | }, 59 | } 60 | 61 | return d.suggestFn 62 | } 63 | 64 | // getDescriptions load descriptions from recent time entries and list than 65 | // unique ones 66 | func (dc *descriptionCompleter) getDescriptions() []string { 67 | if dc.loaded { 68 | return dc.descriptions 69 | } 70 | 71 | tes, err := dc.client.GetUserTimeEntries(dc.param) 72 | 73 | dc.loaded = true 74 | if err != nil { 75 | return dc.descriptions 76 | } 77 | 78 | var ss []string 79 | 80 | for _, t := range tes { 81 | ss = append(ss, t.Description) 82 | } 83 | 84 | dc.descriptions = strhlp.Unique(ss) 85 | return dc.descriptions 86 | } 87 | 88 | // suggestFn returns a list of suggested descriptions based on a input string 89 | func (dc *descriptionCompleter) suggestFn(toComplete string) []string { 90 | toComplete = strings.TrimSpace(toComplete) 91 | if toComplete == "" { 92 | return dc.getDescriptions() 93 | } 94 | 95 | toComplete = strhlp.Normalize(toComplete) 96 | return strhlp.Filter( 97 | func(s string) bool { 98 | return strings.Contains(strhlp.Normalize(s), toComplete) 99 | }, 100 | dc.getDescriptions(), 101 | ) 102 | } 103 | 104 | func newDescriptionAutoComplete(f cmdutil.Factory) cmdcompl.SuggestFn { 105 | return func( 106 | _ *cobra.Command, _ []string, toComplete string, 107 | ) (cmdcompl.ValidArgs, error) { 108 | if !f.Config().GetBool(cmdutil.CONF_DESCR_AUTOCOMP) { 109 | return cmdcompl.EmptyValidArgs(), nil 110 | } 111 | 112 | dc := NewDescriptionCompleter(f) 113 | return cmdcompl.ValidArgsSlide(dc(toComplete)), nil 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /strhlp/strhlp.go: -------------------------------------------------------------------------------- 1 | package strhlp 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "unicode" 7 | 8 | "golang.org/x/text/runes" 9 | "golang.org/x/text/transform" 10 | "golang.org/x/text/unicode/norm" 11 | ) 12 | 13 | // Normalize a string removes all non-english characters and lower 14 | // case the string to help compare it with other strings 15 | func Normalize(s string) string { 16 | if r, _, err := transform.String( 17 | transform.Chain( 18 | norm.NFD, 19 | runes.Remove(runes.In(unicode.Mn)), 20 | norm.NFC, 21 | ), 22 | strings.ToLower(s), 23 | ); err == nil { 24 | s = r 25 | } 26 | 27 | return s 28 | } 29 | 30 | // InSlice will return true if the needle is one of the values in list 31 | func InSlice(needle string, list []string) bool { 32 | return Search(needle, list) != -1 33 | } 34 | 35 | // Search will search for a exact match of the string on the slide 36 | // provided and if found will return its index position, or -1 if not 37 | func Search(s string, list []string) int { 38 | for i, sl := range list { 39 | if sl == s { 40 | return i 41 | } 42 | } 43 | 44 | return -1 45 | } 46 | 47 | // Map will apply the map function provided on every entry of 48 | // the string slice and return a slice with the changes 49 | func Map(f func(string) string, s []string) []string { 50 | for i, e := range s { 51 | s[i] = f(e) 52 | } 53 | return s 54 | } 55 | 56 | // Filter will run the function for every entry of the slice 57 | // and will return a new slice with the entries that return true 58 | // when used on the function 59 | func Filter(f func(string) bool, s []string) []string { 60 | ns := make([]string, 0) 61 | for _, e := range s { 62 | if f(e) { 63 | ns = append(ns, e) 64 | } 65 | } 66 | return ns 67 | } 68 | 69 | // Unique will remove all duplicated strings from the slice 70 | func Unique(ss []string) []string { 71 | r := make([]string, 0) 72 | for _, s := range ss { 73 | if Search(s, r) == -1 { 74 | r = append(r, s) 75 | } 76 | } 77 | 78 | return r 79 | } 80 | 81 | // ListForHumans returns a string listing the strings from the parameter 82 | // 83 | // Example: ListForHumans([]string{"one", "two", "three"}) will output: 84 | // "one, two and three" 85 | func ListForHumans(s []string) string { 86 | if len(s) == 1 { 87 | return s[0] 88 | } 89 | 90 | return strings.Join(s[:len(s)-1], ", ") + " and " + s[len(s)-1] 91 | } 92 | 93 | // PadSpace will add spaces to the end of a string until it reaches the size 94 | // set at the second parameter 95 | func PadSpace(s string, size int) string { 96 | for i := len(s); i < size; i++ { 97 | s = s + " " 98 | } 99 | return s 100 | } 101 | 102 | // IsSimilar will convert the string into a regex and return a function the 103 | // checks if a second string is similar to it. 104 | // 105 | // Both strings will normalized before mathing and any space on the filter 106 | // string will be taken as .* on a regex 107 | func IsSimilar(filter string) func(string) bool { 108 | // skipcq: GO-C4007 109 | filter = regexp.MustCompile(`[\[\]\^\\\,\.\(\)\-]+`). 110 | ReplaceAllString(Normalize(filter), " ") 111 | filter = regexp.MustCompile(`\s+`).ReplaceAllString(filter, " ") 112 | filter = strings.ReplaceAll(filter, " ", ".*") 113 | filter = strings.ReplaceAll(filter, "*", ".*") 114 | 115 | r := regexp.MustCompile(filter) 116 | 117 | return func(s string) bool { 118 | return r.MatchString(Normalize(s)) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /pkg/cmd/task/delete/delete.go: -------------------------------------------------------------------------------- 1 | package del 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "strings" 7 | 8 | "github.com/MakeNowJust/heredoc" 9 | "github.com/lucassabreu/clockify-cli/api" 10 | "github.com/lucassabreu/clockify-cli/api/dto" 11 | "github.com/lucassabreu/clockify-cli/pkg/cmd/task/util" 12 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 13 | "github.com/lucassabreu/clockify-cli/pkg/cmdcomplutil" 14 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 15 | "github.com/lucassabreu/clockify-cli/pkg/search" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | // NewCmdDelete represents the close command 20 | func NewCmdDelete( 21 | f cmdutil.Factory, 22 | report func(io.Writer, *util.OutputFlags, dto.Task) error, 23 | ) *cobra.Command { 24 | of := util.OutputFlags{} 25 | cmd := &cobra.Command{ 26 | Use: "delete ", 27 | Aliases: []string{"remove", "rm", "del"}, 28 | Args: cmdutil.RequiredNamedArgs("task"), 29 | ValidArgsFunction: cmdcompl.CombineSuggestionsToArgs( 30 | cmdcomplutil.NewTaskAutoComplete(f, false)), 31 | Short: "Deletes a task from a project on Clockify", 32 | Long: heredoc.Doc(` 33 | Deletes a task from a project on Clockify 34 | This action can't be reverted, and all time entries using this task will revert to not having one 35 | `), 36 | Example: heredoc.Doc(` 37 | $ clockify-cli task delete -p "special" very 38 | +--------------------------+----------------+--------+ 39 | | ID | NAME | STATUS | 40 | +--------------------------+----------------+--------+ 41 | | 62aa5d7049445270d7b979d6 | Very Important | ACTIVE | 42 | +--------------------------+----------------+--------+ 43 | 44 | $ clockify-cli task delete -p "special" 62aa4eed49445270d7b9666c 45 | +--------------------------+----------+--------+ 46 | | ID | NAME | STATUS | 47 | +--------------------------+----------+--------+ 48 | | 62aa4eed49445270d7b9666c | Inactive | DONE | 49 | +--------------------------+----------+--------+ 50 | 51 | $ clockify-cli task delete -p "special" 62aa4eed49445270d7b9666c 52 | No task with id or name containing '62aa4eed49445270d7b9666c' was found 53 | `), 54 | RunE: func(cmd *cobra.Command, args []string) error { 55 | project, _ := cmd.Flags().GetString("project") 56 | task := strings.TrimSpace(args[0]) 57 | if project == "" || task == "" { 58 | return errors.New("project and task id should not be empty") 59 | } 60 | 61 | w, err := f.GetWorkspaceID() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | c, err := f.Client() 67 | if err != nil { 68 | return err 69 | } 70 | 71 | if f.Config().IsAllowNameForID() { 72 | if project, err = search.GetProjectByName( 73 | c, f.Config(), w, project, ""); err != nil { 74 | return err 75 | } 76 | 77 | if task, err = search.GetTaskByName( 78 | c, 79 | api.GetTasksParam{Workspace: w, ProjectID: project}, 80 | task, 81 | ); err != nil { 82 | return err 83 | } 84 | } 85 | 86 | t, err := c.DeleteTask(api.DeleteTaskParam{ 87 | Workspace: w, 88 | ProjectID: project, 89 | TaskID: task, 90 | }) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | if report == nil { 96 | return util.TaskReport(cmd, of, t) 97 | } 98 | 99 | return report(cmd.OutOrStdout(), &of, t) 100 | }, 101 | } 102 | 103 | cmdutil.AddProjectFlags(cmd, f) 104 | util.TaskAddReportFlags(cmd, &of) 105 | 106 | return cmd 107 | } 108 | -------------------------------------------------------------------------------- /site/layouts/partials/logo.html: -------------------------------------------------------------------------------- 1 | 86 | -------------------------------------------------------------------------------- /pkg/timehlp/time.go: -------------------------------------------------------------------------------- 1 | package timehlp 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | const ( 10 | FullTimeFormat = "2006-01-02 15:04:05" 11 | SimplerTimeFormat = "2006-01-02 15:04" 12 | OnlyTimeFormat = "15:04:05" 13 | SimplerOnlyTimeFormat = "15:04" 14 | SimplerOnlyTimeFormatWL = "5:04" 15 | NowTimeFormat = "now" 16 | SimplestOnlyTimeFormat = "1504" 17 | SimplestOnlyTimeFormatWL = "504" 18 | ) 19 | 20 | // ConvertToTime will try to convert a string do time.Time looking for the 21 | // format that best fits it and assuming "today" when necessary. 22 | // If the string starts with `yesterday`, than it will be exchanged for a 23 | // date-string with the format: 2006-01-02 24 | // If the string starts with `+` or `-` than the string will be treated as 25 | // "relative time expressions", and will be calculated as the diff from now and 26 | // it. 27 | // If the string is "now" than `time.Now()` in the local timezone will be 28 | // returned. 29 | func ConvertToTime(timeString string) (t time.Time, err error) { 30 | timeString = strings.ToLower(strings.TrimSpace(timeString)) 31 | 32 | if NowTimeFormat == timeString { 33 | return Now(), nil 34 | } 35 | 36 | if strings.HasPrefix(timeString, "+") || 37 | strings.HasPrefix(timeString, "-") { 38 | return relativeToTime(timeString) 39 | } 40 | 41 | if strings.HasPrefix(timeString, "yesterday ") { 42 | timeString = Today(). 43 | Add(-1).Format("2006-01-02") + " " + timeString[10:] 44 | } 45 | 46 | l := len(timeString) 47 | if len(FullTimeFormat) != l && 48 | len(SimplerTimeFormat) != l && 49 | len(OnlyTimeFormat) != l && 50 | len(SimplerOnlyTimeFormat) != l && 51 | len(SimplestOnlyTimeFormat) != l && 52 | len(SimplestOnlyTimeFormatWL) != l { 53 | return t, fmt.Errorf( 54 | "supported formats are: %s", 55 | strings.Join( 56 | []string{ 57 | FullTimeFormat, SimplerTimeFormat, OnlyTimeFormat, 58 | SimplerOnlyTimeFormat, SimplerOnlyTimeFormatWL, NowTimeFormat, 59 | SimplestOnlyTimeFormat, SimplestOnlyTimeFormatWL, 60 | }, 61 | ", ", 62 | ), 63 | ) 64 | } 65 | 66 | timeString = normalizeFormats(timeString) 67 | t, err = time.ParseInLocation(FullTimeFormat, timeString, time.Local) 68 | if err != nil { 69 | return t, err 70 | } 71 | 72 | return t.Truncate(time.Second), nil 73 | } 74 | 75 | // Adds data to the partial timeString to match a full 76 | // datetime with seconds precission. 77 | // Receives a time in any of the defined formats, and return 78 | // a date in the FullTimeFormat 79 | func normalizeFormats(timeString string) string { 80 | l := len(timeString) 81 | 82 | // change from 9:14 to 09:14 83 | if len(SimplerOnlyTimeFormatWL) == l && strings.Contains(timeString, ":") { 84 | timeString = "0" + timeString 85 | l = l + 1 86 | } 87 | 88 | // change from 914 to 0914 89 | if len(SimplestOnlyTimeFormatWL) == l && !strings.Contains(timeString, ":") { 90 | timeString = "0" + timeString 91 | l = l + 1 92 | } 93 | 94 | // change from 0914 to 09:14 95 | if len(SimplestOnlyTimeFormat) == l { 96 | timeString = timeString[0:2] + ":" + timeString[2:] 97 | l = l + 1 98 | } 99 | 100 | // change from 09:14 to 09:14:00 101 | if len(SimplerOnlyTimeFormat) == l || len(SimplerTimeFormat) == l { 102 | timeString = timeString + ":00" 103 | l = l + 3 104 | } 105 | 106 | // change from 09:14 to 2006-01-02 09:14:00 107 | if len(OnlyTimeFormat) == l { 108 | timeString = Today().Format("2006-01-02") + " " + timeString 109 | } 110 | return timeString 111 | } 112 | -------------------------------------------------------------------------------- /pkg/cmd/time-entry/manual/manual.go: -------------------------------------------------------------------------------- 1 | package manual 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/MakeNowJust/heredoc" 8 | "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util" 9 | "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" 10 | "github.com/lucassabreu/clockify-cli/pkg/cmdcomplutil" 11 | "github.com/lucassabreu/clockify-cli/pkg/cmdutil" 12 | output "github.com/lucassabreu/clockify-cli/pkg/output/time-entry" 13 | "github.com/lucassabreu/clockify-cli/pkg/timehlp" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | // NewCmdManual represents the manual command 18 | func NewCmdManual(f cmdutil.Factory) *cobra.Command { 19 | of := util.OutputFlags{TimeFormat: output.TimeFormatSimple} 20 | cmd := &cobra.Command{ 21 | Use: "manual [] [] [] []", 22 | Short: "Create a new complete time entry", 23 | Long: heredoc.Doc(` 24 | Create a new complete time entry with start and end. 25 | 26 | This command will not stop running time entries. 27 | 28 | The rules defined in the workspace and project will be checked before creating it. 29 | `) + "\n" + 30 | util.HelpTimeEntryNowIfNotSet + 31 | "The same applies to end time (`--when-to-close`).\n\n" + 32 | util.HelpInteractiveByDefault + "\n" + 33 | util.HelpTimeInputOnTimeEntry + "\n" + 34 | util.HelpNamesForIds + "\n" + 35 | util.HelpMoreInfoAboutStarting + "\n" + 36 | util.HelpMoreInfoAboutPrinting, 37 | Args: cobra.MaximumNArgs(4), 38 | ValidArgsFunction: cmdcompl.CombineSuggestionsToArgs( 39 | cmdcomplutil.NewProjectAutoComplete(f, f.Config())), 40 | RunE: func(cmd *cobra.Command, args []string) error { 41 | var whenToCloseDate time.Time 42 | var err error 43 | tei := util.TimeEntryDTO{ 44 | Start: timehlp.Now(), 45 | } 46 | 47 | if tei.Workspace, err = f.GetWorkspaceID(); err != nil { 48 | return err 49 | } 50 | 51 | if tei.UserID, err = f.GetUserID(); err != nil { 52 | return err 53 | } 54 | 55 | c, err := f.Client() 56 | if err != nil { 57 | return err 58 | } 59 | 60 | if len(args) > 0 { 61 | tei.ProjectID = args[0] 62 | } 63 | 64 | if len(args) > 1 { 65 | tei.Start, err = timehlp.ConvertToTime(args[1]) 66 | if err != nil { 67 | return fmt.Errorf( 68 | "fail to convert when to start: %w", err) 69 | } 70 | } 71 | 72 | if len(args) > 2 { 73 | whenToCloseDate, err = timehlp.ConvertToTime(args[2]) 74 | if err != nil { 75 | return fmt.Errorf( 76 | "fail to convert when to end: %w", err) 77 | } 78 | tei.End = &whenToCloseDate 79 | } 80 | 81 | if len(args) > 3 { 82 | tei.Description = args[3] 83 | } 84 | 85 | dc := util.NewDescriptionCompleter(f) 86 | 87 | if tei, err = util.Do( 88 | tei, 89 | util.FillTimeEntryWithFlags(cmd.Flags()), 90 | func(tei util.TimeEntryDTO) (util.TimeEntryDTO, error) { 91 | if tei.End != nil { 92 | return tei, nil 93 | } 94 | 95 | now, _ := timehlp.ConvertToTime(timehlp.NowTimeFormat) 96 | tei.End = &now 97 | return tei, nil 98 | }, 99 | util.GetAllowNameForIDsFn(f.Config(), c), 100 | util.GetPropsInteractiveFn(dc, f), 101 | util.GetDatesInteractiveFn(f), 102 | util.ValidateClosingTimeEntry(f), 103 | util.CreateTimeEntryFn(c), 104 | ); err != nil { 105 | return err 106 | } 107 | 108 | return util.PrintTimeEntryImpl( 109 | util.TimeEntryDTOToImpl(tei), f, cmd.OutOrStdout(), of) 110 | }, 111 | } 112 | 113 | util.AddTimeEntryFlags(cmd, f, &of) 114 | util.AddTimeEntryDateFlags(cmd) 115 | 116 | return cmd 117 | } 118 | --------------------------------------------------------------------------------