mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
94e0d06e5d | |||
088bd17803 | |||
2976203ecc | |||
e75bd94531 | |||
4cc8c21f67 | |||
f182b804bb | |||
9586dbf781 | |||
c8ea1a503f | |||
ebbc21f0b1 | |||
6e5bc38e5e | |||
9424c49760 | |||
efd6ba36e3 | |||
b1d7f87095 | |||
ffbcfc7467 | |||
41f6db8f34 | |||
8a21be4306 | |||
31ca4a1e02 | |||
7cab2b0be7 | |||
777997c883 |
@ -21,6 +21,9 @@ app:
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
tsx: TSX
|
||||
cjs: JavaScript
|
||||
ipynb: Python
|
||||
svelte: Svelte
|
||||
|
||||
# url template for user avatar images (to be used with services like gravatar or dicebear)
|
||||
|
@ -98,6 +98,7 @@ type dbConfig struct {
|
||||
Dialect string `yaml:"-"`
|
||||
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
|
||||
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"`
|
||||
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
||||
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
|
||||
|
@ -66,6 +66,10 @@ func mysqlConnectionString(config *dbConfig) string {
|
||||
}
|
||||
|
||||
func postgresConnectionString(config *dbConfig) string {
|
||||
if len(config.DSN) > 0 {
|
||||
return config.DSN
|
||||
}
|
||||
|
||||
sslmode := "disable"
|
||||
if config.Ssl {
|
||||
sslmode = "require"
|
||||
|
File diff suppressed because it is too large
Load Diff
3
main.go
3
main.go
@ -35,6 +35,8 @@ import (
|
||||
_ "gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
_ "github.com/muety/wakapi/static/docs"
|
||||
)
|
||||
|
||||
// Embed version.txt
|
||||
@ -282,6 +284,7 @@ func main() {
|
||||
|
||||
router.PathPrefix("/contribute.json").Handler(staticFileServer)
|
||||
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)
|
||||
|
||||
// Listen HTTP
|
||||
|
35
migrations/20221016_drop_rank_column.go
Normal file
35
migrations/20221016_drop_rank_column.go
Normal 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)
|
||||
}
|
71
migrations/20221028_fix_heartbeats_time_user_idx.go
Normal file
71
migrations/20221028_fix_heartbeats_time_user_idx.go
Normal 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)
|
||||
}
|
@ -3,6 +3,7 @@ package v1
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/developers#all_time_since_today
|
||||
@ -28,11 +29,19 @@ type AllTimeRange struct {
|
||||
|
||||
func NewAllTimeFrom(summary *models.Summary) *AllTimeViewModel {
|
||||
total := summary.TotalTime()
|
||||
tzName, _ := summary.FromTime.T().Zone()
|
||||
return &AllTimeViewModel{
|
||||
Data: &AllTimeData{
|
||||
TotalSeconds: float32(total.Seconds()),
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -13,9 +13,17 @@ import (
|
||||
// https://pastr.de/v/736450
|
||||
|
||||
type SummariesViewModel struct {
|
||||
Data []*SummariesData `json:"data"`
|
||||
End time.Time `json:"end"`
|
||||
Start time.Time `json:"start"`
|
||||
Data []*SummariesData `json:"data"`
|
||||
End time.Time `json:"end"`
|
||||
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 {
|
||||
@ -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{
|
||||
Data: data,
|
||||
End: maxDate,
|
||||
Start: minDate,
|
||||
CumulativeTotal: &SummariesCumulativeTotal{
|
||||
Decimal: fmt.Sprintf("%.2f", totalHrs),
|
||||
Digital: fmt.Sprintf("%d:%d", int(totalHrs), int(totalMins)),
|
||||
Seconds: totalSecs,
|
||||
Text: utils.FmtWakatimeDuration(totalTime),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
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)"`
|
||||
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"`
|
||||
Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
|
||||
OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
|
||||
|
@ -11,7 +11,6 @@ type LeaderboardItem struct {
|
||||
ID uint `json:"-" gorm:"primary_key; size:32"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
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"`
|
||||
By *uint8 `json:"aggregated_by" gorm:"index:idx_leaderboard_combined"` // pointer because nullable
|
||||
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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}))
|
||||
}
|
||||
|
||||
func (l Leaderboard) HasUser(userId string) bool {
|
||||
return slice.Contain(l.UserIDs(), userId)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@ -65,7 +93,7 @@ func (l Leaderboard) TopKeys(by uint8) []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
|
||||
})).TopKeys(by)
|
||||
}
|
||||
|
@ -34,6 +34,11 @@ type KeyedInterval struct {
|
||||
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)
|
||||
type CustomTime time.Time
|
||||
|
||||
@ -99,3 +104,17 @@ func (j CustomTime) T() time.Time {
|
||||
func (j CustomTime) Valid() bool {
|
||||
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
|
||||
}
|
||||
|
@ -10,10 +10,11 @@ type LeaderboardViewModel struct {
|
||||
User *models.User
|
||||
By string
|
||||
Key string
|
||||
Items []*models.LeaderboardItem
|
||||
Items []*models.LeaderboardItemRanked
|
||||
TopKeys []string
|
||||
UserLanguages map[string][]string
|
||||
ApiKey string
|
||||
PageParams *models.PageParams
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
@ -28,7 +29,7 @@ func (s *LeaderboardViewModel) WithError(m string) *LeaderboardViewModel {
|
||||
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 {
|
||||
return "self"
|
||||
}
|
||||
@ -47,25 +48,32 @@ func (s *LeaderboardViewModel) ColorModifier(item *models.LeaderboardItem, princ
|
||||
func (s *LeaderboardViewModel) LangIcon(lang string) string {
|
||||
// https://icon-sets.iconify.design/mdi/
|
||||
langs := map[string]string{
|
||||
"c++": "cpp",
|
||||
"cpp": "cpp",
|
||||
"go": "go",
|
||||
"haskell": "haskell",
|
||||
"html": "html5",
|
||||
"java": "java",
|
||||
"javascript": "javascript",
|
||||
"kotlin": "kotlin",
|
||||
"lua": "lua",
|
||||
"php": "php",
|
||||
"python": "python",
|
||||
"r": "r",
|
||||
"ruby": "ruby",
|
||||
"rust": "rust",
|
||||
"swift": "swift",
|
||||
"typescript": "typescript",
|
||||
"c++": "language-cpp",
|
||||
"cpp": "language-cpp",
|
||||
"go": "language-go",
|
||||
"haskell": "language-haskell",
|
||||
"html": "language-html5",
|
||||
"java": "language-java",
|
||||
"javascript": "language-javascript",
|
||||
"jsx": "language-javascript",
|
||||
"kotlin": "language-kotlin",
|
||||
"lua": "language-lua",
|
||||
"php": "language-php",
|
||||
"python": "language-python",
|
||||
"r": "language-r",
|
||||
"ruby": "language-ruby",
|
||||
"rust": "language-rust",
|
||||
"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 {
|
||||
return "mdi:language-" + match
|
||||
return "mdi:" + match
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
@ -9,7 +9,7 @@
|
||||
"compress": "brotli -f static/assets/css/*.dist.css && brotli -f static/assets/js/*.dist.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^1.1.444",
|
||||
"@iconify/json": "^2.1.136",
|
||||
"@iconify/json-tools": "^1.0.10",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"tailwindcss": "^3.1.8"
|
||||
|
@ -33,13 +33,27 @@ func (r *LeaderboardRepository) CountAllByUser(userId string) (int64, error) {
|
||||
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 ?
|
||||
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\"").
|
||||
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 {
|
||||
return nil, err
|
||||
@ -47,13 +61,16 @@ func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalK
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) {
|
||||
var items []*models.LeaderboardItem
|
||||
q := r.db.
|
||||
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8, limit, skip int) ([]*models.LeaderboardItemRanked, error) {
|
||||
var items []*models.LeaderboardItemRanked
|
||||
subq := r.db.
|
||||
Table("leaderboard_items").
|
||||
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
|
||||
Where("user_id = ?", userId).
|
||||
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 {
|
||||
return nil, err
|
||||
@ -79,3 +96,13 @@ func (r *LeaderboardRepository) DeleteByUserAndInterval(userId string, key *mode
|
||||
}
|
||||
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
|
||||
}
|
||||
|
@ -90,8 +90,9 @@ type IUserRepository interface {
|
||||
type ILeaderboardRepository interface {
|
||||
InsertBatch([]*models.LeaderboardItem) error
|
||||
CountAllByUser(string) (int64, error)
|
||||
CountUsers() (int64, error)
|
||||
DeleteByUser(string) error
|
||||
DeleteByUserAndInterval(string, *models.IntervalKey) error
|
||||
GetAllAggregatedByInterval(*models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error)
|
||||
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error)
|
||||
GetAllAggregatedByInterval(*models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
|
||||
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8, int, int) ([]*models.LeaderboardItemRanked, error)
|
||||
}
|
||||
|
@ -86,7 +86,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
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) &&
|
||||
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
@ -3,6 +3,7 @@ package routes
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/schema"
|
||||
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 != "" {
|
||||
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{
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@ -56,6 +57,10 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
||||
user := middlewares.GetPrincipal(r)
|
||||
byParam := strings.ToLower(r.URL.Query().Get("by"))
|
||||
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 leaderboard models.Leaderboard
|
||||
@ -63,20 +68,42 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
||||
var topKeys []string
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
// 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, true)
|
||||
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}
|
||||
}
|
||||
|
||||
userLeaderboards := slice.GroupWith[*models.LeaderboardItem, string](leaderboard, func(item *models.LeaderboardItem) string {
|
||||
// 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
|
||||
})
|
||||
userLanguages = map[string][]string{}
|
||||
@ -110,6 +137,7 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
||||
UserLanguages: userLanguages,
|
||||
TopKeys: topKeys,
|
||||
ApiKey: apiKey,
|
||||
PageParams: pageParams,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
|
@ -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
|
||||
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 {
|
||||
|
@ -6,7 +6,6 @@ import (
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -43,7 +42,7 @@ func GetBadgeParams(r *http.Request, requestedUser *models.User) (*models.KeyedI
|
||||
Key: intervalKey,
|
||||
}
|
||||
|
||||
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
|
||||
minStart := rangeTo.AddDate(0, 0, -requestedUser.ShareDataMaxDays)
|
||||
// negative value means no limit
|
||||
if rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
||||
return nil, nil, errors.New("requested time range too broad")
|
||||
|
@ -10,6 +10,7 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { Collection } = require('@iconify/json-tools')
|
||||
const { locate } = require("@iconify/json");
|
||||
|
||||
let icons = [
|
||||
'fxemoji:key',
|
||||
@ -70,6 +71,11 @@ let icons = [
|
||||
'mdi:language-rust',
|
||||
'mdi:language-swift',
|
||||
'mdi:language-typescript',
|
||||
'mdi:language-markdown',
|
||||
'mdi:vuejs',
|
||||
'mdi:react',
|
||||
'mdi:code-json',
|
||||
'mdi:bash',
|
||||
'twemoji:frowning-face',
|
||||
]
|
||||
|
||||
@ -102,7 +108,7 @@ icons.forEach(icon => {
|
||||
let code = ''
|
||||
Object.keys(filtered).forEach(prefix => {
|
||||
let collection = new Collection()
|
||||
if (!collection.loadIconifyCollection(prefix)) {
|
||||
if (!collection.loadFromFile(locate(prefix))) {
|
||||
console.error('Error loading collection', prefix)
|
||||
return
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
@ -29,12 +30,14 @@ const (
|
||||
)
|
||||
|
||||
type WakatimeHeartbeatImporter struct {
|
||||
ApiKey string
|
||||
ApiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewWakatimeHeartbeatImporter(apiKey string) *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
|
||||
}
|
||||
|
||||
userAgents, err := w.fetchUserAgents(baseUrl)
|
||||
if err != nil {
|
||||
userAgents := map[string]*wakatime.UserAgentEntry{}
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
machinesNames, err := w.fetchMachineNames(baseUrl)
|
||||
if err != nil {
|
||||
machinesNames := map[string]*wakatime.MachineEntry{}
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -88,7 +97,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
d := day.Format(config.SimpleDateFormat)
|
||||
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
|
||||
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 {
|
||||
@ -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://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -123,12 +130,13 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string, baseUrl string)
|
||||
q.Add("date", day)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
res, err := httpClient.Do(w.withHeaders(req))
|
||||
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if res.StatusCode >= 400 {
|
||||
return nil, errors.New(fmt.Sprintf("got status %d from wakatime api", res.StatusCode))
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var heartbeatsData wakatime.HeartbeatsViewModel
|
||||
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://pastr.de/p/w8xb4biv575pu32pox7jj2gr
|
||||
func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.Time, error) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
notime := time.Time{}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
res, err := httpClient.Do(w.withHeaders(req))
|
||||
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||
if err != nil {
|
||||
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://pastr.de/p/05k5do8q108k94lic4lfl3pc
|
||||
func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]*wakatime.UserAgentEntry, error) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
userAgents := make(map[string]*wakatime.UserAgentEntry)
|
||||
|
||||
for page := 1; ; page++ {
|
||||
@ -188,10 +192,11 @@ func (w *WakatimeHeartbeatImporter) fetchUserAgents(baseUrl string) (map[string]
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := httpClient.Do(w.withHeaders(req))
|
||||
res, err := w.httpClient.Do(w.withHeaders(req))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var userAgentsData wakatime.UserAgentsViewModel
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var machineData wakatime.MachineViewModel
|
||||
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
|
||||
@ -259,9 +265,17 @@ func mapHeartbeat(
|
||||
) *models.Heartbeat {
|
||||
ua := userAgents[entry.UserAgentId]
|
||||
if ua == nil {
|
||||
ua = &wakatime.UserAgentEntry{
|
||||
Editor: "unknown",
|
||||
Os: "unknown",
|
||||
// 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{
|
||||
Editor: "unknown",
|
||||
Os: "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@ -125,25 +126,41 @@ func (srv *LeaderboardService) ExistsAnyByUser(userId string) (bool, error) {
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, resolveUsers bool) (models.Leaderboard, error) {
|
||||
return srv.GetAggregatedByInterval(interval, nil, resolveUsers)
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, resolveUsers bool) (models.Leaderboard, error) {
|
||||
func (srv *LeaderboardService) CountUsers() (int64, error) {
|
||||
// check cache
|
||||
cacheKey := srv.getHash(interval, by)
|
||||
cacheKey := "count_total"
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resolveUsers {
|
||||
a := models.Leaderboard(items).UserIDs()
|
||||
println(a)
|
||||
users, err := srv.userService.GetManyMapped(models.Leaderboard(items).UserIDs())
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
if err != nil {
|
||||
@ -208,10 +252,13 @@ func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, inter
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8) string {
|
||||
k := strings.Join(*interval, "__")
|
||||
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8, user string, pageParams *models.PageParams) string {
|
||||
k := strings.Join(*interval, "__") + "__" + user
|
||||
if by != nil && !reflect.ValueOf(by).IsNil() {
|
||||
k += "__" + models.GetEntityColumn(*by)
|
||||
}
|
||||
if pageParams != nil {
|
||||
k += "__" + strconv.Itoa(pageParams.Page) + "__" + strconv.Itoa(pageParams.PageSize)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
@ -101,8 +101,11 @@ type ILeaderboardService interface {
|
||||
ScheduleDefault()
|
||||
Run([]*models.User, *models.IntervalKey, []uint8) error
|
||||
ExistsAnyByUser(string) (bool, error)
|
||||
GetByInterval(*models.IntervalKey, bool) (models.Leaderboard, error)
|
||||
GetAggregatedByInterval(*models.IntervalKey, *uint8, bool) (models.Leaderboard, error)
|
||||
CountUsers() (int64, 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)
|
||||
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.
10
utils/db.go
10
utils/db.go
@ -39,3 +39,13 @@ func WhereNullable(query *gorm.DB, col string, val any) *gorm.DB {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package utils
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@ -42,3 +43,27 @@ func IsNoCache(r *http.Request, cacheTtl time.Duration) bool {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
@ -553,6 +553,7 @@
|
||||
<label class="font-semibold text-gray-300" for="select-timezone">WakaTime</label>
|
||||
<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>
|
||||
To forward data to another Wakapi instance, use <span class="text-xs font-mono">https://<your-server>/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
|
||||
class="link" target="_blank" href="https://github.com/muety/wakapi/issues/94" rel="noopener noreferrer">#94</a>) to be implemented.
|
||||
</span>
|
||||
|
76
yarn.lock
76
yarn.lock
@ -7,10 +7,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@iconify/json-tools/-/json-tools-1.0.10.tgz#d9a7050dbbe8bb29d684d4b3f9446ed2d0bea3cc"
|
||||
integrity sha512-LFelJDOLZ6JHlmlAkgrvmcu4hpNPB91KYcr4f60D/exzU1eNOb4/KCVHIydGHIQFaOacIOD+Xy+B7P1z812cZg==
|
||||
|
||||
"@iconify/json@^1.1.444":
|
||||
version "1.1.461"
|
||||
resolved "https://registry.yarnpkg.com/@iconify/json/-/json-1.1.461.tgz#9e76f2339292e1a89855f93e497439afeb642f11"
|
||||
integrity sha512-9Y41Tk9s3LDt4WI20XySNhNX6qTJ/WOBeE3O2iyoV9LJ6gFEDjp0uTPzfRU9NUx7D6VkvQ/htJEuRe9LmyMqUA==
|
||||
"@iconify/json@^2.1.136":
|
||||
version "2.1.136"
|
||||
resolved "https://registry.yarnpkg.com/@iconify/json/-/json-2.1.136.tgz#f5601e37ef3d1e29532b09ad9643224a7f78692d"
|
||||
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":
|
||||
version "2.1.5"
|
||||
@ -161,9 +169,9 @@ decamelize@^1.2.0:
|
||||
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
|
||||
|
||||
defined@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
|
||||
integrity sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.1.tgz#c0b9db27bfaffd95d6f61399419b893df0f91ebf"
|
||||
integrity sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==
|
||||
|
||||
detective@^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"
|
||||
integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
|
||||
|
||||
fast-glob@^3.2.11:
|
||||
fast-glob@^3.2.12:
|
||||
version "3.2.12"
|
||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
|
||||
integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==
|
||||
@ -265,9 +273,9 @@ is-binary-path@~2.1.0:
|
||||
binary-extensions "^2.0.0"
|
||||
|
||||
is-core-module@^2.9.0:
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
|
||||
integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
|
||||
integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==
|
||||
dependencies:
|
||||
has "^1.0.3"
|
||||
|
||||
@ -321,7 +329,7 @@ merge2@^1.3.0:
|
||||
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
||||
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
||||
|
||||
micromatch@^4.0.4:
|
||||
micromatch@^4.0.4, micromatch@^4.0.5:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
|
||||
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
|
||||
@ -330,9 +338,9 @@ micromatch@^4.0.4:
|
||||
picomatch "^2.3.1"
|
||||
|
||||
minimist@^1.2.6:
|
||||
version "1.2.6"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
|
||||
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
|
||||
version "1.2.7"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
|
||||
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
|
||||
|
||||
nanoid@^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"
|
||||
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:
|
||||
version "1.0.0"
|
||||
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"
|
||||
yaml "^1.10.2"
|
||||
|
||||
postcss-nested@5.0.6:
|
||||
version "5.0.6"
|
||||
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-5.0.6.tgz#466343f7fc8d3d46af3e7dba3fcd47d052a945bc"
|
||||
integrity sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==
|
||||
postcss-nested@6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.0.0.tgz#1572f1984736578f360cffc7eb7dca69e30d1735"
|
||||
integrity sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
|
||||
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"
|
||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||
|
||||
postcss@^8.4.14:
|
||||
version "8.4.17"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.17.tgz#f87863ec7cd353f81f7ab2dec5d67d861bbb1be5"
|
||||
integrity sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==
|
||||
postcss@^8.4.18:
|
||||
version "8.4.19"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.19.tgz#61178e2add236b17351897c8bcc0b4c8ecab56fc"
|
||||
integrity sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==
|
||||
dependencies:
|
||||
nanoid "^3.3.4"
|
||||
picocolors "^1.0.0"
|
||||
@ -533,9 +546,9 @@ supports-preserve-symlinks-flag@^1.0.0:
|
||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||
|
||||
tailwindcss@^3.1.8:
|
||||
version "3.1.8"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.1.8.tgz#4f8520550d67a835d32f2f4021580f9fddb7b741"
|
||||
integrity sha512-YSneUCZSFDYMwk+TGq8qYFdCA3yfBRdBlS7txSq0LUmzyeqRe3a8fBQzbz9M3WS/iFT4BNf/nmw9mEzrnSaC0g==
|
||||
version "3.2.4"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.2.4.tgz#afe3477e7a19f3ceafb48e4b083e292ce0dc0250"
|
||||
integrity sha512-AhwtHCKMtR71JgeYDaswmZXhPcW9iuI9Sp2LvZPo9upDZ7231ZJ7eA9RaURbhpXGVlrjX4cFNlB4ieTetEb7hQ==
|
||||
dependencies:
|
||||
arg "^5.0.2"
|
||||
chokidar "^3.5.3"
|
||||
@ -543,18 +556,19 @@ tailwindcss@^3.1.8:
|
||||
detective "^5.2.1"
|
||||
didyoumean "^1.2.2"
|
||||
dlv "^1.1.3"
|
||||
fast-glob "^3.2.11"
|
||||
fast-glob "^3.2.12"
|
||||
glob-parent "^6.0.2"
|
||||
is-glob "^4.0.3"
|
||||
lilconfig "^2.0.6"
|
||||
micromatch "^4.0.5"
|
||||
normalize-path "^3.0.0"
|
||||
object-hash "^3.0.0"
|
||||
picocolors "^1.0.0"
|
||||
postcss "^8.4.14"
|
||||
postcss "^8.4.18"
|
||||
postcss-import "^14.1.0"
|
||||
postcss-js "^4.0.0"
|
||||
postcss-load-config "^3.1.4"
|
||||
postcss-nested "5.0.6"
|
||||
postcss-nested "6.0.0"
|
||||
postcss-selector-parser "^6.0.10"
|
||||
postcss-value-parser "^4.2.0"
|
||||
quick-lru "^5.1.1"
|
||||
|
Reference in New Issue
Block a user