kimchi/kimchi.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)
}
}