Skip to content

Commit

Permalink
feat: add initial Toggl Track integration (#13)
Browse files Browse the repository at this point in the history
* refactor: extract entry splitting
* refactor: add error code to send request error response
* feat(toggl): add initial Toggl Track integration
* docs: adjust Clockify docs
  • Loading branch information
gabor-boros committed Oct 15, 2021
1 parent d27c124 commit 59c2a17
Show file tree
Hide file tree
Showing 14 changed files with 759 additions and 159 deletions.
57 changes: 32 additions & 25 deletions README.md
Expand Up @@ -56,30 +56,37 @@ Usage:
minutes [flags]
Flags:
--clockify-api-key string set the API key
--clockify-url string set the base URL
--clockify-workspace string set the workspace ID
--config string config file (default is $HOME/.minutes.yaml)
--date-format string set start and end date format (in Go style) (default "2006-01-02 15:04:05")
--dry-run fetch entries, but do not sync them
--end string set the end date (defaults to now)
--force-billed-duration treat every second spent as billed
-h, --help help for minutes
--round-to-closest-minute round time to closest minute
-s, --source string set the source of the sync [clockify tempo]
--source-user string set the source user ID
--start string set the start date (defaults to 00:00:00)
--table-hide-column strings hide table column [summary project client start end]
--table-sort-by strings sort table by column [task summary project client start end billable unbillable] (default [start,project,task,summary])
-t, --target string set the target of the sync [tempo]
--target-user string set the source user ID
--tags-as-tasks treat tags matching the value of tags-as-tasks-regex as tasks
--tags-as-tasks-regex string regex of the task pattern
--tempo-password string set the login password
--tempo-url string set the base URL
--tempo-username string set the login user ID
--verbose print verbose messages
--version show command version
--clockify-api-key string set the API key
--clockify-url string set the base URL
--clockify-workspace string set the workspace ID
--config string config file (default is $HOME/.minutes.yaml)
--date-format string set start and end date format (in Go style) (default "2006-01-02 15:04:05")
--dry-run fetch entries, but do not sync them
--end string set the end date (defaults to now)
--force-billed-duration treat every second spent as billed
-h, --help help for minutes
--round-to-closest-minute round time to closest minute
-s, --source string set the source of the sync [clockify tempo timewarrior toggl]
--source-user string set the source user ID
--start string set the start date (defaults to 00:00:00)
--table-hide-column strings hide table column [summary project client start end]
--table-sort-by strings sort table by column [task summary project client start end billable unbillable] (default [start,project,task,summary])
--tags-as-tasks treat tags matching the value of tags-as-tasks-regex as tasks
--tags-as-tasks-regex string regex of the task pattern
-t, --target string set the target of the sync [tempo]
--target-user string set the source user ID
--tempo-password string set the login password
--tempo-url string set the base URL
--tempo-username string set the login user ID
--timewarrior-arguments strings set additional arguments
--timewarrior-client-tag-regex string regex of client tag pattern
--timewarrior-command string set the executable name (default "timew")
--timewarrior-project-tag-regex string regex of project tag pattern
--timewarrior-unbillable-tag string set the unbillable tag (default "unbillable")
--toggl-api-key string set the API key
--toggl-url string set the base URL (default "https://api.track.toggl.com")
--toggl-workspace int set the workspace ID
--version show command version
```

### Usage examples
Expand Down Expand Up @@ -179,7 +186,7 @@ widthmax = 40
| Time Doctor | upon request | upon request |
| TimeCamp | upon request | upon request |
| Timewarrior | **yes** | upon request |
| Toggl Track | **planned** | upon request |
| Toggl Track | **yes** | upon request |
| Zoho Books | upon request | **planned** |

See the [open issues](https://github.com/gabor-boros/minutes/issues) for a full list of proposed features, tools and known issues.
Expand Down
34 changes: 32 additions & 2 deletions cmd/root.go
Expand Up @@ -12,6 +12,8 @@ import (
"strings"
"time"

"github.com/gabor-boros/minutes/internal/pkg/client/toggl"

"github.com/gabor-boros/minutes/internal/pkg/client/timewarrior"

"github.com/gabor-boros/minutes/internal/cmd/utils"
Expand Down Expand Up @@ -40,7 +42,7 @@ var (
commit string
date string

sources = []string{"clockify", "tempo", "timewarrior"}
sources = []string{"clockify", "tempo", "timewarrior", "toggl"}
targets = []string{"tempo"}

ErrNoSourceImplementation = errors.New("no source implementation found")
Expand Down Expand Up @@ -76,6 +78,7 @@ func init() {
initClockifyFlags()
initTempoFlags()
initTimewarriorFlags()
initTogglFlags()
}

func initConfig() {
Expand Down Expand Up @@ -134,7 +137,7 @@ func initCommonFlags() {
}

func initClockifyFlags() {
rootCmd.Flags().StringP("clockify-url", "", "", "set the base URL")
rootCmd.Flags().StringP("clockify-url", "", "https://api.clockify.me", "set the base URL")
rootCmd.Flags().StringP("clockify-api-key", "", "", "set the API key")
rootCmd.Flags().StringP("clockify-workspace", "", "", "set the workspace ID")
}
Expand All @@ -154,6 +157,12 @@ func initTimewarriorFlags() {
rootCmd.Flags().StringP("timewarrior-project-tag-regex", "", "", "regex of project tag pattern")
}

func initTogglFlags() {
rootCmd.Flags().StringP("toggl-url", "", "https://api.track.toggl.com", "set the base URL")
rootCmd.Flags().StringP("toggl-api-key", "", "", "set the API key")
rootCmd.Flags().IntP("toggl-workspace", "", 0, "set the workspace ID")
}

func validateFlags() {
source := viper.GetString("source")
target := viper.GetString("target")
Expand Down Expand Up @@ -301,6 +310,27 @@ func getFetcher() (client.Fetcher, error) {
ClientTagRegex: viper.GetString("timewarrior-client-tag-regex"),
ProjectTagRegex: viper.GetString("timewarrior-project-tag-regex"),
}), nil
case "toggl":
opts, err := getClientOpts(
"toggl-url",
"toggl-api-key",
"",
"",
"",
)

// Toggl requires basic auth with the token set as the username and
// "api_token" set for password as a fix value to access their APIs
opts.Password = "api_token"

if err != nil {
return nil, err
}

return toggl.NewClient(&toggl.ClientOpts{
BaseClientOpts: *opts,
Workspace: viper.GetInt("toggl-workspace"),
}), nil
default:
return nil, ErrNoSourceImplementation
}
Expand Down
3 changes: 2 additions & 1 deletion internal/pkg/client/client.go
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
Expand Down Expand Up @@ -166,7 +167,7 @@ func SendRequest(ctx context.Context, method string, path string, data interface
return nil, err
}

return nil, errors.New(string(errBody))
return nil, fmt.Errorf("%d: %s", resp.StatusCode, string(errBody))
}

return resp, err
Expand Down
79 changes: 17 additions & 62 deletions internal/pkg/client/clockify/clockify.go
Expand Up @@ -26,24 +26,11 @@ const (

// Project represents the project assigned to an entry.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
worklog.IDNameField
ClientID string `json:"clientId"`
ClientName string `json:"clientName"`
}

// Tag represents a tag assigned to an entry.
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
}

// Task represents the task assigned to an entry.
type Task struct {
ID string `json:"id"`
Name string `json:"name"`
}

// Interval represents the Start and End date of an entry.
type Interval struct {
Start time.Time `json:"start"`
Expand All @@ -52,12 +39,12 @@ type Interval struct {

// FetchEntry represents the entry fetched from Clockify.
type FetchEntry struct {
Description string `json:"description"`
Billable bool `json:"billable"`
Project Project `json:"project"`
TimeInterval Interval `json:"timeInterval"`
Task Task `json:"task"`
Tags []Tag `json:"tags"`
Description string `json:"description"`
Billable bool `json:"billable"`
Project Project `json:"project"`
TimeInterval Interval `json:"timeInterval"`
Task worklog.IDNameField `json:"task"`
Tags []worklog.IDNameField `json:"tags"`
}

// WorklogSearchParams represents the parameters used to filter search results.
Expand Down Expand Up @@ -101,48 +88,20 @@ func (c *clockifyClient) getSearchURL(user string, params *WorklogSearchParams)
return fmt.Sprintf("%s?%s", worklogURL.Path, worklogURL.Query().Encode()), nil
}

func (c *clockifyClient) splitEntry(entry worklog.Entry, fetchedEntry FetchEntry) ([]worklog.Entry, error) {
r, err := regexp.Compile(c.opts.TagsAsTasksRegex)
if err != nil {
return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

tasks := map[string]string{}
for _, tag := range fetchedEntry.Tags {
if task := r.FindString(tag.Name); task != "" {
tasks[tag.ID] = task
}
}

var entries []worklog.Entry
totalTasks := len(tasks)

for taskID, taskName := range tasks {
splitBillable, splitUnbillable := entry.SplitDuration(totalTasks)

entries = append(entries, worklog.Entry{
Client: entry.Client,
Project: entry.Project,
Task: worklog.IDNameField{
ID: taskID,
Name: taskName,
},
Summary: fetchedEntry.Description,
Notes: fetchedEntry.Description,
Start: entry.Start,
BillableDuration: splitBillable,
UnbillableDuration: splitUnbillable,
})
}

return entries, nil
}

func (c *clockifyClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) ([]worklog.Entry, error) {
var err error
var entries []worklog.Entry
currentPage := 1
pageSize := 100

var tagsAsTasksRegex *regexp.Regexp
if c.opts.TagsAsTasks {
tagsAsTasksRegex, err = regexp.Compile(c.opts.TagsAsTasksRegex)
if err != nil {
return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}
}

// Naive pagination as the API does not return the number of total entries
for currentPage*pageSize < MaxPageLength {
searchParams := &WorklogSearchParams{
Expand Down Expand Up @@ -204,11 +163,7 @@ func (c *clockifyClient) FetchEntries(ctx context.Context, opts *client.FetchOpt
}

if c.opts.TagsAsTasks && len(entry.Tags) > 0 {
pageEntries, err := c.splitEntry(worklogEntry, entry)
if err != nil {
return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
}

pageEntries := worklogEntry.SplitByTagsAsTasks(entry.Description, tagsAsTasksRegex, entry.Tags)
entries = append(entries, pageEntries...)
} else {
entries = append(entries, worklogEntry)
Expand Down
40 changes: 24 additions & 16 deletions internal/pkg/client/clockify/clockify_test.go
Expand Up @@ -115,20 +115,22 @@ func TestClockifyClient_FetchEntries(t *testing.T) {
Description: "Have a coffee with Tony",
Billable: true,
Project: clockify.Project{
ID: "123",
Name: "MARVEL-101",
IDNameField: worklog.IDNameField{
ID: "123",
Name: "MARVEL-101",
},
ClientID: "456",
ClientName: "My Awesome Company",
},
TimeInterval: clockify.Interval{
Start: start,
End: end,
},
Task: clockify.Task{
Task: worklog.IDNameField{
ID: "789",
Name: "Meet with Iron Man",
},
Tags: []clockify.Tag{
Tags: []worklog.IDNameField{
{
ID: "1234",
Name: "Coffee",
Expand All @@ -147,20 +149,22 @@ func TestClockifyClient_FetchEntries(t *testing.T) {
Description: "Go back for my wallet",
Billable: false,
Project: clockify.Project{
ID: "123",
Name: "MARVEL-101",
IDNameField: worklog.IDNameField{
ID: "123",
Name: "MARVEL-101",
},
ClientID: "456",
ClientName: "My Awesome Company",
},
TimeInterval: clockify.Interval{
Start: start,
End: end,
},
Task: clockify.Task{
Task: worklog.IDNameField{
ID: "789",
Name: "Meet with Iron Man",
},
Tags: []clockify.Tag{
Tags: []worklog.IDNameField{
{
ID: "1234",
Name: "Coffee",
Expand Down Expand Up @@ -284,17 +288,19 @@ func TestClockifyClient_FetchEntries_TasksAsTags(t *testing.T) {
Description: "Have a coffee with Tony",
Billable: true,
Project: clockify.Project{
ID: "123",
Name: "MARVEL-101",
IDNameField: worklog.IDNameField{
ID: "123",
Name: "MARVEL-101",
},
ClientID: "456",
ClientName: "My Awesome Company",
},
TimeInterval: clockify.Interval{
Start: start,
End: end,
},
Task: clockify.Task{},
Tags: []clockify.Tag{
Task: worklog.IDNameField{},
Tags: []worklog.IDNameField{
{
ID: "1234",
Name: "Coffee",
Expand All @@ -313,17 +319,19 @@ func TestClockifyClient_FetchEntries_TasksAsTags(t *testing.T) {
Description: "Go back for my wallet",
Billable: false,
Project: clockify.Project{
ID: "123",
Name: "MARVEL-101",
IDNameField: worklog.IDNameField{
ID: "123",
Name: "MARVEL-101",
},
ClientID: "456",
ClientName: "My Awesome Company",
},
TimeInterval: clockify.Interval{
Start: start,
End: end,
},
Task: clockify.Task{},
Tags: []clockify.Tag{
Task: worklog.IDNameField{},
Tags: []worklog.IDNameField{
{
ID: "1234",
Name: "Coffee",
Expand Down

0 comments on commit 59c2a17

Please sign in to comment.