mirror of https://github.com/muety/wakapi.git
feat: add wakatime-compatible alltime endpoint
This commit is contained in:
parent
97cb29ee4d
commit
587ac6a330
8
main.go
8
main.go
|
@ -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")))
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -1 +1 @@
|
||||||
1.8.4
|
1.9.0
|
Loading…
Reference in New Issue