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/models"
"github.com/muety/wakapi/routes"
v1Routes "github.com/muety/wakapi/routes/compat/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
@ -86,12 +87,15 @@ func main() {
go heartbeatService.ScheduleCleanUp()
}
// TODO: move endpoint registration to the respective routes files
// Handlers
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db)
settingsHandler := routes.NewSettingsHandler(userService)
publicHandler := routes.NewIndexHandler(userService, keyValueService)
compatV1AllHandler := v1Routes.NewCompatV1AllHandler(summaryService)
// Setup Routers
router := mux.NewRouter()
@ -99,6 +103,7 @@ func main() {
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter()
compatV1Router := apiRouter.PathPrefix("/compat/v1").Subrouter()
// Middlewares
recoveryMiddleware := handlers.RecoveryHandler()
@ -136,6 +141,9 @@ func main() {
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.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
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
)
const (
IntervalToday string = "today"
IntervalLastDay string = "day"
IntervalLastWeek string = "week"
IntervalLastMonth string = "month"
IntervalLastYear string = "year"
IntervalAny string = "any"
)
const UnknownSummaryKey = "unknown"
type Summary struct {
@ -48,6 +57,31 @@ type SummaryViewModel struct {
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.
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"
@ -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".
*/
func (s *Summary) FillUnknown() {
types := []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
types := s.Types()
typeItems := s.MappedItems()
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 {
if len(*typeItems[t]) == 0 {
missingTypes = append(missingTypes, t)
} else {
somePresentType = t
}
}
@ -84,11 +109,7 @@ func (s *Summary) FillUnknown() {
return
}
// calculate total duration from any of the present sets of items
var timeSum time.Duration
for _, item := range *typeItems[somePresentType] {
timeSum += item.Total
}
timeSum := s.TotalTime()
// construct dummy item for all missing types
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
import (
"errors"
"net/http"
"time"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
const (
IntervalToday string = "today"
IntervalLastDay string = "day"
IntervalLastWeek string = "week"
IntervalLastMonth string = "month"
IntervalLastYear string = "year"
IntervalAny string = "any"
"net/http"
)
type SummaryHandler struct {
@ -32,7 +20,7 @@ func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler
}
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 {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
@ -53,7 +41,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
r.URL.RawQuery = q.Encode()
}
summary, err, status := loadUserSummary(r, h.summarySrvc)
summary, err, status := h.loadUserSummary(r)
if err != nil {
respondAlert(w, err.Error(), "", "summary.tpl.html", status)
return
@ -74,39 +62,13 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
templates["summary.tpl.html"].Execute(w, vm)
}
func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) {
user := r.Context().Value(models.UserKey).(*models.User)
params := r.URL.Query()
interval := params.Get("interval")
from, err := utils.ParseDate(params.Get("from"))
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
summaryParams, err := utils.ParseSummaryParams(r)
if err != nil {
switch interval {
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
}
return nil, err, http.StatusBadRequest
}
live := (params.Get("live") != "" && params.Get("live") != "false") || interval == IntervalToday
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
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
}

View File

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

View File

@ -1,6 +1,9 @@
package utils
import "time"
import (
"fmt"
"time"
)
func StartOfDay() time.Time {
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())
}
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
func firstDayOfISOWeek(year int, week int, timezone *time.Location) time.Time {
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