diff --git a/main.go b/main.go index 9830a14..648decf 100644 --- a/main.go +++ b/main.go @@ -59,6 +59,7 @@ func main() { Addr: config.DbHost, DBName: config.DbName, AllowNativePasswords: true, + ParseTime: true, } db, _ := sql.Open("mysql", dbConfig.FormatDSN()) defer db.Close() @@ -70,9 +71,11 @@ func main() { // Services heartbeatSrvc := &services.HeartbeatService{db} userSrvc := &services.UserService{db} + aggregationSrvc := &services.AggregationService{db, heartbeatSrvc} // Handlers heartbeatHandler := &routes.HeartbeatHandler{HeartbeatSrvc: heartbeatSrvc} + aggregationHandler := &routes.AggregationHandler{AggregationSrvc: aggregationSrvc} // Middlewares authenticate := &middlewares.AuthenticateMiddleware{UserSrvc: userSrvc} @@ -85,6 +88,9 @@ func main() { heartbeats := apiRouter.Path("/heartbeat").Subrouter() heartbeats.Methods("POST").HandlerFunc(heartbeatHandler.Post) + aggreagations := apiRouter.Path("/aggregation").Subrouter() + aggreagations.Methods("GET").HandlerFunc(aggregationHandler.Get) + // Sub-Routes Setup router.PathPrefix("/api").Handler(negroni.Classic().With( negroni.HandlerFunc(authenticate.Handle), diff --git a/models/aggregation.go b/models/aggregation.go new file mode 100644 index 0000000..c506f60 --- /dev/null +++ b/models/aggregation.go @@ -0,0 +1,24 @@ +package models + +import "time" + +type AggregationType string + +const ( + AggregationProject AggregationType = "project" + AggregationLanguage AggregationType = "language" + AggregationEditor AggregationType = "editor" + AggregationOS AggregationType = "os" +) + +type Aggregation struct { + From time.Time + To time.Time + Type AggregationType + Items []AggregationItem +} + +type AggregationItem struct { + Key string + Total time.Duration +} diff --git a/models/heartbeat.go b/models/heartbeat.go index c3ec908..45d8628 100644 --- a/models/heartbeat.go +++ b/models/heartbeat.go @@ -9,15 +9,17 @@ import ( type HeartbeatReqTime time.Time type Heartbeat struct { - User string `json:"user"` - Entity string `json:"entity"` - Type string `json:"type"` - Category string `json:"category"` - Project string `json:"project"` - Branch string `json:"branch"` - Language string `json:"language"` - IsWrite bool `json:"is_write"` - Time HeartbeatReqTime `json:"time"` + User string `json:"user"` + Entity string `json:"entity"` + Type string `json:"type"` + Category string `json:"category"` + Project string `json:"project"` + Branch string `json:"branch"` + Language string `json:"language"` + IsWrite bool `json:"is_write"` + Editor string `json:"editor"` + OperatingSystem string `json:"operating_system"` + Time HeartbeatReqTime `json:"time"` } func (j *HeartbeatReqTime) UnmarshalJSON(b []byte) error { diff --git a/routes/aggregation.go b/routes/aggregation.go new file mode 100644 index 0000000..2d83040 --- /dev/null +++ b/routes/aggregation.go @@ -0,0 +1,39 @@ +package routes + +import ( + "net/http" + "time" + + "github.com/n1try/wakapi/models" + "github.com/n1try/wakapi/services" + "github.com/n1try/wakapi/utils" +) + +type AggregationHandler struct { + AggregationSrvc *services.AggregationService +} + +func (h *AggregationHandler) Get(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + w.WriteHeader(415) + return + } + + user := r.Context().Value(models.UserKey).(*models.User) + params := r.URL.Query() + from, err := utils.ParseDate(params.Get("from")) + if err != nil { + w.WriteHeader(400) + w.Write([]byte("Missing 'from' parameter")) + return + } + + to, err := utils.ParseDate(params.Get("to")) + if err != nil { + to = time.Now() + } + + h.AggregationSrvc.Aggregate(from, to, user) + + w.WriteHeader(200) +} diff --git a/routes/heartbeat.go b/routes/heartbeat.go index 56df7bc..5ef13d2 100644 --- a/routes/heartbeat.go +++ b/routes/heartbeat.go @@ -6,6 +6,7 @@ import ( "os" "github.com/n1try/wakapi/services" + "github.com/n1try/wakapi/utils" _ "github.com/go-sql-driver/mysql" "github.com/n1try/wakapi/models" @@ -20,14 +21,21 @@ func (h *HeartbeatHandler) Post(w http.ResponseWriter, r *http.Request) { w.WriteHeader(415) return } + + opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent")) + dec := json.NewDecoder(r.Body) - var heartbeats []models.Heartbeat + var heartbeats []*models.Heartbeat err := dec.Decode(&heartbeats) if err != nil { w.WriteHeader(400) w.Write([]byte(err.Error())) return } + for _, h := range heartbeats { + h.OperatingSystem = opSys + h.Editor = editor + } user := r.Context().Value(models.UserKey).(*models.User) err = h.HeartbeatSrvc.InsertBatch(heartbeats, user) @@ -37,5 +45,5 @@ func (h *HeartbeatHandler) Post(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(201) + w.WriteHeader(200) } diff --git a/services/aggregation.go b/services/aggregation.go new file mode 100644 index 0000000..8eb2519 --- /dev/null +++ b/services/aggregation.go @@ -0,0 +1,29 @@ +package services + +import ( + "database/sql" + "fmt" + "log" + "time" + + "github.com/n1try/wakapi/models" +) + +type AggregationService struct { + Db *sql.DB + HeartbeatService *HeartbeatService +} + +func (srv *AggregationService) Aggregate(from time.Time, to time.Time, user *models.User) { + heartbeats, err := srv.HeartbeatService.GetAllFrom(from, user) + if err != nil { + log.Fatal(err) + } + for _, h := range heartbeats { + fmt.Printf("%+v\n", h) + } +} + +func (srv *AggregationService) aggregateBy(*[]models.Heartbeat, models.AggregationType) *models.Aggregation { + return &models.Aggregation{} +} diff --git a/services/heartbeat.go b/services/heartbeat.go index 7d07f43..b27f740 100644 --- a/services/heartbeat.go +++ b/services/heartbeat.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "time" "github.com/n1try/wakapi/models" ) @@ -14,14 +15,14 @@ type HeartbeatService struct { Db *sql.DB } -func (srv *HeartbeatService) InsertBatch(heartbeats []models.Heartbeat, user *models.User) error { - qTpl := "INSERT INTO %+s (user, time, entity, type, category, is_write, project, branch, language) VALUES %+s;" +func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat, user *models.User) error { + qTpl := "INSERT INTO %+s (user, time, entity, type, category, is_write, project, branch, language, operating_system, editor) VALUES %+s;" qFill := "" vals := []interface{}{} for _, h := range heartbeats { - qFill = "(?, ?, ?, ?, ?, ?, ?, ?, ?)," - vals = append(vals, user.UserId, h.Time.String(), h.Entity, h.Type, h.Category, h.IsWrite, h.Project, h.Branch, h.Language) + qFill = qFill + "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)," + vals = append(vals, user.UserId, h.Time.String(), h.Entity, h.Type, h.Category, h.IsWrite, h.Project, h.Branch, h.Language, h.OperatingSystem, h.Editor) } q := fmt.Sprintf(qTpl, TableHeartbeat, qFill[0:len(qFill)-1]) @@ -36,3 +37,42 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []models.Heartbeat, user *mo } return nil } + +func (srv *HeartbeatService) GetAllFrom(date time.Time, user *models.User) ([]models.Heartbeat, error) { + q := fmt.Sprintf("SELECT user, time, language, project, operating_system, editor FROM %+s WHERE time >= ? AND user = ?", TableHeartbeat) + rows, err := srv.Db.Query(q, date.String(), user.UserId) + defer rows.Close() + if err != nil { + return make([]models.Heartbeat, 0), err + } + + var heartbeats []models.Heartbeat + for rows.Next() { + var h models.Heartbeat + var language sql.NullString + var project sql.NullString + var operatingSystem sql.NullString + var editor sql.NullString + + err := rows.Scan(&h.User, &h.Time, &language, &project, &operatingSystem, &editor) + + if language.Valid { + h.Language = language.String + } + if project.Valid { + h.Project = project.String + } + if operatingSystem.Valid { + h.OperatingSystem = operatingSystem.String + } + if editor.Valid { + h.Editor = editor.String + } + + if err != nil { + return make([]models.Heartbeat, 0), err + } + heartbeats = append(heartbeats, h) + } + return heartbeats, nil +} diff --git a/utils/common.go b/utils/common.go new file mode 100644 index 0000000..8d7c5ed --- /dev/null +++ b/utils/common.go @@ -0,0 +1,24 @@ +package utils + +import ( + "errors" + "regexp" + "time" +) + +func ParseDate(date string) (time.Time, error) { + return time.Parse("2006-01-02 15:04:05", date) +} + +func FormatDate(date time.Time) string { + return date.Format("2006-01-02 15:04:05") +} + +func ParseUserAgent(ua string) (string, string, error) { + re := regexp.MustCompile(`^wakatime\/[\d+.]+\s\((\w+).*\)\s.+\s(\w+)\/.+$`) + groups := re.FindAllStringSubmatch(ua, -1) + if len(groups) == 0 || len(groups[0]) != 3 { + return "", "", errors.New("Failed to parse user agent string") + } + return groups[0][1], groups[0][2], nil +}