mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
45 Commits
Author | SHA1 | Date | |
---|---|---|---|
664714de8f | |||
7befb82814 | |||
2f12d8efde | |||
8ddd9904a0 | |||
78874566a4 | |||
e269b37b0e | |||
e6a04cc76d | |||
cb8f68df82 | |||
b4d2ee7d16 | |||
1224024913 | |||
8efc3854ab | |||
755cabb5f4 | |||
96ff490d8d | |||
68e66298b8 | |||
c2d30826f6 | |||
861c81e414 | |||
892d265c4d | |||
e19761337f | |||
3f973a28ea | |||
86fc751e58 | |||
178c417757 | |||
395d039d41 | |||
fdf2289f8e | |||
06b3fdd17c | |||
4506493353 | |||
11728b80ac | |||
b7c7817923 | |||
c78ee5465c | |||
4336d732c9 | |||
177cbb12fc | |||
a4c344aaa1 | |||
c575b2fd5c | |||
67a59561c8 | |||
f7520b2b4a | |||
54a944ec41 | |||
44b6efb6ee | |||
efd4764728 | |||
dd50b4076f | |||
21b822de42 | |||
4d22756b8a | |||
c54f2743fd | |||
a8d5d69629 | |||
0111aa7543 | |||
0760be86ff | |||
a059c637a7 |
1
.github/workflows/linux-build-on-release.yml
vendored
1
.github/workflows/linux-build-on-release.yml
vendored
@ -31,6 +31,7 @@ jobs:
|
||||
uses: TheDoctor0/zip-release@v0.3.0
|
||||
with:
|
||||
filename: release.zip
|
||||
exclusions: '*.git*'
|
||||
|
||||
- name: Upload built executable to Release
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -6,4 +6,5 @@ wakapi
|
||||
build
|
||||
*.exe
|
||||
*.db
|
||||
config.yml
|
||||
config.yml
|
||||
config.ini
|
@ -1,9 +1,12 @@
|
||||
# Build Stage
|
||||
|
||||
FROM golang:1.13 AS build-env
|
||||
ADD . /src
|
||||
RUN cd /src && go build -o wakapi
|
||||
WORKDIR /src
|
||||
ADD ./go.mod .
|
||||
RUN go mod download
|
||||
|
||||
ADD . .
|
||||
RUN go build -o wakapi
|
||||
|
||||
# Final Stage
|
||||
|
||||
@ -45,4 +48,4 @@ ADD wait-for-it.sh .
|
||||
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ./wait-for-it.sh
|
||||
ENTRYPOINT ./wait-for-it.sh
|
||||
|
31
README.md
31
README.md
@ -1,10 +1,20 @@
|
||||
# 📈 wakapi
|
||||
|
||||
[](https://liberapay.com/muety/)
|
||||
[](https://saythanks.io/to/n1try)
|
||||

|
||||
[](https://goreportcard.com/report/github.com/muety/wakapi)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](https://saythanks.io/to/n1try)
|
||||
[](https://liberapay.com/muety/)
|
||||

|
||||
[](https://goreportcard.com/report/github.com/muety/wakapi)
|
||||

|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
[](https://sonarcloud.io/dashboard?id=muety_wakapi)
|
||||
---
|
||||
|
||||
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
|
||||
@ -13,10 +23,10 @@
|
||||
|
||||
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
|
||||
|
||||
## 👀 Demo
|
||||
🔥 **New:** There is a hosted [demo version](https://apps.muetsch.io/wakapi) available now. Go check it out! Please use responsibly.
|
||||
## 👀 Hosted Service
|
||||
🔥 **New:** Wakapi is available as a hosted service now. Check out **[wakapi.dev](https://wakapi.dev)**. Please use responsibly.
|
||||
|
||||
To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartbeat`. However, this hosted instance might be taken down again in the future, so you might potentially lose your data ❕
|
||||
To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
|
||||
|
||||
## ⚙️ Prerequisites
|
||||
**On the server side:**
|
||||
@ -54,7 +64,6 @@ You can specify configuration options either via a config file (default: `config
|
||||
| YAML Key | Environment Variable | Default | Description |
|
||||
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
|
||||
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
||||
| `app.cleanup` | `WAKAPI_CLEANUP` | `false` | Whether or not to clean up old heartbeats (be careful!) |
|
||||
| `app.custom_languages` | - | - | Map from file endings to language names |
|
||||
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
|
||||
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | Network address to listen on |
|
||||
@ -106,8 +115,8 @@ The following API endpoints are available. A more detailed Swagger documentation
|
||||
* `POST /api/heartbeat`
|
||||
* `GET /api/summary`
|
||||
* `string` parameter `interval`: One of `today`, `day`, `week`, `month`, `year`, `any`
|
||||
* `GET /api/compat/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
|
||||
* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
|
||||
* `GET /api/compat/wakatime/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
|
||||
* `GET /api/compat/wakatime/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
|
||||
* `GET /api/health`
|
||||
|
||||
## ⤴️ Prometheus Export
|
||||
|
@ -6,7 +6,7 @@ server:
|
||||
base_path: /
|
||||
|
||||
app:
|
||||
cleanup: false # only edit, if you know what you're doing
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
|
@ -3,11 +3,15 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/models"
|
||||
migrate "github.com/rubenv/sql-migrate"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
@ -18,6 +22,10 @@ const (
|
||||
defaultConfigPath = "config.yml"
|
||||
defaultConfigPathLegacy = "config.ini"
|
||||
defaultEnvConfigPathLegacy = ".env"
|
||||
|
||||
SQLDialectMysql = "mysql"
|
||||
SQLDialectPostgres = "postgres"
|
||||
SQLDialectSqlite = "sqlite3"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -26,7 +34,7 @@ var (
|
||||
)
|
||||
|
||||
type appConfig struct {
|
||||
CleanUp bool `default:"false" env:"WAKAPI_CLEANUP"`
|
||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||
LanguageColors map[string]string `yaml:"-"`
|
||||
}
|
||||
@ -74,30 +82,15 @@ func (c *Config) IsDev() bool {
|
||||
|
||||
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
||||
switch dbDialect {
|
||||
case "sqlite3":
|
||||
return func(db *gorm.DB) error {
|
||||
migrations := &migrate.FileMigrationSource{
|
||||
Dir: "migrations/sqlite3",
|
||||
}
|
||||
|
||||
migrate.SetIgnoreUnknown(true)
|
||||
n, err := migrate.Exec(db.DB(), "sqlite3", migrations, migrate.Up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("applied %d migrations\n", n)
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return func(db *gorm.DB) error {
|
||||
db.AutoMigrate(&models.User{})
|
||||
db.AutoMigrate(&models.KeyStringValue{})
|
||||
db.AutoMigrate(&models.Alias{})
|
||||
db.AutoMigrate(&models.Heartbeat{})
|
||||
db.AutoMigrate(&models.Summary{})
|
||||
db.AutoMigrate(&models.SummaryItem{})
|
||||
db.AutoMigrate(&models.User{})
|
||||
db.AutoMigrate(&models.Heartbeat{}).AddForeignKey("user_id", "users(id)", "RESTRICT", "RESTRICT")
|
||||
db.AutoMigrate(&models.SummaryItem{}).AddForeignKey("summary_id", "summaries(id)", "CASCADE", "CASCADE")
|
||||
db.AutoMigrate(&models.KeyStringValue{})
|
||||
db.AutoMigrate(&models.LanguageMapping{})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -110,7 +103,8 @@ func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
|
||||
}
|
||||
|
||||
migrate.SetIgnoreUnknown(true)
|
||||
n, err := migrate.Exec(db.DB(), dbDialect, migrations, migrate.Up)
|
||||
sqlDb, _ := db.DB()
|
||||
n, err := migrate.Exec(sqlDb, dbDialect, migrations, migrate.Up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -120,6 +114,49 @@ func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||
switch c.Dialect {
|
||||
case SQLDialectMysql:
|
||||
return mysql.New(mysql.Config{
|
||||
DriverName: c.Dialect,
|
||||
DSN: mysqlConnectionString(c),
|
||||
})
|
||||
case SQLDialectPostgres:
|
||||
return postgres.New(postgres.Config{
|
||||
DSN: postgresConnectionString(c),
|
||||
})
|
||||
case SQLDialectSqlite:
|
||||
return sqlite.Open(sqliteConnectionString(c))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlConnectionString(config *dbConfig) string {
|
||||
//location, _ := time.LoadLocation("Local")
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
config.User,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Name,
|
||||
"Local",
|
||||
)
|
||||
}
|
||||
|
||||
func postgresConnectionString(config *dbConfig) string {
|
||||
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.User,
|
||||
config.Name,
|
||||
config.Password,
|
||||
)
|
||||
}
|
||||
|
||||
func sqliteConnectionString(config *dbConfig) string {
|
||||
return config.Name
|
||||
}
|
||||
|
||||
func IsDev(env string) bool {
|
||||
return env == "dev" || env == "development"
|
||||
}
|
||||
@ -191,7 +228,6 @@ func Load() *Config {
|
||||
|
||||
config.Version = readVersion()
|
||||
config.App.LanguageColors = readLanguageColors()
|
||||
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
|
||||
config.Security.SecureCookie = securecookie.New(
|
||||
securecookie.GenerateRandomKey(64),
|
||||
securecookie.GenerateRandomKey(32),
|
||||
|
@ -59,7 +59,7 @@ func migrateLegacyConfig() error {
|
||||
}
|
||||
|
||||
if dbType == "" {
|
||||
dbType = "sqlite3"
|
||||
dbType = SQLDialectSqlite
|
||||
}
|
||||
|
||||
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(2)
|
||||
@ -76,8 +76,6 @@ func migrateLegacyConfig() error {
|
||||
basePath = basePathEnv
|
||||
}
|
||||
|
||||
cleanUp := cfg.Section("app").Key("cleanup").MustBool(false)
|
||||
|
||||
// Read custom languages
|
||||
customLangs := make(map[string]string)
|
||||
languageKeys := cfg.Section("languages").Keys()
|
||||
@ -89,7 +87,6 @@ func migrateLegacyConfig() error {
|
||||
config := &Config{
|
||||
Env: env,
|
||||
App: appConfig{
|
||||
CleanUp: cleanUp,
|
||||
CustomLanguages: customLangs,
|
||||
},
|
||||
Security: securityConfig{
|
||||
|
9
config/templates.go
Normal file
9
config/templates.go
Normal file
@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
IndexTemplate = "index.tpl.html"
|
||||
ImprintTemplate = "imprint.tpl.html"
|
||||
SignupTemplate = "signup.tpl.html"
|
||||
SettingsTemplate = "settings.tpl.html"
|
||||
SummaryTemplate = "summary.tpl.html"
|
||||
)
|
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@ -0,0 +1,23 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
wakapi:
|
||||
build: .
|
||||
ports:
|
||||
- 3000:3000
|
||||
restart: always
|
||||
environment:
|
||||
WAKAPI_DB_TYPE: "postgres"
|
||||
WAKAPI_DB_NAME: "wakapi"
|
||||
WAKAPI_DB_USER: "wakapi"
|
||||
WAKAPI_DB_PASSWORD: "CHANGE_ME!!!"
|
||||
WAKAPI_DB_HOST: "db"
|
||||
WAKAPI_DB_PORT: "5432"
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
db:
|
||||
image: postgres:12.3
|
||||
environment:
|
||||
POSTGRES_USER: "wakapi"
|
||||
POSTGRES_PASSWORD: "CHANGE_ME!!!"
|
||||
POSTGRES_DB: "wakapi"
|
15
go.mod
15
go.mod
@ -9,14 +9,19 @@ require (
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
|
||||
github.com/jinzhu/configor v1.2.0
|
||||
github.com/jinzhu/gorm v1.9.11
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/kr/pretty v0.2.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/t-tiger/gorm-bulk-insert v1.3.0
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
gopkg.in/ini.v1 v1.50.0
|
||||
gopkg.in/yaml.v2 v2.2.5
|
||||
gopkg.in/yaml.v2 v2.2.8
|
||||
gorm.io/driver/mysql v1.0.3
|
||||
gorm.io/driver/postgres v1.0.5
|
||||
gorm.io/driver/sqlite v1.1.3
|
||||
gorm.io/gorm v1.20.5
|
||||
)
|
||||
|
172
go.sum
172
go.sum
@ -1,6 +1,5 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
@ -32,21 +31,24 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
|
||||
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0 h1:epsH3lb7KVbXHYk7LYGN5EiE0MxcevHU85CKITJ0wUY=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
@ -57,8 +59,6 @@ github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaB
|
||||
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
|
||||
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
||||
@ -72,8 +72,9 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
|
||||
github.com/gobuffalo/envy v1.7.1 h1:OQl5ys5MBea7OGCdvPbBJWRgnhC/fGona6QKfvFeau8=
|
||||
@ -85,6 +86,8 @@ github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b
|
||||
github.com/gobuffalo/packr/v2 v2.7.1 h1:n3CIW5T17T8v4GGK5sWXLVWJhCz7b5aNLSxW6gYim4o=
|
||||
github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=
|
||||
github.com/godror/godror v0.13.3/go.mod h1:2ouUT4kdhUBk7TAkHWD4SN0CdI0pgEQbo8FVHhbSKWg=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
@ -95,7 +98,6 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
|
||||
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@ -107,11 +109,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
@ -153,16 +152,66 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
|
||||
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
|
||||
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=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||
github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
|
||||
github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
|
||||
github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
|
||||
github.com/jackc/pgconn v1.7.0 h1:pwjzcYyfmz/HQOQlENvG1OcDqauTGaqlVahq934F0/U=
|
||||
github.com/jackc/pgconn v1.7.0/go.mod h1:sF/lPpNEMEOp+IYhyQGdAvrG20gWf6A1tKlr0v7JMeA=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
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=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.5 h1:NUbEWPmCQZbMmYlTjVoNPhc0CfnYyz2bfUAh6A5ZVJM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.5/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||
github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
|
||||
github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
|
||||
github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
|
||||
github.com/jackc/pgtype v1.5.0 h1:jzBqRk2HFG2CV4AIwgCI2PwTgm6UUoCAK2ofHHRirtc=
|
||||
github.com/jackc/pgtype v1.5.0/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||
github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
|
||||
github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
|
||||
github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
|
||||
github.com/jackc/pgx/v4 v4.9.0 h1:6STjDqppM2ROy5p1wNDcsC7zJTjSHeuCsguZmXyzx7c=
|
||||
github.com/jackc/pgx/v4 v4.9.0/go.mod h1:MNGWmViCgqbZck9ujOOBN63gK9XVGILXWCvKLGKmnms=
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg=
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0=
|
||||
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA=
|
||||
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||
github.com/jinzhu/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE=
|
||||
github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
|
||||
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
|
||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
@ -170,7 +219,6 @@ github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
@ -181,29 +229,38 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
|
||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
|
||||
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
|
||||
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
|
||||
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
|
||||
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI=
|
||||
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
|
||||
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.12.0 h1:u/x3mp++qUxvYfulZ4HKOvVO0JWhk7HtE8lWhbGz/Do=
|
||||
github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
@ -226,6 +283,8 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE
|
||||
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
|
||||
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
|
||||
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
@ -255,6 +314,7 @@ github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9
|
||||
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@ -283,6 +343,9 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
|
||||
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.4.0 h1:LUa41nrWTQNGhzdsZ5lTnkwbNjj6rXTdazA1cSdjkOY=
|
||||
github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc h1:+2DdDcxVYlarHjYcZTt8dZ4Ec8cXZirzL5ko0mkKPjU=
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
@ -292,8 +355,12 @@ github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
|
||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
@ -315,20 +382,19 @@ github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3
|
||||
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f h1:Op5lFYUNE7tPxu6gJfwkgY8HMIWpLqiLApBJfGs71U8=
|
||||
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f/go.mod h1:SK1RZT4TR1aMUNGtbk6YxTPgx2D/gfbxB571QGnAV+c=
|
||||
github.com/t-tiger/gorm-bulk-insert v1.3.0 h1:9k7BaVEhw/3fsvh6GTOBwJ2RXk3asc5xs5m6hwozq20=
|
||||
github.com/t-tiger/gorm-bulk-insert v1.3.0/go.mod h1:ruDlk8xDl+8sX4bA7PQuYly9YEb3pbp1eP2LCyeRrFY=
|
||||
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
@ -337,10 +403,14 @@ go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
@ -348,12 +418,16 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c h1:/nJuwDLoL/zrqY6gf57vxC+Pi+pZ8bfhpPkicO5H7W4=
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@ -397,19 +471,24 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f h1:68K/z8GLUxV76xGSqwTWw2gyk/jwn79LUL43rES2g8o=
|
||||
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@ -419,24 +498,27 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
@ -451,14 +533,16 @@ google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
|
||||
gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw=
|
||||
gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/ini.v1 v1.50.0 h1:c/4YI/GUgB7d2yOkxdsQyYDhW67nWrTl6Zyd9vagYmg=
|
||||
gopkg.in/ini.v1 v1.50.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
@ -467,11 +551,21 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg=
|
||||
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
|
||||
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw=
|
||||
gorm.io/driver/postgres v1.0.5/go.mod h1:qrD92UurYzNctBMVCJ8C3VQEjffEuphycXtxOudXNCA=
|
||||
gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
|
||||
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
|
||||
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.20.5 h1:g3tpSF9kggASzReK+Z3dYei1IJODLqNUbOjSuCczY8g=
|
||||
gorm.io/gorm v1.20.5/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
|
||||
|
138
main.go
138
main.go
@ -3,23 +3,23 @@ package main
|
||||
import (
|
||||
"github.com/gorilla/handlers"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/migrations/common"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jinzhu/gorm"
|
||||
_ "github.com/jinzhu/gorm/dialects/mysql"
|
||||
_ "github.com/jinzhu/gorm/dialects/postgres"
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/routes"
|
||||
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
|
||||
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
_ "gorm.io/driver/mysql"
|
||||
_ "gorm.io/driver/postgres"
|
||||
_ "gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -28,12 +28,22 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
aliasService *services.AliasService
|
||||
heartbeatService *services.HeartbeatService
|
||||
userService *services.UserService
|
||||
summaryService *services.SummaryService
|
||||
aggregationService *services.AggregationService
|
||||
keyValueService *services.KeyValueService
|
||||
aliasRepository *repositories.AliasRepository
|
||||
heartbeatRepository *repositories.HeartbeatRepository
|
||||
userRepository *repositories.UserRepository
|
||||
languageMappingRepository *repositories.LanguageMappingRepository
|
||||
summaryRepository *repositories.SummaryRepository
|
||||
keyValueRepository *repositories.KeyValueRepository
|
||||
)
|
||||
|
||||
var (
|
||||
aliasService *services.AliasService
|
||||
heartbeatService *services.HeartbeatService
|
||||
userService *services.UserService
|
||||
languageMappingService *services.LanguageMappingService
|
||||
summaryService *services.SummaryService
|
||||
aggregationService *services.AggregationService
|
||||
keyValueService *services.KeyValueService
|
||||
)
|
||||
|
||||
// TODO: Refactor entire project to be structured after business domains
|
||||
@ -46,57 +56,59 @@ func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
}
|
||||
|
||||
// Show data loss warning
|
||||
if config.App.CleanUp {
|
||||
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
var err error
|
||||
db, err = gorm.Open(config.Db.Dialect, utils.MakeConnectionString(config))
|
||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{})
|
||||
if config.Db.Dialect == "sqlite3" {
|
||||
db.DB().Exec("PRAGMA foreign_keys = ON;")
|
||||
db.Raw("PRAGMA foreign_keys = ON;")
|
||||
}
|
||||
db.LogMode(config.IsDev())
|
||||
db.DB().SetMaxIdleConns(int(config.Db.MaxConn))
|
||||
db.DB().SetMaxOpenConns(int(config.Db.MaxConn))
|
||||
|
||||
if config.IsDev() {
|
||||
db = db.Debug()
|
||||
}
|
||||
sqlDb, _ := db.DB()
|
||||
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
|
||||
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
log.Fatal("could not connect to database")
|
||||
}
|
||||
// TODO: Graceful shutdown
|
||||
defer db.Close()
|
||||
defer sqlDb.Close()
|
||||
|
||||
// Migrate database schema
|
||||
common.RunCustomPreMigrations(db, config)
|
||||
runDatabaseMigrations()
|
||||
applyFixtures()
|
||||
common.RunCustomPostMigrations(db, config)
|
||||
|
||||
// Repositories
|
||||
aliasRepository = repositories.NewAliasRepository(db)
|
||||
heartbeatRepository = repositories.NewHeartbeatRepository(db)
|
||||
userRepository = repositories.NewUserRepository(db)
|
||||
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
|
||||
summaryRepository = repositories.NewSummaryRepository(db)
|
||||
keyValueRepository = repositories.NewKeyValueRepository(db)
|
||||
|
||||
// Services
|
||||
aliasService = services.NewAliasService(db)
|
||||
heartbeatService = services.NewHeartbeatService(db)
|
||||
userService = services.NewUserService(db)
|
||||
summaryService = services.NewSummaryService(db, heartbeatService, aliasService)
|
||||
aggregationService = services.NewAggregationService(db, userService, summaryService, heartbeatService)
|
||||
keyValueService = services.NewKeyValueService(db)
|
||||
|
||||
// Custom migrations and initial data
|
||||
migrateLanguages()
|
||||
aliasService = services.NewAliasService(aliasRepository)
|
||||
userService = services.NewUserService(userRepository)
|
||||
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
|
||||
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
||||
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
|
||||
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||
|
||||
// Aggregate heartbeats to summaries and persist them
|
||||
go aggregationService.Schedule()
|
||||
|
||||
if config.App.CleanUp {
|
||||
go heartbeatService.ScheduleCleanUp()
|
||||
}
|
||||
|
||||
// TODO: move endpoint registration to the respective routes files
|
||||
|
||||
// Handlers
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||
healthHandler := routes.NewHealthHandler(db)
|
||||
settingsHandler := routes.NewSettingsHandler(userService)
|
||||
publicHandler := routes.NewIndexHandler(userService, keyValueService)
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService)
|
||||
homeHandler := routes.NewHomeHandler(userService)
|
||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
|
||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
|
||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||
@ -127,12 +139,12 @@ func main() {
|
||||
apiRouter.Use(corsMiddleware, authenticateMiddleware)
|
||||
|
||||
// Public Routes
|
||||
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
|
||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
|
||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
|
||||
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
|
||||
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
|
||||
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
|
||||
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(homeHandler.GetIndex)
|
||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogin)
|
||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogout)
|
||||
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(homeHandler.GetSignup)
|
||||
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(homeHandler.PostSignup)
|
||||
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint)
|
||||
|
||||
// Summary Routes
|
||||
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
|
||||
@ -140,8 +152,11 @@ func main() {
|
||||
// Settings Routes
|
||||
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
|
||||
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
|
||||
settingsRouter.Path("/language_mappings").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostLanguageMapping)
|
||||
settingsRouter.Path("/language_mappings/delete").Methods(http.MethodPost).HandlerFunc(settingsHandler.DeleteLanguageMapping)
|
||||
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
|
||||
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
|
||||
settingsRouter.Path("/regenerate").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostRegenerateSummaries)
|
||||
|
||||
// API Routes
|
||||
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
|
||||
@ -175,32 +190,3 @@ func runDatabaseMigrations() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func applyFixtures() {
|
||||
if err := config.GetFixturesFunc(config.Db.Dialect)(db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateLanguages() {
|
||||
for k, v := range config.App.CustomLanguages {
|
||||
result := db.Model(models.Heartbeat{}).
|
||||
Where("language = ?", "").
|
||||
Where("entity LIKE ?", "%."+k).
|
||||
Updates(models.Heartbeat{Language: v})
|
||||
if result.Error != nil {
|
||||
log.Fatal(result.Error)
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("Migrated %+v rows for custom language %+s.\n", result.RowsAffected, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func promptAbort(message string, timeoutSec int) {
|
||||
log.Printf("[WARNING] %s.\nTo abort server startup, press Ctrl+C.\n", message)
|
||||
for i := timeoutSec; i > 0; i-- {
|
||||
log.Printf("Starting in %d seconds ...\n", i)
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
type AuthenticateMiddleware struct {
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
cache *cache.Cache
|
||||
whitelistPaths []string
|
||||
@ -26,7 +26,7 @@ type AuthenticateMiddleware struct {
|
||||
|
||||
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
|
||||
return &AuthenticateMiddleware{
|
||||
config: config2.Get(),
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
cache: cache.New(1*time.Hour, 2*time.Hour),
|
||||
whitelistPaths: whitelistPaths,
|
||||
@ -117,12 +117,12 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
|
||||
// migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility
|
||||
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.UserService) bool {
|
||||
if utils.IsMd5(user.Password) {
|
||||
if utils.CheckPasswordMd5(user, login.Password) {
|
||||
if utils.CompareMd5(user.Password, login.Password, "") {
|
||||
log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID)
|
||||
userServiceRef.MigrateMd5Password(user, login)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return utils.CheckPasswordBcrypt(user, login.Password, salt)
|
||||
return utils.CompareBcrypt(user.Password, login.Password, salt)
|
||||
}
|
||||
|
11
migrations/common/common.go
Normal file
11
migrations/common/common.go
Normal file
@ -0,0 +1,11 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type migrationFunc struct {
|
||||
f func(db *gorm.DB, cfg *config.Config) error
|
||||
name string
|
||||
}
|
30
migrations/common/custom_post.go
Normal file
30
migrations/common/custom_post.go
Normal file
@ -0,0 +1,30 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
)
|
||||
|
||||
var customPostMigrations []migrationFunc
|
||||
|
||||
func init() {
|
||||
customPostMigrations = []migrationFunc{
|
||||
{
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
return cfg.GetFixturesFunc(cfg.Db.Dialect)(db)
|
||||
},
|
||||
name: "apply fixtures",
|
||||
},
|
||||
// TODO: add function to modify aggregated summaries according to configured custom language mappings
|
||||
}
|
||||
}
|
||||
|
||||
func RunCustomPostMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
for _, m := range customPostMigrations {
|
||||
log.Printf("running migration '%s'\n", m.name)
|
||||
if err := m.f(db, cfg); err != nil {
|
||||
log.Fatalf("migration '%s' failed – %v\n", m.name, err)
|
||||
}
|
||||
}
|
||||
}
|
108
migrations/common/custom_pre.go
Normal file
108
migrations/common/custom_pre.go
Normal file
@ -0,0 +1,108 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
)
|
||||
|
||||
var customPreMigrations []migrationFunc
|
||||
|
||||
func init() {
|
||||
customPreMigrations = []migrationFunc{
|
||||
{
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
migrator := db.Migrator()
|
||||
oldTableName, newTableName := "custom_rules", "language_mappings"
|
||||
oldIndexName, newIndexName := "idx_customrule_user", "idx_language_mapping_user"
|
||||
|
||||
if migrator.HasTable(oldTableName) {
|
||||
log.Printf("renaming '%s' table to '%s'\n", oldTableName, newTableName)
|
||||
if err := migrator.RenameTable(oldTableName, &models.LanguageMapping{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("renaming '%s' index to '%s'\n", oldIndexName, newIndexName)
|
||||
return migrator.RenameIndex(&models.LanguageMapping{}, oldIndexName, newIndexName)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
name: "rename language mappings table",
|
||||
},
|
||||
{
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
// drop all already existing foreign key constraints
|
||||
// afterwards let them be re-created by auto migrate with the newly introduced cascade settings,
|
||||
|
||||
migrator := db.Migrator()
|
||||
const lookupKey = "20201106-migration_cascade_constraints"
|
||||
|
||||
if cfg.Db.Dialect == config.SQLDialectSqlite {
|
||||
// https://stackoverflow.com/a/1884893/3112139
|
||||
// unfortunately, we can't migrate existing sqlite databases to the newly introduced cascade settings
|
||||
// things like deleting all summaries won't work in those cases unless an entirely new db is created
|
||||
log.Println("not attempting to drop and regenerate constraints on sqlite")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !migrator.HasTable(&models.KeyStringValue{}) {
|
||||
log.Println("key-value table not yet existing")
|
||||
return nil
|
||||
}
|
||||
|
||||
condition := "key = ?"
|
||||
if cfg.Db.Dialect == config.SQLDialectMysql {
|
||||
condition = "`key` = ?"
|
||||
}
|
||||
lookupResult := db.Where(condition, lookupKey).First(&models.KeyStringValue{})
|
||||
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
|
||||
log.Println("no need to migrate cascade constraints")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SELECT * FROM INFORMATION_SCHEMA.table_constraints;
|
||||
constraints := map[string]interface{}{
|
||||
"fk_summaries_editors": &models.SummaryItem{},
|
||||
"fk_summaries_languages": &models.SummaryItem{},
|
||||
"fk_summaries_machines": &models.SummaryItem{},
|
||||
"fk_summaries_operating_systems": &models.SummaryItem{},
|
||||
"fk_summaries_projects": &models.SummaryItem{},
|
||||
"fk_summary_items_summary": &models.SummaryItem{},
|
||||
"fk_summaries_user": &models.Summary{},
|
||||
"fk_language_mappings_user": &models.LanguageMapping{},
|
||||
"fk_heartbeats_user": &models.Heartbeat{},
|
||||
"fk_aliases_user": &models.Alias{},
|
||||
}
|
||||
|
||||
for name, table := range constraints {
|
||||
if migrator.HasConstraint(table, name) {
|
||||
log.Printf("dropping constraint '%s'", name)
|
||||
if err := migrator.DropConstraint(table, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Create(&models.KeyStringValue{
|
||||
Key: lookupKey,
|
||||
Value: "done",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
name: "add cascade constraints",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func RunCustomPreMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
for _, m := range customPreMigrations {
|
||||
log.Printf("running migration '%s'\n", m.name)
|
||||
if err := m.f(db, cfg); err != nil {
|
||||
log.Fatalf("migration '%s' failed – %v\n", m.name, err)
|
||||
}
|
||||
}
|
||||
}
|
25
migrations/common/languages.go
Normal file
25
migrations/common/languages.go
Normal file
@ -0,0 +1,25 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
)
|
||||
|
||||
func MigrateLanguages(db *gorm.DB) {
|
||||
cfg := config.Get()
|
||||
|
||||
for k, v := range cfg.App.CustomLanguages {
|
||||
result := db.Model(models.Heartbeat{}).
|
||||
Where("language = ?", "").
|
||||
Where("entity LIKE ?", "%."+k).
|
||||
Updates(models.Heartbeat{Language: v})
|
||||
if result.Error != nil {
|
||||
log.Fatal(result.Error)
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("Migrated %+v rows for custom language %+s.\n", result.RowsAffected, k)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
create table aliases
|
||||
(
|
||||
id integer primary key autoincrement,
|
||||
type integer not null,
|
||||
user_id varchar(255) not null,
|
||||
key varchar(255) not null,
|
||||
value varchar(255) not null
|
||||
);
|
||||
|
||||
create index idx_alias_type_key
|
||||
on aliases (type, key);
|
||||
|
||||
create index idx_alias_user
|
||||
on aliases (user_id);
|
||||
|
||||
create table summaries
|
||||
(
|
||||
id integer primary key autoincrement,
|
||||
user_id varchar(255) not null,
|
||||
from_time timestamp default CURRENT_TIMESTAMP not null,
|
||||
to_time timestamp default CURRENT_TIMESTAMP not null
|
||||
);
|
||||
|
||||
create index idx_time_summary_user
|
||||
on summaries (user_id, from_time, to_time);
|
||||
|
||||
create table summary_items
|
||||
(
|
||||
id integer primary key autoincrement,
|
||||
summary_id integer REFERENCES summaries (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
type integer,
|
||||
key varchar(255),
|
||||
total bigint
|
||||
);
|
||||
|
||||
create table users
|
||||
(
|
||||
id varchar(255) primary key,
|
||||
api_key varchar(255) unique,
|
||||
password varchar(255)
|
||||
);
|
||||
|
||||
create table heartbeats
|
||||
(
|
||||
id integer primary key autoincrement,
|
||||
user_id varchar(255) not null REFERENCES users (id) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
entity varchar(255) not null,
|
||||
type varchar(255),
|
||||
category varchar(255),
|
||||
project varchar(255),
|
||||
branch varchar(255),
|
||||
language varchar(255),
|
||||
is_write bool,
|
||||
editor varchar(255),
|
||||
operating_system varchar(255),
|
||||
time timestamp default CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
create index idx_entity
|
||||
on heartbeats (entity);
|
||||
|
||||
create index idx_language
|
||||
on heartbeats (language);
|
||||
|
||||
create index idx_time
|
||||
on heartbeats (time);
|
||||
|
||||
create index idx_time_user
|
||||
on heartbeats (user_id, time);
|
||||
|
||||
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
DROP INDEX idx_alias_user;
|
||||
DROP INDEX idx_alias_type_key;
|
||||
DROP TABLE aliases;
|
||||
DROP INDEX idx_time_summary_user;
|
||||
DROP TABLE summaries;
|
||||
DROP TABLE summary_items;
|
||||
DROP TABLE heartbeats;
|
||||
DROP INDEX idx_entity;
|
||||
DROP INDEX idx_language;
|
||||
DROP INDEX idx_time;
|
||||
DROP INDEX idx_time_user;
|
@ -1,11 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
create table key_string_values
|
||||
(
|
||||
key varchar(255) primary key,
|
||||
value text
|
||||
);
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
drop table key_string_value;
|
@ -1,20 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
|
||||
-- SQLite does not allow altering a table to add a new column with default of CURRENT_TIMESTAMP
|
||||
-- See https://www.sqlite.org/lang_altertable.html
|
||||
|
||||
alter table users
|
||||
add `created_at` timestamp default '2020-01-01T00:00:00.000' not null;
|
||||
|
||||
alter table users
|
||||
add `last_logged_in_at` timestamp default '2020-01-01T00:00:00.000' not null;
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
alter table users
|
||||
drop column `created_at`;
|
||||
|
||||
alter table users
|
||||
drop column `last_logged_in_at`;
|
@ -1,11 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
|
||||
alter table heartbeats
|
||||
add column `machine` varchar(255);
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
alter table heartbeats
|
||||
drop column `machine`;
|
@ -1,11 +0,0 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
|
||||
alter table users
|
||||
add column `badges_enabled` tinyint(1) default 0 not null;
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
alter table users
|
||||
drop column `badges_enabled`;
|
@ -2,7 +2,8 @@ package models
|
||||
|
||||
type Alias struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
Type uint8 `gorm:"not null; index:idx_alias_type_key"`
|
||||
Type uint8 `gorm:"not null; index:idx_alias_type_key; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
UserID string `gorm:"not null; index:idx_alias_user"`
|
||||
Key string `gorm:"not null; index:idx_alias_type_key"`
|
||||
Value string `gorm:"not null"`
|
||||
|
@ -22,8 +22,8 @@ type BadgeData struct {
|
||||
|
||||
func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData {
|
||||
var total time.Duration
|
||||
if hasFilter, filterType, filterKey := filters.First(); hasFilter {
|
||||
total = summary.TotalTimeByKey(filterType, filterKey)
|
||||
if hasFilter, _, _ := filters.First(); hasFilter {
|
||||
total = summary.TotalTimeByFilters(filters)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ type allTimeData struct {
|
||||
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
|
||||
var total time.Duration
|
||||
if key := filters.Project; key != "" {
|
||||
total = summary.TotalTimeByKey(models.SummaryProject, key)
|
||||
total = summary.TotalTimeByFilters(filters)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
@ -64,11 +64,11 @@ func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *Sum
|
||||
for i, s := range summaries {
|
||||
data[i] = newDataFrom(s)
|
||||
|
||||
if s.FromTime.Before(minDate) {
|
||||
minDate = s.FromTime
|
||||
if s.FromTime.T().Before(minDate) {
|
||||
minDate = s.FromTime.T()
|
||||
}
|
||||
if s.ToTime.After(maxDate) {
|
||||
maxDate = s.ToTime
|
||||
if s.ToTime.T().After(maxDate) {
|
||||
maxDate = s.ToTime.T()
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,8 +101,8 @@ func newDataFrom(s *models.Summary) *summariesData {
|
||||
},
|
||||
Range: &summariesRange{
|
||||
Date: time.Now().Format(time.RFC3339),
|
||||
End: s.ToTime,
|
||||
Start: s.FromTime,
|
||||
End: s.ToTime.T(),
|
||||
Start: s.FromTime.T(),
|
||||
Text: "",
|
||||
Timezone: zone,
|
||||
},
|
||||
@ -158,13 +158,17 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEn
|
||||
hrs := int(total.Hours())
|
||||
mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
|
||||
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())
|
||||
percentage := math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100
|
||||
if math.IsNaN(percentage) || math.IsInf(percentage, 0) {
|
||||
percentage = 0
|
||||
}
|
||||
|
||||
return &summariesEntry{
|
||||
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
|
||||
Hours: hrs,
|
||||
Minutes: mins,
|
||||
Name: e.Key,
|
||||
Percent: math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100,
|
||||
Percent: percentage,
|
||||
Seconds: secs,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
|
67
models/filters.go
Normal file
67
models/filters.go
Normal file
@ -0,0 +1,67 @@
|
||||
package models
|
||||
|
||||
type Filters struct {
|
||||
Project string
|
||||
OS string
|
||||
Language string
|
||||
Editor string
|
||||
Machine string
|
||||
}
|
||||
|
||||
type FilterElement struct {
|
||||
Type uint8
|
||||
Key string
|
||||
}
|
||||
|
||||
func NewFiltersWith(entity uint8, key string) *Filters {
|
||||
switch entity {
|
||||
case SummaryProject:
|
||||
return &Filters{Project: key}
|
||||
case SummaryOS:
|
||||
return &Filters{OS: key}
|
||||
case SummaryLanguage:
|
||||
return &Filters{Language: key}
|
||||
case SummaryEditor:
|
||||
return &Filters{Editor: key}
|
||||
case SummaryMachine:
|
||||
return &Filters{Machine: key}
|
||||
}
|
||||
return &Filters{}
|
||||
}
|
||||
|
||||
func (f *Filters) First() (bool, uint8, string) {
|
||||
if f.Project != "" {
|
||||
return true, SummaryProject, f.Project
|
||||
} else if f.OS != "" {
|
||||
return true, SummaryOS, f.OS
|
||||
} else if f.Language != "" {
|
||||
return true, SummaryLanguage, f.Language
|
||||
} else if f.Editor != "" {
|
||||
return true, SummaryEditor, f.Editor
|
||||
} else if f.Machine != "" {
|
||||
return true, SummaryMachine, f.Machine
|
||||
}
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
func (f *Filters) All() []*FilterElement {
|
||||
all := make([]*FilterElement, 0)
|
||||
|
||||
if f.Project != "" {
|
||||
all = append(all, &FilterElement{Type: SummaryProject, Key: f.Project})
|
||||
}
|
||||
if f.Editor != "" {
|
||||
all = append(all, &FilterElement{Type: SummaryEditor, Key: f.Editor})
|
||||
}
|
||||
if f.Language != "" {
|
||||
all = append(all, &FilterElement{Type: SummaryLanguage, Key: f.Language})
|
||||
}
|
||||
if f.Machine != "" {
|
||||
all = append(all, &FilterElement{Type: SummaryMachine, Key: f.Machine})
|
||||
}
|
||||
if f.OS != "" {
|
||||
all = append(all, &FilterElement{Type: SummaryOS, Key: f.OS})
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
@ -7,7 +7,7 @@ import (
|
||||
|
||||
type Heartbeat struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
|
||||
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
|
||||
Type string `json:"type"`
|
||||
@ -19,7 +19,7 @@ type Heartbeat struct {
|
||||
Editor string `json:"editor"`
|
||||
OperatingSystem string `json:"operating_system"`
|
||||
Machine string `json:"machine"`
|
||||
Time CustomTime `json:"time" gorm:"type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time,idx_time_user"`
|
||||
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
|
||||
languageRegex *regexp.Regexp
|
||||
}
|
||||
|
||||
@ -27,19 +27,38 @@ func (h *Heartbeat) Valid() bool {
|
||||
return h.User != nil && h.UserID != "" && h.Time != CustomTime(time.Time{})
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Augment(customLangs map[string]string) {
|
||||
if h.Language == "" {
|
||||
if h.languageRegex == nil {
|
||||
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
|
||||
}
|
||||
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
|
||||
if len(groups) == 0 || len(groups[0]) != 2 {
|
||||
return
|
||||
}
|
||||
ending := groups[0][1]
|
||||
if _, ok := customLangs[ending]; !ok {
|
||||
return
|
||||
}
|
||||
h.Language, _ = customLangs[ending]
|
||||
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||
if h.languageRegex == nil {
|
||||
h.languageRegex = regexp.MustCompile(`^.+\.(.+)$`)
|
||||
}
|
||||
groups := h.languageRegex.FindAllStringSubmatch(h.Entity, -1)
|
||||
if len(groups) == 0 || len(groups[0]) != 2 {
|
||||
return
|
||||
}
|
||||
ending := groups[0][1]
|
||||
if _, ok := languageMappings[ending]; !ok {
|
||||
return
|
||||
}
|
||||
h.Language, _ = languageMappings[ending]
|
||||
}
|
||||
|
||||
func (h *Heartbeat) GetKey(t uint8) (key string) {
|
||||
switch t {
|
||||
case SummaryProject:
|
||||
key = h.Project
|
||||
case SummaryEditor:
|
||||
key = h.Editor
|
||||
case SummaryLanguage:
|
||||
key = h.Language
|
||||
case SummaryOS:
|
||||
key = h.OperatingSystem
|
||||
case SummaryMachine:
|
||||
key = h.Machine
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
key = UnknownSummaryKey
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
38
models/heartbeats.go
Normal file
38
models/heartbeats.go
Normal file
@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
import "sort"
|
||||
|
||||
type Heartbeats []*Heartbeat
|
||||
|
||||
func (h Heartbeats) Len() int {
|
||||
return len(h)
|
||||
}
|
||||
|
||||
func (h Heartbeats) Less(i, j int) bool {
|
||||
return h[i].Time.T().Before(h[j].Time.T())
|
||||
}
|
||||
|
||||
func (h Heartbeats) Swap(i, j int) {
|
||||
h[i], h[j] = h[j], h[i]
|
||||
}
|
||||
|
||||
func (h *Heartbeats) Sorted() *Heartbeats {
|
||||
sort.Sort(h)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Heartbeats) First() *Heartbeat {
|
||||
// assumes slice to be sorted
|
||||
if h.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
return (*h)[0]
|
||||
}
|
||||
|
||||
func (h *Heartbeats) Last() *Heartbeat {
|
||||
// assumes slice to be sorted
|
||||
if h.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
return (*h)[h.Len()-1]
|
||||
}
|
21
models/language_mapping.go
Normal file
21
models/language_mapping.go
Normal file
@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
type LanguageMapping struct {
|
||||
ID uint `json:"id" gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_language_mapping_user; uniqueIndex:idx_language_mapping_composite"`
|
||||
Extension string `json:"extension" gorm:"uniqueIndex:idx_language_mapping_composite; type:varchar(16)"`
|
||||
Language string `json:"language" gorm:"type:varchar(64)"`
|
||||
}
|
||||
|
||||
func (m *LanguageMapping) IsValid() bool {
|
||||
return m.validateLanguage() && m.validateExtension()
|
||||
}
|
||||
|
||||
func (m *LanguageMapping) validateLanguage() bool {
|
||||
return len(m.Language) >= 1
|
||||
}
|
||||
|
||||
func (m *LanguageMapping) validateExtension() bool {
|
||||
return len(m.Extension) >= 1
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
package models
|
||||
|
||||
func init() {
|
||||
// nothing no init here, yet
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jinzhu/gorm"
|
||||
"gorm.io/gorm"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -24,43 +24,9 @@ type KeyStringValue struct {
|
||||
Value string `gorm:"type:text"`
|
||||
}
|
||||
|
||||
type Filters struct {
|
||||
Project string
|
||||
OS string
|
||||
Language string
|
||||
Editor string
|
||||
Machine string
|
||||
}
|
||||
|
||||
func NewFiltersWith(entity uint8, key string) *Filters {
|
||||
switch entity {
|
||||
case SummaryProject:
|
||||
return &Filters{Project: key}
|
||||
case SummaryOS:
|
||||
return &Filters{Project: key}
|
||||
case SummaryLanguage:
|
||||
return &Filters{Project: key}
|
||||
case SummaryEditor:
|
||||
return &Filters{Project: key}
|
||||
case SummaryMachine:
|
||||
return &Filters{Project: key}
|
||||
}
|
||||
return &Filters{}
|
||||
}
|
||||
|
||||
func (f *Filters) First() (bool, uint8, string) {
|
||||
if f.Project != "" {
|
||||
return true, SummaryProject, f.Project
|
||||
} else if f.OS != "" {
|
||||
return true, SummaryOS, f.OS
|
||||
} else if f.Language != "" {
|
||||
return true, SummaryLanguage, f.Language
|
||||
} else if f.Editor != "" {
|
||||
return true, SummaryEditor, f.Editor
|
||||
} else if f.Machine != "" {
|
||||
return true, SummaryMachine, f.Machine
|
||||
}
|
||||
return false, 0, ""
|
||||
type Interval struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
type CustomTime time.Time
|
||||
@ -76,28 +42,38 @@ func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
|
||||
func (j *CustomTime) Scan(value interface{}) error {
|
||||
var (
|
||||
t time.Time
|
||||
err error
|
||||
)
|
||||
|
||||
switch value.(type) {
|
||||
case string:
|
||||
t, err := time.Parse("2006-01-02 15:04:05-07:00", value.(string))
|
||||
t, err = time.Parse("2006-01-02 15:04:05-07:00", value.(string))
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
|
||||
}
|
||||
*j = CustomTime(t)
|
||||
case int64:
|
||||
*j = CustomTime(time.Unix(value.(int64), 0))
|
||||
t = time.Unix(0, value.(int64))
|
||||
break
|
||||
case time.Time:
|
||||
*j = CustomTime(value.(time.Time))
|
||||
t = value.(time.Time)
|
||||
break
|
||||
default:
|
||||
return errors.New(fmt.Sprintf("unsupported type: %T", value))
|
||||
}
|
||||
|
||||
t = time.Unix(0, (t.UnixNano()/int64(time.Millisecond))*int64(time.Millisecond)) // round to millisecond precision
|
||||
*j = CustomTime(t)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j CustomTime) Value() (driver.Value, error) {
|
||||
return time.Time(j), nil
|
||||
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (j CustomTime) String() string {
|
||||
@ -105,6 +81,10 @@ func (j CustomTime) String() string {
|
||||
return t.Format("2006-01-02 15:04:05.000")
|
||||
}
|
||||
|
||||
func (j CustomTime) Time() time.Time {
|
||||
func (j CustomTime) T() time.Time {
|
||||
return time.Time(j)
|
||||
}
|
||||
|
||||
func (j CustomTime) Valid() bool {
|
||||
return j.T().Unix() >= 0
|
||||
}
|
||||
|
@ -35,18 +35,20 @@ const UnknownSummaryKey = "unknown"
|
||||
|
||||
type Summary struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime time.Time `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
|
||||
ToTime time.Time `json:"to" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
|
||||
Projects []*SummaryItem `json:"projects"`
|
||||
Languages []*SummaryItem `json:"languages"`
|
||||
Editors []*SummaryItem `json:"editors"`
|
||||
OperatingSystems []*SummaryItem `json:"operating_systems"`
|
||||
Machines []*SummaryItem `json:"machines"`
|
||||
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
Projects []*SummaryItem `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Languages []*SummaryItem `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Editors []*SummaryItem `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
OperatingSystems []*SummaryItem `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Machines []*SummaryItem `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
type SummaryItem struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
SummaryID uint `json:"-"`
|
||||
Type uint8 `json:"-"`
|
||||
Key string `json:"key"`
|
||||
@ -73,6 +75,8 @@ type SummaryParams struct {
|
||||
Recompute bool
|
||||
}
|
||||
|
||||
type AliasResolver func(t uint8, k string) string
|
||||
|
||||
func SummaryTypes() []uint8 {
|
||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
}
|
||||
@ -147,31 +151,82 @@ func (s *Summary) TotalTime() time.Duration {
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeBy(entityType uint8) time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
func (s *Summary) TotalTimeBy(entityType uint8) (timeSum time.Duration) {
|
||||
mappedItems := s.MappedItems()
|
||||
if items := mappedItems[entityType]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
timeSum += item.Total
|
||||
timeSum = timeSum + item.Total*time.Second
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
return timeSum
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeByKey(entityType uint8, key string) time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
func (s *Summary) TotalTimeByKey(entityType uint8, key string) (timeSum time.Duration) {
|
||||
mappedItems := s.MappedItems()
|
||||
if items := mappedItems[entityType]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
if item.Key != key {
|
||||
continue
|
||||
}
|
||||
timeSum += item.Total
|
||||
timeSum = timeSum + item.Total*time.Second
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
return timeSum
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
|
||||
for _, f := range filter.All() {
|
||||
timeSum += s.TotalTimeByKey(f.Type, f.Key)
|
||||
}
|
||||
return timeSum
|
||||
}
|
||||
|
||||
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||
processAliases := func(origin []*SummaryItem) []*SummaryItem {
|
||||
target := make([]*SummaryItem, 0)
|
||||
|
||||
findItem := func(key string) *SummaryItem {
|
||||
for _, item := range target {
|
||||
if item.Key == key {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all "top-level" items, i.e. such without aliases
|
||||
if key := resolve(item.Type, item.Key); key == item.Key {
|
||||
target = append(target, item)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all remaining projects and merge with their alias
|
||||
if key := resolve(item.Type, item.Key); key != item.Key {
|
||||
if targetItem := findItem(key); targetItem != nil {
|
||||
targetItem.Total += item.Total
|
||||
} else {
|
||||
target = append(target, &SummaryItem{
|
||||
ID: item.ID,
|
||||
SummaryID: item.SummaryID,
|
||||
Type: item.Type,
|
||||
Key: key,
|
||||
Total: item.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
// Resolve aliases
|
||||
s.Projects = processAliases(s.Projects)
|
||||
s.Editors = processAliases(s.Editors)
|
||||
s.Languages = processAliases(s.Languages)
|
||||
s.OperatingSystems = processAliases(s.OperatingSystems)
|
||||
s.Machines = processAliases(s.Machines)
|
||||
|
||||
return s
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ type User struct {
|
||||
Password string `json:"-"`
|
||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
||||
BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"`
|
||||
BadgesEnabled bool `json:"-" gorm:"default:false; type:bool"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
@ -26,6 +26,11 @@ type CredentialsReset struct {
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
}
|
||||
|
||||
type TimeByUser struct {
|
||||
User string
|
||||
Time CustomTime
|
||||
}
|
||||
|
||||
func (c *CredentialsReset) IsValid() bool {
|
||||
return validatePassword(c.PasswordNew) &&
|
||||
c.PasswordNew == c.PasswordRepeat
|
||||
|
16
models/view/home.go
Normal file
16
models/view/home.go
Normal file
@ -0,0 +1,16 @@
|
||||
package view
|
||||
|
||||
type HomeViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *HomeViewModel) WithError(m string) *HomeViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
22
models/view/imprint.go
Normal file
22
models/view/imprint.go
Normal file
@ -0,0 +1,22 @@
|
||||
package view
|
||||
|
||||
type ImprintViewModel struct {
|
||||
HtmlText string
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithSuccess(m string) *ImprintViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithError(m string) *ImprintViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithHtmlText(t string) *ImprintViewModel {
|
||||
s.HtmlText = t
|
||||
return s
|
||||
}
|
20
models/view/settings.go
Normal file
20
models/view/settings.go
Normal file
@ -0,0 +1,20 @@
|
||||
package view
|
||||
|
||||
import "github.com/muety/wakapi/models"
|
||||
|
||||
type SettingsViewModel struct {
|
||||
User *models.User
|
||||
LanguageMappings []*models.LanguageMapping
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithError(m string) *SettingsViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
16
models/view/summary.go
Normal file
16
models/view/summary.go
Normal file
@ -0,0 +1,16 @@
|
||||
package view
|
||||
|
||||
type SummaryViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SummaryViewModel) WithError(m string) *SummaryViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
24
repositories/alias.go
Normal file
24
repositories/alias.go
Normal file
@ -0,0 +1,24 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AliasRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAliasRepository(db *gorm.DB) *AliasRepository {
|
||||
return &AliasRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.
|
||||
Where(&models.Alias{UserID: userId}).
|
||||
Find(&aliases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
54
repositories/heartbeart.go
Normal file
54
repositories/heartbeart.go
Normal file
@ -0,0 +1,54 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HeartbeatRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
|
||||
return &HeartbeatRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
if err := r.db.Create(&heartbeats).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := r.db.
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Where("time >= ?", from).
|
||||
Where("time < ?", to).
|
||||
Order("time asc").
|
||||
Find(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, min(time) as time").
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Group("user").
|
||||
Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||
if err := r.db.
|
||||
Where("time <= ?", t).
|
||||
Delete(models.Heartbeat{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
58
repositories/key_value.go
Normal file
58
repositories/key_value.go
Normal file
@ -0,0 +1,58 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type KeyValueRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
|
||||
return &KeyValueRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) {
|
||||
kv := &models.KeyStringValue{}
|
||||
if err := r.db.
|
||||
Where(&models.KeyStringValue{Key: key}).
|
||||
First(&kv).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
|
||||
result := r.db.
|
||||
Where(&models.KeyStringValue{Key: kv.Key}).
|
||||
Assign(kv).
|
||||
FirstOrCreate(kv)
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *KeyValueRepository) DeleteString(key string) error {
|
||||
result := r.db.
|
||||
Delete(&models.KeyStringValue{}, &models.KeyStringValue{Key: key})
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("nothing deleted")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
52
repositories/language_mapping.go
Normal file
52
repositories/language_mapping.go
Normal file
@ -0,0 +1,52 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LanguageMappingRepository struct {
|
||||
config *config.Config
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLanguageMappingRepository(db *gorm.DB) *LanguageMappingRepository {
|
||||
return &LanguageMappingRepository{config: config.Get(), db: db}
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) {
|
||||
mapping := &models.LanguageMapping{}
|
||||
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {
|
||||
return mapping, err
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.LanguageMapping, error) {
|
||||
var mappings []*models.LanguageMapping
|
||||
if err := r.db.
|
||||
Where(&models.LanguageMapping{UserID: userId}).
|
||||
Find(&mappings).Error; err != nil {
|
||||
return mappings, err
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) Insert(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
|
||||
if !mapping.IsValid() {
|
||||
return nil, errors.New("invalid mapping")
|
||||
}
|
||||
result := r.db.Create(mapping)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) Delete(id uint) error {
|
||||
return r.db.
|
||||
Where("id = ?", id).
|
||||
Delete(models.LanguageMapping{}).Error
|
||||
}
|
59
repositories/summary.go
Normal file
59
repositories/summary.go
Normal file
@ -0,0 +1,59 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SummaryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSummaryRepository(db *gorm.DB) *SummaryRepository {
|
||||
return &SummaryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) Insert(summary *models.Summary) error {
|
||||
if err := r.db.Create(summary).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := r.db.
|
||||
Where(&models.Summary{UserID: user.ID}).
|
||||
Where("from_time >= ?", from).
|
||||
Where("to_time <= ?", to).
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) GetLastByUser() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, max(to_time) as time").
|
||||
Joins("left join summaries on users.id = summaries.user_id").
|
||||
Group("user").
|
||||
Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) DeleteByUser(userId string) error {
|
||||
if err := r.db.
|
||||
Where("user_id = ?", userId).
|
||||
Delete(models.Summary{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
80
repositories/user.go
Normal file
80
repositories/user.go
Normal file
@ -0,0 +1,80 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetById(userId string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetAll() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
Table("users").
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
|
||||
result := r.db.FirstOrCreate(user, &models.User{ID: user.ID})
|
||||
if err := result.Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if result.RowsAffected == 1 {
|
||||
return user, true, nil
|
||||
}
|
||||
|
||||
return user, false, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
result := r.db.Model(user).Updates(user)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateField(user *models.User, key string, value interface{}) (*models.User, error) {
|
||||
result := r.db.Model(user).Update(key, value)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
@ -20,6 +20,7 @@ const (
|
||||
type BadgeHandler struct {
|
||||
userSrvc *services.UserService
|
||||
summarySrvc *services.SummaryService
|
||||
aliasSrvc *services.AliasService
|
||||
config *config2.Config
|
||||
}
|
||||
|
||||
@ -57,18 +58,20 @@ func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
interval = groups[1]
|
||||
}
|
||||
|
||||
filters := &models.Filters{}
|
||||
var filters *models.Filters
|
||||
switch filterEntity {
|
||||
case "project":
|
||||
filters.Project = filterKey
|
||||
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
|
||||
case "os":
|
||||
filters.OS = filterKey
|
||||
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
|
||||
case "editor":
|
||||
filters.Editor = filterKey
|
||||
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
|
||||
case "language":
|
||||
filters.Language = filterKey
|
||||
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
|
||||
case "machine":
|
||||
filters.Machine = filterKey
|
||||
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
|
||||
default:
|
||||
filters = &models.Filters{}
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(user, interval)
|
||||
@ -94,7 +97,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
|
||||
User: user,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute)
|
||||
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
retrieveSummary = h.summarySrvc.Summarize
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")})
|
||||
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
@ -55,7 +55,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
|
||||
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
retrieveSummary = h.summarySrvc.Summarize
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -86,7 +86,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
||||
summaries := make([]*models.Summary, len(intervals))
|
||||
|
||||
for i, interval := range intervals {
|
||||
summary, err := h.summarySrvc.Construct(interval[0], interval[1], user, false) // 'to' is always constant
|
||||
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jinzhu/gorm"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@ -16,8 +16,10 @@ func NewHealthHandler(db *gorm.DB) *HealthHandler {
|
||||
|
||||
func (h *HealthHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
var dbStatus int
|
||||
if err := h.db.DB().Ping(); err == nil {
|
||||
dbStatus = 1
|
||||
if sqlDb, err := h.db.DB(); err == nil {
|
||||
if err := sqlDb.Ping(); err == nil {
|
||||
dbStatus = 1
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
|
@ -2,7 +2,7 @@ package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@ -13,14 +13,16 @@ import (
|
||||
)
|
||||
|
||||
type HeartbeatHandler struct {
|
||||
config *config2.Config
|
||||
heartbeatSrvc *services.HeartbeatService
|
||||
config *conf.Config
|
||||
heartbeatSrvc *services.HeartbeatService
|
||||
languageMappingSrvc *services.LanguageMappingService
|
||||
}
|
||||
|
||||
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService) *HeartbeatHandler {
|
||||
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService, languageMappingService *services.LanguageMappingService) *HeartbeatHandler {
|
||||
return &HeartbeatHandler{
|
||||
config: config2.Get(),
|
||||
heartbeatSrvc: heartbeatService,
|
||||
config: conf.Get(),
|
||||
heartbeatSrvc: heartbeatService,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +49,6 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
hb.Machine = machineName
|
||||
hb.User = user
|
||||
hb.UserID = user.ID
|
||||
hb.Augment(h.config.App.CustomLanguages)
|
||||
|
||||
if !hb.Valid() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
@ -3,9 +3,10 @@ package routes
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/schema"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -13,24 +14,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type IndexHandler struct {
|
||||
config *config2.Config
|
||||
userSrvc *services.UserService
|
||||
keyValueSrvc *services.KeyValueService
|
||||
type HomeHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
}
|
||||
|
||||
var loginDecoder = schema.NewDecoder()
|
||||
var signupDecoder = schema.NewDecoder()
|
||||
|
||||
func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler {
|
||||
return &IndexHandler{
|
||||
config: config2.Get(),
|
||||
userSrvc: userService,
|
||||
keyValueSrvc: keyValueService,
|
||||
func NewHomeHandler(userService *services.UserService) *HomeHandler {
|
||||
return &HomeHandler{
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -40,29 +39,10 @@ func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if handleAlerts(w, r, "") {
|
||||
return
|
||||
}
|
||||
|
||||
templates["index.tpl.html"].Execute(w, nil)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
text := "failed to load content"
|
||||
if data, err := h.keyValueSrvc.GetString(models.ImprintKey); err == nil {
|
||||
text = data.Value
|
||||
}
|
||||
|
||||
templates["imprint.tpl.html"].Execute(w, &struct {
|
||||
HtmlText string
|
||||
}{HtmlText: text})
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -74,29 +54,34 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var login models.Login
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSrvc.GetUserById(login.Username)
|
||||
if err != nil {
|
||||
respondAlert(w, "resource not found", "", "", http.StatusNotFound)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: depending on middleware package here is a hack
|
||||
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, h.userSrvc) {
|
||||
respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
if err != nil {
|
||||
respondAlert(w, "internal server error", "", "", http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -114,7 +99,7 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -123,7 +108,7 @@ func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -133,14 +118,10 @@ func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if handleAlerts(w, r, "signup.tpl.html") {
|
||||
return
|
||||
}
|
||||
|
||||
templates["signup.tpl.html"].Execute(w, nil)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -152,29 +133,41 @@ func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var signup models.Signup
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if !signup.IsValid() {
|
||||
respondAlert(w, "invalid parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
_, created, err := h.userSrvc.CreateOrGet(&signup)
|
||||
if err != nil {
|
||||
respondAlert(w, "failed to create new user", "", "signup.tpl.html", http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict)
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape("account created successfully")
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
|
||||
return &view.HomeViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
41
routes/imprint.go
Normal file
41
routes/imprint.go
Normal file
@ -0,0 +1,41 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ImprintHandler struct {
|
||||
config *conf.Config
|
||||
keyValueSrvc *services.KeyValueService
|
||||
}
|
||||
|
||||
func NewImprintHandler(keyValueService *services.KeyValueService) *ImprintHandler {
|
||||
return &ImprintHandler{
|
||||
config: conf.Get(),
|
||||
keyValueSrvc: keyValueService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ImprintHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
text := "failed to load content"
|
||||
if data, err := h.keyValueSrvc.GetString(models.ImprintKey); err == nil {
|
||||
text = data.Value
|
||||
}
|
||||
|
||||
templates[conf.ImprintTemplate].Execute(w, h.buildViewModel(r).WithHtmlText(text))
|
||||
}
|
||||
|
||||
func (h *ImprintHandler) buildViewModel(r *http.Request) *view.ImprintViewModel {
|
||||
return &view.ImprintViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
@ -6,7 +6,6 @@ import (
|
||||
"github.com/muety/wakapi/utils"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
@ -30,6 +29,9 @@ func loadTemplates() {
|
||||
"getVersion": func() string {
|
||||
return config.Get().Version
|
||||
},
|
||||
"getDbType": func() string {
|
||||
return strings.ToLower(config.Get().Db.Dialect)
|
||||
},
|
||||
"htmlSafe": func(html string) template.HTML {
|
||||
return template.HTML(html)
|
||||
},
|
||||
@ -55,33 +57,3 @@ func loadTemplates() {
|
||||
templates[tplName] = tpl
|
||||
}
|
||||
}
|
||||
|
||||
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
|
||||
w.WriteHeader(status)
|
||||
if tplName == "" {
|
||||
tplName = "index.tpl.html"
|
||||
}
|
||||
templates[tplName].Execute(w, struct {
|
||||
Error string
|
||||
Success string
|
||||
}{Error: error, Success: success})
|
||||
}
|
||||
|
||||
// TODO: do better
|
||||
func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool {
|
||||
if err := r.URL.Query().Get("error"); err != "" {
|
||||
if err == "unauthorized" {
|
||||
respondAlert(w, err, "", tplName, http.StatusUnauthorized)
|
||||
} else {
|
||||
respondAlert(w, err, "", tplName, http.StatusInternalServerError)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if success := r.URL.Query().Get("success"); success != "" {
|
||||
respondAlert(w, "", success, tplName, http.StatusOK)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
@ -3,25 +3,34 @@ package routes
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/schema"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
config *config2.Config
|
||||
userSrvc *services.UserService
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
summarySrvc *services.SummaryService
|
||||
aggregationSrvc *services.AggregationService
|
||||
languageMappingSrvc *services.LanguageMappingService
|
||||
}
|
||||
|
||||
var credentialsDecoder = schema.NewDecoder()
|
||||
|
||||
func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
|
||||
func NewSettingsHandler(userService *services.UserService, summaryService *services.SummaryService, aggregationService *services.AggregationService, languageMappingService *services.LanguageMappingService) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
config: config2.Get(),
|
||||
userSrvc: userService,
|
||||
config: conf.Get(),
|
||||
summarySrvc: summaryService,
|
||||
aggregationSrvc: aggregationService,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
userSrvc: userService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,16 +39,7 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
data := map[string]interface{}{
|
||||
"User": user,
|
||||
}
|
||||
|
||||
// TODO: when alerts are present, other data will not be passed to the template
|
||||
if handleAlerts(w, r, "settings.tpl.html") {
|
||||
return
|
||||
}
|
||||
templates["settings.tpl.html"].Execute(w, data)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
@ -51,32 +51,40 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
|
||||
var credentials models.CredentialsReset
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.Security.PasswordSalt) {
|
||||
respondAlert(w, "invalid credentials", "", "settings.tpl.html", http.StatusUnauthorized)
|
||||
if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
if !credentials.IsValid() {
|
||||
respondAlert(w, "invalid parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = credentials.PasswordNew
|
||||
if err := utils.HashPassword(user, h.config.Security.PasswordSalt); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
} else {
|
||||
user.Password = hash
|
||||
}
|
||||
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -86,7 +94,8 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
if err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -99,8 +108,62 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
msg := url.QueryEscape("password was updated successfully")
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
|
||||
return
|
||||
}
|
||||
|
||||
mapping := &models.LanguageMapping{
|
||||
ID: uint(id),
|
||||
UserID: user.ID,
|
||||
}
|
||||
|
||||
err = h.languageMappingSrvc.Delete(mapping)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping deleted successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
extension := r.PostFormValue("extension")
|
||||
language := r.PostFormValue("language")
|
||||
|
||||
if extension[0] == '.' {
|
||||
extension = extension[1:]
|
||||
}
|
||||
|
||||
mapping := &models.LanguageMapping{
|
||||
UserID: user.ID,
|
||||
Extension: extension,
|
||||
Language: language,
|
||||
}
|
||||
|
||||
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping added successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
@ -110,12 +173,13 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
|
||||
@ -124,11 +188,47 @@ func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.Server.BasePath), http.StatusFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostRegenerateSummaries(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
log.Printf("clearing summaries for user '%s'\n", user.ID)
|
||||
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
|
||||
log.Printf("failed to clear summaries: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to delete old summaries"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
|
||||
log.Printf("failed to regenerate summaries: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate aggregations"))
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("summaries are being regenerated – this may take a few seconds"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
||||
return &view.SettingsViewModel{
|
||||
User: user,
|
||||
LanguageMappings: mappings,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -10,13 +11,13 @@ import (
|
||||
|
||||
type SummaryHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
}
|
||||
|
||||
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
|
||||
return &SummaryHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: config2.Get(),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,13 +45,15 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
summary, err, status := h.loadUserSummary(r)
|
||||
if err != nil {
|
||||
respondAlert(w, err.Error(), "", "summary.tpl.html", status)
|
||||
w.WriteHeader(status)
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
if user == nil {
|
||||
respondAlert(w, "unauthorized", "", "summary.tpl.html", http.StatusUnauthorized)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -60,7 +63,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
ApiKey: user.ApiKey,
|
||||
}
|
||||
|
||||
templates["summary.tpl.html"].Execute(w, vm)
|
||||
templates[conf.SummaryTemplate].Execute(w, vm)
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
|
||||
@ -69,10 +72,22 @@ func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, erro
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
|
||||
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
retrieveSummary = h.summarySrvc.Summarize
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
|
||||
return &view.SummaryViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
||||
|
3
scripts/docker_mysql.sh
Normal file
3
scripts/docker_mysql.sh
Normal file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secretpassword -e MYSQL_DATABASE=wakapi_local -e MYSQL_USER=wakapi_user -e MYSQL_PASSWORD=wakapi --name wakapi-mysql mysql:5
|
3
scripts/docker_postgres.sh
Normal file
3
scripts/docker_postgres.sh
Normal file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker run -d -p 5432:5432 -e POSTGRES_DATABASE=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres
|
@ -1,15 +1,17 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from typing import List, Union
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
N_PROJECTS = 5
|
||||
N_PAST_HOURS = 24
|
||||
UA = 'wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 vscode/1.42.1 vscode-wakatime/4.0.0'
|
||||
UA = 'wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 generator/1.42.1 generator-wakatime/4.0.0'
|
||||
LANGUAGES = {
|
||||
'Go': 'go',
|
||||
'Java': 'java',
|
||||
@ -36,23 +38,25 @@ class Heartbeat:
|
||||
self.is_write: bool = is_write
|
||||
self.branch: str = branch
|
||||
self.type: str = type
|
||||
self.category: str = None
|
||||
self.category: Union[str, None] = None
|
||||
|
||||
|
||||
def generate_data(n: int) -> List[Heartbeat]:
|
||||
def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[Heartbeat]:
|
||||
data: List[Heartbeat] = []
|
||||
now: datetime = datetime.today()
|
||||
projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(5)]
|
||||
projects: List[str] = [randomword(random.randint(5, 10)) for _ in range(n_projects)]
|
||||
languages: List[str] = list(LANGUAGES.keys())
|
||||
|
||||
for i in range(n):
|
||||
for _ in range(n):
|
||||
p: str = random.choice(projects)
|
||||
l: str = random.choice(languages)
|
||||
f: str = randomword(random.randint(2, 8))
|
||||
delta: timedelta = timedelta(
|
||||
hours=random.randint(0, N_PAST_HOURS - 1),
|
||||
hours=random.randint(0, n_past_hours - 1),
|
||||
minutes=random.randint(0, 59),
|
||||
seconds=random.randint(0, 59)
|
||||
seconds=random.randint(0, 59),
|
||||
milliseconds=random.randint(0, 999),
|
||||
microseconds=random.randint(0, 999)
|
||||
)
|
||||
|
||||
data.append(Heartbeat(
|
||||
@ -65,29 +69,43 @@ def generate_data(n: int) -> List[Heartbeat]:
|
||||
return data
|
||||
|
||||
|
||||
def post_data_sync(data: List[Heartbeat], url: str):
|
||||
for h in data:
|
||||
def post_data_sync(data: List[Heartbeat], url: str, api_key: str):
|
||||
encoded_key: str = str(base64.b64encode(api_key.encode('utf-8')), 'utf-8')
|
||||
|
||||
for h in tqdm(data):
|
||||
r = requests.post(url, json=[h.__dict__], headers={
|
||||
'User-Agent': UA
|
||||
'User-Agent': UA,
|
||||
'Authorization': f'Basic {encoded_key}'
|
||||
})
|
||||
if r.status_code != 200:
|
||||
if r.status_code != 201:
|
||||
print(r.text)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def randomword(length: int) -> str:
|
||||
letters = string.ascii_lowercase
|
||||
return ''.join(random.choice(letters) for i in range(length))
|
||||
return ''.join(random.choice(letters) for _ in range(length))
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(description='Wakapi test data insertion script.')
|
||||
parser.add_argument('-n', type=int, default=20, help='total number of random heartbeats to generate and insert')
|
||||
parser.add_argument('-u', '--url', type=str, default='http://localhost:3000/api/heartbeat',
|
||||
help='url of your api\'s heartbeats endpoint')
|
||||
parser.add_argument('-k', '--apikey', type=str, required=True,
|
||||
help='your api key (to get one, go to the web interface, create a new user, log in and copy the key)')
|
||||
parser.add_argument('-p', '--projects', type=int, default=5, help='number of different fake projects to generate')
|
||||
parser.add_argument('-o', '--offset', type=int, default=24,
|
||||
help='negative time offset in hours from now for to be used as an interval within which to generate heartbeats for')
|
||||
parser.add_argument('-s', '--seed', type=int, default=2020,
|
||||
help='a seed for initializing the pseudo-random number generator')
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
n: int = 10
|
||||
url: str = 'http://admin:admin@localhost:3000/api/heartbeat'
|
||||
args = parse_arguments()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
n = int(sys.argv[1])
|
||||
if len(sys.argv) > 2:
|
||||
url = sys.argv[2]
|
||||
random.seed(args.seed)
|
||||
|
||||
data: List[Heartbeat] = generate_data(n)
|
||||
post_data_sync(data, url)
|
||||
data: List[Heartbeat] = generate_data(args.n, args.projects, args.offset)
|
||||
post_data_sync(data, args.url, args.apikey)
|
||||
|
@ -7,29 +7,26 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jasonlvhit/gocron"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
const (
|
||||
aggregateIntervalDays int = 1 // TODO: Make configurable
|
||||
aggregateIntervalDays int = 1
|
||||
)
|
||||
|
||||
type AggregationService struct {
|
||||
Config *config.Config
|
||||
Db *gorm.DB
|
||||
UserService *UserService
|
||||
SummaryService *SummaryService
|
||||
HeartbeatService *HeartbeatService
|
||||
config *config.Config
|
||||
userService *UserService
|
||||
summaryService *SummaryService
|
||||
heartbeatService *HeartbeatService
|
||||
}
|
||||
|
||||
func NewAggregationService(db *gorm.DB, userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
|
||||
func NewAggregationService(userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
|
||||
return &AggregationService{
|
||||
Config: config.Get(),
|
||||
Db: db,
|
||||
UserService: userService,
|
||||
SummaryService: summaryService,
|
||||
HeartbeatService: heartbeatService,
|
||||
config: config.Get(),
|
||||
userService: userService,
|
||||
summaryService: summaryService,
|
||||
heartbeatService: heartbeatService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,31 +37,41 @@ type AggregationJob struct {
|
||||
}
|
||||
|
||||
// Schedule a job to (re-)generate summaries every day shortly after midnight
|
||||
// TODO: Make configurable
|
||||
func (srv *AggregationService) Schedule() {
|
||||
// Run once initially
|
||||
if err := srv.Run(nil); err != nil {
|
||||
log.Fatalf("failed to run aggregation jobs: %v\n", err)
|
||||
}
|
||||
|
||||
gocron.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, nil)
|
||||
<-gocron.Start()
|
||||
}
|
||||
|
||||
func (srv *AggregationService) Run(userIds map[string]bool) error {
|
||||
jobs := make(chan *AggregationJob)
|
||||
summaries := make(chan *models.Summary)
|
||||
defer close(jobs)
|
||||
defer close(summaries)
|
||||
|
||||
for i := 0; i < runtime.NumCPU(); i++ {
|
||||
go srv.summaryWorker(jobs, summaries)
|
||||
}
|
||||
|
||||
for i := 0; i < int(srv.Config.Db.MaxConn); i++ {
|
||||
for i := 0; i < int(srv.config.Db.MaxConn); i++ {
|
||||
go srv.persistWorker(summaries)
|
||||
}
|
||||
|
||||
// Run once initially
|
||||
srv.trigger(jobs)
|
||||
// don't leak open channels
|
||||
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
|
||||
defer close(c1)
|
||||
defer close(c2)
|
||||
time.Sleep(1 * time.Hour)
|
||||
}(jobs, summaries)
|
||||
|
||||
gocron.Every(1).Day().At("02:15").Do(srv.trigger, jobs)
|
||||
<-gocron.Start()
|
||||
return srv.trigger(jobs, userIds)
|
||||
}
|
||||
|
||||
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
||||
for job := range jobs {
|
||||
if summary, err := srv.SummaryService.Construct(job.From, job.To, &models.User{ID: job.UserID}, true); err != nil {
|
||||
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
|
||||
log.Printf("Failed to generate summary (%v, %v, %s) – %v.\n", job.From, job.To, job.UserID, err)
|
||||
} else {
|
||||
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
|
||||
@ -75,72 +82,83 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
|
||||
|
||||
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
|
||||
for summary := range summaries {
|
||||
if err := srv.SummaryService.Insert(summary); err != nil {
|
||||
if err := srv.summaryService.Insert(summary); err != nil {
|
||||
log.Printf("Failed to save summary (%v, %v, %s) – %v.\n", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob) error {
|
||||
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[string]bool) error {
|
||||
log.Println("Generating summaries.")
|
||||
|
||||
users, err := srv.UserService.GetAll()
|
||||
if err != nil {
|
||||
var users []*models.User
|
||||
if allUsers, err := srv.userService.GetAll(); err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
latestSummaries, err := srv.SummaryService.GetLatestByUser()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
userSummaryTimes := make(map[string]time.Time)
|
||||
for _, s := range latestSummaries {
|
||||
userSummaryTimes[s.UserID] = s.ToTime
|
||||
}
|
||||
|
||||
missingUserIDs := make([]string, 0)
|
||||
for _, u := range users {
|
||||
if _, ok := userSummaryTimes[u.ID]; !ok {
|
||||
missingUserIDs = append(missingUserIDs, u.ID)
|
||||
} else if userIds != nil && len(userIds) > 0 {
|
||||
users = make([]*models.User, len(userIds))
|
||||
for i, u := range allUsers {
|
||||
if yes, ok := userIds[u.ID]; yes && ok {
|
||||
users[i] = u
|
||||
}
|
||||
}
|
||||
} else {
|
||||
users = allUsers
|
||||
}
|
||||
|
||||
firstHeartbeats, err := srv.HeartbeatService.GetFirstUserHeartbeats(missingUserIDs)
|
||||
// Get a map from user ids to the time of their latest summary or nil if none exists yet
|
||||
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
for id, t := range userSummaryTimes {
|
||||
generateUserJobs(id, t, jobs)
|
||||
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
|
||||
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, h := range firstHeartbeats {
|
||||
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
|
||||
// Build actual lookup table from it
|
||||
firstUserHeartbeatLookup := make(map[string]models.CustomTime)
|
||||
for _, e := range firstUserHeartbeatTimes {
|
||||
firstUserHeartbeatLookup[e.User] = e.Time
|
||||
}
|
||||
|
||||
// Generate summary aggregation jobs
|
||||
for _, e := range lastUserSummaryTimes {
|
||||
if e.Time.Valid() {
|
||||
// Case 1: User has aggregated summaries already
|
||||
// -> Spawn jobs to create summaries from their latest aggregation to now
|
||||
generateUserJobs(e.User, e.Time.T(), jobs)
|
||||
} else if t := firstUserHeartbeatLookup[e.User]; t.Valid() {
|
||||
// Case 2: User has no aggregated summaries, yet, but has heartbeats
|
||||
// -> Spawn jobs to create summaries from their first heartbeat to now
|
||||
generateUserJobs(e.User, t.T(), jobs)
|
||||
}
|
||||
// Case 3: User doesn't have heartbeats at all
|
||||
// -> Nothing to do
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateUserJobs(userId string, lastAggregation time.Time, jobs chan<- *AggregationJob) {
|
||||
var from, to time.Time
|
||||
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
|
||||
var to time.Time
|
||||
|
||||
// Go to next day of either user's first heartbeat or latest aggregation
|
||||
from.Add(-1 * time.Second)
|
||||
from = time.Date(
|
||||
from.Year(),
|
||||
from.Month(),
|
||||
from.Day()+aggregateIntervalDays,
|
||||
0, 0, 0, 0,
|
||||
from.Location(),
|
||||
)
|
||||
|
||||
// Iteratively aggregate per-day summaries until end of yesterday is reached
|
||||
end := getStartOfToday().Add(-1 * time.Second)
|
||||
|
||||
if lastAggregation.Hour() == 0 {
|
||||
from = lastAggregation
|
||||
} else {
|
||||
from = time.Date(
|
||||
lastAggregation.Year(),
|
||||
lastAggregation.Month(),
|
||||
lastAggregation.Day()+aggregateIntervalDays,
|
||||
0, 0, 0, 0,
|
||||
lastAggregation.Location(),
|
||||
)
|
||||
}
|
||||
|
||||
for from.Before(end) && to.Before(end) {
|
||||
to = time.Date(
|
||||
from.Year(),
|
||||
|
@ -1,50 +1,49 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"sync"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
type AliasService struct {
|
||||
Config *config.Config
|
||||
Db *gorm.DB
|
||||
config *config.Config
|
||||
repository *repositories.AliasRepository
|
||||
}
|
||||
|
||||
func NewAliasService(db *gorm.DB) *AliasService {
|
||||
func NewAliasService(aliasRepo *repositories.AliasRepository) *AliasService {
|
||||
return &AliasService{
|
||||
Config: config.Get(),
|
||||
Db: db,
|
||||
config: config.Get(),
|
||||
repository: aliasRepo,
|
||||
}
|
||||
}
|
||||
|
||||
var userAliases sync.Map
|
||||
|
||||
func (srv *AliasService) LoadUserAliases(userId string) error {
|
||||
var aliases []*models.Alias
|
||||
if err := srv.Db.
|
||||
Where(&models.Alias{UserID: userId}).
|
||||
Find(&aliases).Error; err != nil {
|
||||
return err
|
||||
aliases, err := srv.repository.GetByUser(userId)
|
||||
if err == nil {
|
||||
userAliases.Store(userId, aliases)
|
||||
}
|
||||
|
||||
userAliases.Store(userId, aliases)
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
|
||||
if ua, ok := userAliases.Load(userId); ok {
|
||||
for _, a := range ua.([]*models.Alias) {
|
||||
if a.Type == summaryType && a.Value == value {
|
||||
return a.Key, nil
|
||||
}
|
||||
if !srv.IsInitialized(userId) {
|
||||
if err := srv.LoadUserAliases(userId); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
return "", errors.New("user aliases not initialized")
|
||||
|
||||
aliases, _ := userAliases.Load(userId)
|
||||
for _, a := range aliases.([]*models.Alias) {
|
||||
if a.Type == summaryType && a.Value == value {
|
||||
return a.Key, nil
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (srv *AliasService) IsInitialized(userId string) bool {
|
||||
|
@ -1,95 +1,56 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/jasonlvhit/gocron"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"log"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/models"
|
||||
gormbulk "github.com/t-tiger/gorm-bulk-insert"
|
||||
)
|
||||
|
||||
const (
|
||||
TableHeartbeat = "heartbeat"
|
||||
cleanUpInterval = time.Duration(aggregateIntervalDays) * 2 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type HeartbeatService struct {
|
||||
Config *config.Config
|
||||
Db *gorm.DB
|
||||
config *config.Config
|
||||
repository *repositories.HeartbeatRepository
|
||||
languageMappingSrvc *LanguageMappingService
|
||||
}
|
||||
|
||||
func NewHeartbeatService(db *gorm.DB) *HeartbeatService {
|
||||
func NewHeartbeatService(heartbeatRepo *repositories.HeartbeatRepository, languageMappingService *LanguageMappingService) *HeartbeatService {
|
||||
return &HeartbeatService{
|
||||
Config: config.Get(),
|
||||
Db: db,
|
||||
config: config.Get(),
|
||||
repository: heartbeatRepo,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
var batch []interface{}
|
||||
for _, h := range heartbeats {
|
||||
batch = append(batch, *h)
|
||||
}
|
||||
|
||||
if err := gormbulk.BulkInsert(srv.Db, batch, 3000); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return srv.repository.InsertBatch(heartbeats)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := srv.Db.
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Where("time >= ?", from).
|
||||
Where("time <= ?", to).
|
||||
Order("time asc").
|
||||
Find(&heartbeats).Error; err != nil {
|
||||
heartbeats, err := srv.repository.GetAllWithin(from, to, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
return srv.augmented(heartbeats, user.ID)
|
||||
}
|
||||
|
||||
// Will return *models.Heartbeat object with only user_id and time fields filled
|
||||
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := srv.Db.
|
||||
Table("heartbeats").
|
||||
Select("user_id, min(time) as time").
|
||||
Where("user_id IN (?)", userIds).
|
||||
Group("user_id").
|
||||
Scan(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
return srv.repository.GetFirstByUsers()
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||
if err := srv.Db.
|
||||
Where("time <= ?", t).
|
||||
Delete(models.Heartbeat{}).Error; err != nil {
|
||||
return err
|
||||
return srv.repository.DeleteBefore(t)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
|
||||
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) CleanUp() error {
|
||||
refTime := utils.StartOfToday().Add(-cleanUpInterval)
|
||||
if err := srv.DeleteBefore(refTime); err != nil {
|
||||
log.Printf("Failed to clean up heartbeats older than %v – %v\n", refTime, err)
|
||||
return err
|
||||
for i := range heartbeats {
|
||||
heartbeats[i].Augment(languageMapping)
|
||||
}
|
||||
log.Printf("Successfully cleaned up heartbeats older than %v\n", refTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) ScheduleCleanUp() {
|
||||
srv.CleanUp()
|
||||
|
||||
gocron.Every(1).Day().At("02:30").Do(srv.CleanUp)
|
||||
<-gocron.Start()
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
@ -1,63 +1,31 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
)
|
||||
|
||||
type KeyValueService struct {
|
||||
Config *config.Config
|
||||
Db *gorm.DB
|
||||
config *config.Config
|
||||
repository *repositories.KeyValueRepository
|
||||
}
|
||||
|
||||
func NewKeyValueService(db *gorm.DB) *KeyValueService {
|
||||
func NewKeyValueService(keyValueRepo *repositories.KeyValueRepository) *KeyValueService {
|
||||
return &KeyValueService{
|
||||
Config: config.Get(),
|
||||
Db: db,
|
||||
config: config.Get(),
|
||||
repository: keyValueRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error) {
|
||||
kv := &models.KeyStringValue{}
|
||||
if err := srv.Db.
|
||||
Where(&models.KeyStringValue{Key: key}).
|
||||
First(&kv).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kv, nil
|
||||
return srv.repository.GetString(key)
|
||||
}
|
||||
|
||||
func (srv *KeyValueService) PutString(kv *models.KeyStringValue) error {
|
||||
result := srv.Db.
|
||||
Where(&models.KeyStringValue{Key: kv.Key}).
|
||||
Assign(kv).
|
||||
FirstOrCreate(kv)
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return nil
|
||||
return srv.repository.PutString(kv)
|
||||
}
|
||||
|
||||
func (srv *KeyValueService) DeleteString(key string) error {
|
||||
result := srv.Db.
|
||||
Delete(&models.KeyStringValue{}, &models.KeyStringValue{Key: key})
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("nothing deleted")
|
||||
}
|
||||
|
||||
return nil
|
||||
return srv.repository.DeleteString(key)
|
||||
}
|
||||
|
73
services/language_mapping.go
Normal file
73
services/language_mapping.go
Normal file
@ -0,0 +1,73 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LanguageMappingService struct {
|
||||
config *config.Config
|
||||
repository *repositories.LanguageMappingRepository
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
func NewLanguageMappingService(languageMappingsRepo *repositories.LanguageMappingRepository) *LanguageMappingService {
|
||||
return &LanguageMappingService{
|
||||
config: config.Get(),
|
||||
repository: languageMappingsRepo,
|
||||
cache: cache.New(1*time.Hour, 2*time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) GetById(id uint) (*models.LanguageMapping, error) {
|
||||
return srv.repository.GetById(id)
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) GetByUser(userId string) ([]*models.LanguageMapping, error) {
|
||||
if mappings, found := srv.cache.Get(userId); found {
|
||||
return mappings.([]*models.LanguageMapping), nil
|
||||
}
|
||||
|
||||
mappings, err := srv.repository.GetByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srv.cache.Set(userId, mappings, cache.DefaultExpiration)
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) ResolveByUser(userId string) (map[string]string, error) {
|
||||
mappings := srv.getServerMappings()
|
||||
userMappings, err := srv.GetByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range userMappings {
|
||||
mappings[m.Extension] = m.Language
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) Create(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
|
||||
result, err := srv.repository.Insert(mapping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srv.cache.Delete(result.UserID)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (srv *LanguageMappingService) Delete(mapping *models.LanguageMapping) error {
|
||||
err := srv.repository.Delete(mapping.ID)
|
||||
srv.cache.Delete(mapping.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv LanguageMappingService) getServerMappings() map[string]string {
|
||||
return srv.config.App.CustomLanguages
|
||||
}
|
@ -4,87 +4,128 @@ import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
const HeartbeatDiffThreshold = 2 * time.Minute
|
||||
|
||||
type SummaryService struct {
|
||||
Config *config.Config
|
||||
Cache *cache.Cache
|
||||
Db *gorm.DB
|
||||
HeartbeatService *HeartbeatService
|
||||
AliasService *AliasService
|
||||
config *config.Config
|
||||
cache *cache.Cache
|
||||
repository *repositories.SummaryRepository
|
||||
heartbeatService *HeartbeatService
|
||||
aliasService *AliasService
|
||||
}
|
||||
|
||||
func NewSummaryService(db *gorm.DB, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
|
||||
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
|
||||
|
||||
func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
|
||||
return &SummaryService{
|
||||
Config: config.Get(),
|
||||
Cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
Db: db,
|
||||
HeartbeatService: heartbeatService,
|
||||
AliasService: aliasService,
|
||||
config: config.Get(),
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
repository: summaryRepo,
|
||||
heartbeatService: heartbeatService,
|
||||
aliasService: aliasService,
|
||||
}
|
||||
}
|
||||
|
||||
type Interval struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
// Public summary generation methods
|
||||
|
||||
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
|
||||
var existingSummaries []*models.Summary
|
||||
var cacheKey string
|
||||
|
||||
if recompute {
|
||||
existingSummaries = make([]*models.Summary, 0)
|
||||
} else {
|
||||
cacheKey = getHash([]time.Time{from, to}, user)
|
||||
if result, ok := srv.Cache.Get(cacheKey); ok {
|
||||
return result.(*models.Summary), nil
|
||||
}
|
||||
summaries, err := srv.GetByUserWithin(user, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existingSummaries = summaries
|
||||
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever) (*models.Summary, error) {
|
||||
// Check cache
|
||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
||||
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||
return cacheResult.(*models.Summary), nil
|
||||
}
|
||||
|
||||
missingIntervals := getMissingIntervals(from, to, existingSummaries)
|
||||
// Wrap alias resolution
|
||||
resolve := func(t uint8, k string) string {
|
||||
s, _ := srv.aliasService.GetAliasOrDefault(user.ID, t, k)
|
||||
return s
|
||||
}
|
||||
|
||||
heartbeats := make([]*models.Heartbeat, 0)
|
||||
// Initialize alias resolver service
|
||||
if err := srv.aliasService.LoadUserAliases(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get actual summary
|
||||
s, err := f(from, to, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Post-process summary and cache it
|
||||
summary := s.WithResolvedAliases(resolve)
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||
// Check cache
|
||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
||||
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||
return cacheResult.(*models.Summary), nil
|
||||
}
|
||||
|
||||
// Get all already existing, pre-generated summaries that fall into the requested interval
|
||||
summaries, err := srv.repository.GetByUserWithin(user, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate missing slots (especially before and after existing summaries) from raw heartbeats
|
||||
missingIntervals := srv.getMissingIntervals(from, to, summaries)
|
||||
for _, interval := range missingIntervals {
|
||||
hb, err := srv.HeartbeatService.GetAllWithin(interval.Start, interval.End, user)
|
||||
if err != nil {
|
||||
if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil {
|
||||
summaries = append(summaries, s)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
heartbeats = append(heartbeats, hb...)
|
||||
}
|
||||
|
||||
// Merge existing and newly generated summary snippets
|
||||
summary, err := srv.mergeSummaries(summaries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache 'em
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||
// Initialize and fetch data
|
||||
var heartbeats models.Heartbeats
|
||||
if rawHeartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user); err == nil {
|
||||
heartbeats = rawHeartbeats
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
types := models.SummaryTypes()
|
||||
|
||||
typedAggregations := make(chan models.SummaryItemContainer)
|
||||
defer close(typedAggregations)
|
||||
for _, t := range types {
|
||||
go srv.aggregateBy(heartbeats, t, typedAggregations)
|
||||
}
|
||||
|
||||
// Aggregate raw heartbeats by types in parallel and collect them
|
||||
var projectItems []*models.SummaryItem
|
||||
var languageItems []*models.SummaryItem
|
||||
var editorItems []*models.SummaryItem
|
||||
var osItems []*models.SummaryItem
|
||||
var machineItems []*models.SummaryItem
|
||||
|
||||
if err := srv.AliasService.LoadUserAliases(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := make(chan models.SummaryItemContainer)
|
||||
for _, t := range types {
|
||||
go srv.aggregateBy(heartbeats, t, user, c)
|
||||
}
|
||||
|
||||
for i := 0; i < len(types); i++ {
|
||||
item := <-c
|
||||
item := <-typedAggregations
|
||||
switch item.Type {
|
||||
case models.SummaryProject:
|
||||
projectItems = item.Items
|
||||
@ -98,31 +139,16 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
machineItems = item.Items
|
||||
}
|
||||
}
|
||||
close(c)
|
||||
|
||||
realFrom, realTo := from, to
|
||||
if len(existingSummaries) > 0 {
|
||||
realFrom = existingSummaries[0].FromTime
|
||||
realTo = existingSummaries[len(existingSummaries)-1].ToTime
|
||||
|
||||
for _, summary := range existingSummaries {
|
||||
summary.FillUnknown()
|
||||
}
|
||||
}
|
||||
if len(heartbeats) > 0 {
|
||||
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
|
||||
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
|
||||
realFrom = t1
|
||||
}
|
||||
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
|
||||
realTo = t2
|
||||
}
|
||||
if heartbeats.Len() > 0 {
|
||||
from = time.Time(heartbeats.First().Time)
|
||||
to = time.Time(heartbeats.Last().Time)
|
||||
}
|
||||
|
||||
aggregatedSummary := &models.Summary{
|
||||
summary := &models.Summary{
|
||||
UserID: user.ID,
|
||||
FromTime: realFrom,
|
||||
ToTime: realTo,
|
||||
FromTime: models.CustomTime(from),
|
||||
ToTime: models.CustomTime(to),
|
||||
Projects: projectItems,
|
||||
Languages: languageItems,
|
||||
Editors: editorItems,
|
||||
@ -130,84 +156,32 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
Machines: machineItems,
|
||||
}
|
||||
|
||||
allSummaries := []*models.Summary{aggregatedSummary}
|
||||
allSummaries = append(allSummaries, existingSummaries...)
|
||||
|
||||
summary, err := mergeSummaries(allSummaries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cacheKey != "" {
|
||||
srv.Cache.SetDefault(cacheKey, summary)
|
||||
}
|
||||
summary.FillUnknown()
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// CRUD methods
|
||||
|
||||
func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
|
||||
return srv.repository.GetLastByUser()
|
||||
}
|
||||
|
||||
func (srv *SummaryService) DeleteByUser(userId string) error {
|
||||
return srv.repository.DeleteByUser(userId)
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
||||
if err := srv.Db.Create(summary).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return srv.repository.Insert(summary)
|
||||
}
|
||||
|
||||
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := srv.Db.
|
||||
Where(&models.Summary{UserID: user.ID}).
|
||||
Where("from_time >= ?", from).
|
||||
Where("to_time <= ?", to).
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
// Private summary generation and utility methods
|
||||
|
||||
// Will return *models.Index objects with only user_id and to_time filled
|
||||
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := srv.Db.
|
||||
Table("summaries").
|
||||
Select("user_id, max(to_time) as to_time").
|
||||
Group("user_id").
|
||||
Scan(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, user *models.User, c chan models.SummaryItemContainer) {
|
||||
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) {
|
||||
durations := make(map[string]time.Duration)
|
||||
|
||||
for i, h := range heartbeats {
|
||||
var key string
|
||||
switch summaryType {
|
||||
case models.SummaryProject:
|
||||
key = h.Project
|
||||
case models.SummaryEditor:
|
||||
key = h.Editor
|
||||
case models.SummaryLanguage:
|
||||
key = h.Language
|
||||
case models.SummaryOS:
|
||||
key = h.OperatingSystem
|
||||
case models.SummaryMachine:
|
||||
key = h.Machine
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
key = models.UnknownSummaryKey
|
||||
}
|
||||
|
||||
if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil {
|
||||
key = aliasedKey
|
||||
}
|
||||
key := h.GetKey(summaryType)
|
||||
|
||||
if _, ok := durations[key]; !ok {
|
||||
durations[key] = time.Duration(0)
|
||||
@ -217,9 +191,16 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
||||
continue
|
||||
}
|
||||
|
||||
timePassed := h.Time.Time().Sub(heartbeats[i-1].Time.Time())
|
||||
timeThresholded := math.Min(float64(timePassed), float64(time.Duration(2)*time.Minute))
|
||||
durations[key] += time.Duration(int64(timeThresholded))
|
||||
t1, t2, tdiff := h.Time.T(), heartbeats[i-1].Time.T(), time.Duration(0)
|
||||
// This is a hack. The time difference between two heartbeats from two subsequent day (e.g. 23:59:59 and 00:00:01) are ignored.
|
||||
// This is to prevent a discrepancy between summaries computed solely from heartbeats and summaries involving pre-aggregated per-day summaries.
|
||||
// For the latter, a duration is already pre-computed and information about individual heartbeats is lost, so there can be no cross-day overflow.
|
||||
// Essentially, we simply ignore such edge-case heartbeats here, which makes the eventual total duration potentially a bit shorter.
|
||||
if t1.Day() == t2.Day() {
|
||||
timePassed := t1.Sub(t2)
|
||||
tdiff = time.Duration(int64(math.Min(float64(timePassed), float64(HeartbeatDiffThreshold))))
|
||||
}
|
||||
durations[key] += tdiff
|
||||
}
|
||||
|
||||
items := make([]*models.SummaryItem, 0)
|
||||
@ -238,43 +219,7 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
||||
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
||||
}
|
||||
|
||||
func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary) []*Interval {
|
||||
if len(existingSummaries) == 0 {
|
||||
return []*Interval{{from, to}}
|
||||
}
|
||||
|
||||
intervals := make([]*Interval, 0)
|
||||
|
||||
// Pre
|
||||
if from.Before(existingSummaries[0].FromTime) {
|
||||
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime})
|
||||
}
|
||||
|
||||
// Between
|
||||
for i := 0; i < len(existingSummaries)-1; i++ {
|
||||
t1, t2 := existingSummaries[i].ToTime, existingSummaries[i+1].FromTime
|
||||
if t1.Equal(t2) {
|
||||
continue
|
||||
}
|
||||
|
||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||
// one or more day missing in between?
|
||||
if td1.Before(td2) {
|
||||
intervals = append(intervals, &Interval{existingSummaries[i].ToTime, existingSummaries[i+1].FromTime})
|
||||
}
|
||||
}
|
||||
|
||||
// Post
|
||||
if to.After(existingSummaries[len(existingSummaries)-1].ToTime) {
|
||||
intervals = append(intervals, &Interval{to, existingSummaries[len(existingSummaries)-1].ToTime})
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
if len(summaries) < 1 {
|
||||
return nil, errors.New("no summaries given")
|
||||
}
|
||||
@ -296,28 +241,28 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
return nil, errors.New("users don't match")
|
||||
}
|
||||
|
||||
if s.FromTime.Before(minTime) {
|
||||
minTime = s.FromTime
|
||||
if s.FromTime.T().Before(minTime) {
|
||||
minTime = s.FromTime.T()
|
||||
}
|
||||
|
||||
if s.ToTime.After(maxTime) {
|
||||
maxTime = s.ToTime
|
||||
if s.ToTime.T().After(maxTime) {
|
||||
maxTime = s.ToTime.T()
|
||||
}
|
||||
|
||||
finalSummary.Projects = mergeSummaryItems(finalSummary.Projects, s.Projects)
|
||||
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages)
|
||||
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||
finalSummary.Projects = srv.mergeSummaryItems(finalSummary.Projects, s.Projects)
|
||||
finalSummary.Languages = srv.mergeSummaryItems(finalSummary.Languages, s.Languages)
|
||||
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||
}
|
||||
|
||||
finalSummary.FromTime = minTime
|
||||
finalSummary.ToTime = maxTime
|
||||
finalSummary.FromTime = models.CustomTime(minTime)
|
||||
finalSummary.ToTime = models.CustomTime(maxTime)
|
||||
|
||||
return finalSummary, nil
|
||||
}
|
||||
|
||||
func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
|
||||
func (srv *SummaryService) mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
|
||||
items := make(map[string]*models.SummaryItem)
|
||||
|
||||
// Build map from existing
|
||||
@ -347,11 +292,46 @@ func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem
|
||||
return itemList
|
||||
}
|
||||
|
||||
func getHash(times []time.Time, user *models.User) string {
|
||||
digest := md5.New()
|
||||
for _, t := range times {
|
||||
digest.Write([]byte(strconv.Itoa(int(t.Unix()))))
|
||||
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
|
||||
if len(summaries) == 0 {
|
||||
return []*models.Interval{{from, to}}
|
||||
}
|
||||
|
||||
intervals := make([]*models.Interval, 0)
|
||||
|
||||
// Pre
|
||||
if from.Before(summaries[0].FromTime.T()) {
|
||||
intervals = append(intervals, &models.Interval{from, summaries[0].FromTime.T()})
|
||||
}
|
||||
|
||||
// Between
|
||||
for i := 0; i < len(summaries)-1; i++ {
|
||||
t1, t2 := summaries[i].ToTime.T(), summaries[i+1].FromTime.T()
|
||||
if t1.Equal(t2) {
|
||||
continue
|
||||
}
|
||||
|
||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||
// one or more day missing in between?
|
||||
if td1.Before(td2) {
|
||||
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
|
||||
}
|
||||
}
|
||||
|
||||
// Post
|
||||
if to.After(summaries[len(summaries)-1].ToTime.T()) {
|
||||
intervals = append(intervals, &models.Interval{summaries[len(summaries)-1].ToTime.T(), to})
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
func (srv *SummaryService) getHash(args ...string) string {
|
||||
digest := md5.New()
|
||||
for _, a := range args {
|
||||
digest.Write([]byte(a))
|
||||
}
|
||||
digest.Write([]byte(user.ID))
|
||||
return string(digest.Sum(nil))
|
||||
}
|
||||
|
@ -1,50 +1,35 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/utils"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
Config *config.Config
|
||||
Db *gorm.DB
|
||||
Config *config.Config
|
||||
repository *repositories.UserRepository
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB) *UserService {
|
||||
func NewUserService(userRepo *repositories.UserRepository) *UserService {
|
||||
return &UserService{
|
||||
Config: config.Get(),
|
||||
Db: db,
|
||||
Config: config.Get(),
|
||||
repository: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *UserService) GetUserById(userId string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := srv.Db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
return srv.repository.GetById(userId)
|
||||
}
|
||||
|
||||
func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := srv.Db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
return srv.repository.GetByApiKey(key)
|
||||
}
|
||||
|
||||
func (srv *UserService) GetAll() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := srv.Db.
|
||||
Table("users").
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
return srv.repository.GetAll()
|
||||
}
|
||||
|
||||
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
|
||||
@ -54,33 +39,17 @@ func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool,
|
||||
Password: signup.Password,
|
||||
}
|
||||
|
||||
if err := utils.HashPassword(u, srv.Config.Security.PasswordSalt); err != nil {
|
||||
if hash, err := utils.HashBcrypt(u.Password, srv.Config.Security.PasswordSalt); err != nil {
|
||||
return nil, false, err
|
||||
} else {
|
||||
u.Password = hash
|
||||
}
|
||||
|
||||
result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID})
|
||||
if err := result.Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if result.RowsAffected == 1 {
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
return u, false, nil
|
||||
return srv.repository.InsertOrGet(u)
|
||||
}
|
||||
|
||||
func (srv *UserService) Update(user *models.User) (*models.User, error) {
|
||||
result := srv.Db.Model(&models.User{}).Updates(user)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
return srv.repository.Update(user)
|
||||
}
|
||||
|
||||
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
@ -89,30 +58,15 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
}
|
||||
|
||||
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
|
||||
result := srv.Db.Model(user).Update("badges_enabled", !user.BadgesEnabled)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
return srv.repository.UpdateField(user, "badges_enabled", !user.BadgesEnabled)
|
||||
}
|
||||
|
||||
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
||||
user.Password = login.Password
|
||||
if err := utils.HashPassword(user, srv.Config.Security.PasswordSalt); err != nil {
|
||||
if hash, err := utils.HashBcrypt(user.Password, srv.Config.Security.PasswordSalt); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
user.Password = hash
|
||||
}
|
||||
|
||||
result := srv.Db.Model(user).Update("password", user.Password)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
} else if result.RowsAffected < 1 {
|
||||
return nil, errors.New("nothing changes")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
return srv.repository.UpdateField(user, "password", user.Password)
|
||||
}
|
||||
|
@ -203,7 +203,7 @@ function togglePlaceholders(mask) {
|
||||
}
|
||||
|
||||
function getPresentDataMask() {
|
||||
return data.map(list => list.reduce((acc, e) => acc + e.total, 0) > 0)
|
||||
return data.map(list => list ? list.reduce((acc, e) => acc + e.total, 0) : 0 > 0)
|
||||
}
|
||||
|
||||
function getContainer(chart) {
|
||||
@ -242,8 +242,8 @@ function equalizeHeights() {
|
||||
})
|
||||
}
|
||||
|
||||
function getTotal(data) {
|
||||
let total = data.reduce((acc, d) => acc + d.total, 0)
|
||||
function getTotal(items) {
|
||||
let total = items.reduce((acc, d) => acc + d.total, 0)
|
||||
document.getElementById('total-span').innerText = total.toString().toHHMMSS()
|
||||
}
|
||||
|
||||
@ -303,4 +303,5 @@ window.addEventListener('load', function () {
|
||||
setTopLabels()
|
||||
togglePlaceholders(getPresentDataMask())
|
||||
draw()
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -63,28 +63,29 @@ func IsMd5(hash string) bool {
|
||||
return md5Regex.Match([]byte(hash))
|
||||
}
|
||||
|
||||
func CheckPasswordBcrypt(user *models.User, password, salt string) bool {
|
||||
plainPassword := []byte(strings.TrimSpace(password) + salt)
|
||||
err := bcrypt.CompareHashAndPassword([]byte(user.Password), plainPassword)
|
||||
func CompareBcrypt(wanted, actual, pepper string) bool {
|
||||
plainPassword := []byte(strings.TrimSpace(actual) + pepper)
|
||||
err := bcrypt.CompareHashAndPassword([]byte(wanted), plainPassword)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// deprecated, only here for backwards compatibility
|
||||
func CheckPasswordMd5(user *models.User, password string) bool {
|
||||
hash := md5.Sum([]byte(password))
|
||||
hashStr := hex.EncodeToString(hash[:])
|
||||
if hashStr == user.Password {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
func CompareMd5(wanted, actual, pepper string) bool {
|
||||
return HashMd5(actual, pepper) == wanted
|
||||
}
|
||||
|
||||
// inplace
|
||||
func HashPassword(u *models.User, salt string) error {
|
||||
plainSaltedPassword := []byte(strings.TrimSpace(u.Password) + salt)
|
||||
bytes, err := bcrypt.GenerateFromPassword(plainSaltedPassword, bcrypt.DefaultCost)
|
||||
func HashBcrypt(plain, pepper string) (string, error) {
|
||||
plainPepperedPassword := []byte(strings.TrimSpace(plain) + pepper)
|
||||
bytes, err := bcrypt.GenerateFromPassword(plainPepperedPassword, bcrypt.DefaultCost)
|
||||
if err == nil {
|
||||
u.Password = string(bytes)
|
||||
return string(bytes), nil
|
||||
}
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
func HashMd5(plain, pepper string) string {
|
||||
plainPepperedPassword := []byte(strings.TrimSpace(plain) + pepper)
|
||||
hash := md5.Sum(plainPepperedPassword)
|
||||
hashStr := hex.EncodeToString(hash[:])
|
||||
return hashStr
|
||||
}
|
||||
|
@ -2,8 +2,6 @@ package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/config"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
@ -28,41 +26,3 @@ func ParseUserAgent(ua string) (string, string, error) {
|
||||
}
|
||||
return groups[0][1], groups[0][2], nil
|
||||
}
|
||||
|
||||
func MakeConnectionString(config *config.Config) string {
|
||||
switch config.Db.Dialect {
|
||||
case "mysql":
|
||||
return mysqlConnectionString(config)
|
||||
case "postgres":
|
||||
return postgresConnectionString(config)
|
||||
case "sqlite3":
|
||||
return sqliteConnectionString(config)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mysqlConnectionString(config *config.Config) string {
|
||||
//location, _ := time.LoadLocation("Local")
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
config.Db.User,
|
||||
config.Db.Password,
|
||||
config.Db.Host,
|
||||
config.Db.Port,
|
||||
config.Db.Name,
|
||||
"Local",
|
||||
)
|
||||
}
|
||||
|
||||
func postgresConnectionString(config *config.Config) string {
|
||||
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
|
||||
config.Db.Host,
|
||||
config.Db.Port,
|
||||
config.Db.User,
|
||||
config.Db.Name,
|
||||
config.Db.Password,
|
||||
)
|
||||
}
|
||||
|
||||
func sqliteConnectionString(config *config.Config) string {
|
||||
return config.Db.Name
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@ -9,7 +10,7 @@ func RespondJSON(w http.ResponseWriter, status int, object interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(object); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
log.Printf("error while writing json response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1 +1 @@
|
||||
1.12.1
|
||||
1.16.2
|
||||
|
@ -1,6 +1,6 @@
|
||||
<footer class="flex justify-between w-full text-center text-gray-500 text-xs mt-12">
|
||||
<div class="text-xs font-mono">
|
||||
v{{ getVersion }}
|
||||
v{{ getVersion }} @ {{ getDbType }}
|
||||
</div>
|
||||
<div>
|
||||
Made with 🤍 by <a href="https://muetsch.io" class="border-b border-green-700">Ferdinand Mütsch</a> as <a
|
||||
|
@ -1,4 +1,5 @@
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
@ -47,7 +48,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Reset API Key
|
||||
</div>
|
||||
@ -65,7 +66,60 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Language Mappings
|
||||
</div>
|
||||
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">
|
||||
You can specify custom mapping from file extensions to programming languages (e.g. a <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">.jsx</span> file could be mapped to <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">React</span>.
|
||||
</div>
|
||||
|
||||
{{ if .LanguageMappings }}
|
||||
{{ range $i, $mapping := .LanguageMappings }}
|
||||
<div class="text-white border-1 w-full border-green-700 inline-block my-1 py-1 text-align">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" >When filename ends in:</label>
|
||||
{{ $mapping.Extension }}
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" >Change the language to:</label>
|
||||
{{ $mapping.Language }}
|
||||
|
||||
<form class="float-right" action="settings/language_mappings/delete" method="post">
|
||||
<input type="hidden" id="mapping_id" name="mapping_id" required value="{{ $mapping.ID }}">
|
||||
<button type="submit" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="text-white border-1 w-full border-green-700 inline-block my-1 py-1">
|
||||
No rules.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="settings/language_mappings" method="post">
|
||||
<div class="inline-block justify-around mt-4 w-full">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="extension">When filename ends in:</label>
|
||||
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||
type="text" id="extension"
|
||||
name="extension" placeholder=".py" minlength="1" required>
|
||||
</div>
|
||||
<div class="inline-block justify-around mt-4 w-full">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="language">Change the language to:</label>
|
||||
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||
type="text" id="language"
|
||||
name="language" placeholder="Python" minlength="1" required>
|
||||
</div>
|
||||
<div class="flex justify-between float-right">
|
||||
<button type="submit" class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Badges
|
||||
</div>
|
||||
@ -86,7 +140,7 @@
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="flex justify-between my-2">
|
||||
<div>
|
||||
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today"/>
|
||||
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today" alt="Shields.io badge"/>
|
||||
</div>
|
||||
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
|
||||
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today
|
||||
@ -94,7 +148,7 @@
|
||||
</div>
|
||||
<div class="flex justify-between my-2">
|
||||
<div>
|
||||
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d"/>
|
||||
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d" alt="Shields.io badge"/>
|
||||
</div>
|
||||
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
|
||||
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d
|
||||
@ -104,7 +158,7 @@
|
||||
|
||||
<p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span> to the URL to filter by project.</p>
|
||||
{{ else }}
|
||||
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
|
||||
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800" rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
|
||||
<div class="flex justify-around mt-4">
|
||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
||||
<button type="submit" class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs" title="Make endpoint public to enable badges">
|
||||
@ -115,6 +169,34 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
⚠️ Danger Zone
|
||||
</div>
|
||||
<div class="mt-10 text-gray-300 text-sm">
|
||||
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
|
||||
Regenerate summaries
|
||||
</h3>
|
||||
<p>
|
||||
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to summaries on a per-day basis.
|
||||
That is, historic summaries, i.e. such from past days, are generated once and only fetched from the database in a static fashion afterwards, unless you pass <span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span> with your request.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g. you modified language mappings retrospectively), you may want to re-generate them from raw heartbeats.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost is case heartbeats were deleted after the respective summaries had been generated.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-10 flex justify-center">
|
||||
<form action="settings/regenerate" method="post" id="form-regenerate-summaries">
|
||||
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm" id="btn-regenerate-summaries">
|
||||
Clear & Regenerate
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -128,6 +210,14 @@
|
||||
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
|
||||
e.classList.remove('hidden')
|
||||
})
|
||||
|
||||
const btnRegenerate = document.querySelector("#btn-regenerate-summaries")
|
||||
const formRegenerate = document.querySelector('#form-regenerate-summaries')
|
||||
btnRegenerate.addEventListener('click', () => {
|
||||
if (confirm('Are you sure?')) {
|
||||
formRegenerate.submit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
@ -135,4 +225,5 @@
|
||||
{{ template "foot.tpl.html" . }}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
@ -19,9 +20,11 @@
|
||||
<p class="text-sm text-gray-300">
|
||||
💡 In order to use Wakapi, you need to create an account.
|
||||
After successful signup, you still need to set up the <a href="https://wakatime.com" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-b border-green-700">WakaTime</a>
|
||||
client tools.
|
||||
Please refer to <a href="https://github.com/muety/wakapi#client-setup" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-b border-green-700">this readme section</a> for instructions.
|
||||
You will be able to view you <strong>API Key</strong> once you log in.
|
||||
</p>
|
||||
|
@ -1,4 +1,5 @@
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
@ -54,8 +55,8 @@
|
||||
<div class="flex justify-center">
|
||||
<div class="p-1">
|
||||
<div class="flex justify-center p-4 bg-white rounded shadow">
|
||||
<p class="mx-2"><strong>▶️</strong> <span title="Start Time">{{ .FromTime | date }}</span></p>
|
||||
<p class="mx-2"><strong>⏹</strong> <span title="End Time">{{ .ToTime | date }}</span></p>
|
||||
<p class="mx-2"><strong>▶️</strong> <span title="Start Time">{{ .FromTime.T | date }}</span></p>
|
||||
<p class="mx-2"><strong>⏹</strong> <span title="End Time">{{ .ToTime.T | date }}</span></p>
|
||||
<p class="mx-2"><strong>⏱</strong> <span id="total-span" title="Total Hours"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
@ -69,7 +70,7 @@
|
||||
</div>
|
||||
<canvas id="chart-projects"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -82,7 +83,7 @@
|
||||
</div>
|
||||
<canvas id="chart-os"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -95,7 +96,7 @@
|
||||
</div>
|
||||
<canvas id="chart-language"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -108,7 +109,7 @@
|
||||
</div>
|
||||
<canvas id="chart-editor"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -121,7 +122,7 @@
|
||||
</div>
|
||||
<canvas id="chart-machine"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user