feat: add wakatime-compatible alltime endpoint

This commit is contained in:
Ferdinand Mütsch 2020-09-06 12:15:46 +02:00
parent 97cb29ee4d
commit 587ac6a330
9 changed files with 216 additions and 64 deletions

View File

@ -15,6 +15,7 @@ import (
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes" "github.com/muety/wakapi/routes"
v1Routes "github.com/muety/wakapi/routes/compat/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
) )
@ -86,12 +87,15 @@ func main() {
go heartbeatService.ScheduleCleanUp() go heartbeatService.ScheduleCleanUp()
} }
// TODO: move endpoint registration to the respective routes files
// Handlers // Handlers
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService) heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
summaryHandler := routes.NewSummaryHandler(summaryService) summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db) healthHandler := routes.NewHealthHandler(db)
settingsHandler := routes.NewSettingsHandler(userService) settingsHandler := routes.NewSettingsHandler(userService)
publicHandler := routes.NewIndexHandler(userService, keyValueService) publicHandler := routes.NewIndexHandler(userService, keyValueService)
compatV1AllHandler := v1Routes.NewCompatV1AllHandler(summaryService)
// Setup Routers // Setup Routers
router := mux.NewRouter() router := mux.NewRouter()
@ -99,6 +103,7 @@ func main() {
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter() settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter() summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter() apiRouter := router.PathPrefix("/api").Subrouter()
compatV1Router := apiRouter.PathPrefix("/compat/v1").Subrouter()
// Middlewares // Middlewares
recoveryMiddleware := handlers.RecoveryHandler() recoveryMiddleware := handlers.RecoveryHandler()
@ -136,6 +141,9 @@ func main() {
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet) apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet)
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet) apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
// Compat V1 API Routes
compatV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(compatV1AllHandler.ApiGet)
// Static Routes // Static Routes
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static"))) router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))

View File

@ -0,0 +1,9 @@
package v1
// https://wakatime.com/developers#all_time_since_today
type AllTimeVieModel struct {
Seconds float32 `json:"seconds"` // total number of seconds logged since account created
Text string `json:"text"` // total time logged since account created as human readable string>
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
}

View File

@ -13,6 +13,15 @@ const (
SummaryMachine uint8 = 4 SummaryMachine uint8 = 4
) )
const (
IntervalToday string = "today"
IntervalLastDay string = "day"
IntervalLastWeek string = "week"
IntervalLastMonth string = "month"
IntervalLastYear string = "year"
IntervalAny string = "any"
)
const UnknownSummaryKey = "unknown" const UnknownSummaryKey = "unknown"
type Summary struct { type Summary struct {
@ -48,6 +57,31 @@ type SummaryViewModel struct {
ApiKey string ApiKey string
} }
type SummaryParams struct {
From time.Time
To time.Time
User *User
Recompute bool
}
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
}
func (s *Summary) Types() []uint8 {
return SummaryTypes()
}
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
return map[uint8]*[]*SummaryItem{
SummaryProject: &s.Projects,
SummaryLanguage: &s.Languages,
SummaryEditor: &s.Editors,
SummaryOS: &s.OperatingSystems,
SummaryMachine: &s.Machines,
}
}
/* Augments the summary in a way that at least one item is present for every type. /* Augments the summary in a way that at least one item is present for every type.
If a summary has zero items for a given type, but one or more for any of the other types, If a summary has zero items for a given type, but one or more for any of the other types,
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown" the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
@ -60,22 +94,13 @@ To avoid having to modify persisted data retrospectively, i.e. inserting a dummy
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown". such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
*/ */
func (s *Summary) FillUnknown() { func (s *Summary) FillUnknown() {
types := []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine} types := s.Types()
typeItems := s.MappedItems()
missingTypes := make([]uint8, 0) missingTypes := make([]uint8, 0)
typeItems := map[uint8]*[]*SummaryItem{
SummaryProject: &s.Projects,
SummaryLanguage: &s.Languages,
SummaryEditor: &s.Editors,
SummaryOS: &s.OperatingSystems,
SummaryMachine: &s.Machines,
}
var somePresentType uint8
for _, t := range types { for _, t := range types {
if len(*typeItems[t]) == 0 { if len(*typeItems[t]) == 0 {
missingTypes = append(missingTypes, t) missingTypes = append(missingTypes, t)
} else {
somePresentType = t
} }
} }
@ -84,11 +109,7 @@ func (s *Summary) FillUnknown() {
return return
} }
// calculate total duration from any of the present sets of items timeSum := s.TotalTime()
var timeSum time.Duration
for _, item := range *typeItems[somePresentType] {
timeSum += item.Total
}
// construct dummy item for all missing types // construct dummy item for all missing types
for _, t := range missingTypes { for _, t := range missingTypes {
@ -99,3 +120,21 @@ func (s *Summary) FillUnknown() {
}) })
} }
} }
func (s *Summary) TotalTime() time.Duration {
var timeSum time.Duration
mappedItems := s.MappedItems()
// calculate total duration from any of the present sets of items
for _, t := range s.Types() {
if items := mappedItems[t]; len(*items) > 0 {
for _, item := range *items {
timeSum += item.Total
}
break
}
}
return timeSum
}

View File

@ -0,0 +1,71 @@
package v1
import (
"github.com/gorilla/mux"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
"time"
)
type CompatV1AllHandler struct {
summarySrvc *services.SummaryService
config *models.Config
}
func NewCompatV1AllHandler(summaryService *services.SummaryService) *CompatV1AllHandler {
return &CompatV1AllHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
}
}
func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
requestedUser := vars["user"]
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
if requestedUser != authorizedUser.ID && requestedUser != "current" {
w.WriteHeader(http.StatusForbidden)
return
}
values, _ := url.ParseQuery(r.URL.RawQuery)
values.Set("interval", models.IntervalAny)
r.URL.RawQuery = values.Encode()
summary, err, status := h.loadUserSummary(authorizedUser)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
total := summary.TotalTime()
vm := &v1.AllTimeVieModel{
Seconds: float32(total),
Text: utils.FmtWakatimeDuration(total * time.Second),
IsUpToDate: true,
}
utils.RespondJSON(w, http.StatusOK, vm)
}
func (h *CompatV1AllHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
summaryParams := &models.SummaryParams{
From: time.Time{},
To: time.Now(),
User: user,
Recompute: false,
}
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}

View File

@ -1,22 +1,10 @@
package routes package routes
import ( import (
"errors"
"net/http"
"time"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
) "net/http"
const (
IntervalToday string = "today"
IntervalLastDay string = "day"
IntervalLastWeek string = "week"
IntervalLastMonth string = "month"
IntervalLastYear string = "year"
IntervalAny string = "any"
) )
type SummaryHandler struct { type SummaryHandler struct {
@ -32,7 +20,7 @@ func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler
} }
func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) { func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
summary, err, status := loadUserSummary(r, h.summarySrvc) summary, err, status := h.loadUserSummary(r)
if err != nil { if err != nil {
w.WriteHeader(status) w.WriteHeader(status)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
@ -53,7 +41,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
r.URL.RawQuery = q.Encode() r.URL.RawQuery = q.Encode()
} }
summary, err, status := loadUserSummary(r, h.summarySrvc) summary, err, status := h.loadUserSummary(r)
if err != nil { if err != nil {
respondAlert(w, err.Error(), "", "summary.tpl.html", status) respondAlert(w, err.Error(), "", "summary.tpl.html", status)
return return
@ -74,39 +62,13 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
templates["summary.tpl.html"].Execute(w, vm) templates["summary.tpl.html"].Execute(w, vm)
} }
func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) { func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
user := r.Context().Value(models.UserKey).(*models.User) summaryParams, err := utils.ParseSummaryParams(r)
params := r.URL.Query()
interval := params.Get("interval")
from, err := utils.ParseDate(params.Get("from"))
if err != nil { if err != nil {
switch interval { return nil, err, http.StatusBadRequest
case IntervalToday:
from = utils.StartOfDay()
case IntervalLastDay:
from = utils.StartOfDay().Add(-24 * time.Hour)
case IntervalLastWeek:
from = utils.StartOfWeek()
case IntervalLastMonth:
from = utils.StartOfMonth()
case IntervalLastYear:
from = utils.StartOfYear()
case IntervalAny:
from = time.Time{}
default:
return nil, errors.New("missing 'from' parameter"), http.StatusBadRequest
}
} }
live := (params.Get("live") != "" && params.Get("live") != "false") || interval == IntervalToday summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
to := utils.StartOfDay()
if live {
to = time.Now()
}
var summary *models.Summary
summary, err = summaryService.Construct(from, to, user, recompute) // 'to' is always constant
if err != nil { if err != nil {
return nil, err, http.StatusInternalServerError return nil, err, http.StatusInternalServerError
} }

View File

@ -65,7 +65,7 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
heartbeats = append(heartbeats, hb...) heartbeats = append(heartbeats, hb...)
} }
types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS, models.SummaryMachine} types := models.SummaryTypes()
var projectItems []*models.SummaryItem var projectItems []*models.SummaryItem
var languageItems []*models.SummaryItem var languageItems []*models.SummaryItem

View File

@ -1,6 +1,9 @@
package utils package utils
import "time" import (
"fmt"
"time"
)
func StartOfDay() time.Time { func StartOfDay() time.Time {
ref := time.Now() ref := time.Now()
@ -23,6 +26,14 @@ func StartOfYear() time.Time {
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location()) return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
} }
func FmtWakatimeDuration(d time.Duration) string {
d = d.Round(time.Minute)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
return fmt.Sprintf("%d hrs %d mins", h, m)
}
// https://stackoverflow.com/a/18632496 // https://stackoverflow.com/a/18632496
func firstDayOfISOWeek(year int, week int, timezone *time.Location) time.Time { func firstDayOfISOWeek(year int, week int, timezone *time.Location) time.Time {
date := time.Date(year, 0, 0, 0, 0, 0, 0, timezone) date := time.Date(year, 0, 0, 0, 0, 0, 0, timezone)

52
utils/summary.go Normal file
View File

@ -0,0 +1,52 @@
package utils
import (
"errors"
"github.com/muety/wakapi/models"
"net/http"
"time"
)
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
user := r.Context().Value(models.UserKey).(*models.User)
params := r.URL.Query()
interval := params.Get("interval")
from, err := ParseDate(params.Get("from"))
if err != nil {
switch interval {
case models.IntervalToday:
from = StartOfDay()
case models.IntervalLastDay:
from = StartOfDay().Add(-24 * time.Hour)
case models.IntervalLastWeek:
from = StartOfWeek()
case models.IntervalLastMonth:
from = StartOfMonth()
case models.IntervalLastYear:
from = StartOfYear()
case models.IntervalAny:
from = time.Time{}
default:
return nil, errors.New("missing 'from' parameter")
}
}
live := (params.Get("live") != "" && params.Get("live") != "false") || interval == models.IntervalToday
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
to := StartOfDay()
if live {
to = time.Now()
}
return &models.SummaryParams{
From: from,
To: to,
User: user,
Recompute: recompute,
}, nil
}

View File

@ -1 +1 @@
1.8.4 1.9.0