mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
feat: implement summaries compat endpoint (resolve #44)
fix: fix all time view model
This commit is contained in:
parent
a8009e107d
commit
21567e7601
2
main.go
2
main.go
@ -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")))
|
||||
|
@ -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
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
5
models/compat/v1/common.go
Normal file
5
models/compat/v1/common.go
Normal file
@ -0,0 +1,5 @@
|
||||
package v1
|
||||
|
||||
type Filters struct {
|
||||
Project string
|
||||
}
|
147
models/compat/v1/summaries.go
Normal file
147
models/compat/v1/summaries.go
Normal 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(),
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
96
routes/compat/v1/summaries.go
Normal file
96
routes/compat/v1/summaries.go
Normal 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
|
||||
}
|
@ -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
|
||||
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
1.9.2
|
||||
1.10.1
|
Loading…
Reference in New Issue
Block a user