├── 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 |
2 |
3 |
84 |
85 |
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 |
--------------------------------------------------------------------------------