402 lines
11 KiB
Go
402 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"math"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/decentral1se/go-kimai/client"
|
|
"github.com/decentral1se/go-kimai/client/activity"
|
|
"github.com/decentral1se/go-kimai/client/customer"
|
|
"github.com/decentral1se/go-kimai/client/project"
|
|
"github.com/decentral1se/go-kimai/client/timesheet"
|
|
"github.com/decentral1se/go-kimai/models"
|
|
|
|
"github.com/AlecAivazis/survey/v2"
|
|
"github.com/go-openapi/runtime"
|
|
httpTransport "github.com/go-openapi/runtime/client"
|
|
"github.com/go-openapi/strfmt"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
var dateFormat = "2006-01-02T15:04:05"
|
|
|
|
// homePath is the configuration home directory path.
|
|
var homePath = os.ExpandEnv("$HOME/.kimchi")
|
|
|
|
// confPath is the configuration file path.
|
|
var confPath = path.Join(homePath, "conf.txt")
|
|
|
|
// config stores all user config at run-time.
|
|
type config struct {
|
|
auth runtime.ClientAuthInfoWriter
|
|
client *client.GoKimai
|
|
domain string
|
|
token string
|
|
username string
|
|
hourlyRate string
|
|
}
|
|
|
|
// prompt feeds input from user into the configuration.
|
|
func (c *config) prompt() error {
|
|
usernamePrompt := &survey.Input{Message: "Kimai username?"}
|
|
if err := survey.AskOne(usernamePrompt, &c.username); err != nil {
|
|
log.Fatalf("Unable to read kimai username: %s", err)
|
|
}
|
|
|
|
tokenPrompt := &survey.Password{Message: "Kimai token?"}
|
|
if err := survey.AskOne(tokenPrompt, &c.token); err != nil {
|
|
log.Fatalf("Unable to read kimai token: %s", err)
|
|
}
|
|
|
|
hourlyPrompt := &survey.Input{Message: "Hourly rate?"}
|
|
if err := survey.AskOne(hourlyPrompt, &c.hourlyRate); err != nil {
|
|
log.Fatalf("Unable to read hourly rate: %s", err)
|
|
}
|
|
|
|
if err := c.save(); err != nil {
|
|
log.Fatalf("Unable to write config to the FS: %s", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// toBytes converts a configuration to bytes.
|
|
func (c *config) toBytes() []byte {
|
|
content := ""
|
|
content += fmt.Sprintf("%s\n", c.username)
|
|
content += fmt.Sprintf("%s\n", c.token)
|
|
content += fmt.Sprintf("%s\n", c.domain)
|
|
content += fmt.Sprintf("%s\n", c.hourlyRate)
|
|
return []byte(content)
|
|
}
|
|
|
|
// save writes a config to the FS as plain text.
|
|
func (c config) save() error {
|
|
if err := ioutil.WriteFile(confPath, c.toBytes(), 0644); err != nil {
|
|
return fmt.Errorf("Unable to write %s: %s", confPath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// load reads a config from the FS.
|
|
func (c *config) load() error {
|
|
contents, err := ioutil.ReadFile(confPath)
|
|
if err != nil {
|
|
return fmt.Errorf("Unable to read %s: %s", confPath, err)
|
|
}
|
|
|
|
lines := strings.Split(string(contents), "\n")
|
|
if len(lines) < 4 {
|
|
return fmt.Errorf("Unable to read %s, it's badly formatted?", confPath)
|
|
}
|
|
|
|
c.username = lines[0]
|
|
c.token = lines[1]
|
|
c.domain = lines[2]
|
|
c.hourlyRate = lines[3]
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *config) initClient() {
|
|
c.auth = httpTransport.Compose(
|
|
httpTransport.APIKeyAuth("X-AUTH-USER", "header", c.username),
|
|
httpTransport.APIKeyAuth("X-AUTH-TOKEN", "header", c.token),
|
|
)
|
|
transport := httpTransport.New(c.domain, "", []string{"https"})
|
|
c.client = client.New(transport, strfmt.Default)
|
|
}
|
|
|
|
// selectCustomer allows uers to select a single customer.
|
|
func selectCustomer(conf *config) (*models.CustomerCollection, error) {
|
|
params := customer.NewGetAPICustomersParams()
|
|
params.WithOrder("ASC")
|
|
params.WithOrderBy("name")
|
|
|
|
resp, err := conf.client.Customer.GetAPICustomers(params, conf.auth)
|
|
if err != nil {
|
|
return &models.CustomerCollection{}, fmt.Errorf("Unable to retrieve customers: %s", err)
|
|
}
|
|
|
|
var names []string
|
|
for _, customer := range resp.Payload {
|
|
names = append(names, *customer.Name)
|
|
}
|
|
|
|
prompt := &survey.Select{
|
|
Message: "Select a customer:",
|
|
Options: names,
|
|
}
|
|
|
|
var choice string
|
|
if err := survey.AskOne(prompt, &choice, survey.WithPageSize(20)); err != nil {
|
|
return &models.CustomerCollection{}, fmt.Errorf("Unable to choose customer name: %s", err)
|
|
}
|
|
|
|
var chosenCustomer *models.CustomerCollection
|
|
for _, customer := range resp.Payload {
|
|
if choice == *customer.Name {
|
|
chosenCustomer = customer
|
|
}
|
|
}
|
|
|
|
return chosenCustomer, nil
|
|
}
|
|
|
|
// selectProject allows uers to select a single project.
|
|
func selectProject(conf *config, customer *models.CustomerCollection) (*models.ProjectCollection, error) {
|
|
params := project.NewGetAPIProjectsParams()
|
|
params.Customer = strconv.FormatInt(customer.ID, 10)
|
|
params.IgnoreDates = "1"
|
|
params.GlobalActivities = "1"
|
|
params.WithOrder("ASC")
|
|
params.WithOrderBy("name")
|
|
|
|
resp, err := conf.client.Project.GetAPIProjects(params, conf.auth)
|
|
if err != nil {
|
|
return &models.ProjectCollection{}, fmt.Errorf("Unable to retrieve projects: %s", err)
|
|
}
|
|
|
|
var names []string
|
|
for _, project := range resp.Payload {
|
|
names = append(names, *project.Name)
|
|
}
|
|
|
|
prompt := &survey.Select{
|
|
Message: "Select a project:",
|
|
Options: names,
|
|
}
|
|
|
|
var choice string
|
|
if err := survey.AskOne(prompt, &choice, survey.WithPageSize(20)); err != nil {
|
|
return &models.ProjectCollection{}, fmt.Errorf("Unable to choose project: %s", err)
|
|
}
|
|
|
|
var chosenProject *models.ProjectCollection
|
|
for _, project := range resp.Payload {
|
|
if choice == *project.Name {
|
|
chosenProject = project
|
|
}
|
|
}
|
|
|
|
return chosenProject, nil
|
|
}
|
|
|
|
// selectActivity allows uers to select a single activity.
|
|
func selectActivity(conf *config, project *models.ProjectCollection) (*models.ActivityCollection, error) {
|
|
params := activity.NewGetAPIActivitiesParams()
|
|
params.Project = strconv.FormatInt(project.ID, 10)
|
|
params.WithOrder("ASC")
|
|
params.WithOrderBy("name")
|
|
|
|
resp, err := conf.client.Activity.GetAPIActivities(params, conf.auth)
|
|
if err != nil {
|
|
return &models.ActivityCollection{}, fmt.Errorf("Unable to retrieve activities: %s", err)
|
|
}
|
|
|
|
var names []string
|
|
for _, activity := range resp.Payload {
|
|
names = append(names, *activity.Name)
|
|
}
|
|
|
|
prompt := &survey.Select{
|
|
Message: "Select an activity:",
|
|
Options: names,
|
|
}
|
|
|
|
var choice string
|
|
if err := survey.AskOne(prompt, &choice, survey.WithPageSize(20)); err != nil {
|
|
return &models.ActivityCollection{}, fmt.Errorf("Unable to choose an activity: %s", err)
|
|
}
|
|
|
|
var chosenActivity *models.ActivityCollection
|
|
for _, activity := range resp.Payload {
|
|
if choice == *activity.Name {
|
|
chosenActivity = activity
|
|
}
|
|
}
|
|
|
|
return chosenActivity, nil
|
|
}
|
|
|
|
func main() {
|
|
app := &cli.App{
|
|
Name: "kimchi",
|
|
Usage: "Simple Kimai terminal client 🤟",
|
|
Description: "Run without sub-commands for current status.",
|
|
Before: func(c *cli.Context) error {
|
|
conf := config{domain: "kimai.autonomic.zone"}
|
|
if err := os.Mkdir(homePath, 0764); err != nil {
|
|
if !os.IsExist(err) {
|
|
log.Fatalf("Unable to create ~/.kimchi: %s", err)
|
|
}
|
|
}
|
|
|
|
if err := conf.load(); err != nil {
|
|
if err := conf.prompt(); err != nil {
|
|
log.Fatalf("Unable to load config from the FS: %s", err)
|
|
}
|
|
}
|
|
|
|
c.Context = context.WithValue(c.Context, "config", conf)
|
|
|
|
return nil
|
|
},
|
|
Commands: []*cli.Command{
|
|
{
|
|
Name: "start",
|
|
Aliases: []string{"s"},
|
|
Description: "Fuzzy search enabled interface.",
|
|
Usage: "Start tracking time",
|
|
Action: func(cCtx *cli.Context) error {
|
|
conf := cCtx.Context.Value("config").(config)
|
|
conf.initClient()
|
|
|
|
customer, err := selectCustomer(&conf)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
project, err := selectProject(&conf, customer)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
activity, err := selectActivity(&conf, project)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
var description string
|
|
descPrompt := &survey.Input{Message: "Description (leave empty to ignore):"}
|
|
if err := survey.AskOne(descPrompt, &description); err != nil {
|
|
log.Fatalf("Unable to read description: %s", err)
|
|
}
|
|
|
|
var hourly string
|
|
hourlyPrompt := &survey.Input{Message: "Hourly:", Default: conf.hourlyRate}
|
|
if err := survey.AskOne(hourlyPrompt, &hourly); err != nil {
|
|
log.Fatalf("Unable to read hourly: %s", err)
|
|
}
|
|
|
|
parsedHourly, err := strconv.ParseFloat(hourly, 32)
|
|
if err != nil {
|
|
log.Fatalf("Unable to determine hourly rate: %s", err)
|
|
}
|
|
parsedHourly = math.Round(parsedHourly*100) / 100 // round to closest
|
|
|
|
now := strfmt.DateTime(time.Now())
|
|
|
|
startParams := timesheet.NewPostAPITimesheetsParams()
|
|
startParams.WithBody(&models.TimesheetEditForm{
|
|
Project: &project.ID,
|
|
Activity: &activity.ID,
|
|
Begin: &now,
|
|
HourlyRate: parsedHourly,
|
|
})
|
|
|
|
if description != "" {
|
|
startParams.Body.Description = description
|
|
}
|
|
|
|
_, err = conf.client.Timesheet.PostAPITimesheets(startParams, conf.auth)
|
|
if err != nil {
|
|
log.Fatalf("Unable to start timesheet entry: %s", err)
|
|
}
|
|
|
|
fmt.Println("Started tracking 👏")
|
|
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
Name: "stop",
|
|
Aliases: []string{"t"},
|
|
Usage: "Stop tracking time",
|
|
Action: func(cCtx *cli.Context) error {
|
|
conf := cCtx.Context.Value("config").(config)
|
|
conf.initClient()
|
|
|
|
timesheetParams := timesheet.NewGetAPITimesheetsActiveParams()
|
|
tsResp, err := conf.client.Timesheet.GetAPITimesheetsActive(timesheetParams, conf.auth)
|
|
if err != nil {
|
|
log.Fatalf("Unable to retrieve active timesheet: %s", err)
|
|
}
|
|
|
|
if len(tsResp.Payload) == 0 {
|
|
fmt.Println("No active timesheets currently")
|
|
return nil
|
|
}
|
|
|
|
id := tsResp.Payload[0].ID
|
|
stopParams := timesheet.NewPatchAPITimesheetsIDStopParams()
|
|
stopParams.ID = id
|
|
|
|
_, err = conf.client.Timesheet.PatchAPITimesheetsIDStop(stopParams, conf.auth)
|
|
if err != nil {
|
|
log.Fatalf("Unable to stop active timesheet: %s", err)
|
|
}
|
|
|
|
fmt.Println("Tracking stopped 🤚")
|
|
|
|
return nil
|
|
},
|
|
},
|
|
},
|
|
Action: func(c *cli.Context) error {
|
|
if c.Args().Len() > 0 { // error out with incorrect sub-commands
|
|
if err := cli.ShowSubcommandHelp(c); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
conf := c.Context.Value("config").(config)
|
|
conf.initClient()
|
|
|
|
timesheetParams := timesheet.NewGetAPITimesheetsActiveParams()
|
|
resp, err := conf.client.Timesheet.GetAPITimesheetsActive(timesheetParams, conf.auth)
|
|
if err != nil {
|
|
log.Fatalf("Unable to retrieve active timesheet: %s", err)
|
|
}
|
|
|
|
if len(resp.Payload) == 0 {
|
|
fmt.Println("No active timesheets currently")
|
|
return nil
|
|
}
|
|
|
|
active := resp.Payload[0]
|
|
|
|
// FIXME have this show as a nicer duration
|
|
begin := time.Time(*active.Begin).Local()
|
|
|
|
fmt.Printf("👉 %s %s %s\n",
|
|
*active.Project.Customer.Name,
|
|
*active.Project.Name,
|
|
*active.Activity.Name,
|
|
)
|
|
|
|
if active.Description != "" {
|
|
fmt.Printf("📓 %s \n", active.Description)
|
|
}
|
|
|
|
fmt.Printf("⏳ %s", begin)
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
if err := app.Run(os.Args); err != nil {
|
|
log.Fatalf("Woops, something went wrong: %s", err)
|
|
}
|
|
}
|