1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

...

19 Commits
2.5.1 ... 2.5.3

Author SHA1 Message Date
94e0d06e5d fix: user agents and machine names in wakatime import 2022-11-15 23:53:30 +01:00
088bd17803 chore: update iconify 2022-11-13 20:20:41 +01:00
2976203ecc fix: missing icons 2022-11-13 20:11:53 +01:00
e75bd94531 fix: include cumulative total key in wakatime summary compat endpoint (resolve #426) 2022-11-13 19:52:53 +01:00
4cc8c21f67 fix: importing data from wakapi instance (resolve #428) 2022-11-13 19:27:44 +01:00
f182b804bb chore: add additional language icons
fix: support ipynb, cjs, tsx file endings
2022-11-11 16:13:41 +01:00
9586dbf781 fix: make intervals robust to daylight saving time shift 2022-10-31 23:24:54 +01:00
c8ea1a503f Merge pull request #424 from f0x52/postgres-dsn
Add postgres DSN config option
2022-10-31 19:22:03 +01:00
f0x
ebbc21f0b1 add postgres DSN config option 2022-10-31 18:07:16 +01:00
6e5bc38e5e fix: index migration for sqlite 2022-10-28 10:32:47 +02:00
9424c49760 fix: composite index on heartbeats table 2022-10-28 09:54:11 +02:00
efd6ba36e3 fix: errors during leaderboard generation 2022-10-20 08:33:12 +02:00
b1d7f87095 chore: add maximum default leaderboard length 2022-10-19 18:28:30 +02:00
ffbcfc7467 fix: cache key 2022-10-19 17:23:40 +02:00
41f6db8f34 feat(wip): leaderboard pagination (resolve #417) [ci-skip] 2022-10-16 19:38:43 +02:00
8a21be4306 fix: ignore rank column in migrations 2022-10-16 18:59:00 +02:00
31ca4a1e02 chore: logging 2022-10-16 17:42:32 +02:00
7cab2b0be7 chore: add clarification on relaying to other wakapi instance (resolve #420) [skip-ci] 2022-10-15 11:08:44 +02:00
777997c883 fix: swagger ui (resolve #421) 2022-10-14 12:00:56 +02:00
33 changed files with 1491 additions and 1063 deletions

View File

@ -21,6 +21,9 @@ app:
custom_languages: custom_languages:
vue: Vue vue: Vue
jsx: JSX jsx: JSX
tsx: TSX
cjs: JavaScript
ipynb: Python
svelte: Svelte svelte: Svelte
# url template for user avatar images (to be used with services like gravatar or dicebear) # url template for user avatar images (to be used with services like gravatar or dicebear)

View File

@ -98,6 +98,7 @@ type dbConfig struct {
Dialect string `yaml:"-"` Dialect string `yaml:"-"`
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"` Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"` Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
DSN string `yaml:"DSN" default:"" env:"WAKAPI_DB_DSN"`
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"` MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"` Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"` AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`

View File

@ -66,6 +66,10 @@ func mysqlConnectionString(config *dbConfig) string {
} }
func postgresConnectionString(config *dbConfig) string { func postgresConnectionString(config *dbConfig) string {
if len(config.DSN) > 0 {
return config.DSN
}
sslmode := "disable" sslmode := "disable"
if config.Ssl { if config.Ssl {
sslmode = "require" sslmode = "require"

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,8 @@ import (
_ "gorm.io/driver/sqlite" _ "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
_ "github.com/muety/wakapi/static/docs"
) )
// Embed version.txt // Embed version.txt
@ -282,6 +284,7 @@ func main() {
router.PathPrefix("/contribute.json").Handler(staticFileServer) router.PathPrefix("/contribute.json").Handler(staticFileServer)
router.PathPrefix("/assets").Handler(assetsFileServer) router.PathPrefix("/assets").Handler(assetsFileServer)
router.Path("/swagger-ui").Handler(http.RedirectHandler("swagger-ui/", http.StatusMovedPermanently)) // https://github.com/swaggo/http-swagger/issues/44
router.PathPrefix("/swagger-ui").Handler(httpSwagger.WrapHandler) router.PathPrefix("/swagger-ui").Handler(httpSwagger.WrapHandler)
// Listen HTTP // Listen HTTP

View File

@ -0,0 +1,35 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20221016-drop_rank_column"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
migrator := db.Migrator()
if migrator.HasTable(&models.LeaderboardItem{}) && migrator.HasColumn(&models.LeaderboardItem{}, "rank") {
logbuch.Info("running migration '%s'", name)
if err := migrator.DropColumn(&models.LeaderboardItem{}, "rank"); err != nil {
logbuch.Warn("failed to drop 'rank' column (%v)", err)
}
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,71 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"regexp"
"strings"
)
// due to an error in the model definition, idx_time_user used to only cover 'user_id', but not time column
// if that's the case in the current state of the database, drop the index and let it be recreated by auto migration afterwards
func init() {
const name = "20221028-fix_heartbeats_time_user_idx"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
if !migrator.HasTable(&models.Heartbeat{}) {
return nil
}
var drop bool
if cfg.Db.IsSQLite() {
// sqlite migrator doesn't support GetIndexes() currently
var ddl string
if err := db.
Table("sqlite_schema").
Select("sql").
Where("type = 'index'").
Where("tbl_name = 'heartbeats'").
Where("name = 'idx_time_user'").
Scan(&ddl).Error; err != nil {
return err
}
matches := regexp.MustCompile("(?i)\\((.+)\\)$").FindStringSubmatch(ddl)
if len(matches) > 0 && !strings.Contains(matches[0], "`user_id`") || !strings.Contains(matches[0], "`time`") {
drop = true
}
} else {
indexes, err := migrator.GetIndexes(&models.Heartbeat{})
if err != nil {
return err
}
for _, idx := range indexes {
if idx.Table() == "heartbeats" && idx.Name() == "idx_time_user" && len(idx.Columns()) == 1 {
drop = true
break
}
}
}
if !drop {
return nil
}
if err := migrator.DropIndex(&models.Heartbeat{}, "idx_time_user"); err != nil {
return err
}
logbuch.Info("index 'idx_time_user' needs to be recreated, this may take a while")
return nil
},
}
registerPreMigration(f)
}

View File

@ -3,6 +3,7 @@ package v1
import ( import (
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"time"
) )
// https://wakatime.com/developers#all_time_since_today // https://wakatime.com/developers#all_time_since_today
@ -28,11 +29,19 @@ type AllTimeRange struct {
func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel { func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel {
total := summary.TotalTime() total := summary.TotalTime()
tzName, _ := summary.FromTime.T().Zone()
return &AllTimeViewModel{ return &AllTimeViewModel{
Data: &AllTimeData{ Data: &AllTimeData{
TotalSeconds: float32(total.Seconds()), TotalSeconds: float32(total.Seconds()),
Text: utils.FmtWakatimeDuration(total), Text: utils.FmtWakatimeDuration(total),
IsUpToDate: true, IsUpToDate: true,
Range: &AllTimeRange{
End: summary.ToTime.T().Format(time.RFC3339),
EndDate: utils.FormatDate(summary.ToTime.T()),
Start: summary.FromTime.T().Format(time.RFC3339),
StartDate: utils.FormatDate(summary.FromTime.T()),
Timezone: tzName,
},
}, },
} }
} }

View File

@ -16,6 +16,14 @@ type SummariesViewModel struct {
Data []*SummariesData `json:"data"` Data []*SummariesData `json:"data"`
End time.Time `json:"end"` End time.Time `json:"end"`
Start time.Time `json:"start"` Start time.Time `json:"start"`
CumulativeTotal *SummariesCumulativeTotal `json:"cummulative_total"` // typo is intended
}
type SummariesCumulativeTotal struct {
Decimal string `json:"decimal"`
Digital string `json:"digital"`
Seconds float64 `json:"seconds"`
Text string `json:"text"`
} }
type SummariesData struct { type SummariesData struct {
@ -73,10 +81,23 @@ func NewSummariesFrom(summaries []*models.Summary) *SummariesViewModel {
} }
} }
var totalTime time.Duration
for _, s := range summaries {
totalTime += s.TotalTime()
}
totalHrs, totalMins, totalSecs := totalTime.Hours(), (totalTime - time.Duration(totalTime.Hours())*time.Hour).Minutes(), totalTime.Seconds()
return &SummariesViewModel{ return &SummariesViewModel{
Data: data, Data: data,
End: maxDate, End: maxDate,
Start: minDate, Start: minDate,
CumulativeTotal: &SummariesCumulativeTotal{
Decimal: fmt.Sprintf("%.2f", totalHrs),
Digital: fmt.Sprintf("%d:%d", int(totalHrs), int(totalMins)),
Seconds: totalSecs,
Text: utils.FmtWakatimeDuration(totalTime),
},
} }
} }

View File

@ -23,7 +23,7 @@ type Heartbeat struct {
OperatingSystem string `json:"operating_system" gorm:"index:idx_operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime OperatingSystem string `json:"operating_system" gorm:"index:idx_operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" gorm:"index:idx_machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently Machine string `json:"machine" gorm:"index:idx_machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
UserAgent string `json:"user_agent" hash:"ignore" gorm:"type:varchar(255)"` UserAgent string `json:"user_agent" hash:"ignore" gorm:"type:varchar(255)"`
Time CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time,idx_time_user" swaggertype:"primitive,number"` Time CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time; index:idx_time_user" swaggertype:"primitive,number"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"` Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"` Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"` OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`

View File

@ -11,7 +11,6 @@ type LeaderboardItem struct {
ID uint `json:"-" gorm:"primary_key; size:32"` ID uint `json:"-" gorm:"primary_key; size:32"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_leaderboard_user"` UserID string `json:"user_id" gorm:"not null; index:idx_leaderboard_user"`
Rank uint `json:"rank" gorm:"->"`
Interval string `json:"interval" gorm:"not null; size:32; index:idx_leaderboard_combined"` Interval string `json:"interval" gorm:"not null; size:32; index:idx_leaderboard_combined"`
By *uint8 `json:"aggregated_by" gorm:"index:idx_leaderboard_combined"` // pointer because nullable By *uint8 `json:"aggregated_by" gorm:"index:idx_leaderboard_combined"` // pointer because nullable
Total time.Duration `json:"total" gorm:"not null" swaggertype:"primitive,integer"` Total time.Duration `json:"total" gorm:"not null" swaggertype:"primitive,integer"`
@ -19,16 +18,45 @@ type LeaderboardItem struct {
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
} }
type Leaderboard []*LeaderboardItem // https://github.com/go-gorm/gorm/issues/5789
// https://github.com/go-gorm/gorm/issues/5284#issuecomment-1107775806
type LeaderboardItemRanked struct {
LeaderboardItem
Rank uint
}
func (l1 *LeaderboardItemRanked) Equals(l2 *LeaderboardItemRanked) bool {
return l1.ID == l2.ID
}
type Leaderboard []*LeaderboardItemRanked
func (l *Leaderboard) Add(item *LeaderboardItemRanked) {
if _, found := slice.Find[*LeaderboardItemRanked](*l, func(i int, item2 *LeaderboardItemRanked) bool {
return item.Equals(item2)
}); !found {
*l = append(*l, item)
}
}
func (l *Leaderboard) AddMany(items []*LeaderboardItemRanked) {
for _, item := range items {
l.Add(item)
}
}
func (l Leaderboard) UserIDs() []string { func (l Leaderboard) UserIDs() []string {
return slice.Unique[string](slice.Map[*LeaderboardItem, string](l, func(i int, item *LeaderboardItem) string { return slice.Unique[string](slice.Map[*LeaderboardItemRanked, string](l, func(i int, item *LeaderboardItemRanked) string {
return item.UserID return item.UserID
})) }))
} }
func (l Leaderboard) HasUser(userId string) bool {
return slice.Contain(l.UserIDs(), userId)
}
func (l Leaderboard) TopByKey(by uint8, key string) Leaderboard { func (l Leaderboard) TopByKey(by uint8, key string) Leaderboard {
return slice.Filter[*LeaderboardItem](l, func(i int, item *LeaderboardItem) bool { return slice.Filter[*LeaderboardItemRanked](l, func(i int, item *LeaderboardItemRanked) bool {
return item.By != nil && *item.By == by && item.Key != nil && strings.ToLower(*item.Key) == strings.ToLower(key) return item.By != nil && *item.By == by && item.Key != nil && strings.ToLower(*item.Key) == strings.ToLower(key)
}) })
} }
@ -65,7 +93,7 @@ func (l Leaderboard) TopKeys(by uint8) []string {
} }
func (l Leaderboard) TopKeysByUser(by uint8, userId string) []string { func (l Leaderboard) TopKeysByUser(by uint8, userId string) []string {
return Leaderboard(slice.Filter[*LeaderboardItem](l, func(i int, item *LeaderboardItem) bool { return Leaderboard(slice.Filter[*LeaderboardItemRanked](l, func(i int, item *LeaderboardItemRanked) bool {
return item.UserID == userId return item.UserID == userId
})).TopKeys(by) })).TopKeys(by)
} }

View File

@ -34,6 +34,11 @@ type KeyedInterval struct {
Key *IntervalKey Key *IntervalKey
} }
type PageParams struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468) // CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
type CustomTime time.Time type CustomTime time.Time
@ -99,3 +104,17 @@ func (j CustomTime) T() time.Time {
func (j CustomTime) Valid() bool { func (j CustomTime) Valid() bool {
return j.T().Unix() >= 0 return j.T().Unix() >= 0
} }
func (p *PageParams) Limit() int {
if p.PageSize < 0 {
return 0
}
return p.PageSize
}
func (p *PageParams) Offset() int {
if p.PageSize <= 0 {
return 0
}
return (p.Page - 1) * p.PageSize
}

View File

@ -10,10 +10,11 @@ type LeaderboardViewModel struct {
User *models.User User *models.User
By string By string
Key string Key string
Items []*models.LeaderboardItem Items []*models.LeaderboardItemRanked
TopKeys []string TopKeys []string
UserLanguages map[string][]string UserLanguages map[string][]string
ApiKey string ApiKey string
PageParams *models.PageParams
Success string Success string
Error string Error string
} }
@ -28,7 +29,7 @@ func (s *LeaderboardViewModel) WithError(m string) *LeaderboardViewModel {
return s return s
} }
func (s *LeaderboardViewModel) ColorModifier(item *models.LeaderboardItem, principal *models.User) string { func (s *LeaderboardViewModel) ColorModifier(item *models.LeaderboardItemRanked, principal *models.User) string {
if principal != nil && item.UserID == principal.ID { if principal != nil && item.UserID == principal.ID {
return "self" return "self"
} }
@ -47,25 +48,32 @@ func (s *LeaderboardViewModel) ColorModifier(item *models.LeaderboardItem, princ
func (s *LeaderboardViewModel) LangIcon(lang string) string { func (s *LeaderboardViewModel) LangIcon(lang string) string {
// https://icon-sets.iconify.design/mdi/ // https://icon-sets.iconify.design/mdi/
langs := map[string]string{ langs := map[string]string{
"c++": "cpp", "c++": "language-cpp",
"cpp": "cpp", "cpp": "language-cpp",
"go": "go", "go": "language-go",
"haskell": "haskell", "haskell": "language-haskell",
"html": "html5", "html": "language-html5",
"java": "java", "java": "language-java",
"javascript": "javascript", "javascript": "language-javascript",
"kotlin": "kotlin", "jsx": "language-javascript",
"lua": "lua", "kotlin": "language-kotlin",
"php": "php", "lua": "language-lua",
"python": "python", "php": "language-php",
"r": "r", "python": "language-python",
"ruby": "ruby", "r": "language-r",
"rust": "rust", "ruby": "language-ruby",
"swift": "swift", "rust": "language-rust",
"typescript": "typescript", "swift": "language-swift",
"typescript": "language-typescript",
"tsx": "language-typescript",
"markdown": "language-markdown",
"vue": "vuejs",
"react": "react",
"bash": "bash",
"json": "code-json",
} }
if match, ok := langs[strings.ToLower(lang)]; ok { if match, ok := langs[strings.ToLower(lang)]; ok {
return "mdi:language-" + match return "mdi:" + match
} }
return "" return ""
} }

View File

@ -9,7 +9,7 @@
"compress": "brotli -f static/assets/css/*.dist.css && brotli -f static/assets/js/*.dist.js" "compress": "brotli -f static/assets/css/*.dist.css && brotli -f static/assets/js/*.dist.js"
}, },
"devDependencies": { "devDependencies": {
"@iconify/json": "^1.1.444", "@iconify/json": "^2.1.136",
"@iconify/json-tools": "^1.0.10", "@iconify/json-tools": "^1.0.10",
"chokidar-cli": "^3.0.0", "chokidar-cli": "^3.0.0",
"tailwindcss": "^3.1.8" "tailwindcss": "^3.1.8"

View File

@ -33,13 +33,27 @@ func (r *LeaderboardRepository) CountAllByUser(userId string) (int64, error) {
return count, err return count, err
} }
func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) { func (r *LeaderboardRepository) CountUsers() (int64, error) {
var count int64
err := r.db.
Table("leaderboard_items").
Distinct("user_id").
Count(&count).Error
return count, err
}
func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItemRanked, error) {
// TODO: distinct by (user, key) to filter out potential duplicates ? // TODO: distinct by (user, key) to filter out potential duplicates ?
var items []*models.LeaderboardItem
q := r.db. var items []*models.LeaderboardItemRanked
subq := r.db.
Table("leaderboard_items").
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\""). Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
Where("\"interval\" in ?", *key) Where("\"interval\" in ?", *key)
q = utils.WhereNullable(q, "\"by\"", by) subq = utils.WhereNullable(subq, "\"by\"", by)
q := r.db.Table("(?) as ranked", subq)
q = r.withPaging(q, limit, skip)
if err := q.Find(&items).Error; err != nil { if err := q.Find(&items).Error; err != nil {
return nil, err return nil, err
@ -47,13 +61,16 @@ func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalK
return items, nil return items, nil
} }
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) { func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItemRanked, error) {
var items []*models.LeaderboardItem var items []*models.LeaderboardItemRanked
q := r.db. subq := r.db.
Table("leaderboard_items").
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\""). Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
Where("user_id = ?", userId).
Where("\"interval\" in ?", *key) Where("\"interval\" in ?", *key)
q = utils.WhereNullable(q, "\"by\"", by) subq = utils.WhereNullable(subq, "\"by\"", by)
q := r.db.Table("(?) as ranked", subq).Where("user_id = ?", userId)
q = r.withPaging(q, limit, skip)
if err := q.Find(&items).Error; err != nil { if err := q.Find(&items).Error; err != nil {
return nil, err return nil, err
@ -79,3 +96,13 @@ func (r *LeaderboardRepository) DeleteByUserAndInterval(userId string, key *mode
} }
return nil return nil
} }
func (r *LeaderboardRepository) withPaging(q *gorm.DB, limit, skip int) *gorm.DB {
if limit > 0 {
q = q.Where("\"rank\" <= ?", skip+limit)
}
if skip > 0 {
q = q.Where("\"rank\" > ?", skip)
}
return q
}

View File

@ -90,8 +90,9 @@ type IUserRepository interface {
type ILeaderboardRepository interface { type ILeaderboardRepository interface {
InsertBatch([]*models.LeaderboardItem) error InsertBatch([]*models.LeaderboardItem) error
CountAllByUser(string) (int64, error) CountAllByUser(string) (int64, error)
CountUsers() (int64, error)
DeleteByUser(string) error DeleteByUser(string) error
DeleteByUserAndInterval(string, *models.IntervalKey) error DeleteByUserAndInterval(string, *models.IntervalKey) error
GetAllAggregatedByInterval(*models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error) GetAllAggregatedByInterval(*models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error) GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
} }

View File

@ -86,7 +86,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)) minStart := rangeTo.AddDate(0, 0, -requestedUser.ShareDataMaxDays)
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) && if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 { rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)

View File

@ -3,6 +3,7 @@ package routes
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/emvi/logbuch"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/schema" "github.com/gorilla/schema"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
@ -66,7 +67,9 @@ func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
} }
if kv, err := h.keyValueSrvc.GetString(conf.KeyNewsbox); err == nil && kv != nil && kv.Value != "" { if kv, err := h.keyValueSrvc.GetString(conf.KeyNewsbox); err == nil && kv != nil && kv.Value != "" {
json.NewDecoder(strings.NewReader(kv.Value)).Decode(&newsbox) if err := json.NewDecoder(strings.NewReader(kv.Value)).Decode(&newsbox); err != nil {
logbuch.Error("failed to decode newsbox message - %v", err)
}
} }
return &view.HomeViewModel{ return &view.HomeViewModel{

View File

@ -10,6 +10,7 @@ import (
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view" "github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http" "net/http"
"strings" "strings"
) )
@ -56,6 +57,10 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
user := middlewares.GetPrincipal(r) user := middlewares.GetPrincipal(r)
byParam := strings.ToLower(r.URL.Query().Get("by")) byParam := strings.ToLower(r.URL.Query().Get("by"))
keyParam := strings.ToLower(r.URL.Query().Get("key")) keyParam := strings.ToLower(r.URL.Query().Get("key"))
pageParams := utils.ParsePageParamsWithDefault(r, 1, 100)
// note: pagination is not fully implemented, yet
// count function to get total item / total pages is missing
// and according ui (+ optionally search bar) is missing, too
var err error var err error
var leaderboard models.Leaderboard var leaderboard models.Leaderboard
@ -63,20 +68,42 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
var topKeys []string var topKeys []string
if byParam == "" { if byParam == "" {
leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, true) leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, pageParams, true)
if err != nil {
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
return &view.LeaderboardViewModel{Error: criticalError}
}
} else {
if by, ok := allowedAggregations[byParam]; ok {
leaderboard, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by, true)
if err != nil { if err != nil {
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err) conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
return &view.LeaderboardViewModel{Error: criticalError} return &view.LeaderboardViewModel{Error: criticalError}
} }
userLeaderboards := slice.GroupWith[*models.LeaderboardItem, string](leaderboard, func(item *models.LeaderboardItem) string { // regardless of page, always show own rank
if user != nil && !leaderboard.HasUser(user.ID) {
// but only if leaderboard spans multiple pages
if count, err := h.leaderboardService.CountUsers(); err == nil && count > int64(pageParams.PageSize) {
if l, err := h.leaderboardService.GetByIntervalAndUser(models.IntervalPast7Days, user.ID, true); err == nil && len(l) > 0 {
leaderboard = append(leaderboard, l[0])
}
}
}
} else {
if by, ok := allowedAggregations[byParam]; ok {
leaderboard, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by, pageParams, true)
if err != nil {
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
return &view.LeaderboardViewModel{Error: criticalError}
}
// regardless of page, always show own rank
if user != nil {
// but only if leaderboard could, in theory, span multiple pages
if count, err := h.leaderboardService.CountUsers(); err == nil && count > int64(pageParams.PageSize) {
if l, err := h.leaderboardService.GetAggregatedByIntervalAndUser(models.IntervalPast7Days, user.ID, &by, true); err == nil {
leaderboard.AddMany(l)
} else {
conf.Log().Request(r).Error("error while fetching own aggregated user leaderboard - %v", err)
}
}
}
userLeaderboards := slice.GroupWith[*models.LeaderboardItemRanked, string](leaderboard, func(item *models.LeaderboardItemRanked) string {
return item.UserID return item.UserID
}) })
userLanguages = map[string][]string{} userLanguages = map[string][]string{}
@ -110,6 +137,7 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
UserLanguages: userLanguages, UserLanguages: userLanguages,
TopKeys: topKeys, TopKeys: topKeys,
ApiKey: apiKey, ApiKey: apiKey,
PageParams: pageParams,
Success: r.URL.Query().Get("success"), Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"), Error: r.URL.Query().Get("error"),
} }

View File

@ -464,7 +464,7 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
// Healthcheck, if a new API key is set, i.e. the feature is activated // Healthcheck, if a new API key is set, i.e. the feature is activated
if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey, apiUrl) { if (user.WakatimeApiKey == "" && apiKey != "") && !h.validateWakatimeKey(apiKey, apiUrl) {
return http.StatusBadRequest, "", "failed to connect to WakaTime, API key invalid?" return http.StatusBadRequest, "", "failed to connect to WakaTime, API key or endpoint URL invalid?"
} }
if _, err := h.userSrvc.SetWakatimeApiCredentials(user, apiKey, apiUrl); err != nil { if _, err := h.userSrvc.SetWakatimeApiCredentials(user, apiKey, apiUrl); err != nil {

View File

@ -6,7 +6,6 @@ import (
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
"regexp" "regexp"
"time"
) )
const ( const (
@ -43,7 +42,7 @@ func GetBadgeParams(r *http.Request, requestedUser *models.User) (*models.KeyedI
Key: intervalKey, Key: intervalKey,
} }
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)) minStart := rangeTo.AddDate(0, 0, -requestedUser.ShareDataMaxDays)
// negative value means no limit // negative value means no limit
if rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 { if rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
return nil, nil, errors.New("requested time range too broad") return nil, nil, errors.New("requested time range too broad")

View File

@ -10,6 +10,7 @@
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const { Collection } = require('@iconify/json-tools') const { Collection } = require('@iconify/json-tools')
const { locate } = require("@iconify/json");
let icons = [ let icons = [
'fxemoji:key', 'fxemoji:key',
@ -70,6 +71,11 @@ let icons = [
'mdi:language-rust', 'mdi:language-rust',
'mdi:language-swift', 'mdi:language-swift',
'mdi:language-typescript', 'mdi:language-typescript',
'mdi:language-markdown',
'mdi:vuejs',
'mdi:react',
'mdi:code-json',
'mdi:bash',
'twemoji:frowning-face', 'twemoji:frowning-face',
] ]
@ -102,7 +108,7 @@ icons.forEach(icon => {
let code = '' let code = ''
Object.keys(filtered).forEach(prefix => { Object.keys(filtered).forEach(prefix => {
let collection = new Collection() let collection = new Collection()
if (!collection.loadIconifyCollection(prefix)) { if (!collection.loadFromFile(locate(prefix))) {
console.error('Error loading collection', prefix) console.error('Error loading collection', prefix)
return return
} }

View File

@ -9,6 +9,7 @@ import (
"github.com/duke-git/lancet/v2/datetime" "github.com/duke-git/lancet/v2/datetime"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
@ -30,11 +31,13 @@ const (
type WakatimeHeartbeatImporter struct { type WakatimeHeartbeatImporter struct {
ApiKey string ApiKey string
httpClient *http.Client
} }
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter { func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter {
return &WakatimeHeartbeatImporter{ return &WakatimeHeartbeatImporter{
ApiKey: apiKey, ApiKey: apiKey,
httpClient: &http.Client{Timeout: 10 * time.Second},
} }
} }
@ -57,14 +60,20 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
endDate = maxTo endDate = maxTo
} }
userAgents, err := w.fetchUserAgents(baseUrl) userAgents := map[string]*wakatime.UserAgentEntry{}
if err != nil { if data, err := w.fetchUserAgents(baseUrl); err == nil {
userAgents = data
} else if strings.Contains(baseUrl, "wakatime.com") {
// when importing from wakatime, resolving user agents is mandatorily required
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err) config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
return return
} }
machinesNames, err := w.fetchMachineNames(baseUrl) machinesNames := map[string]*wakatime.MachineEntry{}
if err != nil { if data, err := w.fetchMachineNames(baseUrl); err == nil {
machinesNames = data
} else if strings.Contains(baseUrl, "wakatime.com") {
// when importing from wakatime, resolving machine names is mandatorily required
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err) config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
return return
} }
@ -88,7 +97,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
d := day.Format(config.SimpleDateFormat) d := day.Format(config.SimpleDateFormat)
heartbeats, err := w.fetchHeartbeats(d, baseUrl) heartbeats, err := w.fetchHeartbeats(d, baseUrl)
if err != nil { if err != nil {
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - &v", d, user.ID, err) config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - %v", d, user.ID, err)
} }
for _, h := range heartbeats { for _, h := range heartbeats {
@ -112,8 +121,6 @@ func (w *WakatimeHeartbeatImporter) ImportAll(user *models.User) <-chan *models.
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05 // https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
// https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy // https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string) ([]*wakatime.HeartbeatEntry, error) { func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string) ([]*wakatime.HeartbeatEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiHeartbeatsUrl, nil) req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiHeartbeatsUrl, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@ -123,12 +130,13 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string)
q.Add("date", day) q.Add("date", day)
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
res, err := httpClient.Do(w.withHeaders(req)) res, err := w.httpClient.Do(w.withHeaders(req))
if err != nil { if err != nil {
return nil, err return nil, err
} else if res.StatusCode >= 400 { } else if res.StatusCode >= 400 {
return nil, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode)) return nil, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
} }
defer res.Body.Close()
var heartbeatsData wakatime.HeartbeatsViewModel var heartbeatsData wakatime.HeartbeatsViewModel
if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil { if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil {
@ -141,8 +149,6 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string)
// https://wakatime.com/api/v1/users/current/all_time_since_today // https://wakatime.com/api/v1/users/current/all_time_since_today
// https://pastr.de/p/w8xb4biv575pu32pox7jj2gr // https://pastr.de/p/w8xb4biv575pu32pox7jj2gr
func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.Time, error) { func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.Time, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
notime := time.Time{} notime := time.Time{}
req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiAllTimeUrl, nil) req, err := http.NewRequest(http.MethodGet, baseUrl+config.WakatimeApiAllTimeUrl, nil)
@ -150,7 +156,7 @@ func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.
return notime, notime, err return notime, notime, err
} }
res, err := httpClient.Do(w.withHeaders(req)) res, err := w.httpClient.Do(w.withHeaders(req))
if err != nil { if err != nil {
return notime, notime, err return notime, notime, err
} }
@ -177,8 +183,6 @@ func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.
// https://wakatime.com/api/v1/users/current/user_agents // https://wakatime.com/api/v1/users/current/user_agents
// https://pastr.de/p/05k5do8q108k94lic4lfl3pc // https://pastr.de/p/05k5do8q108k94lic4lfl3pc
func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]*wakatime.UserAgentEntry, error) { func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]*wakatime.UserAgentEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
userAgents := make(map[string]*wakatime.UserAgentEntry) userAgents := make(map[string]*wakatime.UserAgentEntry)
for page := 1; ; page++ { for page := 1; ; page++ {
@ -188,10 +192,11 @@ func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]
return nil, err return nil, err
} }
res, err := httpClient.Do(w.withHeaders(req)) res, err := w.httpClient.Do(w.withHeaders(req))
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer res.Body.Close()
var userAgentsData wakatime.UserAgentsViewModel var userAgentsData wakatime.UserAgentsViewModel
if err := json.NewDecoder(res.Body).Decode(&userAgentsData); err != nil { if err := json.NewDecoder(res.Body).Decode(&userAgentsData); err != nil {
@ -228,6 +233,7 @@ func (w *WakatimeHeartbeatImporter) fetchMachineNames(baseUrl string) (map[strin
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer res.Body.Close()
var machineData wakatime.MachineViewModel var machineData wakatime.MachineViewModel
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil { if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
@ -259,11 +265,19 @@ func mapHeartbeat(
) *models.Heartbeat { ) *models.Heartbeat {
ua := userAgents[entry.UserAgentId] ua := userAgents[entry.UserAgentId]
if ua == nil { if ua == nil {
// try to parse id as an actual user agent string (as returned by wakapi)
if opSys, editor, err := utils.ParseUserAgent(entry.UserAgentId); err == nil {
ua = &wakatime.UserAgentEntry{
Editor: opSys,
Os: editor,
}
} else {
ua = &wakatime.UserAgentEntry{ ua = &wakatime.UserAgentEntry{
Editor: "unknown", Editor: "unknown",
Os: "unknown", Os: "unknown",
} }
} }
}
ma := machineNames[entry.MachineNameId] ma := machineNames[entry.MachineNameId]
if ma == nil { if ma == nil {

View File

@ -10,6 +10,7 @@ import (
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"reflect" "reflect"
"strconv"
"strings" "strings"
"time" "time"
) )
@ -125,25 +126,41 @@ func (srv *LeaderboardService) ExistsAnyByUser(userId string) (bool, error) {
return count > 0, err return count > 0, err
} }
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, resolveUsers bool) (models.Leaderboard, error) { func (srv *LeaderboardService) CountUsers() (int64, error) {
return srv.GetAggregatedByInterval(interval, nil, resolveUsers)
}
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, resolveUsers bool) (models.Leaderboard, error) {
// check cache // check cache
cacheKey := srv.getHash(interval, by) cacheKey := "count_total"
if cacheResult, ok := srv.cache.Get(cacheKey); ok { if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.([]*models.LeaderboardItem), nil return cacheResult.(int64), nil
} }
items, err := srv.repository.GetAllAggregatedByInterval(interval, by) count, err := srv.repository.CountUsers()
if err != nil {
srv.cache.SetDefault(cacheKey, count)
}
return count, err
}
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, pageParams *models.PageParams, resolveUsers bool) (models.Leaderboard, error) {
return srv.GetAggregatedByInterval(interval, nil, pageParams, resolveUsers)
}
func (srv *LeaderboardService) GetByIntervalAndUser(interval *models.IntervalKey, userId string, resolveUser bool) (models.Leaderboard, error) {
return srv.GetAggregatedByIntervalAndUser(interval, userId, nil, resolveUser)
}
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, pageParams *models.PageParams, resolveUsers bool) (models.Leaderboard, error) {
// check cache
cacheKey := srv.getHash(interval, by, "", pageParams)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.([]*models.LeaderboardItemRanked), nil
}
items, err := srv.repository.GetAllAggregatedByInterval(interval, by, pageParams.Limit(), pageParams.Offset())
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resolveUsers { if resolveUsers {
a := models.Leaderboard(items).UserIDs()
println(a)
users, err := srv.userService.GetManyMapped(models.Leaderboard(items).UserIDs()) users, err := srv.userService.GetManyMapped(models.Leaderboard(items).UserIDs())
if err != nil { if err != nil {
config.Log().Error("failed to resolve users for leaderboard item - %v", err) config.Log().Error("failed to resolve users for leaderboard item - %v", err)
@ -160,6 +177,33 @@ func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.Interval
return items, nil return items, nil
} }
func (srv *LeaderboardService) GetAggregatedByIntervalAndUser(interval *models.IntervalKey, userId string, by *uint8, resolveUser bool) (models.Leaderboard, error) {
// check cache
cacheKey := srv.getHash(interval, by, userId, nil)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.([]*models.LeaderboardItemRanked), nil
}
items, err := srv.repository.GetAggregatedByUserAndInterval(userId, interval, by, 0, 0)
if err != nil {
return nil, err
}
if resolveUser {
u, err := srv.userService.GetUserById(userId)
if err != nil {
config.Log().Error("failed to resolve user for leaderboard item - %v", err)
} else {
for _, item := range items {
item.User = u
}
}
}
srv.cache.SetDefault(cacheKey, items)
return items, nil
}
func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *models.IntervalKey) (*models.LeaderboardItem, error) { func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *models.IntervalKey) (*models.LeaderboardItem, error) {
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ()) err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
if err != nil { if err != nil {
@ -208,10 +252,13 @@ func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, inter
return items, nil return items, nil
} }
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8) string { func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8, user string, pageParams *models.PageParams) string {
k := strings.Join(*interval, "__") k := strings.Join(*interval, "__") + "__" + user
if by != nil && !reflect.ValueOf(by).IsNil() { if by != nil && !reflect.ValueOf(by).IsNil() {
k += "__" + models.GetEntityColumn(*by) k += "__" + models.GetEntityColumn(*by)
} }
if pageParams != nil {
k += "__" + strconv.Itoa(pageParams.Page) + "__" + strconv.Itoa(pageParams.PageSize)
}
return k return k
} }

View File

@ -101,8 +101,11 @@ type ILeaderboardService interface {
ScheduleDefault() ScheduleDefault()
Run([]*models.User, *models.IntervalKey, []uint8) error Run([]*models.User, *models.IntervalKey, []uint8) error
ExistsAnyByUser(string) (bool, error) ExistsAnyByUser(string) (bool, error)
GetByInterval(*models.IntervalKey, bool) (models.Leaderboard, error) CountUsers() (int64, error)
GetAggregatedByInterval(*models.IntervalKey, *uint8, bool) (models.Leaderboard, error) GetByInterval(*models.IntervalKey, *models.PageParams, bool) (models.Leaderboard, error)
GetByIntervalAndUser(*models.IntervalKey, string, bool) (models.Leaderboard, error)
GetAggregatedByInterval(*models.IntervalKey, *uint8, *models.PageParams, bool) (models.Leaderboard, error)
GetAggregatedByIntervalAndUser(*models.IntervalKey, string, *uint8, bool) (models.Leaderboard, error)
GenerateByUser(*models.User, *models.IntervalKey) (*models.LeaderboardItem, error) GenerateByUser(*models.User, *models.IntervalKey) (*models.LeaderboardItem, error)
GenerateAggregatedByUser(*models.User, *models.IntervalKey, uint8) ([]*models.LeaderboardItem, error) GenerateAggregatedByUser(*models.User, *models.IntervalKey, uint8) ([]*models.LeaderboardItem, error)
} }

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -39,3 +39,13 @@ func WhereNullable(query *gorm.DB, col string, val any) *gorm.DB {
} }
return query.Where(fmt.Sprintf("%s = ?", col), val) return query.Where(fmt.Sprintf("%s = ?", col), val)
} }
func WithPaging(query *gorm.DB, limit, skip int) *gorm.DB {
if limit >= 0 {
query = query.Limit(limit)
}
if skip >= 0 {
query = query.Offset(skip)
}
return query
}

View File

@ -3,6 +3,7 @@ package utils
import ( import (
"encoding/json" "encoding/json"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
@ -42,3 +43,27 @@ func IsNoCache(r *http.Request, cacheTtl time.Duration) bool {
} }
return false return false
} }
func ParsePageParams(r *http.Request) *models.PageParams {
pageParams := &models.PageParams{}
page := r.URL.Query().Get("page")
pageSize := r.URL.Query().Get("page_size")
if p, err := strconv.Atoi(page); err == nil {
pageParams.Page = p
}
if p, err := strconv.Atoi(pageSize); err == nil && pageParams.Page > 0 {
pageParams.PageSize = p
}
return pageParams
}
func ParsePageParamsWithDefault(r *http.Request, page, size int) *models.PageParams {
pageParams := ParsePageParams(r)
if pageParams.Page == 0 {
pageParams.Page = page
}
if pageParams.PageSize == 0 {
pageParams.PageSize = size
}
return pageParams
}

View File

@ -553,6 +553,7 @@
<label class="font-semibold text-gray-300" for="select-timezone">WakaTime</label> <label class="font-semibold text-gray-300" for="select-timezone">WakaTime</label>
<span class="block text-sm text-gray-600"> <span class="block text-sm text-gray-600">
You can connect Wakapi with the official WakaTime (or another Wakapi instance, when optionally specifying a custom API URL) in a way that all heartbeats sent to Wakapi are relayed. This way, you can use both services at the same time. To get started, get <a class="link" href="https://wakatime.com/developers#authentication" rel="noopener noreferrer" target="_blank">your API key</a> and paste it here.<br><br> You can connect Wakapi with the official WakaTime (or another Wakapi instance, when optionally specifying a custom API URL) in a way that all heartbeats sent to Wakapi are relayed. This way, you can use both services at the same time. To get started, get <a class="link" href="https://wakatime.com/developers#authentication" rel="noopener noreferrer" target="_blank">your API key</a> and paste it here.<br><br>
To forward data to another Wakapi instance, use <span class="text-xs font-mono">https://&lt;your-server&gt;/api/compat/wakatime/v1</span> as a URL.<br><br>
Please note: When enabling this feature, the operators of this server will, in theory, have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a Please note: When enabling this feature, the operators of this server will, in theory, have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
class="link" target="_blank" href="https://github.com/muety/wakapi/issues/94" rel="noopener noreferrer">#94</a>) to be implemented. class="link" target="_blank" href="https://github.com/muety/wakapi/issues/94" rel="noopener noreferrer">#94</a>) to be implemented.
</span> </span>

View File

@ -7,10 +7,18 @@
resolved "https://registry.yarnpkg.com/@iconify/json-tools/-/json-tools-1.0.10.tgz#d9a7050dbbe8bb29d684d4b3f9446ed2d0bea3cc" resolved "https://registry.yarnpkg.com/@iconify/json-tools/-/json-tools-1.0.10.tgz#d9a7050dbbe8bb29d684d4b3f9446ed2d0bea3cc"
integrity sha512-LFelJDOLZ6JHlmlAkgrvmcu4hpNPB91KYcr4f60D/exzU1eNOb4/KCVHIydGHIQFaOacIOD+Xy+B7P1z812cZg== integrity sha512-LFelJDOLZ6JHlmlAkgrvmcu4hpNPB91KYcr4f60D/exzU1eNOb4/KCVHIydGHIQFaOacIOD+Xy+B7P1z812cZg==
"@iconify/json@^1.1.444": "@iconify/json@^2.1.136":
version "1.1.461" version "2.1.136"
resolved "https://registry.yarnpkg.com/@iconify/json/-/json-1.1.461.tgz#9e76f2339292e1a89855f93e497439afeb642f11" resolved "https://registry.yarnpkg.com/@iconify/json/-/json-2.1.136.tgz#f5601e37ef3d1e29532b09ad9643224a7f78692d"
integrity sha512-9Y41Tk9s3LDt4WI20XySNhNX6qTJ/WOBeE3O2iyoV9LJ6gFEDjp0uTPzfRU9NUx7D6VkvQ/htJEuRe9LmyMqUA== integrity sha512-tO5hV+yXn87+OCQqiVzis6i4YQiRX4044ZjubP6GmbeclE6tsypK+by/tXjbm90GTX0jhsOJ6YLzWl3szivywg==
dependencies:
"@iconify/types" "*"
pathe "^0.3.0"
"@iconify/types@*":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57"
integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
@ -161,9 +169,9 @@ decamelize@^1.2.0:
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
defined@^1.0.0: defined@^1.0.0:
version "1.0.0" version "1.0.1"
resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.1.tgz#c0b9db27bfaffd95d6f61399419b893df0f91ebf"
integrity sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ== integrity sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==
detective@^5.2.1: detective@^5.2.1:
version "5.2.1" version "5.2.1"
@ -189,7 +197,7 @@ emoji-regex@^7.0.1:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
fast-glob@^3.2.11: fast-glob@^3.2.12:
version "3.2.12" version "3.2.12"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==
@ -265,9 +273,9 @@ is-binary-path@~2.1.0:
binary-extensions "^2.0.0" binary-extensions "^2.0.0"
is-core-module@^2.9.0: is-core-module@^2.9.0:
version "2.10.0" version "2.11.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg== integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==
dependencies: dependencies:
has "^1.0.3" has "^1.0.3"
@ -321,7 +329,7 @@ merge2@^1.3.0:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
micromatch@^4.0.4: micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5" version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
@ -330,9 +338,9 @@ micromatch@^4.0.4:
picomatch "^2.3.1" picomatch "^2.3.1"
minimist@^1.2.6: minimist@^1.2.6:
version "1.2.6" version "1.2.7"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
nanoid@^3.3.4: nanoid@^3.3.4:
version "3.3.4" version "3.3.4"
@ -378,6 +386,11 @@ path-parse@^1.0.7:
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
pathe@^0.3.0:
version "0.3.9"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-0.3.9.tgz#4baff768f37f03e3d9341502865fb93116f65191"
integrity sha512-6Y6s0vT112P3jD8dGfuS6r+lpa0qqNrLyHPOwvXMnyNTQaYiwgau2DP3aNDsR13xqtGj7rrPo+jFUATpU6/s+g==
picocolors@^1.0.0: picocolors@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
@ -417,14 +430,14 @@ postcss-load-config@^3.1.4:
lilconfig "^2.0.5" lilconfig "^2.0.5"
yaml "^1.10.2" yaml "^1.10.2"
postcss-nested@5.0.6: postcss-nested@6.0.0:
version "5.0.6" version "6.0.0"
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-5.0.6.tgz#466343f7fc8d3d46af3e7dba3fcd47d052a945bc" resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.0.0.tgz#1572f1984736578f360cffc7eb7dca69e30d1735"
integrity sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA== integrity sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==
dependencies: dependencies:
postcss-selector-parser "^6.0.6" postcss-selector-parser "^6.0.10"
postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.6: postcss-selector-parser@^6.0.10:
version "6.0.10" version "6.0.10"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
@ -437,10 +450,10 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.14: postcss@^8.4.18:
version "8.4.17" version "8.4.19"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.17.tgz#f87863ec7cd353f81f7ab2dec5d67d861bbb1be5" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.19.tgz#61178e2add236b17351897c8bcc0b4c8ecab56fc"
integrity sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q== integrity sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==
dependencies: dependencies:
nanoid "^3.3.4" nanoid "^3.3.4"
picocolors "^1.0.0" picocolors "^1.0.0"
@ -533,9 +546,9 @@ supports-preserve-symlinks-flag@^1.0.0:
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
tailwindcss@^3.1.8: tailwindcss@^3.1.8:
version "3.1.8" version "3.2.4"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.1.8.tgz#4f8520550d67a835d32f2f4021580f9fddb7b741" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.2.4.tgz#afe3477e7a19f3ceafb48e4b083e292ce0dc0250"
integrity sha512-YSneUCZSFDYMwk+TGq8qYFdCA3yfBRdBlS7txSq0LUmzyeqRe3a8fBQzbz9M3WS/iFT4BNf/nmw9mEzrnSaC0g== integrity sha512-AhwtHCKMtR71JgeYDaswmZXhPcW9iuI9Sp2LvZPo9upDZ7231ZJ7eA9RaURbhpXGVlrjX4cFNlB4ieTetEb7hQ==
dependencies: dependencies:
arg "^5.0.2" arg "^5.0.2"
chokidar "^3.5.3" chokidar "^3.5.3"
@ -543,18 +556,19 @@ tailwindcss@^3.1.8:
detective "^5.2.1" detective "^5.2.1"
didyoumean "^1.2.2" didyoumean "^1.2.2"
dlv "^1.1.3" dlv "^1.1.3"
fast-glob "^3.2.11" fast-glob "^3.2.12"
glob-parent "^6.0.2" glob-parent "^6.0.2"
is-glob "^4.0.3" is-glob "^4.0.3"
lilconfig "^2.0.6" lilconfig "^2.0.6"
micromatch "^4.0.5"
normalize-path "^3.0.0" normalize-path "^3.0.0"
object-hash "^3.0.0" object-hash "^3.0.0"
picocolors "^1.0.0" picocolors "^1.0.0"
postcss "^8.4.14" postcss "^8.4.18"
postcss-import "^14.1.0" postcss-import "^14.1.0"
postcss-js "^4.0.0" postcss-js "^4.0.0"
postcss-load-config "^3.1.4" postcss-load-config "^3.1.4"
postcss-nested "5.0.6" postcss-nested "6.0.0"
postcss-selector-parser "^6.0.10" postcss-selector-parser "^6.0.10"
postcss-value-parser "^4.2.0" postcss-value-parser "^4.2.0"
quick-lru "^5.1.1" quick-lru "^5.1.1"