feat: implement summaries compat endpoint (resolve #44)

fix: fix all time view model
This commit is contained in:
Ferdinand Mütsch 2020-09-11 23:24:51 +02:00
parent a8009e107d
commit 21567e7601
12 changed files with 326 additions and 44 deletions

View File

@ -96,6 +96,7 @@ func main() {
settingsHandler := routes.NewSettingsHandler(userService)
publicHandler := routes.NewIndexHandler(userService, keyValueService)
compatV1AllHandler := v1Routes.NewCompatV1AllHandler(summaryService)
compatV1SummariesHandler := v1Routes.NewCompatV1SummariesHandler(summaryService)
// Setup Routers
router := mux.NewRouter()
@ -143,6 +144,7 @@ func main() {
// Compat V1 API Routes
compatV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(compatV1AllHandler.ApiGet)
compatV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(compatV1SummariesHandler.ApiGet)
// Static Routes
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))

View File

@ -1,13 +1,36 @@
package v1
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
)
// https://wakatime.com/developers#all_time_since_today
type AllTimeViewModel struct {
Data *AllTimeViewModelData `json:"data"`
type WakatimeAllTime struct {
Data *wakatimeAllTimeData `json:"data"`
}
type AllTimeViewModelData 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>
type wakatimeAllTimeData struct {
TotalSeconds float32 `json:"total_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>
}
func NewAllTimeFrom(summary *models.Summary, filters *Filters) *WakatimeAllTime {
var total time.Duration
if key := filters.Project; key != "" {
total = summary.TotalTimeByKey(models.SummaryProject, key)
} else {
total = summary.TotalTime()
}
return &WakatimeAllTime{
Data: &wakatimeAllTimeData{
TotalSeconds: float32(total.Seconds()),
Text: utils.FmtWakatimeDuration(total),
IsUpToDate: true,
},
}
}

View File

@ -0,0 +1,5 @@
package v1
type Filters struct {
Project string
}

View File

@ -0,0 +1,147 @@
package v1
import (
"fmt"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"math"
"time"
)
// https://wakatime.com/developers#summaries
// https://pastr.de/v/736450
type WakatimeSummaries struct {
Data []*wakatimeSummariesData `json:"data"`
End time.Time `json:"end"`
Start time.Time `json:"start"`
}
type wakatimeSummariesData struct {
Categories []*wakatimeSummariesEntry `json:"categories"`
Dependencies []*wakatimeSummariesEntry `json:"dependencies"`
Editors []*wakatimeSummariesEntry `json:"editors"`
Languages []*wakatimeSummariesEntry `json:"languages"`
Machines []*wakatimeSummariesEntry `json:"machines"`
OperatingSystems []*wakatimeSummariesEntry `json:"operating_systems"`
Projects []*wakatimeSummariesEntry `json:"projects"`
GrandTotal *wakatimeSummariesGrandTotal `json:"grand_total"`
Range *wakatimeSummariesRange `json:"range"`
}
type wakatimeSummariesEntry struct {
Digital string `json:"digital"`
Hours int `json:"hours"`
Minutes int `json:"minutes"`
Name string `json:"name"`
Percent float64 `json:"percent"`
Seconds int `json:"seconds"`
Text string `json:"text"`
TotalSeconds float64 `json:"total_seconds"`
}
type wakatimeSummariesGrandTotal struct {
Digital string `json:"digital"`
Hours int `json:"hours"`
Minutes int `json:"minutes"`
Text string `json:"text"`
TotalSeconds float64 `json:"total_seconds"`
}
type wakatimeSummariesRange struct {
Date string `json:"date"`
End time.Time `json:"end"`
Start time.Time `json:"start"`
Text string `json:"text"`
Timezone string `json:"timezone"`
}
func NewSummariesFrom(summaries []*models.Summary, filters *Filters) *WakatimeSummaries {
data := make([]*wakatimeSummariesData, len(summaries))
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
for i, s := range summaries {
data[i] = newDataFrom(s)
if s.FromTime.Before(minDate) {
minDate = s.FromTime
}
if s.ToTime.After(maxDate) {
maxDate = s.ToTime
}
}
return &WakatimeSummaries{
Data: data,
End: maxDate,
Start: minDate,
}
}
func newDataFrom(s *models.Summary) *wakatimeSummariesData {
zone, _ := time.Now().Zone()
total := s.TotalTime()
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
data := &wakatimeSummariesData{
Categories: make([]*wakatimeSummariesEntry, 0),
Dependencies: make([]*wakatimeSummariesEntry, 0),
Editors: make([]*wakatimeSummariesEntry, len(s.Editors)),
Languages: make([]*wakatimeSummariesEntry, len(s.Languages)),
Machines: make([]*wakatimeSummariesEntry, len(s.Machines)),
OperatingSystems: make([]*wakatimeSummariesEntry, len(s.OperatingSystems)),
Projects: make([]*wakatimeSummariesEntry, len(s.Projects)),
GrandTotal: &wakatimeSummariesGrandTotal{
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
Hours: totalHrs,
Minutes: totalMins,
Text: utils.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(),
},
Range: &wakatimeSummariesRange{
Date: time.Now().Format(time.RFC3339),
End: s.ToTime,
Start: s.FromTime,
Text: "",
Timezone: zone,
},
}
for i, e := range s.Projects {
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
}
for i, e := range s.Editors {
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
}
for i, e := range s.Languages {
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
}
for i, e := range s.OperatingSystems {
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
}
for i, e := range s.Machines {
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
}
return data
}
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *wakatimeSummariesEntry {
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
// TODO: fix some day, while migrating persisted summary items
total := e.Total * time.Second
hrs := int(total.Hours())
mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())
return &wakatimeSummariesEntry{
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
Hours: hrs,
Minutes: mins,
Name: e.Key,
Percent: math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100,
Seconds: secs,
Text: utils.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(),
}
}

View File

@ -125,7 +125,6 @@ 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 {
@ -136,15 +135,26 @@ func (s *Summary) TotalTime() time.Duration {
}
}
return timeSum
return timeSum * time.Second
}
func (s *Summary) TotalTimeBy(entityType uint8, key string) time.Duration {
func (s *Summary) TotalTimeBy(entityType uint8) time.Duration {
var timeSum time.Duration
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
timeSum += item.Total
}
}
// calculate total duration from any of the present sets of items
return timeSum * time.Second
}
func (s *Summary) TotalTimeByKey(entityType uint8, key string) time.Duration {
var timeSum time.Duration
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
if item.Key != key {
@ -154,5 +164,5 @@ func (s *Summary) TotalTimeBy(entityType uint8, key string) time.Duration {
}
}
return timeSum
return timeSum * time.Second
}

View File

@ -25,6 +25,8 @@ func NewCompatV1AllHandler(summaryService *services.SummaryService) *CompatV1All
func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
values, _ := url.ParseQuery(r.URL.RawQuery)
requestedUser := vars["user"]
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
@ -33,10 +35,6 @@ func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
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)
@ -44,21 +42,7 @@ func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
return
}
var total time.Duration
if key := values.Get("project"); key != "" {
total = summary.TotalTimeBy(models.SummaryProject, key)
} else {
total = summary.TotalTime()
}
vm := &v1.AllTimeViewModel{
Data: &v1.AllTimeViewModelData{
Seconds: float32(total),
Text: utils.FmtWakatimeDuration(total * time.Second),
IsUpToDate: true,
},
}
vm := v1.NewAllTimeFrom(summary, &v1.Filters{Project: values.Get("project")})
utils.RespondJSON(w, http.StatusOK, vm)
}

View File

@ -0,0 +1,96 @@
package v1
import (
"errors"
"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"
"strings"
"time"
)
type CompatV1SummariesHandler struct {
summarySrvc *services.SummaryService
config *models.Config
}
func NewCompatV1SummariesHandler(summaryService *services.SummaryService) *CompatV1SummariesHandler {
return &CompatV1SummariesHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
}
}
/*
TODO: support parameters: branches, timeout, writes_only, timezone
https://wakatime.com/developers#summaries
timezone can be specified via an offset suffix (e.g. +02:00) in date strings
*/
func (h *CompatV1SummariesHandler) 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
}
summaries, err, status := h.loadUserSummaries(r)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
vm := v1.NewSummariesFrom(summaries, &v1.Filters{})
utils.RespondJSON(w, http.StatusOK, vm)
}
func (h *CompatV1SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
user := r.Context().Value(models.UserKey).(*models.User)
params := r.URL.Query()
var start, end time.Time
// TODO: find out what other special dates are supported by wakatime (e.g. tomorrow, yesterday, ...?)
if startKey, endKey := params.Get("start"), params.Get("end"); startKey == "today" && startKey == endKey {
start = utils.StartOfToday()
end = time.Now()
} else {
var err error
start, err = time.Parse(time.RFC3339, strings.Replace(startKey, " ", "+", 1))
if err != nil {
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
}
end, err = time.Parse(time.RFC3339, strings.Replace(endKey, " ", "+", 1))
if err != nil {
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
}
}
overallParams := &models.SummaryParams{
From: start,
To: end,
User: user,
Recompute: false,
}
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals {
summary, err := h.summarySrvc.Construct(interval[0], interval[1], user, false) // 'to' is always constant
if err != nil {
return nil, err, http.StatusInternalServerError
}
summaries[i] = summary
}
return summaries, nil, http.StatusOK
}

View File

@ -77,7 +77,7 @@ func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
}
func (srv *HeartbeatService) CleanUp() error {
refTime := utils.StartOfDay().Add(-cleanUpInterval)
refTime := utils.StartOfToday().Add(-cleanUpInterval)
if err := srv.DeleteBefore(refTime); err != nil {
log.Printf("Failed to clean up heartbeats older than %v %v\n", refTime, err)
return err

View File

@ -43,15 +43,14 @@ func MakeConnectionString(config *models.Config) string {
}
func mySqlConnectionString(config *models.Config) string {
location, _ := time.LoadLocation("Local")
return fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s",
//location, _ := time.LoadLocation("Local")
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s",
config.DbUser,
config.DbPassword,
config.DbHost,
config.DbPort,
config.DbName,
location.String(),
"Local",
)
}

View File

@ -5,9 +5,12 @@ import (
"time"
)
func StartOfDay() time.Time {
ref := time.Now()
return time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, ref.Location())
func StartOfToday() time.Time {
return StartOfDay(time.Now())
}
func StartOfDay(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
}
func StartOfWeek() time.Time {
@ -26,6 +29,21 @@ func StartOfYear() time.Time {
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
}
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
intervals := make([][]time.Time, 0)
for t1 := from; t1.Before(to); {
t2 := StartOfDay(t1).Add(24 * time.Hour)
if t2.After(to) {
t2 = to
}
intervals = append(intervals, []time.Time{t1, t2})
t1 = t2
}
return intervals
}
func FmtWakatimeDuration(d time.Duration) string {
d = d.Round(time.Minute)
h := d / time.Hour

View File

@ -9,18 +9,16 @@ import (
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()
from = StartOfToday()
case models.IntervalLastDay:
from = StartOfDay().Add(-24 * time.Hour)
from = StartOfToday().Add(-24 * time.Hour)
case models.IntervalLastWeek:
from = StartOfWeek()
case models.IntervalLastMonth:
@ -38,7 +36,7 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
to := StartOfDay()
to := StartOfToday()
if live {
to = time.Now()
}

View File

@ -1 +1 @@
1.9.2
1.10.1