mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
23 Commits
Author | SHA1 | Date | |
---|---|---|---|
94e0d06e5d | |||
088bd17803 | |||
2976203ecc | |||
e75bd94531 | |||
4cc8c21f67 | |||
f182b804bb | |||
9586dbf781 | |||
c8ea1a503f | |||
ebbc21f0b1 | |||
6e5bc38e5e | |||
9424c49760 | |||
efd6ba36e3 | |||
b1d7f87095 | |||
ffbcfc7467 | |||
41f6db8f34 | |||
8a21be4306 | |||
31ca4a1e02 | |||
7cab2b0be7 | |||
777997c883 | |||
060a33263a | |||
33d259592c | |||
fbae5f8757 | |||
bc99dc990a |
@ -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
22
go.mod
22
go.mod
@ -8,8 +8,8 @@ require (
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
|
||||
github.com/emersion/go-smtp v0.15.0
|
||||
github.com/emvi/logbuch v1.2.0
|
||||
github.com/getsentry/sentry-go v0.13.0
|
||||
github.com/glebarez/sqlite v1.4.7
|
||||
github.com/getsentry/sentry-go v0.14.0
|
||||
github.com/glebarez/sqlite v1.5.0
|
||||
github.com/go-co-op/gocron v1.17.0
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
@ -27,12 +27,12 @@ require (
|
||||
github.com/swaggo/http-swagger v1.3.3
|
||||
github.com/swaggo/swag v1.8.6
|
||||
go.uber.org/atomic v1.10.0
|
||||
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
|
||||
gorm.io/driver/mysql v1.3.6
|
||||
gorm.io/driver/postgres v1.3.10
|
||||
gorm.io/driver/sqlite v1.3.6
|
||||
gorm.io/gorm v1.23.10
|
||||
gorm.io/driver/mysql v1.4.1
|
||||
gorm.io/driver/postgres v1.4.4
|
||||
gorm.io/driver/sqlite v1.4.2
|
||||
gorm.io/gorm v1.24.0
|
||||
)
|
||||
|
||||
require (
|
||||
@ -40,7 +40,7 @@ require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||
github.com/glebarez/go-sqlite v1.18.2 // indirect
|
||||
github.com/glebarez/go-sqlite v1.19.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||
github.com/go-openapi/spec v0.20.7 // indirect
|
||||
@ -69,13 +69,13 @@ require (
|
||||
github.com/stretchr/objx v0.4.0 // indirect
|
||||
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a // indirect
|
||||
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 // indirect
|
||||
golang.org/x/net v0.0.0-20220927171203-f486391704dc // indirect
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af // indirect
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.20.0 // indirect
|
||||
modernc.org/libc v1.20.3 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.4.0 // indirect
|
||||
modernc.org/sqlite v1.19.1 // indirect
|
||||
|
32
go.sum
32
go.sum
@ -31,13 +31,20 @@ github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBd
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
|
||||
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
|
||||
github.com/getsentry/sentry-go v0.14.0 h1:rlOBkuFZRKKdUnKO+0U3JclRDQKlRu5vVQtkWSQvC70=
|
||||
github.com/getsentry/sentry-go v0.14.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
|
||||
github.com/glebarez/go-sqlite v1.18.2 h1:ck3PQVaEzzzapP0g7pfhzbB3Jw4rNk+IldLMy/lgdeQ=
|
||||
github.com/glebarez/go-sqlite v1.18.2/go.mod h1:/kOdnnt5T0ztYXqBPdjRVM8JwMpFtyAQp1mtRoNxziM=
|
||||
github.com/glebarez/go-sqlite v1.19.1 h1:o2XhjyR8CQ2m84+bVz10G0cabmG0tY4sIMiCbrcUTrY=
|
||||
github.com/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M=
|
||||
github.com/glebarez/sqlite v1.4.7 h1:tIBxEWLJOPkekuQcwfenNfh13itj9GoVJYxp7GidJAo=
|
||||
github.com/glebarez/sqlite v1.4.7/go.mod h1:UY1smw9rBTSGnJE0He8pVRPvlxCP1C8hlB8Z24K8fG4=
|
||||
github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk=
|
||||
github.com/glebarez/sqlite v1.5.0/go.mod h1:0wzXzTvfVJIN2GqRhCdMbnYd+m+aH5/QV7B30rM6NgY=
|
||||
github.com/go-co-op/gocron v1.17.0 h1:IixLXsti+Qo0wMvmn6Kmjp2csk2ykpkcL+EmHmST18w=
|
||||
github.com/go-co-op/gocron v1.17.0/go.mod h1:IpDBSaJOVfFw7hXZuTag3SCSkqazXBBUkbQ1m1aesBs=
|
||||
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
@ -60,6 +67,7 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF0
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@ -73,6 +81,7 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
@ -93,6 +102,7 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
@ -242,6 +252,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A=
|
||||
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0=
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 h1:Lj6HJGCSn5AjxRAH2+r35Mir4icalbqku+CLUtjnvXY=
|
||||
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
@ -259,6 +271,8 @@ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220927171203-f486391704dc h1:FxpXZdoBqT8RjqTy6i1E8nXHhW21wK7ptQ/EPIGxzPQ=
|
||||
golang.org/x/net v0.0.0-20220927171203-f486391704dc/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4=
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY=
|
||||
@ -281,6 +295,8 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 h1:AzgQNqF+FKwyQ5LbVrVqOcuuFB67N47F9+htZYH0wFM=
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@ -324,23 +340,33 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.3.6 h1:BhX1Y/RyALb+T9bZ3t07wLnPZBukt+IRkMn8UZSNbGM=
|
||||
gorm.io/driver/mysql v1.3.6/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
|
||||
gorm.io/driver/mysql v1.4.1 h1:4InA6SOaYtt4yYpV1NF9B2kvUKe9TbvUd1iWrvxnjic=
|
||||
gorm.io/driver/mysql v1.4.1/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
|
||||
gorm.io/driver/postgres v1.3.10 h1:Fsd+pQpFMGlGxxVMUPJhNo8gG8B1lKtk8QQ4/VZZAJw=
|
||||
gorm.io/driver/postgres v1.3.10/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
|
||||
gorm.io/driver/postgres v1.4.4 h1:zt1fxJ+C+ajparn0SteEnkoPg0BQ6wOWXEQ99bteAmw=
|
||||
gorm.io/driver/postgres v1.4.4/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
|
||||
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
||||
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
|
||||
gorm.io/driver/sqlite v1.4.2 h1:F6vYJcmR4Cnh0ErLyoY8JSfabBGyR0epIGuhgHJuNws=
|
||||
gorm.io/driver/sqlite v1.4.2/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.7/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.10 h1:4Ne9ZbzID9GUxRkllxN4WjJKpsHx8YbKvekVdgyWh24=
|
||||
gorm.io/gorm v1.23.10/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74=
|
||||
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
|
||||
modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
|
||||
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
|
||||
modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
|
||||
@ -355,8 +381,11 @@ modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
|
||||
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
|
||||
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
|
||||
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
|
||||
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/libc v1.20.0 h1:MEbCfCKpuDC/LRb3HOCM9fZOqnPx8le3kzTJVmUGDbU=
|
||||
modernc.org/libc v1.20.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/libc v1.20.3 h1:BodaDPuUse7taQchAClMmbE/yZp3T2ZBiwCDFyBLEXw=
|
||||
modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
@ -367,12 +396,15 @@ modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
|
||||
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.18.2/go.mod h1:kvrTLEWgxUcHa2GfHBQtanR1H9ht3hTJNtKpzH9k1u0=
|
||||
modernc.org/sqlite v1.19.1 h1:8xmS5oLnZtAK//vnd4aTVj8VOeTAccEFOtUnIzfSw+4=
|
||||
modernc.org/sqlite v1.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
|
||||
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.13.2/go.mod h1:7CLiGIPo1M8Rv1Mitpv5akc2+8fxUd2y2UzC/MfMzy0=
|
||||
modernc.org/tcl v1.14.0/go.mod h1:gQ7c1YPMvryCHCcmf8acB6VPabE59QBeuRQLL7cTUlM=
|
||||
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
|
||||
modernc.org/z v1.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
||||
|
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
|
||||
|
@ -60,6 +60,17 @@ func init() {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// https://github.com/muety/wakapi/issues/416#issuecomment-1271674792
|
||||
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summary_items_summary") {
|
||||
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summary_items_summary"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_labels") {
|
||||
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_labels"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Migrator().AlterColumn(&models.Summary{}, "id"); err != nil {
|
||||
return err
|
||||
|
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)
|
||||
})
|
||||
}
|
||||
@ -45,10 +73,11 @@ func (l Leaderboard) TopKeys(by uint8) []string {
|
||||
if item.Key == nil || item.By == nil || *item.By != by {
|
||||
continue
|
||||
}
|
||||
if _, ok := totalsMapped[*item.Key]; !ok {
|
||||
totalsMapped[*item.Key] = &keyTotal{Key: *item.Key, Total: 0}
|
||||
key := strings.ToLower(*item.Key)
|
||||
if _, ok := totalsMapped[key]; !ok {
|
||||
totalsMapped[key] = &keyTotal{Key: *item.Key, Total: 0}
|
||||
}
|
||||
totalsMapped[*item.Key].Total += item.Total
|
||||
totalsMapped[key].Total += item.Total
|
||||
}
|
||||
|
||||
totals := slice.Map[*keyTotal, keyTotal](maputil.Values[string, *keyTotal](totalsMapped), func(i int, item *keyTotal) keyTotal {
|
||||
@ -64,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,26 +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": "c",
|
||||
"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,10 +9,10 @@
|
||||
"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": "2.2.19"
|
||||
"tailwindcss": "^3.1.8"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
|
@ -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{}
|
||||
@ -87,8 +114,9 @@ func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardVi
|
||||
topKeys = leaderboard.TopKeys(by)
|
||||
if len(topKeys) > 0 {
|
||||
if keyParam == "" {
|
||||
keyParam = strings.ToLower(topKeys[0])
|
||||
keyParam = topKeys[0]
|
||||
}
|
||||
keyParam = strings.ToLower(keyParam)
|
||||
leaderboard = leaderboard.TopByKey(by, keyParam)
|
||||
}
|
||||
} else {
|
||||
@ -109,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.
@ -1,17 +1,24 @@
|
||||
const colors = require('tailwindcss/colors')
|
||||
|
||||
module.exports = {
|
||||
purge: {
|
||||
enabled: true,
|
||||
mode: 'all',
|
||||
content: ['./views/*.tpl.html'],
|
||||
safelist: [
|
||||
'newsbox-default',
|
||||
'newsbox-warning',
|
||||
'newsbox-danger',
|
||||
'leaderboard-self',
|
||||
'leaderboard-default',
|
||||
'leaderboard-gold',
|
||||
'leaderboard-silver',
|
||||
'leaderboard-bronze',
|
||||
]
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
green: colors.emerald,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
content: [
|
||||
'./views/*.tpl.html',
|
||||
],
|
||||
safelist: [
|
||||
'newsbox-default',
|
||||
'newsbox-warning',
|
||||
'newsbox-danger',
|
||||
'leaderboard-self',
|
||||
'leaderboard-default',
|
||||
'leaderboard-gold',
|
||||
'leaderboard-silver',
|
||||
'leaderboard-bronze',
|
||||
]
|
||||
}
|
||||
|
@ -1,15 +1,16 @@
|
||||
BEGIN TRANSACTION;
|
||||
INSERT INTO "key_string_values" VALUES ('20210213-add_has_data_field','done');
|
||||
INSERT INTO "key_string_values" VALUES ('20210221-add_created_date_column','done');
|
||||
INSERT INTO "key_string_values" VALUES ('imprint','no content here');
|
||||
INSERT INTO "key_string_values" VALUES ('20210411-add_imprint_content','done');
|
||||
INSERT INTO "key_string_values" VALUES ('20210806-remove_persisted_project_labels','done');
|
||||
INSERT INTO "key_string_values" VALUES ('20211215-migrate_id_to_bigint-add_has_data_field','done');
|
||||
INSERT INTO "key_string_values" VALUES ('latest_total_time','0s');
|
||||
INSERT INTO "key_string_values" VALUES ('latest_total_users','0');
|
||||
INSERT INTO "key_string_values" ("key","value") VALUES ('20210213-add_has_data_field','done');
|
||||
INSERT INTO "key_string_values" ("key","value") VALUES ('20210221-add_created_date_column','done');
|
||||
INSERT INTO "key_string_values" ("key","value") VALUES ('imprint','no content here');
|
||||
INSERT INTO "key_string_values" ("key","value") VALUES ('20210411-add_imprint_content','done');
|
||||
INSERT INTO "key_string_values" ("key","value") VALUES ('20210806-remove_persisted_project_labels','done');
|
||||
INSERT INTO "key_string_values" ("key","value") VALUES ('20211215-migrate_id_to_bigint-add_has_data_field','done');
|
||||
INSERT INTO "key_string_values" ("key","value") VALUES ('20212212-total_summary_heartbeats','done');
|
||||
INSERT INTO "key_string_values" ("key","value") VALUES ('20220317-align_num_heartbeats','done');
|
||||
INSERT INTO "key_string_values" ("key","value") VALUES ('20220318-mysql_timestamp_precision','done');
|
||||
INSERT INTO "key_string_values" ("key","value") VALUES ('202203191-drop_diagnostics_user','done');
|
||||
COMMIT;
|
||||
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
|
||||
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
|
||||
|
@ -7,29 +7,31 @@ BEGIN TRANSACTION;
|
||||
DROP TABLE IF EXISTS "aliases";
|
||||
CREATE TABLE `aliases` (`id` integer,`type` integer NOT NULL,`user_id` text NOT NULL,`key` text NOT NULL,`value` text NOT NULL,PRIMARY KEY (`id`),CONSTRAINT `fk_aliases_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
DROP TABLE IF EXISTS "diagnostics";
|
||||
CREATE TABLE `diagnostics` (`id` integer,`user_id` text NOT NULL,`platform` text,`architecture` text,`plugin` text,`cli_version` text,`logs` text,`stack_trace` text,PRIMARY KEY (`id`),CONSTRAINT `fk_diagnostics_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
CREATE TABLE `diagnostics` (`id` integer,`platform` text,`architecture` text,`plugin` text,`cli_version` text,`logs` text,`stack_trace` text,PRIMARY KEY (`id`));
|
||||
DROP TABLE IF EXISTS "heartbeats";
|
||||
CREATE TABLE `heartbeats` (`id` integer,`user_id` text NOT NULL,`entity` text NOT NULL,`type` text,`category` text,`project` text,`branch` text,`language` text,`is_write` numeric,`editor` text,`operating_system` text,`machine` text,`user_agent` text,`time` timestamp,`hash` varchar(17),`origin` text,`origin_id` text,`created_at` timestamp,PRIMARY KEY (`id`),CONSTRAINT `fk_heartbeats_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
CREATE TABLE `heartbeats` (`id` integer,`user_id` text NOT NULL,`entity` text NOT NULL,`type` text,`category` text,`project` text,`branch` text,`language` text,`is_write` numeric,`editor` text,`operating_system` text,`machine` text,`user_agent` varchar(255),`time` timestamp(3),`hash` varchar(17),`origin` varchar(255),`origin_id` varchar(255),`created_at` timestamp(3),PRIMARY KEY (`id`),CONSTRAINT `fk_heartbeats_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
DROP TABLE IF EXISTS "key_string_values";
|
||||
CREATE TABLE `key_string_values` (`key` text,`value` text,PRIMARY KEY (`key`));
|
||||
DROP TABLE IF EXISTS "language_mappings";
|
||||
CREATE TABLE `language_mappings` (`id` integer,`user_id` text NOT NULL,`extension` varchar(16),`language` varchar(64),PRIMARY KEY (`id`),CONSTRAINT `fk_language_mappings_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
DROP TABLE IF EXISTS "leaderboard_items";
|
||||
CREATE TABLE `leaderboard_items` (`id` integer,`user_id` text NOT NULL,`rank` integer,`interval` text NOT NULL,`by` integer,`total` integer NOT NULL,`key` text,`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (`id`),CONSTRAINT `fk_leaderboard_items_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
DROP TABLE IF EXISTS "project_labels";
|
||||
CREATE TABLE `project_labels` (`id` integer,`user_id` text NOT NULL,`project_key` text,`label` varchar(64),PRIMARY KEY (`id`),CONSTRAINT `fk_project_labels_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
DROP TABLE IF EXISTS "summaries";
|
||||
CREATE TABLE "summaries" (`id` integer,`user_id` text NOT NULL,`from_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,`to_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (`id`),CONSTRAINT `fk_summaries_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
CREATE TABLE "summaries" (`id` integer,`user_id` text NOT NULL,`from_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,`to_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,`num_heartbeats` integer DEFAULT 0,PRIMARY KEY (`id`),CONSTRAINT `fk_summaries_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
DROP TABLE IF EXISTS "summary_items";
|
||||
CREATE TABLE `summary_items` (`id` integer,`summary_id` integer,`type` integer,`key` text,`total` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_summaries_editors` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_operating_systems` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_machines` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_projects` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_languages` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
CREATE TABLE `summary_items` (`id` integer,`summary_id` integer,`type` integer,`key` text,`total` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_summaries_machines` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_projects` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_languages` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_editors` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT `fk_summaries_operating_systems` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
|
||||
DROP TABLE IF EXISTS "users";
|
||||
CREATE TABLE `users` (`id` text,`api_key` text UNIQUE,`email` text,`location` text,`password` text,`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,`last_logged_in_at` timestamp DEFAULT CURRENT_TIMESTAMP,`share_data_max_days` integer DEFAULT 0,`share_editors` numeric DEFAULT false,`share_languages` numeric DEFAULT false,`share_projects` numeric DEFAULT false,`share_oss` numeric DEFAULT false,`share_machines` numeric DEFAULT false,`share_labels` numeric DEFAULT false,`is_admin` numeric DEFAULT false,`has_data` numeric DEFAULT false,`wakatime_api_key` text,`reset_token` text,`reports_weekly` numeric DEFAULT false,PRIMARY KEY (`id`));
|
||||
CREATE TABLE "users" (`id` text,`api_key` text UNIQUE DEFAULT NULL,`email` text,`location` text,`password` text,`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,`last_logged_in_at` timestamp DEFAULT CURRENT_TIMESTAMP,`share_data_max_days` integer DEFAULT 0,`share_editors` numeric DEFAULT false,`share_languages` numeric DEFAULT false,`share_projects` numeric DEFAULT false,`share_oss` numeric DEFAULT false,`share_machines` numeric DEFAULT false,`share_labels` numeric DEFAULT false,`is_admin` numeric DEFAULT false,`has_data` numeric DEFAULT false,`wakatime_api_key` text,`wakatime_api_url` text,`reset_token` text,`reports_weekly` numeric DEFAULT false,`public_leaderboard` numeric DEFAULT false,PRIMARY KEY (`id`));
|
||||
DROP INDEX IF EXISTS "idx_alias_type_key";
|
||||
CREATE INDEX `idx_alias_type_key` ON `aliases`(`type`,`key`);
|
||||
DROP INDEX IF EXISTS "idx_alias_user";
|
||||
CREATE INDEX `idx_alias_user` ON `aliases`(`user_id`);
|
||||
DROP INDEX IF EXISTS "idx_diagnostics_user";
|
||||
CREATE INDEX `idx_diagnostics_user` ON `diagnostics`(`user_id`);
|
||||
DROP INDEX IF EXISTS "idx_entity";
|
||||
CREATE INDEX `idx_entity` ON `heartbeats`(`entity`);
|
||||
DROP INDEX IF EXISTS "idx_branch";
|
||||
CREATE INDEX `idx_branch` ON `heartbeats`(`branch`);
|
||||
DROP INDEX IF EXISTS "idx_editor";
|
||||
CREATE INDEX `idx_editor` ON `heartbeats`(`editor`);
|
||||
DROP INDEX IF EXISTS "idx_heartbeats_hash";
|
||||
CREATE UNIQUE INDEX `idx_heartbeats_hash` ON `heartbeats`(`hash`);
|
||||
DROP INDEX IF EXISTS "idx_language";
|
||||
@ -38,6 +40,16 @@ DROP INDEX IF EXISTS "idx_language_mapping_composite";
|
||||
CREATE UNIQUE INDEX `idx_language_mapping_composite` ON `language_mappings`(`user_id`,`extension`);
|
||||
DROP INDEX IF EXISTS "idx_language_mapping_user";
|
||||
CREATE INDEX `idx_language_mapping_user` ON `language_mappings`(`user_id`);
|
||||
DROP INDEX IF EXISTS "idx_leaderboard_combined";
|
||||
CREATE INDEX `idx_leaderboard_combined` ON `leaderboard_items`(`interval`,`by`);
|
||||
DROP INDEX IF EXISTS "idx_leaderboard_user";
|
||||
CREATE INDEX `idx_leaderboard_user` ON `leaderboard_items`(`user_id`);
|
||||
DROP INDEX IF EXISTS "idx_machine";
|
||||
CREATE INDEX `idx_machine` ON `heartbeats`(`machine`);
|
||||
DROP INDEX IF EXISTS "idx_operating_system";
|
||||
CREATE INDEX `idx_operating_system` ON `heartbeats`(`operating_system`);
|
||||
DROP INDEX IF EXISTS "idx_project";
|
||||
CREATE INDEX `idx_project` ON `heartbeats`(`project`);
|
||||
DROP INDEX IF EXISTS "idx_project_label_user";
|
||||
CREATE INDEX `idx_project_label_user` ON `project_labels`(`user_id`);
|
||||
DROP INDEX IF EXISTS "idx_time";
|
||||
@ -50,4 +62,6 @@ DROP INDEX IF EXISTS "idx_type";
|
||||
CREATE INDEX `idx_type` ON `summary_items`(`type`);
|
||||
DROP INDEX IF EXISTS "idx_user_email";
|
||||
CREATE INDEX `idx_user_email` ON `users`(`email`);
|
||||
DROP INDEX IF EXISTS "idx_user_project";
|
||||
CREATE INDEX `idx_user_project` ON `heartbeats`(`user_id`,`project`);
|
||||
COMMIT;
|
||||
|
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
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
{{ if .Error }}
|
||||
<div class="flex justify-center w-full">
|
||||
<div class="p-4 font-semibold text-white text-sm bg-red-500 rounded mt-16 shadow flex-grow max-w-lg">
|
||||
<div class="p-4 font-semibold text-white text-sm bg-red-500 rounded mt-16 shadow grow max-w-lg">
|
||||
Error: {{ .Error | capitalize }}
|
||||
</div>
|
||||
</div>
|
||||
{{ else if .Success }}
|
||||
<div class="flex justify-center w-full">
|
||||
<div class="p-4 font-semibold text-white text-sm bg-green-500 rounded mt-16 shadow flex-grow max-w-lg">
|
||||
<div class="p-4 font-semibold text-white text-sm bg-green-500 rounded mt-16 shadow grow max-w-lg">
|
||||
{{ .Success | capitalize }}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,10 +1,11 @@
|
||||
<footer class="flex justify-between w-full text-center text-gray-500 text-xs mt-20">
|
||||
<footer class="flex justify-between w-full text-gray-500 mt-20 items-center gap-x-4">
|
||||
<div class="text-xs font-mono font-semibold">
|
||||
{{ getVersion }} @ {{ getDbType }}
|
||||
</div>
|
||||
<div class="font-semibold text-sm hidden sm:inline-block">
|
||||
Made with <span class="iconify inline" data-icon="bi:heart-fill"></span> by <a href="https://muetsch.io" class="text-gray-400 hover:text-gray-300">Ferdinand Mütsch</a> as <a
|
||||
href="https://github.com/muety/wakapi" class="text-gray-400 hover:text-gray-300">open-source</a> software
|
||||
<div class="font-semibold text-sm">
|
||||
Made with <span class="iconify inline" data-icon="bi:heart-fill"></span> by
|
||||
<a href="https://muetsch.io" class="text-gray-400 hover:text-gray-300">Ferdinand Mütsch</a> as
|
||||
<a href="https://github.com/muety/wakapi" class="text-gray-400 hover:text-gray-300">open-source</a> software
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<a href="imprint" class="font-semibold hover:text-gray-400">Imprint, Cookies & Data Privacy</a>
|
||||
|
@ -7,8 +7,8 @@
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 flex-grow flex w-full">
|
||||
<div class="flex-grow max-w-4xl flex flex-col">
|
||||
<main class="mt-10 grow flex w-full">
|
||||
<div class="grow max-w-4xl flex flex-col">
|
||||
<h1 class="h1">Imprint & Data Privacy</h1>
|
||||
<p>
|
||||
{{ htmlSafe .HtmlText }}
|
||||
|
@ -11,7 +11,7 @@
|
||||
|
||||
{{ template "login-btn.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 px-4 md:px-10 lg:px-24 flex-grow flex justify-center w-full">
|
||||
<main class="mt-10 px-4 md:px-10 lg:px-24 grow flex justify-center w-full">
|
||||
<div class="flex flex-col text-white">
|
||||
{{ if and .Newsbox .Newsbox.Text }}
|
||||
<div class="mb-14 -mt-4 newsbox newsbox-{{ .Newsbox.Type }}">
|
||||
|
@ -18,12 +18,12 @@
|
||||
{{ template "login-btn.tpl.html" . }}
|
||||
{{ end }}
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full" id="leaderboard-page">
|
||||
<div class="flex flex-col flex-grow mt-10 max-available">
|
||||
<main class="mt-10 grow flex justify-center w-full" id="leaderboard-page">
|
||||
<div class="flex flex-col grow mt-10 max-available">
|
||||
<h1 class="h1" style="margin-bottom: 0.5rem">Leaderboard</h1>
|
||||
|
||||
<p class="block text-sm text-gray-300 w-full lg:w-3/4 mb-8">
|
||||
Wakapi's leaderboard shows a ranking of the most active users on this servers, given they opted in to get listed on the public leaderboard. Statistics are updated at least every 12 hours and are based on the users' total coding time in the past seven days.
|
||||
Wakapi's leaderboard shows a ranking of the most active users on this server, given they opted in to get listed on the public leaderboard. Statistics are updated at least every 12 hours and are based on the users' total coding time in the past seven days.
|
||||
To participate, log in, go to <a class="link" href="settings#permissions">Settings 🠒 Permissions</a> and enable leaderboards.
|
||||
</p>
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
<div class="flex flex-wrap space-x-2 mb-4">
|
||||
{{ range $i, $key := (strslice .TopKeys 0 10) }}
|
||||
<div class="inline-block mb-4">
|
||||
<a href="leaderboard?by={{ $.By }}&key={{ $key }}" class="{{ if eq $.Key (lower $key) }} btn-primary {{ else }} btn-default {{ end }} btn-small cursor-pointer whitespace-nowrap">
|
||||
<a href="leaderboard?by={{ $.By }}&key={{ lower $key }}" class="{{ if eq (lower $.Key) (lower $key) }} btn-primary {{ else }} btn-default {{ end }} btn-small cursor-pointer whitespace-nowrap">
|
||||
{{ if and (eq (lower $.By) "language") ($.LangIcon $key) }}
|
||||
<span class="align-middle leading-none"><span class="iconify inline text-white text-base" data-icon="{{ ($.LangIcon $key) | urlSafe }}"></span> </span>
|
||||
{{ end }}
|
||||
|
@ -9,8 +9,8 @@
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full">
|
||||
<div class="flex-grow max-w-lg mt-10">
|
||||
<main class="mt-10 grow flex justify-center w-full">
|
||||
<div class="grow max-w-lg mt-10">
|
||||
<div class="mb-8">
|
||||
<h1 class="h1">Welcome!</h1>
|
||||
<span class="h1-subcaption">Log in to continue using Wakapi</span>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script type="module" src="assets/js/components/menu-main.js"></script>
|
||||
|
||||
<div class="flex justify-between space-x-4 items-center relative" id="main-menu" v-scope @vue:mounted="mounted">
|
||||
<div class="mr-8 hidden lg:inline-block flex-shrink-0">
|
||||
<div class="mr-8 hidden lg:inline-block shrink-0">
|
||||
{{ template "logo.tpl.html" }}
|
||||
</div>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
<span class="iconify inline text-xl text-gray-400" data-icon="akar-icons:chevron-down"></span>
|
||||
|
||||
<div v-cloak v-show="state.showDropdownResources" class="flex bg-gray-850 shadow-md z-10 p-2 absolute top-0 right-0 rounded popup mt-12 w-full" id="resources-menu-dropdown" style="min-width: 128px">
|
||||
<div class="flex-grow flex flex-col">
|
||||
<div class="grow flex flex-col">
|
||||
<div class="submenu-item">
|
||||
<a class="flex justify-between w-full text-gray-300 items-center px-2 font-semibold" href="https://github.com/muety/wakapi" target="_blank" rel="noreferrer noopener" @click="state.showDropdownResources = !state.showDropdownResources" data-trigger-for="showDropdownResources">
|
||||
<span class="text-sm">GitHub</span>
|
||||
@ -62,9 +62,9 @@
|
||||
<span class="text-gray-400 hidden lg:inline-block">Settings</span>
|
||||
</a>
|
||||
|
||||
<div class="flex-grow"></div>
|
||||
<div class="grow"></div>
|
||||
|
||||
<div class="flex-shrink-0 menu-item relative" @click="state.showDropdownUser = !state.showDropdownUser" data-trigger-for="showDropdownUser">
|
||||
<div class="shrink-0 menu-item relative" @click="state.showDropdownUser = !state.showDropdownUser" data-trigger-for="showDropdownUser">
|
||||
<div class="hidden md:flex flex flex-col text-right">
|
||||
<a class="text-gray-300">{{ .User.ID }}</a>
|
||||
{{ if .User.Email }}
|
||||
@ -78,7 +78,7 @@
|
||||
{{ end }}
|
||||
|
||||
<div v-cloak v-show="state.showDropdownUser" class="flex bg-gray-850 shadow-md z-10 p-2 absolute top-0 right-0 rounded popup mt-16 w-full" id="user-menu-popup" style="min-width: 156px;">
|
||||
<div class="flex-grow flex flex-col">
|
||||
<div class="grow flex flex-col">
|
||||
<div class="submenu-item hover:bg-gray-800 rounded p-1 text-right">
|
||||
<button class="flex justify-between w-full text-gray-300 items-center px-2 font-semibold" @click="state.showApiKey = true" data-trigger-for="showApiKey">
|
||||
<span class="text-sm">Show API Key</span>
|
||||
@ -86,7 +86,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="submenu-item hover:bg-gray-800 rounded p-1 text-right">
|
||||
<form action="logout" method="post" class="flex-grow">
|
||||
<form action="logout" method="post" class="grow">
|
||||
<button type="submit" class="flex justify-between w-full text-gray-300 items-center px-2 font-semibold">
|
||||
<span class="text-sm">Logout</span>
|
||||
<span class="iconify inline" data-icon="ls:logout"></span>
|
||||
@ -98,7 +98,7 @@
|
||||
</div>
|
||||
|
||||
<div v-cloak v-show="state.showApiKey" class="flex bg-gray-850 shadow-md z-10 p-2 absolute top-0 right-0 rounded popup" id="api-key-popup">
|
||||
<div class="flex-grow flex flex-col px-2">
|
||||
<div class="grow flex flex-col px-2">
|
||||
<span class="text-xxs text-gray-500 mx-1">API Key</span>
|
||||
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly
|
||||
value="{{ .ApiKey }}" style="min-width: 330px">
|
||||
|
@ -9,8 +9,8 @@
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full">
|
||||
<div class="flex-grow max-w-lg mt-10">
|
||||
<main class="mt-10 grow flex justify-center w-full">
|
||||
<div class="grow max-w-lg mt-10">
|
||||
<div class="mb-8">
|
||||
<h1 class="h1">Reset Password</h1>
|
||||
<span class="h1-subcaption">
|
||||
|
@ -9,8 +9,8 @@
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full">
|
||||
<div class="flex-grow max-w-lg mt-10">
|
||||
<main class="mt-10 grow flex justify-center w-full">
|
||||
<div class="grow max-w-lg mt-10">
|
||||
<div class="mb-8">
|
||||
<h1 class="h1">Choose a new password</h1>
|
||||
<span class="h1-subcaption">You have requested to reset your password. Please choose a new one.</span>
|
||||
|
@ -32,8 +32,8 @@
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full" v-scope @vue:mounted="mounted" id="settings-page">
|
||||
<div class="flex flex-col flex-grow mt-10">
|
||||
<main class="mt-10 grow flex justify-center w-full" v-scope @vue:mounted="mounted" id="settings-page">
|
||||
<div class="flex flex-col grow mt-10">
|
||||
<h1 class="font-semibold text-3xl text-white m-0 mb-4">Settings</h1>
|
||||
|
||||
<ul class="flex space-x-4 mb-16 text-gray-600">
|
||||
@ -276,7 +276,7 @@
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center mt-2 w-full text-gray-500 text-sm space-x-4">
|
||||
<select name="key" id="select-project"
|
||||
class="select-default flex-grow">
|
||||
class="select-default grow">
|
||||
{{ range $i, $p := .Projects }}
|
||||
<option value="{{ $p }}">{{ $p }}</option>
|
||||
{{ end }}
|
||||
@ -337,11 +337,11 @@
|
||||
<input type="hidden" name="action" value="add_mapping">
|
||||
<div class="flex items-center w-full text-gray-500 text-sm">
|
||||
<span class="mr-2">When filename ends in</span>
|
||||
<input class="select-default flex-grow"
|
||||
<input class="select-default grow"
|
||||
type="text" id="extension" style="width: 70px"
|
||||
name="extension" placeholder=".py" minlength="1" required>
|
||||
<span class="mx-2">change language to</span>
|
||||
<input class="select-default flex-grow"
|
||||
<input class="select-default grow"
|
||||
type="text" id="language" style="width: 100px"
|
||||
name="language" placeholder="Python" minlength="1" required>
|
||||
<div class="flex justify-end ml-4">
|
||||
@ -394,11 +394,11 @@
|
||||
<input type="hidden" name="action" value="update_leaderboard">
|
||||
|
||||
<div class="flex space-x-8">
|
||||
<div class="flex-grow">
|
||||
<div class="grow">
|
||||
<label class="font-semibold text-gray-300" for="share_projects">Participate in leaderboard</label>
|
||||
</div>
|
||||
<div>
|
||||
<select autocomplete="off" id="enable_leaderboard" name="enable_leaderboard" class="select-default flex-grow">
|
||||
<select autocomplete="off" id="enable_leaderboard" name="enable_leaderboard" class="select-default grow">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.PublicLeaderboard }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.PublicLeaderboard }} selected {{ end }}>Yes
|
||||
@ -434,7 +434,7 @@
|
||||
<input type="hidden" name="action" value="update_sharing">
|
||||
|
||||
<div class="flex space-x-8">
|
||||
<div class="flex-grow">
|
||||
<div class="grow">
|
||||
<label class="font-semibold text-gray-300" for="max_days">Time Range</label>
|
||||
<span class="block text-sm text-gray-600">(in days; 0 = not public, -1 = unlimited)</span>
|
||||
</div>
|
||||
@ -446,11 +446,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-8">
|
||||
<div class="flex-grow">
|
||||
<div class="grow">
|
||||
<label class="font-semibold text-gray-300" for="share_projects">Share Projects</label>
|
||||
</div>
|
||||
<div>
|
||||
<select autocomplete="off" id="share_projects" name="share_projects" class="select-default flex-grow">
|
||||
<select autocomplete="off" id="share_projects" name="share_projects" class="select-default grow">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes
|
||||
@ -460,11 +460,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-8">
|
||||
<div class="flex-grow">
|
||||
<div class="grow">
|
||||
<label class="font-semibold text-gray-300" for="share_languages">Share Languages</label>
|
||||
</div>
|
||||
<div>
|
||||
<select autocomplete="off" id="share_languages" name="share_languages" class="select-default flex-grow">
|
||||
<select autocomplete="off" id="share_languages" name="share_languages" class="select-default grow">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes
|
||||
@ -474,11 +474,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-8">
|
||||
<div class="flex-grow">
|
||||
<div class="grow">
|
||||
<label class="font-semibold text-gray-300" for="share_editors">Share Editors</label>
|
||||
</div>
|
||||
<div>
|
||||
<select autocomplete="off" id="share_editors" name="share_editors" class="select-default flex-grow">
|
||||
<select autocomplete="off" id="share_editors" name="share_editors" class="select-default grow">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes
|
||||
@ -488,11 +488,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-8">
|
||||
<div class="flex-grow">
|
||||
<div class="grow">
|
||||
<label class="font-semibold text-gray-300" for="share_oss">Share OS'</label>
|
||||
</div>
|
||||
<div>
|
||||
<select autocomplete="off" id="share_oss" name="share_oss" class="select-default flex-grow">
|
||||
<select autocomplete="off" id="share_oss" name="share_oss" class="select-default grow">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>Yes
|
||||
@ -502,11 +502,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-8">
|
||||
<div class="flex-grow">
|
||||
<div class="grow">
|
||||
<label class="font-semibold text-gray-300" for="share_machines">Share Machines</label>
|
||||
</div>
|
||||
<div>
|
||||
<select autocomplete="off" id="share_machines" name="share_machines" class="select-default flex-grow">
|
||||
<select autocomplete="off" id="share_machines" name="share_machines" class="select-default grow">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes
|
||||
@ -516,11 +516,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-8">
|
||||
<div class="flex-grow">
|
||||
<div class="grow">
|
||||
<label class="font-semibold text-gray-300" for="share_labels">Share Project Labels</label>
|
||||
</div>
|
||||
<div>
|
||||
<select autocomplete="off" id="share_labels" name="share_labels" class="select-default flex-grow">
|
||||
<select autocomplete="off" id="share_labels" name="share_labels" class="select-default grow">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareLabels }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareLabels }} selected {{ end }}>Yes
|
||||
@ -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>
|
||||
@ -664,7 +665,7 @@
|
||||
class="with-url-src-no-scheme" alt="Readme Stats Card">
|
||||
</div>
|
||||
<textarea
|
||||
class="with-url-inner-no-scheme flex-shrink w-full font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed mt-2"
|
||||
class="with-url-inner-no-scheme shrink w-full font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed mt-2"
|
||||
rows="5" style="resize: none"
|
||||
readonly>https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=1A202C&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact</textarea>
|
||||
{{ end }}
|
||||
|
@ -20,8 +20,8 @@
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full" id="signup-page">
|
||||
<div class="flex-grow max-w-lg mt-10">
|
||||
<main class="mt-10 grow flex justify-center w-full" id="signup-page">
|
||||
<div class="grow max-w-lg mt-10">
|
||||
<div class="mb-8">
|
||||
<h1 class="h1">Sign up to Wakapi</h1>
|
||||
<p class="h1-subcaption">
|
||||
|
@ -16,7 +16,7 @@
|
||||
|
||||
{{ if .User.HasData }}
|
||||
|
||||
<div id="summary-page" class="flex-grow" v-scope>
|
||||
<div id="summary-page" class="grow" v-scope>
|
||||
<div class="flex justify-end mt-12 relative">
|
||||
<div v-scope="TimePicker({
|
||||
fromDate: '{{ .From | simpledate }}',
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
{{ end }}
|
||||
|
||||
<main class="flex flex-col items-center mt-10 flex-grow">
|
||||
<main class="flex flex-col items-center mt-10 grow">
|
||||
|
||||
{{ if .User.HasData }}
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
<template id="time-picker-template">
|
||||
<div class="menu-item flex items-center text-sm font-semibold space-x-2 rounded hover:bg-gray-850 py-2 px-4 cursor-pointer justify-end" @click="state.showDropdownTimepicker = !state.showDropdownTimepicker" data-trigger-for="showDropdownTimepicker">
|
||||
<span class="iconify inline text-2xl text-gray-400 flex-grow" data-icon="fa-regular:calendar-alt"></span>
|
||||
<span class="iconify inline text-2xl text-gray-400 grow" data-icon="fa-regular:calendar-alt"></span>
|
||||
<a v-cloak id="current-time-selection" class="text-gray-300 -mb-1">${timeSelection}</a>
|
||||
<span class="iconify inline text-2xl text-gray-400" data-icon="akar-icons:chevron-down"></span>
|
||||
</div>
|
||||
|
||||
<div v-cloak v-show="state.showDropdownTimepicker" class="z-10 absolute top-0 right-0 popup mt-12 w-40" id="time-picker-dropdown">
|
||||
<div class="flex-grow flex flex-col flex bg-gray-850 shadow-md rounded w-40 p-1 ">
|
||||
<div class="grow flex flex-col flex bg-gray-850 shadow-md rounded w-40 p-1 ">
|
||||
<a id="time-option-today" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" :href="intervalLink('today')" @click="state.showDropdownTimepicker = !state.showDropdownTimepicker" data-trigger-for="showDropdownTimepicker">Today</a>
|
||||
<a id="time-option-yesterday" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" :href="intervalLink('yesterday')" @click="state.showDropdownTimepicker = !state.showDropdownTimepicker" data-trigger-for="showDropdownTimepicker">Yesterday</a>
|
||||
<a id="time-option-week" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" :href="intervalLink('week')" @click="state.showDropdownTimepicker = !state.showDropdownTimepicker" data-trigger-for="showDropdownTimepicker">This Week</a>
|
||||
|
Reference in New Issue
Block a user