diff --git a/README.md b/README.md index aa9cdc2..5d4128f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try) ![](https://img.shields.io/github/license/muety/wakapi?style=flat-square) [![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi) - +![Coding Activity](https://img.shields.io/endpoint?url=https://apps.muetsch.io/wakapi/api/compat/shields/v1/n1try/interval:any/project:wakapi&style=flat-square&color=blue) --- **A minimalist, self-hosted WakaTime-compatible backend for coding statistics** @@ -97,7 +97,10 @@ If you want to export your Wakapi statistics to Prometheus to view them in a Gra It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible. -Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/v1"` and set your API key accordingly. +Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly. + +## Badges + ## Best Practices It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS). diff --git a/main.go b/main.go index 3859f14..8c7c4de 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,8 @@ import ( "github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/models" "github.com/muety/wakapi/routes" - v1Routes "github.com/muety/wakapi/routes/compat/v1" + shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1" + wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" ) @@ -95,8 +96,9 @@ func main() { healthHandler := routes.NewHealthHandler(db) settingsHandler := routes.NewSettingsHandler(userService) publicHandler := routes.NewIndexHandler(userService, keyValueService) - compatV1AllHandler := v1Routes.NewCompatV1AllHandler(summaryService) - compatV1SummariesHandler := v1Routes.NewCompatV1SummariesHandler(summaryService) + wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService) + wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService) + shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService) // Setup Routers router := mux.NewRouter() @@ -104,7 +106,9 @@ func main() { settingsRouter := publicRouter.PathPrefix("/settings").Subrouter() summaryRouter := publicRouter.PathPrefix("/summary").Subrouter() apiRouter := router.PathPrefix("/api").Subrouter() - compatV1Router := apiRouter.PathPrefix("/compat/v1").Subrouter() + compatRouter := apiRouter.PathPrefix("/compat").Subrouter() + wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1").Subrouter() + shieldsV1Router := compatRouter.PathPrefix("/shields/v1").Subrouter() // Middlewares recoveryMiddleware := handlers.RecoveryHandler() @@ -112,7 +116,7 @@ func main() { corsMiddleware := handlers.CORS() authenticateMiddleware := middlewares.NewAuthenticateMiddleware( userService, - []string{"/api/health"}, + []string{"/api/health", "/api/compat/shields/v1"}, ).Handler // Router configs @@ -136,15 +140,19 @@ func main() { settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex) settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials) settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey) + settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges) // API Routes apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost) 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) - compatV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(compatV1SummariesHandler.ApiGet) + // Wakatime compat V1 API Routes + wakatimeV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(wakatimeV1AllHandler.ApiGet) + wakatimeV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(wakatimeV1SummariesHandler.ApiGet) + + // Shields.io compat API Routes + shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet) // Static Routes router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static"))) diff --git a/migrations/sqlite3/5_badges_column.sql b/migrations/sqlite3/5_badges_column.sql new file mode 100644 index 0000000..5fa2f32 --- /dev/null +++ b/migrations/sqlite3/5_badges_column.sql @@ -0,0 +1,11 @@ +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +alter table users + add column `badges_enabled` tinyint(1) default 0 not null; + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +alter table users + drop column `badges_enabled`; \ No newline at end of file diff --git a/models/compat/shields/v1/badge.go b/models/compat/shields/v1/badge.go new file mode 100644 index 0000000..a6cd7ed --- /dev/null +++ b/models/compat/shields/v1/badge.go @@ -0,0 +1,37 @@ +package v1 + +import ( + "github.com/muety/wakapi/models" + "github.com/muety/wakapi/utils" + "time" +) + +// https://shields.io/endpoint + +const ( + defaultLabel = "coding time" + defaultColor = "#2D3748" // not working +) + +type BadgeData struct { + SchemaVersion int `json:"schemaVersion"` + Label string `json:"label"` + Message string `json:"message"` + Color string `json:"color"` +} + +func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData { + var total time.Duration + if hasFilter, filterType, filterKey := filters.First(); hasFilter { + total = summary.TotalTimeByKey(filterType, filterKey) + } else { + total = summary.TotalTime() + } + + return &BadgeData{ + SchemaVersion: 1, + Label: defaultLabel, + Message: utils.FmtWakatimeDuration(total), + Color: defaultColor, + } +} diff --git a/models/compat/v1/common.go b/models/compat/v1/common.go deleted file mode 100644 index 7ddeae6..0000000 --- a/models/compat/v1/common.go +++ /dev/null @@ -1,5 +0,0 @@ -package v1 - -type Filters struct { - Project string -} diff --git a/models/compat/v1/all_time.go b/models/compat/wakatime/v1/all_time.go similarity index 77% rename from models/compat/v1/all_time.go rename to models/compat/wakatime/v1/all_time.go index 3ff00a2..44aa537 100644 --- a/models/compat/v1/all_time.go +++ b/models/compat/wakatime/v1/all_time.go @@ -8,17 +8,17 @@ import ( // https://wakatime.com/developers#all_time_since_today -type WakatimeAllTime struct { - Data *wakatimeAllTimeData `json:"data"` +type AllTimeViewModel struct { + Data *allTimeData `json:"data"` } -type wakatimeAllTimeData struct { +type allTimeData 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 { +func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel { var total time.Duration if key := filters.Project; key != "" { total = summary.TotalTimeByKey(models.SummaryProject, key) @@ -26,8 +26,8 @@ func NewAllTimeFrom(summary *models.Summary, filters *Filters) *WakatimeAllTime total = summary.TotalTime() } - return &WakatimeAllTime{ - Data: &wakatimeAllTimeData{ + return &AllTimeViewModel{ + Data: &allTimeData{ TotalSeconds: float32(total.Seconds()), Text: utils.FmtWakatimeDuration(total), IsUpToDate: true, diff --git a/models/compat/v1/summaries.go b/models/compat/wakatime/v1/summaries.go similarity index 61% rename from models/compat/v1/summaries.go rename to models/compat/wakatime/v1/summaries.go index 2650080..e064ec7 100644 --- a/models/compat/v1/summaries.go +++ b/models/compat/wakatime/v1/summaries.go @@ -12,25 +12,25 @@ import ( // 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 SummariesViewModel struct { + Data []*summariesData `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 summariesData struct { + Categories []*summariesEntry `json:"categories"` + Dependencies []*summariesEntry `json:"dependencies"` + Editors []*summariesEntry `json:"editors"` + Languages []*summariesEntry `json:"languages"` + Machines []*summariesEntry `json:"machines"` + OperatingSystems []*summariesEntry `json:"operating_systems"` + Projects []*summariesEntry `json:"projects"` + GrandTotal *summariesGrandTotal `json:"grand_total"` + Range *summariesRange `json:"range"` } -type wakatimeSummariesEntry struct { +type summariesEntry struct { Digital string `json:"digital"` Hours int `json:"hours"` Minutes int `json:"minutes"` @@ -41,7 +41,7 @@ type wakatimeSummariesEntry struct { TotalSeconds float64 `json:"total_seconds"` } -type wakatimeSummariesGrandTotal struct { +type summariesGrandTotal struct { Digital string `json:"digital"` Hours int `json:"hours"` Minutes int `json:"minutes"` @@ -49,7 +49,7 @@ type wakatimeSummariesGrandTotal struct { TotalSeconds float64 `json:"total_seconds"` } -type wakatimeSummariesRange struct { +type summariesRange struct { Date string `json:"date"` End time.Time `json:"end"` Start time.Time `json:"start"` @@ -57,8 +57,8 @@ type wakatimeSummariesRange struct { Timezone string `json:"timezone"` } -func NewSummariesFrom(summaries []*models.Summary, filters *Filters) *WakatimeSummaries { - data := make([]*wakatimeSummariesData, len(summaries)) +func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel { + data := make([]*summariesData, len(summaries)) minDate, maxDate := time.Now().Add(1*time.Second), time.Time{} for i, s := range summaries { @@ -72,34 +72,34 @@ func NewSummariesFrom(summaries []*models.Summary, filters *Filters) *WakatimeSu } } - return &WakatimeSummaries{ + return &SummariesViewModel{ Data: data, End: maxDate, Start: minDate, } } -func newDataFrom(s *models.Summary) *wakatimeSummariesData { +func newDataFrom(s *models.Summary) *summariesData { 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{ + data := &summariesData{ + Categories: make([]*summariesEntry, 0), + Dependencies: make([]*summariesEntry, 0), + Editors: make([]*summariesEntry, len(s.Editors)), + Languages: make([]*summariesEntry, len(s.Languages)), + Machines: make([]*summariesEntry, len(s.Machines)), + OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)), + Projects: make([]*summariesEntry, len(s.Projects)), + GrandTotal: &summariesGrandTotal{ Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins), Hours: totalHrs, Minutes: totalMins, Text: utils.FmtWakatimeDuration(total), TotalSeconds: total.Seconds(), }, - Range: &wakatimeSummariesRange{ + Range: &summariesRange{ Date: time.Now().Format(time.RFC3339), End: s.ToTime, Start: s.FromTime, @@ -111,21 +111,21 @@ func newDataFrom(s *models.Summary) *wakatimeSummariesData { var wg sync.WaitGroup wg.Add(5) - go func(data *wakatimeSummariesData) { + go func(data *summariesData) { defer wg.Done() for i, e := range s.Projects { data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject)) } }(data) - go func(data *wakatimeSummariesData) { + go func(data *summariesData) { defer wg.Done() for i, e := range s.Editors { data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor)) } }(data) - go func(data *wakatimeSummariesData) { + go func(data *summariesData) { defer wg.Done() for i, e := range s.Languages { data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage)) @@ -133,14 +133,14 @@ func newDataFrom(s *models.Summary) *wakatimeSummariesData { } }(data) - go func(data *wakatimeSummariesData) { + go func(data *summariesData) { defer wg.Done() for i, e := range s.OperatingSystems { data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS)) } }(data) - go func(data *wakatimeSummariesData) { + go func(data *summariesData) { defer wg.Done() for i, e := range s.Machines { data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine)) @@ -151,7 +151,7 @@ func newDataFrom(s *models.Summary) *wakatimeSummariesData { return data } -func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *wakatimeSummariesEntry { +func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry { // 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 @@ -159,7 +159,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *wakatimeSum 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{ + return &summariesEntry{ Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs), Hours: hrs, Minutes: mins, diff --git a/models/shared.go b/models/shared.go index 92a715e..87145a3 100644 --- a/models/shared.go +++ b/models/shared.go @@ -24,6 +24,45 @@ type KeyStringValue struct { Value string `gorm:"type:text"` } +type Filters struct { + Project string + OS string + Language string + Editor string + Machine string +} + +func NewFiltersWith(entity uint8, key string) *Filters { + switch entity { + case SummaryProject: + return &Filters{Project: key} + case SummaryOS: + return &Filters{Project: key} + case SummaryLanguage: + return &Filters{Project: key} + case SummaryEditor: + return &Filters{Project: key} + case SummaryMachine: + return &Filters{Project: key} + } + return &Filters{} +} + +func (f *Filters) First() (bool, uint8, string) { + if f.Project != "" { + return true, SummaryProject, f.Project + } else if f.OS != "" { + return true, SummaryOS, f.OS + } else if f.Language != "" { + return true, SummaryLanguage, f.Language + } else if f.Editor != "" { + return true, SummaryEditor, f.Editor + } else if f.Machine != "" { + return true, SummaryMachine, f.Machine + } + return false, 0, "" +} + type CustomTime time.Time func (j *CustomTime) UnmarshalJSON(b []byte) error { diff --git a/models/summary.go b/models/summary.go index 46ab044..ca33ff6 100644 --- a/models/summary.go +++ b/models/summary.go @@ -25,6 +25,12 @@ const ( IntervalAny string = "any" ) +func Intervals() []string { + return []string{ + IntervalToday, IntervalYesterday, IntervalThisWeek, IntervalThisMonth, IntervalThisYear, IntervalPast7Days, IntervalPast30Days, IntervalPast12Months, IntervalAny, + } +} + const UnknownSummaryKey = "unknown" type Summary struct { diff --git a/models/user.go b/models/user.go index 9a3101d..6016085 100644 --- a/models/user.go +++ b/models/user.go @@ -6,6 +6,7 @@ type User struct { Password string `json:"-"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` + BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"` } type Login struct { diff --git a/routes/compat/shields/v1/badge.go b/routes/compat/shields/v1/badge.go new file mode 100644 index 0000000..99499f7 --- /dev/null +++ b/routes/compat/shields/v1/badge.go @@ -0,0 +1,96 @@ +package v1 + +import ( + "github.com/gorilla/mux" + "github.com/muety/wakapi/models" + v1 "github.com/muety/wakapi/models/compat/shields/v1" + "github.com/muety/wakapi/services" + "github.com/muety/wakapi/utils" + "net/http" + "regexp" +) + +const ( + intervalPattern = `interval:([a-z0-9_]+)` + entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)` +) + +type BadgeHandler struct { + userSrvc *services.UserService + summarySrvc *services.SummaryService + config *models.Config +} + +func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler { + return &BadgeHandler{ + summarySrvc: summaryService, + userSrvc: userService, + config: models.GetConfig(), + } +} + +func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) { + intervalReg := regexp.MustCompile(intervalPattern) + entityFilterReg := regexp.MustCompile(entityFilterPattern) + + requestedUserId := mux.Vars(r)["user"] + user, err := h.userSrvc.GetUserById(requestedUserId) + if err != nil || !user.BadgesEnabled { + w.WriteHeader(http.StatusUnauthorized) + return + } + + var filterEntity, filterKey string + if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 { + filterEntity, filterKey = groups[1], groups[2] + } + + var interval = models.IntervalPast30Days + if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 { + interval = groups[1] + } + + filters := &models.Filters{} + switch filterEntity { + case "project": + filters.Project = filterKey + case "os": + filters.OS = filterKey + case "editor": + filters.Editor = filterKey + case "language": + filters.Language = filterKey + case "machine": + filters.Machine = filterKey + } + + summary, err, status := h.loadUserSummary(user, interval) + if err != nil { + w.WriteHeader(status) + w.Write([]byte(err.Error())) + return + } + + vm := v1.NewBadgeDataFrom(summary, filters) + utils.RespondJSON(w, http.StatusOK, vm) +} + +func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) { + err, from, to := utils.ResolveInterval(interval) + if err != nil { + return nil, err, http.StatusBadRequest + } + + summaryParams := &models.SummaryParams{ + From: from, + To: to, + User: user, + } + + summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) + if err != nil { + return nil, err, http.StatusInternalServerError + } + + return summary, nil, http.StatusOK +} diff --git a/routes/compat/v1/all_time.go b/routes/compat/wakatime/v1/all_time.go similarity index 72% rename from routes/compat/v1/all_time.go rename to routes/compat/wakatime/v1/all_time.go index 0f65c00..faac3dd 100644 --- a/routes/compat/v1/all_time.go +++ b/routes/compat/wakatime/v1/all_time.go @@ -3,7 +3,7 @@ package v1 import ( "github.com/gorilla/mux" "github.com/muety/wakapi/models" - v1 "github.com/muety/wakapi/models/compat/v1" + v1 "github.com/muety/wakapi/models/compat/wakatime/v1" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" "net/http" @@ -11,19 +11,19 @@ import ( "time" ) -type CompatV1AllHandler struct { +type AllTimeHandler struct { summarySrvc *services.SummaryService config *models.Config } -func NewCompatV1AllHandler(summaryService *services.SummaryService) *CompatV1AllHandler { - return &CompatV1AllHandler{ +func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler { + return &AllTimeHandler{ summarySrvc: summaryService, config: models.GetConfig(), } } -func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { +func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) values, _ := url.ParseQuery(r.URL.RawQuery) @@ -42,11 +42,11 @@ func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { return } - vm := v1.NewAllTimeFrom(summary, &v1.Filters{Project: values.Get("project")}) + vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")}) utils.RespondJSON(w, http.StatusOK, vm) } -func (h *CompatV1AllHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) { +func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) { summaryParams := &models.SummaryParams{ From: time.Time{}, To: time.Now(), diff --git a/routes/compat/v1/summaries.go b/routes/compat/wakatime/v1/summaries.go similarity index 83% rename from routes/compat/v1/summaries.go rename to routes/compat/wakatime/v1/summaries.go index 90f2134..2223d95 100644 --- a/routes/compat/v1/summaries.go +++ b/routes/compat/wakatime/v1/summaries.go @@ -4,7 +4,7 @@ import ( "errors" "github.com/gorilla/mux" "github.com/muety/wakapi/models" - v1 "github.com/muety/wakapi/models/compat/v1" + v1 "github.com/muety/wakapi/models/compat/wakatime/v1" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" "net/http" @@ -12,13 +12,13 @@ import ( "time" ) -type CompatV1SummariesHandler struct { +type SummariesHandler struct { summarySrvc *services.SummaryService config *models.Config } -func NewCompatV1SummariesHandler(summaryService *services.SummaryService) *CompatV1SummariesHandler { - return &CompatV1SummariesHandler{ +func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler { + return &SummariesHandler{ summarySrvc: summaryService, config: models.GetConfig(), } @@ -30,7 +30,7 @@ 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) { +func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) requestedUser := vars["user"] authorizedUser := r.Context().Value(models.UserKey).(*models.User) @@ -47,11 +47,11 @@ func (h *CompatV1SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request return } - vm := v1.NewSummariesFrom(summaries, &v1.Filters{}) + vm := v1.NewSummariesFrom(summaries, &models.Filters{}) utils.RespondJSON(w, http.StatusOK, vm) } -func (h *CompatV1SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) { +func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) { user := r.Context().Value(models.UserKey).(*models.User) params := r.URL.Query() diff --git a/routes/settings.go b/routes/settings.go index b4084ed..8dd0ffc 100644 --- a/routes/settings.go +++ b/routes/settings.go @@ -29,10 +29,16 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) { loadTemplates() } + user := r.Context().Value(models.UserKey).(*models.User) + data := map[string]interface{}{ + "User": user, + } + + // TODO: when alerts are present, other data will not be passed to the template if handleAlerts(w, r, "settings.tpl.html") { return } - templates["settings.tpl.html"].Execute(w, nil) + templates["settings.tpl.html"].Execute(w, data) } func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) { @@ -110,3 +116,18 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey)) http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound) } + +func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + user := r.Context().Value(models.UserKey).(*models.User) + + if _, err := h.userSrvc.ToggleBadges(user); err != nil { + respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.BasePath), http.StatusFound) +} diff --git a/services/user.go b/services/user.go index c6a672c..ee66066 100644 --- a/services/user.go +++ b/services/user.go @@ -87,6 +87,19 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) { return srv.Update(user) } +func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) { + result := srv.Db.Model(user).Update("badges_enabled", !user.BadgesEnabled) + if err := result.Error; err != nil { + return nil, err + } + + if result.RowsAffected != 1 { + return nil, errors.New("nothing updated") + } + + return user, nil +} + func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) { user.Password = login.Password if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil { diff --git a/utils/strings.go b/utils/strings.go index 3175d79..3f985a6 100644 --- a/utils/strings.go +++ b/utils/strings.go @@ -8,3 +8,12 @@ import ( func Capitalize(s string) string { return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:]) } + +func FindString(needle string, haystack []string, defaultVal string) string { + for _, s := range haystack { + if s == needle { + return s + } + } + return defaultVal +} diff --git a/utils/summary.go b/utils/summary.go index 9f45ca1..eaa1d13 100644 --- a/utils/summary.go +++ b/utils/summary.go @@ -7,41 +7,46 @@ import ( "time" ) +func ResolveInterval(interval string) (err error, from, to time.Time) { + to = time.Now() + + switch interval { + case models.IntervalToday: + from = StartOfToday() + case models.IntervalYesterday: + from = StartOfToday().Add(-24 * time.Hour) + to = StartOfToday() + case models.IntervalThisWeek: + from = StartOfWeek() + case models.IntervalThisMonth: + from = StartOfMonth() + case models.IntervalThisYear: + from = StartOfYear() + case models.IntervalPast7Days: + from = StartOfToday().AddDate(0, 0, -7) + case models.IntervalPast30Days: + from = StartOfToday().AddDate(0, 0, -30) + case models.IntervalPast12Months: + from = StartOfToday().AddDate(0, -12, 0) + case models.IntervalAny: + from = time.Time{} + default: + err = errors.New("invalid interval") + } + + return err, from, to +} + func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) { user := r.Context().Value(models.UserKey).(*models.User) params := r.URL.Query() + var err error var from, to time.Time if interval := params.Get("interval"); interval != "" { - to = time.Now() - - switch interval { - case models.IntervalToday: - from = StartOfToday() - case models.IntervalYesterday: - from = StartOfToday().Add(-24 * time.Hour) - to = StartOfToday() - case models.IntervalThisWeek: - from = StartOfWeek() - case models.IntervalThisMonth: - from = StartOfMonth() - case models.IntervalThisYear: - from = StartOfYear() - case models.IntervalPast7Days: - from = StartOfToday().AddDate(0, 0, -7) - case models.IntervalPast30Days: - from = StartOfToday().AddDate(0, 0, -30) - case models.IntervalPast12Months: - from = StartOfToday().AddDate(0, -12, 0) - case models.IntervalAny: - from = time.Time{} - default: - return nil, errors.New("invalid interval") - } + err, from, to = ResolveInterval(interval) } else { - var err error - from, err = ParseDate(params.Get("from")) if err != nil { return nil, errors.New("missing 'from' parameter") diff --git a/version.txt b/version.txt index 62321af..169f19b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.10.3 \ No newline at end of file +1.11.0 \ No newline at end of file diff --git a/views/settings.tpl.html b/views/settings.tpl.html index fea0b31..901d412 100644 --- a/views/settings.tpl.html +++ b/views/settings.tpl.html @@ -64,9 +64,72 @@ + +
+
+ Badges +
+ +
+
+ {{ if .User.BadgesEnabled }} +

Badges are currently enabled. You can disable the feature by deactivating the respective API endpoint.

+ +
+ GET /api/compat/shields/v1 + +
+ +

Examples

+
+
+
+ +
+ + https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today + +
+
+
+ +
+ + https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d + +
+
+ +

You can also add /project:your-cool-project to the URL to filter by project.

+ {{ else }} +

You have the ability to create badges from your coding statistics using Shields.io. To do so, you need to grant public, unauthorized access to the respective endpoint.

+
+ GET /api/compat/shields/v1 + +
+ {{ end }} +
+
+
+ + {{ template "footer.tpl.html" . }} {{ template "foot.tpl.html" . }}