diff --git a/main.go b/main.go index 3b99cef..3859f14 100644 --- a/main.go +++ b/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"))) diff --git a/models/compat/v1/all_time.go b/models/compat/v1/all_time.go index 32b2320..3ff00a2 100644 --- a/models/compat/v1/all_time.go +++ b/models/compat/v1/all_time.go @@ -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, + }, + } } diff --git a/models/compat/v1/common.go b/models/compat/v1/common.go new file mode 100644 index 0000000..7ddeae6 --- /dev/null +++ b/models/compat/v1/common.go @@ -0,0 +1,5 @@ +package v1 + +type Filters struct { + Project string +} diff --git a/models/compat/v1/summaries.go b/models/compat/v1/summaries.go new file mode 100644 index 0000000..4a2a280 --- /dev/null +++ b/models/compat/v1/summaries.go @@ -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(), + } +} diff --git a/models/summary.go b/models/summary.go index 9f40d41..2c4d333 100644 --- a/models/summary.go +++ b/models/summary.go @@ -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 } diff --git a/routes/compat/v1/all_time.go b/routes/compat/v1/all_time.go index ad0c95b..0f65c00 100644 --- a/routes/compat/v1/all_time.go +++ b/routes/compat/v1/all_time.go @@ -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) } diff --git a/routes/compat/v1/summaries.go b/routes/compat/v1/summaries.go new file mode 100644 index 0000000..7a69fef --- /dev/null +++ b/routes/compat/v1/summaries.go @@ -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 +} diff --git a/services/heartbeat.go b/services/heartbeat.go index d4f5ee6..d73cd6b 100644 --- a/services/heartbeat.go +++ b/services/heartbeat.go @@ -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 diff --git a/utils/common.go b/utils/common.go index 86f2e94..738fbd2 100644 --- a/utils/common.go +++ b/utils/common.go @@ -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", ) } diff --git a/utils/date.go b/utils/date.go index 2b81b59..faacd60 100644 --- a/utils/date.go +++ b/utils/date.go @@ -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 diff --git a/utils/summary.go b/utils/summary.go index d233115..c85ee10 100644 --- a/utils/summary.go +++ b/utils/summary.go @@ -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() } diff --git a/version.txt b/version.txt index 6f2d365..e33692a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.2 \ No newline at end of file +1.10.1 \ No newline at end of file