diff --git a/README.md b/README.md index 4a3dbdf..a46c09f 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,22 @@ This will show you a table with the time entries in both systems and if there is 2. If some data is in OpenProject but not in tmetric, delete or edit it in OpenProject. To do so use the [cost-report feature](https://www.openproject.org/docs/user-guide/time-and-costs/reporting/). 3. If you want to sync data again from tmetric to OpenProject, remove the `transferred-to-openproject` tag from the time entries in tmetric. **This will create new entries in OpenProject and by that might lead to duplication.** -#### work for a specific time period +#### export data +Data can be exported using the [Go template system](https://pkg.go.dev/text/template). This is useful e.g. to generate an invoice. +First create a template file and then run: + +```bash +go run main.go export --template template.tmpl +``` -By default, the script will work with the current calendar month, but the start and end date can be configured with the `--start` and `--end` flags. The date format is `YYYY-MM-DD`. +- **DetailedReport** with parameters: `clientName string, tagName string, groupName string`. Gets a detailed report from t-metric and returns a `tmetric.Report` object. +- **AllWorkTypes**. Gets all possible work types from t-metric and returns an array of `tmetric.Tag` +- **AllTeams**. Gets all teams from t-metric and returns an array of `tmetric.Team` +- **ServiceDate**. Returns the month of the `--start` date for the export in the format `01/2006` +- **AllTimeEntriesFromOpenProject** with parameter `user string`. Gets all time entries for that user from OpenProject and returns an array of `openproject.TimeEntry` +- **ArbitraryString** with parameter `i int`. Gets the data of the `arbitraryString` command line flag. Useful e.g. to add an invoice number. +- **formatFloat** with parameters `f float64, decimalSeparator string (optional)`. Formats the float value to `%.2f` with that given separator. +- all functions from [spring](https://masterminds.github.io/sprig/) +#### work for a specific time period +By default, the script will work with the current calendar month, but the start and end date can be configured with the `--start` and `--end` flags. The date format is `YYYY-MM-DD`. diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 0000000..e0d61f9 --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,136 @@ +/* +Copyright © 2024 JankariTech Pvt. Ltd. info@jankaritech.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package cmd + +import ( + "fmt" + "github.com/JankariTech/OpenProjectTmetricIntegration/config" + "github.com/JankariTech/OpenProjectTmetricIntegration/openproject" + "github.com/JankariTech/OpenProjectTmetricIntegration/tmetric" + "github.com/Masterminds/sprig/v3" + "github.com/spf13/cobra" + "os" + "path/filepath" + "strings" + "text/template" + "time" +) + +var arbitraryString []string +var tmplFile string + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "export data using a template e.g. to generate an invoice", + Long: ``, + PreRunE: func(cmd *cobra.Command, args []string) error { + _, err := time.Parse("2006-01-02", startDate) + if err != nil { + return fmt.Errorf("start date is not in the format YYYY-MM-DD") + } + _, err = time.Parse("2006-01-02", endDate) + if err != nil { + return fmt.Errorf("end date is not in the format YYYY-MM-DD") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + config := config.NewConfig() + tmetricUser := tmetric.NewUser() + + funcMap := template.FuncMap{ + "ArbitraryString": func(i int) string { + return arbitraryString[i] + }, + "DetailedReport": func(clientName string, tagName string, groupName string) tmetric.Report { + report, _ := tmetric.GetDetailedReport(config, tmetricUser, clientName, tagName, groupName, startDate, endDate) + return report + }, + "AllWorkTypes": func() []tmetric.Tag { + workTypes, _ := tmetric.GetAllWorkTypes(config, tmetricUser) + return workTypes + }, + "AllTeams": func() []tmetric.Team { + teams, _ := tmetric.GetAllTeams(config, tmetricUser) + return teams + }, + "formatFloat": func(f float64, optionalParameters ...string) string { + decimalSeparator := "." + if len(optionalParameters) > 0 { + decimalSeparator = optionalParameters[0] + } + s := fmt.Sprintf("%.2f", f) + return strings.Replace(s, ".", decimalSeparator, -1) + }, + "ServiceDate": func() string { + startTime, _ := time.Parse("2006-01-02", startDate) + return startTime.Format("01/2006") + }, + "AllTimeEntriesFromOpenProject": func(user string) []openproject.TimeEntry { + var openProjectUser openproject.User + openProjectUser, err := openproject.FindUserByName(config, user) + if err != nil { + _, _ = fmt.Fprint(os.Stderr, err) + os.Exit(1) + } + openProjectTimeEntries, err := openproject.GetAllTimeEntries(config, openProjectUser, startDate, endDate) + if err != nil { + _, _ = fmt.Fprint(os.Stderr, err) + os.Exit(1) + } + return openProjectTimeEntries + }, + } + // add all the functions from sprig + for i, f := range sprig.FuncMap() { + funcMap[i] = f + } + + tmpl, err := template.New(filepath.Base(tmplFile)).Funcs(funcMap).ParseFiles(tmplFile) + if err != nil { + _, _ = fmt.Fprint(os.Stderr, fmt.Errorf("could not parse template file '%v': %v", tmplFile, err)) + os.Exit(1) + } + err = tmpl.Execute(os.Stdout, nil) + if err != nil { + _, _ = fmt.Fprint(os.Stderr, fmt.Errorf("could not execute template: %v", err)) + os.Exit(1) + } + }, +} + +func init() { + rootCmd.AddCommand(exportCmd) + + firstDayOfMonth := time.Now().Format("2006-01-02") + firstDayOfMonth = time.Now().AddDate(0, 0, -time.Now().Day()+1).Format("2006-01-02") + + exportCmd.Flags().StringVarP(&startDate, "start", "s", firstDayOfMonth, "start date") + today := time.Now().Format("2006-01-02") + exportCmd.Flags().StringVarP(&endDate, "end", "e", today, "end date") + exportCmd.Flags().StringArrayVarP( + &arbitraryString, + "arbitraryString", + "a", + nil, + "any string that should be placed on the export, e.g. the invoice number", + ) + exportCmd.MarkFlagRequired("arbitraryString") + exportCmd.Flags().StringVarP(&tmplFile, "template", "t", today, "the template file") + exportCmd.MarkFlagRequired("template") +} diff --git a/go.mod b/go.mod index 94f0b60..4940f5f 100644 --- a/go.mod +++ b/go.mod @@ -3,48 +3,58 @@ module github.com/JankariTech/OpenProjectTmetricIntegration go 1.21.7 require ( + github.com/Masterminds/sprig/v3 v3.3.0 + github.com/briandowns/spinner v1.23.1 + github.com/go-chrono/chrono v0.0.0-20240102183611-532f0d0d7c34 github.com/go-resty/resty/v2 v2.15.3 + github.com/jedib0t/go-pretty/v6 v6.6.1 github.com/manifoldco/promptui v0.9.0 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 github.com/tidwall/gjson v1.18.0 + golang.org/x/term v0.25.0 ) require ( - github.com/briandowns/spinner v1.23.1 // indirect + dario.cat/mergo v1.0.1 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/color v1.14.1 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-chrono/chrono v0.0.0-20240102183611-532f0d0d7c34 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jedib0t/go-pretty/v6 v6.6.1 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.26.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.27.0 // indirect golang.org/x/sys v0.26.0 // indirect - golang.org/x/term v0.25.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/text v0.17.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index fda0314..e82b037 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/briandowns/spinner v1.23.1 h1:t5fDPmScwUjozhDj4FA46p5acZWIPXYE30qW2Ptu650= github.com/briandowns/spinner v1.23.1/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= @@ -21,10 +29,14 @@ github.com/go-chrono/chrono v0.0.0-20240102183611-532f0d0d7c34 h1:eG+4Rhfp++D0gL github.com/go-chrono/chrono v0.0.0-20240102183611-532f0d0d7c34/go.mod h1:uTWQdzrjtft2vWY+f+KQ9e3DXHsP0SzhE5SLIicFo08= github.com/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8= github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jedib0t/go-pretty/v6 v6.6.1 h1:iJ65Xjb680rHcikRj6DSIbzCex2huitmc7bDtxYVWyc= @@ -44,8 +56,12 @@ github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPn github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -60,12 +76,14 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -94,22 +112,20 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/tmetric/report.go b/tmetric/report.go new file mode 100644 index 0000000..dc6875b --- /dev/null +++ b/tmetric/report.go @@ -0,0 +1,111 @@ +package tmetric + +import ( + "encoding/json" + "fmt" + "github.com/JankariTech/OpenProjectTmetricIntegration/config" + "github.com/go-resty/resty/v2" + "net/url" + "strconv" + "time" +) + +type ReportItem struct { + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + User string `json:"user"` +} + +type Report struct { + ReportItems []ReportItem + Duration time.Duration +} + +func (reportItem *ReportItem) getParsedTime(startTime bool) (time.Time, error) { + stringToParse := "" + if startTime { + stringToParse = reportItem.StartTime + } else { + stringToParse = reportItem.EndTime + } + + timeParsed, err := time.Parse("2006-01-02T15:04:05Z", stringToParse) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse time: %v", err) + } + return timeParsed, nil +} + +func (reportItem *ReportItem) getDuration() (time.Duration, error) { + startTimeParsed, err := reportItem.getParsedTime(true) + if err != nil { + return 0, err + } + endTimeParsed, err := reportItem.getParsedTime(false) + if err != nil { + return 0, err + } + + duration := endTimeParsed.Sub(startTimeParsed) + if duration < 0 { + return 0, fmt.Errorf("end time is before start time") + } + + return duration, nil +} + +func GetDetailedReport( + config *config.Config, tmetricUser User, clientName string, tagName string, groupName string, startDate string, endDate string, +) (Report, error) { + client, err := getClientByName(config, tmetricUser, clientName) + if err != nil { + return Report{}, err + } + + team, err := getTeamByName(config, tmetricUser, groupName) + if err != nil { + return Report{}, err + } + httpClient := resty.New() + tmetricUrl, _ := url.JoinPath(config.TmetricAPIBaseUrl, "reports/detailed") + request := httpClient.R() + + if tagName != "" { + workType, err := getWorkTypeByName(config, tmetricUser, tagName) + if err != nil { + return Report{}, err + } + request.SetQueryParam("TagList", strconv.Itoa(workType.Id)) + } + + // for this API we have to add one day to actually get the data also for today + endTime, _ := time.Parse("2006-01-02", endDate) + endDate = endTime.AddDate(0, 0, 1).Format("2006-01-02") + + resp, err := request. + SetAuthToken(config.TmetricToken). + SetQueryParam("AccountId", strconv.Itoa(tmetricUser.ActiveAccountId)). + SetQueryParam("ClientList", strconv.Itoa(client.Id)). + SetQueryParam("GroupList", strconv.Itoa(team.Id)). + SetQueryParam("StartDate", startDate). + SetQueryParam("EndDate", endDate). + Get(tmetricUrl) + if err != nil || resp.StatusCode() != 200 { + return Report{}, fmt.Errorf( + "cannot read report from tmetric. Error: '%v'. HTTP status code: %v", err, resp.StatusCode(), + ) + } + + var reportItems []ReportItem + err = json.Unmarshal(resp.Body(), &reportItems) + if err != nil { + return Report{}, fmt.Errorf("error parsing report response: %v\n", err) + } + var report Report + for _, item := range reportItems { + report.ReportItems = append(report.ReportItems, item) + itemDuration, _ := item.getDuration() + report.Duration += itemDuration + } + return report, nil +} diff --git a/tmetric/tmetric.go b/tmetric/tmetric.go index 43e3c72..8490e5f 100644 --- a/tmetric/tmetric.go +++ b/tmetric/tmetric.go @@ -5,10 +5,139 @@ import ( "fmt" "github.com/JankariTech/OpenProjectTmetricIntegration/config" "github.com/go-resty/resty/v2" + "net/url" "sort" + "strconv" "strings" ) +// ClientV2 represents a client that is returned by the Tmetric API V2 +// see https://app.tmetric.com/api-docs/v2/#/Clients/clients-get-api-accounts-accountid-clients +type ClientV2 struct { + Id int `json:"clientId"` + Name string `json:"clientName"` +} + +// TagV2 represents a tag that is returned by the Tmetric API V2 +// see https://app.tmetric.com/api-docs/v2/#/Tags/tags-get-api-accounts-accountid-tags +type TagV2 struct { + Id int `json:"tagId"` + Name string `json:"tagName"` + IsWorkType bool `json:"isWorkType"` +} + +type Team struct { + Name string `json:"name"` + Id int `json:"id"` +} + +func GetAllTeams(config *config.Config, tmetricUser User) ([]Team, error) { + httpClient := resty.New() + tmetricUrl, _ := url.JoinPath( + config.TmetricAPIV3BaseUrl, "accounts/", strconv.Itoa(tmetricUser.ActiveAccountId), "/teams/managed", + ) + resp, err := httpClient.R(). + SetAuthToken(config.TmetricToken). + Get(tmetricUrl) + if err != nil || resp.StatusCode() != 200 { + return nil, fmt.Errorf( + "cannot read teams from tmetric. Error: '%v'. HTTP status code: %v", err, resp.StatusCode(), + ) + } + var teams []Team + err = json.Unmarshal(resp.Body(), &teams) + if err != nil { + return nil, fmt.Errorf("error parsing teams response: %v\n", err) + } + return teams, nil +} + +func getTeamByName(config *config.Config, tmetricUser User, name string) (Team, error) { + teams, err := GetAllTeams(config, tmetricUser) + if err != nil { + return Team{}, err + } + for _, team := range teams { + if team.Name == name { + return team, nil + } + } + return Team{}, fmt.Errorf("could not find any team with name '%v'", name) +} + +func GetAllWorkTypes(config *config.Config, tmetricUser User) ([]Tag, error) { + httpClient := resty.New() + tmetricUrl, _ := url.JoinPath( + config.TmetricAPIBaseUrl, "accounts/", strconv.Itoa(tmetricUser.ActiveAccountId), "/tags", + ) + resp, err := httpClient.R(). + SetAuthToken(config.TmetricToken). + Get(tmetricUrl) + if err != nil || resp.StatusCode() != 200 { + return nil, fmt.Errorf( + "cannot read tags from tmetric. Error: '%v'. HTTP status code: %v", err, resp.StatusCode(), + ) + } + var tags []TagV2 + err = json.Unmarshal(resp.Body(), &tags) + if err != nil { + return nil, fmt.Errorf("error parsing tags response: %v\n", err) + } + var workTypes []Tag + for _, tag := range tags { + if tag.IsWorkType { + workTypes = append(workTypes, Tag{ + Id: tag.Id, + Name: tag.Name, + IsWorkType: tag.IsWorkType, + }) + } + } + return workTypes, err +} + +func getWorkTypeByName(config *config.Config, tmetricUser User, name string) (Tag, error) { + worktypes, err := GetAllWorkTypes(config, tmetricUser) + if err != nil { + return Tag{}, err + } + for _, tag := range worktypes { + if tag.Name == name { + return tag, nil + } + } + return Tag{}, fmt.Errorf("could not find any work type with name '%v'", name) +} + +func getClientByName(config *config.Config, tmetricUser User, name string) (Client, error) { + httpClient := resty.New() + tmetricUrl, _ := url.JoinPath( + config.TmetricAPIBaseUrl, "accounts/", strconv.Itoa(tmetricUser.ActiveAccountId), "/clients", + ) + resp, err := httpClient.R(). + SetAuthToken(config.TmetricToken). + Get(tmetricUrl) + if err != nil || resp.StatusCode() != 200 { + return Client{}, fmt.Errorf( + "cannot read clients from tmetric. Error: '%v'. HTTP status code: %v", err, resp.StatusCode(), + ) + } + var clients []ClientV2 + err = json.Unmarshal(resp.Body(), &clients) + if err != nil { + return Client{}, fmt.Errorf("error parsing clients response: %v\n", err) + } + for _, client := range clients { + if client.Name == name { + return Client{ + Id: client.Id, + Name: client.Name, + }, nil + } + } + return Client{}, fmt.Errorf("could not find any client with name '%v'", name) +} + func GetAllTimeEntries(config *config.Config, tmetricUser User, startDate string, endDate string) ([]TimeEntry, error) { httpClient := resty.New() resp, err := httpClient.R().