diff --git a/main.go b/main.go index 5a40bcf..3b99cef 100644 --- a/main.go +++ b/main.go @@ -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"))) diff --git a/models/compat/v1/all_time.go b/models/compat/v1/all_time.go new file mode 100644 index 0000000..7afcd23 --- /dev/null +++ b/models/compat/v1/all_time.go @@ -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> +} diff --git a/models/summary.go b/models/summary.go index 8bfb6f0..4f09906 100644 --- a/models/summary.go +++ b/models/summary.go @@ -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 +} diff --git a/routes/compat/v1/all_time.go b/routes/compat/v1/all_time.go new file mode 100644 index 0000000..1cb4180 --- /dev/null +++ b/routes/compat/v1/all_time.go @@ -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 +} diff --git a/routes/summary.go b/routes/summary.go index 4b630ab..0a3137f 100644 --- a/routes/summary.go +++ b/routes/summary.go @@ -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 } diff --git a/services/summary.go b/services/summary.go index 574498f..3415858 100644 --- a/services/summary.go +++ b/services/summary.go @@ -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 diff --git a/utils/date.go b/utils/date.go index ad93b55..2b81b59 100644 --- a/utils/date.go +++ b/utils/date.go @@ -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) diff --git a/utils/summary.go b/utils/summary.go new file mode 100644 index 0000000..d233115 --- /dev/null +++ b/utils/summary.go @@ -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 +} diff --git a/version.txt b/version.txt index 7b378be..abb1658 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.8.4 \ No newline at end of file +1.9.0 \ No newline at end of file