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

Compare commits

..

25 Commits

Author SHA1 Message Date
8231d76200 Merge branch '184-fix-time-zone'
# Conflicts:
#	views/settings.tpl.html
2021-04-26 21:28:39 +02:00
c6fd43a964 chore: log requests from json response util method 2021-04-26 21:26:59 +02:00
4ab657ebd5 fix: fix divide by zero (resolve #189) 2021-04-26 21:26:56 +02:00
0a07ac1dd4 docs: document thoughts about time zones 2021-04-25 21:41:41 +02:00
a64201c93b fix: timezone selector 2021-04-25 21:12:36 +02:00
b105b0fe1c chore: version 2021-04-25 21:05:58 +02:00
649c658923 chore: add same date tests 2021-04-25 21:05:05 +02:00
bc9191a514 chore: fix api key on instructions page 2021-04-25 21:05:05 +02:00
04690d287d chore: guess user timezone on signup 2021-04-25 21:05:05 +02:00
c142b525a4 refactor: time zone sensitivity (resolve #184) 2021-04-25 21:05:04 +02:00
304fa3b03f chore: add same date tests 2021-04-25 20:53:17 +02:00
e01e6575db chore: fix api key on instructions page 2021-04-25 20:07:15 +02:00
75e61c0dc3 chore: guess user timezone on signup 2021-04-25 20:02:45 +02:00
6973743f41 refactor: time zone sensitivity (resolve #184) 2021-04-25 14:15:18 +02:00
26ef93c1af chore: minor refactorings to custom time parsing logic 2021-04-25 09:21:21 +02:00
0556efd39a chore: minor tweaks to migration script 2021-04-23 15:50:00 +02:00
030181fb2f sqlitemigrate patches for larger datasets 2021-04-22 23:37:20 +02:00
8b9a9a1a42 fix: merge summaries by unique from date only 2021-04-19 21:14:35 +02:00
6576837396 chore: batch mode for sample data script 2021-04-19 21:01:09 +02:00
1a10a4fb21 fix: prevent duplicate summaries from being counted twice (resolve #179) 2021-04-19 20:48:07 +02:00
0e3ce1e9e4 fix: lock aggregation jobs to one at a time on a per-user basis (resolve #180) 2021-04-19 20:36:37 +02:00
50a54bde22 chore: usage instructions for sqlite migration script [ci-skip] 2021-04-18 11:08:28 +02:00
53f3a9d685 chore: make back button on settings page a relative link 2021-04-18 11:05:59 +02:00
c37278e660 chore: add option to silently fail in case of schema migration errors 2021-04-18 11:03:54 +02:00
e2deadfd44 chore: add experimental sqlite to mysql migration script 2021-04-18 10:59:13 +02:00
46 changed files with 1898 additions and 758 deletions

View File

@ -117,13 +117,14 @@ $ ./wakapi
#### Compile & Run #### Compile & Run
```bash ```bash
# Adapt config to your needs
$ cp config.default.yml config.yml
$ vi config.yml
# Build the executable # Build the executable
$ go build -o wakapi $ go build -o wakapi
# Adapt config to your needs
$ cp config.default.yml config.yml
$ vi config.yml
# Run it # Run it
$ ./wakapi $ ./wakapi
``` ```
@ -175,6 +176,7 @@ You can specify configuration options either via a config file (default: `config
| `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) | | `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
| `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections | | `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
| `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) | | `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
| `db.automgirate_fail_silently` | `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) | | `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
| `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) | | `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yaml) for details | | `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yaml) for details |
@ -182,7 +184,7 @@ You can specify configuration options either via a config file (default: `config
| `sentry.dsn` | `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) | | `sentry.dsn` | `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
| `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing | | `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
| `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry | | `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
| `sentry.sample_rate_heartbats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry | | `sentry.sample_rate_heartbeats` | `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
### Supported databases ### Supported databases
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported. Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.

View File

@ -18,15 +18,16 @@ app:
svelte: Svelte svelte: Svelte
db: db:
host: # leave blank when using sqlite3 host: # leave blank when using sqlite3
port: # leave blank when using sqlite3 port: # leave blank when using sqlite3
user: # leave blank when using sqlite3 user: # leave blank when using sqlite3
password: # leave blank when using sqlite3 password: # leave blank when using sqlite3
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db) name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
dialect: sqlite3 # mysql, postgres, sqlite3 dialect: sqlite3 # mysql, postgres, sqlite3
charset: utf8mb4 # only used for mysql connections charset: utf8mb4 # only used for mysql connections
max_conn: 2 # maximum number of concurrent connections to maintain max_conn: 2 # maximum number of concurrent connections to maintain
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite) ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite)
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
security: security:
password_salt: # CHANGE ! password_salt: # CHANGE !

View File

@ -14,9 +14,6 @@ import (
"github.com/jinzhu/configor" "github.com/jinzhu/configor"
"github.com/muety/wakapi/data" "github.com/muety/wakapi/data"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -82,16 +79,17 @@ type securityConfig struct {
} }
type dbConfig struct { type dbConfig struct {
Host string `env:"WAKAPI_DB_HOST"` Host string `env:"WAKAPI_DB_HOST"`
Port uint `env:"WAKAPI_DB_PORT"` Port uint `env:"WAKAPI_DB_PORT"`
User string `env:"WAKAPI_DB_USER"` User string `env:"WAKAPI_DB_USER"`
Password string `env:"WAKAPI_DB_PASSWORD"` Password string `env:"WAKAPI_DB_PASSWORD"`
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"` Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
Dialect string `yaml:"-"` Dialect string `yaml:"-"`
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"` Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"` Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"` MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"` Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
} }
type serverConfig struct { type serverConfig struct {
@ -176,25 +174,25 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
switch dbDialect { switch dbDialect {
default: default:
return func(db *gorm.DB) error { return func(db *gorm.DB) error {
if err := db.AutoMigrate(&models.User{}); err != nil { if err := db.AutoMigrate(&models.User{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil { if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.Alias{}); err != nil { if err := db.AutoMigrate(&models.Alias{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil { if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.Summary{}); err != nil { if err := db.AutoMigrate(&models.Summary{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil { if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil { if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err return err
} }
return nil return nil
@ -202,56 +200,6 @@ func (c *Config) GetMigrationFunc(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=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.User,
config.Password,
config.Host,
config.Port,
config.Name,
config.Charset,
"Local",
)
}
func postgresConnectionString(config *dbConfig) string {
sslmode := "disable"
if config.Ssl {
sslmode = "require"
}
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
config.Host,
config.Port,
config.User,
config.Name,
config.Password,
sslmode,
)
}
func sqliteConnectionString(config *dbConfig) string {
return config.Name
}
func (c *appConfig) GetCustomLanguages() map[string]string { func (c *appConfig) GetCustomLanguages() map[string]string {
return cloneStringMap(c.CustomLanguages, false) return cloneStringMap(c.CustomLanguages, false)
} }

85
config/db.go Normal file
View File

@ -0,0 +1,85 @@
package config
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
/*
A quick note to myself including some clarifications about time zones.
- There are basically four time zones (at least in case of MySQL):
- User
- Wakapi (host system)
- MySQL server
- MySQL session
- From my understanding, MySQL server tz is only a fallback and can be ignored as long as a connection tz is specified
- All times are currently stored inside TIMESTAMP columns (alternatives would be DATETIME and BIGINT (plain Unix timestamps)
- TIMESTAMP columns, to my understanding, do not keep any time zone information, but only the very time they store
- Setting a session tz will still not cause any conversions to inserted TIMESTAMP, it will only make a difference when running functions like NOW() / CURRENT_TIMESTAMP()
- Query to insert a heartbeat involves, e.g., a `time` value like '2006-01-02 15:04:05-07:00', which doesn't contain time zone information is just saved as is
- However, as long as the Wakapi server always runs in the same time zone, it will always parse these dates the same way (i.e. as time.Local, Europe/Berlin in case of Wakapi.dev)
- Using TIMESTAMP columns would only become problematic when either data needs to be migrated to a Wakapi instance in a different tz or if two consumers in different tzs were reading and writing to the same table
- Wakapi always uses time.Local for everything, i.e. all times in the database have to be interpreted with that tz
- New heartbeats are sent with Python-like Unix timestamps, i.e. are absolute points in time as therefore not subject to any kind of tz issues
- E.g. with Wakapi running in Europe/Berlin, 1619379014.7335322 (2021-04-25T19:30:14.733Z (UTC)) will be inserted as 2021-04-25T21:30:14.733+0200 (CEST), but obviously represents the exact same point in time no matter where it originated from
- The reason why we need to explicitly care about tzs in the first place is the fact that user's can request their data within intervals and the results should correspond to their tz
- Users from California wouldn't have to care about their heartbeats being stored in German time zone
- However, they DO care when requesting their summaries
- A request with `?from=2021-04-25` from California (PST / UTC-7) would ideally have to be translated into a database query like `from >= 2021-04-25T00:00:00+0900)`, assuming that Wakapi runs at CEST (UTC+2)
- This translation comes from either the user explicitly requesting with a specified tz (i.e. sending `from` as ISO8601 / RFC3999) or them having specified a tz in their profile
- Implicit intervals are tricky, too, as they are generated on the server, but still have to respect the user's tz, as `today` is different for a user in Cali and one in Karlsruhe
*/
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=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
config.User,
config.Password,
config.Host,
config.Port,
config.Name,
config.Charset,
"Local",
)
}
func postgresConnectionString(config *dbConfig) string {
sslmode := "disable"
if config.Ssl {
sslmode = "require"
}
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
config.Host,
config.Port,
config.User,
config.Name,
config.Password,
sslmode,
)
}
func sqliteConnectionString(config *dbConfig) string {
return config.Name
}

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,11 @@ type AliasRepositoryMock struct {
mock.Mock mock.Mock
} }
func (m *AliasRepositoryMock) GetAll() ([]*models.Alias, error) {
args := m.Called()
return args.Get(0).([]*models.Alias), args.Error(1)
}
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) { func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
args := m.Called(s) args := m.Called(s)
return args.Get(0).([]*models.Alias), args.Error(1) return args.Get(0).([]*models.Alias), args.Error(1)

View File

@ -15,6 +15,11 @@ func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
return args.Error(0) return args.Error(0)
} }
func (m *SummaryRepositoryMock) GetAll() ([]*models.Summary, error) {
args := m.Called()
return args.Get(0).([]*models.Summary), args.Error(1)
}
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) { func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
args := m.Called(user, time, time2) args := m.Called(user, time, time2)
return args.Get(0).([]*models.Summary), args.Error(1) return args.Get(0).([]*models.Summary), args.Error(1)

View File

@ -2,6 +2,7 @@ package v1
import ( import (
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"math"
"time" "time"
) )
@ -30,6 +31,9 @@ type StatsData struct {
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel { func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
totalTime := summary.TotalTime() totalTime := summary.TotalTime()
numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24) numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24)
if math.IsInf(float64(numDays), 0) {
numDays = 0
}
data := &StatsData{ data := &StatsData{
Username: summary.UserID, Username: summary.UserID,

View File

@ -129,7 +129,6 @@ func newDataFrom(s *models.Summary) *SummariesData {
defer wg.Done() defer wg.Done()
for i, e := range s.Languages { for i, e := range s.Languages {
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage)) data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
} }
}(data) }(data)

View File

@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"gorm.io/gorm" "gorm.io/gorm"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -30,24 +29,24 @@ type Interval struct {
End time.Time End time.Time
} }
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
type CustomTime time.Time type CustomTime time.Time
func (j *CustomTime) MarshalJSON() ([]byte, error) { func (j *CustomTime) MarshalJSON() ([]byte, error) {
return json.Marshal(j.String()) return json.Marshal(j.T())
} }
func (j *CustomTime) UnmarshalJSON(b []byte) error { func (j *CustomTime) UnmarshalJSON(b []byte) error {
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1) s := strings.Trim(string(b), "\"")
i, err := strconv.ParseInt(s, 10, 64) ts, err := strconv.ParseFloat(s, 64)
if err != nil { if err != nil {
return err return err
} }
t := time.Unix(0, i*int64(math.Pow10(19-len(s)))) t := time.Unix(0, int64(ts*1e9)) // ms to ns
*j = CustomTime(t) *j = CustomTime(t)
return nil return nil
} }
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
func (j *CustomTime) Scan(value interface{}) error { func (j *CustomTime) Scan(value interface{}) error {
var ( var (
t time.Time t time.Time
@ -56,13 +55,12 @@ func (j *CustomTime) Scan(value interface{}) error {
switch value.(type) { switch value.(type) {
case string: case string:
// with sqlite, some queries (like GetLastByUser()) return dates as strings,
// however, most of the time they are returned as time.Time
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 { if err != nil {
return errors.New(fmt.Sprintf("unsupported date time format: %s", value)) return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
} }
case int64:
t = time.Unix(0, value.(int64))
break
case time.Time: case time.Time:
t = value.(time.Time) t = value.(time.Time)
break break
@ -76,18 +74,17 @@ func (j *CustomTime) Scan(value interface{}) error {
return nil return nil
} }
func (j *CustomTime) Hash() (uint64, error) {
return uint64((j.T().UnixNano() / 1000) / 1000), nil
}
func (j CustomTime) Value() (driver.Value, error) { func (j CustomTime) Value() (driver.Value, error) {
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
return t, nil return t, nil
} }
func (j *CustomTime) Hash() (uint64, error) {
return uint64((j.T().UnixNano() / 1000) / 1000), nil
}
func (j CustomTime) String() string { func (j CustomTime) String() string {
t := time.Time(j) return j.T().String()
return t.Format("2006-01-02 15:04:05.000")
} }
func (j CustomTime) T() time.Time { func (j CustomTime) T() time.Time {

View File

@ -1,6 +1,9 @@
package models package models
import "regexp" import (
"regexp"
"time"
)
func init() { func init() {
mailRegex = regexp.MustCompile(MailPattern) mailRegex = regexp.MustCompile(MailPattern)
@ -10,6 +13,7 @@ type User struct {
ID string `json:"id" gorm:"primary_key"` ID string `json:"id" gorm:"primary_key"`
ApiKey string `json:"api_key" gorm:"unique"` ApiKey string `json:"api_key" gorm:"unique"`
Email string `json:"email" gorm:"index:idx_user_email; size:255"` Email string `json:"email" gorm:"index:idx_user_email; size:255"`
Location string `json:"location"`
Password string `json:"-"` Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
@ -35,6 +39,7 @@ type Signup struct {
Email string `schema:"email"` Email string `schema:"email"`
Password string `schema:"password"` Password string `schema:"password"`
PasswordRepeat string `schema:"password_repeat"` PasswordRepeat string `schema:"password_repeat"`
Location string `schema:"location"`
} }
type SetPasswordRequest struct { type SetPasswordRequest struct {
@ -54,7 +59,8 @@ type CredentialsReset struct {
} }
type UserDataUpdate struct { type UserDataUpdate struct {
Email string `schema:"email"` Email string `schema:"email"`
Location string `schema:"location"`
} }
type TimeByUser struct { type TimeByUser struct {
@ -67,6 +73,22 @@ type CountByUser struct {
Count int64 Count int64
} }
func (u *User) TZ() *time.Location {
if u.Location == "" {
u.Location = "Local"
}
tz, err := time.LoadLocation(u.Location)
if err != nil {
return time.Local
}
return tz
}
func (u *User) TZOffset() time.Duration {
_, offset := time.Now().In(u.TZ()).Zone()
return time.Duration(offset * int(time.Second))
}
func (c *CredentialsReset) IsValid() bool { func (c *CredentialsReset) IsValid() bool {
return ValidatePassword(c.PasswordNew) && return ValidatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat c.PasswordNew == c.PasswordRepeat
@ -85,7 +107,7 @@ func (s *Signup) IsValid() bool {
} }
func (r *UserDataUpdate) IsValid() bool { func (r *UserDataUpdate) IsValid() bool {
return ValidateEmail(r.Email) return ValidateEmail(r.Email) && ValidateTimezone(r.Location)
} }
func ValidateUsername(username string) bool { func ValidateUsername(username string) bool {
@ -99,3 +121,8 @@ func ValidatePassword(password string) bool {
func ValidateEmail(email string) bool { func ValidateEmail(email string) bool {
return email == "" || mailRegex.Match([]byte(email)) return email == "" || mailRegex.Match([]byte(email))
} }
func ValidateTimezone(tz string) bool {
_, err := time.LoadLocation(tz)
return err == nil
}

19
models/user_test.go Normal file
View File

@ -0,0 +1,19 @@
package models
import (
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestUser_TZ(t *testing.T) {
sut1, sut2 := &User{Location: ""}, &User{Location: "America/Los_Angeles"}
pst, _ := time.LoadLocation("America/Los_Angeles")
_, offset := time.Now().Zone()
assert.Equal(t, time.Local, sut1.TZ())
assert.Equal(t, pst, sut2.TZ())
assert.InDelta(t, time.Duration(offset*int(time.Second)), sut1.TZOffset(), float64(1*time.Second))
assert.InDelta(t, time.Duration(-7*int(time.Hour)), sut2.TZOffset(), float64(1*time.Second))
}

View File

@ -14,6 +14,14 @@ func NewAliasRepository(db *gorm.DB) *AliasRepository {
return &AliasRepository{db: db} return &AliasRepository{db: db}
} }
func (r *AliasRepository) GetAll() ([]*models.Alias, error) {
var aliases []*models.Alias
if err := r.db.Find(&aliases).Error; err != nil {
return nil, err
}
return aliases, nil
}
func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) { func (r *AliasRepository) GetByUser(userId string) ([]*models.Alias, error) {
var aliases []*models.Alias var aliases []*models.Alias
if err := r.db. if err := r.db.

View File

@ -15,6 +15,15 @@ func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
return &HeartbeatRepository{db: db} return &HeartbeatRepository{db: db}
} }
// Use with caution!!
func (r *HeartbeatRepository) GetAll() ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
if err := r.db.Find(&heartbeats).Error; err != nil {
return nil, err
}
return heartbeats, nil
}
func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error { func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error {
if err := r.db. if err := r.db.
Clauses(clause.OnConflict{ Clauses(clause.OnConflict{
@ -45,8 +54,8 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
var heartbeats []*models.Heartbeat var heartbeats []*models.Heartbeat
if err := r.db. if err := r.db.
Where(&models.Heartbeat{UserID: user.ID}). Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from). Where("time >= ?", from.Local()).
Where("time < ?", to). Where("time < ?", to.Local()).
Order("time asc"). Order("time asc").
Find(&heartbeats).Error; err != nil { Find(&heartbeats).Error; err != nil {
return nil, err return nil, err
@ -117,7 +126,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error { func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
if err := r.db. if err := r.db.
Where("time <= ?", t). Where("time <= ?", t.Local()).
Delete(models.Heartbeat{}).Error; err != nil { Delete(models.Heartbeat{}).Error; err != nil {
return err return err
} }

View File

@ -15,6 +15,14 @@ func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
return &KeyValueRepository{db: db} return &KeyValueRepository{db: db}
} }
func (r *KeyValueRepository) GetAll() ([]*models.KeyStringValue, error) {
var keyValues []*models.KeyStringValue
if err := r.db.Find(&keyValues).Error; err != nil {
return nil, err
}
return keyValues, nil
}
func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) { func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, error) {
kv := &models.KeyStringValue{} kv := &models.KeyStringValue{}
if err := r.db. if err := r.db.

View File

@ -16,6 +16,14 @@ func NewLanguageMappingRepository(db *gorm.DB) *LanguageMappingRepository {
return &LanguageMappingRepository{config: config.Get(), db: db} return &LanguageMappingRepository{config: config.Get(), db: db}
} }
func (r *LanguageMappingRepository) GetAll() ([]*models.LanguageMapping, error) {
var mappings []*models.LanguageMapping
if err := r.db.Find(&mappings).Error; err != nil {
return nil, err
}
return mappings, nil
}
func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) { func (r *LanguageMappingRepository) GetById(id uint) (*models.LanguageMapping, error) {
mapping := &models.LanguageMapping{} mapping := &models.LanguageMapping{}
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil { if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {

View File

@ -9,6 +9,7 @@ type IAliasRepository interface {
Insert(*models.Alias) (*models.Alias, error) Insert(*models.Alias) (*models.Alias, error)
Delete(uint) error Delete(uint) error
DeleteBatch([]uint) error DeleteBatch([]uint) error
GetAll() ([]*models.Alias, error)
GetByUser(string) ([]*models.Alias, error) GetByUser(string) ([]*models.Alias, error)
GetByUserAndKey(string, string) ([]*models.Alias, error) GetByUserAndKey(string, string) ([]*models.Alias, error)
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error) GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
@ -17,6 +18,7 @@ type IAliasRepository interface {
type IHeartbeatRepository interface { type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error InsertBatch([]*models.Heartbeat) error
GetAll() ([]*models.Heartbeat, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error) GetFirstByUsers() ([]*models.TimeByUser, error)
GetLastByUsers() ([]*models.TimeByUser, error) GetLastByUsers() ([]*models.TimeByUser, error)
@ -28,12 +30,14 @@ type IHeartbeatRepository interface {
} }
type IKeyValueRepository interface { type IKeyValueRepository interface {
GetAll() ([]*models.KeyStringValue, error)
GetString(string) (*models.KeyStringValue, error) GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error PutString(*models.KeyStringValue) error
DeleteString(string) error DeleteString(string) error
} }
type ILanguageMappingRepository interface { type ILanguageMappingRepository interface {
GetAll() ([]*models.LanguageMapping, error)
GetById(uint) (*models.LanguageMapping, error) GetById(uint) (*models.LanguageMapping, error)
GetByUser(string) ([]*models.LanguageMapping, error) GetByUser(string) ([]*models.LanguageMapping, error)
Insert(*models.LanguageMapping) (*models.LanguageMapping, error) Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
@ -42,6 +46,7 @@ type ILanguageMappingRepository interface {
type ISummaryRepository interface { type ISummaryRepository interface {
Insert(*models.Summary) error Insert(*models.Summary) error
GetAll() ([]*models.Summary, error)
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error) GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
GetLastByUser() ([]*models.TimeByUser, error) GetLastByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error DeleteByUser(string) error

View File

@ -14,6 +14,21 @@ func NewSummaryRepository(db *gorm.DB) *SummaryRepository {
return &SummaryRepository{db: db} return &SummaryRepository{db: db}
} }
func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
var summaries []*models.Summary
if err := r.db.
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) Insert(summary *models.Summary) error { func (r *SummaryRepository) Insert(summary *models.Summary) error {
if err := r.db.Create(summary).Error; err != nil { if err := r.db.Create(summary).Error; err != nil {
return err return err
@ -25,8 +40,8 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
var summaries []*models.Summary var summaries []*models.Summary
if err := r.db. if err := r.db.
Where(&models.Summary{UserID: user.ID}). Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from). Where("from_time >= ?", from.Local()).
Where("to_time <= ?", to). Where("to_time <= ?", to.Local()).
Order("from_time asc"). Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject). Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage). Preload("Languages", "type = ?", models.SummaryLanguage).

View File

@ -77,7 +77,7 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) { func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
var users []*models.User var users []*models.User
if err := r.db. if err := r.db.
Where("last_logged_in_at >= ?", t). Where("last_logged_in_at >= ?", t.Local()).
Find(&users).Error; err != nil { Find(&users).Error; err != nil {
return nil, err return nil, err
} }
@ -96,7 +96,7 @@ func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, erro
if err := r.db. if err := r.db.
Select("user as id"). Select("user as id").
Table("(?) as q", subQuery1). Table("(?) as q", subQuery1).
Where("time >= ?", t). Where("time >= ?", t.Local()).
Scan(&userIds).Error; err != nil { Scan(&userIds).Error; err != nil {
return nil, err return nil, err
} }
@ -142,6 +142,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"wakatime_api_key": user.WakatimeApiKey, "wakatime_api_key": user.WakatimeApiKey,
"has_data": user.HasData, "has_data": user.HasData,
"reset_token": user.ResetToken, "reset_token": user.ResetToken,
"location": user.Location,
} }
result := r.db.Model(user).Updates(updateMap) result := r.db.Model(user).Updates(updateMap)

View File

@ -96,7 +96,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
} }
} }
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats))) utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
} }
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288) // construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)

View File

@ -116,7 +116,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
return nil, err return nil, err
} }
from, to := utils.MustResolveIntervalRaw("today") from, to := utils.MustResolveIntervalRawTZ("today", user.TZ())
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false) summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve, false)
if err != nil { if err != nil {

View File

@ -51,5 +51,5 @@ func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondJSON(w, http.StatusOK, summary) utils.RespondJSON(w, r, http.StatusOK, summary)
} }

View File

@ -74,7 +74,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
_, rangeFrom, rangeTo := utils.ResolveInterval(interval) _, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))) minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
// negative value means no limit // negative value means no limit
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 { if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
@ -101,7 +101,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey) cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
if cacheResult, ok := h.cache.Get(cacheKey); ok { if cacheResult, ok := h.cache.Get(cacheKey); ok {
utils.RespondJSON(w, http.StatusOK, cacheResult.(*v1.BadgeData)) utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
return return
} }
@ -114,11 +114,11 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
vm := v1.NewBadgeDataFrom(summary, filters) vm := v1.NewBadgeDataFrom(summary, filters)
h.cache.SetDefault(cacheKey, vm) h.cache.SetDefault(cacheKey, vm)
utils.RespondJSON(w, http.StatusOK, vm) utils.RespondJSON(w, r, http.StatusOK, vm)
} }
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) { func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
err, from, to := utils.ResolveInterval(interval) err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
if err != nil { if err != nil {
return nil, err, http.StatusBadRequest return nil, err, http.StatusBadRequest
} }

View File

@ -64,7 +64,7 @@ func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
} }
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project"))) vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
utils.RespondJSON(w, http.StatusOK, vm) utils.RespondJSON(w, r, http.StatusOK, vm)
} }
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) { func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {

View File

@ -62,7 +62,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
rangeParam = (*models.IntervalPast7Days)[0] rangeParam = (*models.IntervalPast7Days)[0]
} }
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(rangeParam) err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range")) w.Write([]byte("invalid range"))
@ -103,7 +103,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
stats.Data.Machines = nil stats.Data.Machines = nil
} }
utils.RespondJSON(w, http.StatusOK, stats) utils.RespondJSON(w, r, http.StatusOK, stats)
} }
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) { func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {

View File

@ -76,7 +76,7 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
} }
vm := v1.NewSummariesFrom(summaries, filters) vm := v1.NewSummariesFrom(summaries, filters)
utils.RespondJSON(w, http.StatusOK, vm) utils.RespondJSON(w, r, http.StatusOK, vm)
} }
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) { func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
@ -87,12 +87,12 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
var start, end time.Time var start, end time.Time
if rangeParam != "" { if rangeParam != "" {
// range param takes precedence // range param takes precedence
if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(rangeParam); err == nil { if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, user.TZ()); err == nil {
start, end = parsedFrom, parsedTo start, end = parsedFrom, parsedTo
} else { } else {
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
} }
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(startParam); err == nil && startParam == endParam { } else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, user.TZ()); err == nil && startParam == endParam {
// also accept start param to be a range param // also accept start param to be a range param
start, end = parsedFrom, parsedTo start, end = parsedFrom, parsedTo
} else { } else {

View File

@ -166,6 +166,7 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
} }
user.Email = payload.Email user.Email = payload.Email
user.Location = payload.Location
if _, err := h.userSrvc.Update(user); err != nil { if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", conf.ErrInternalServerError return http.StatusInternalServerError, "", conf.ErrInternalServerError

View File

@ -1,6 +1,7 @@
package utils package utils
import ( import (
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
@ -8,6 +9,7 @@ import (
) )
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) { func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
user := middlewares.GetPrincipal(r)
summaryParams, err := utils.ParseSummaryParams(r) summaryParams, err := utils.ParseSummaryParams(r)
if err != nil { if err != nil {
return nil, err, http.StatusBadRequest return nil, err, http.StatusBadRequest
@ -23,5 +25,8 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ
return nil, err, http.StatusInternalServerError return nil, err, http.StatusInternalServerError
} }
summary.FromTime = models.CustomTime(summary.FromTime.T().In(user.TZ()))
summary.ToTime = models.CustomTime(summary.ToTime.T().In(user.TZ()))
return summary, nil, http.StatusOK return summary, nil, http.StatusOK
} }

View File

@ -1,3 +1,3 @@
#!/bin/bash #!/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 docker run -d -p 5432:5432 -e POSTGRES_DB=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres

View File

@ -9,7 +9,6 @@ from datetime import datetime, timedelta
from typing import List, Union, Callable from typing import List, Union, Callable
import requests import requests
from tqdm import tqdm
MACHINE = "devmachine" MACHINE = "devmachine"
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' 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'
@ -53,6 +52,7 @@ class ConfigParams:
self.n_projects = 0 self.n_projects = 0
self.offset = 0 self.offset = 0
self.seed = 0 self.seed = 0
self.batch = False
def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[Heartbeat]: def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[Heartbeat]:
@ -86,21 +86,21 @@ def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[H
def post_data_sync(data: List[Heartbeat], url: str, api_key: str): 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') encoded_key: str = str(base64.b64encode(api_key.encode('utf-8')), 'utf-8')
for h in data: r = requests.post(url, json=[h.__dict__ for h in data], headers={
r = requests.post(url, json=[h.__dict__], headers={ 'User-Agent': UA,
'User-Agent': UA, 'Authorization': f'Basic {encoded_key}',
'Authorization': f'Basic {encoded_key}', 'X-Machine-Name': MACHINE,
'X-Machine-Name': MACHINE, })
}) if r.status_code != 201:
if r.status_code != 201: print(r.text)
print(r.text) sys.exit(1)
sys.exit(1)
def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) -> ('QApplication', 'QWidget'): def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) -> ('QApplication', 'QWidget'):
# https://doc.qt.io/qt-5/qtwidgets-module.html
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QLabel, \ from PyQt5.QtWidgets import QApplication, QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QLabel, \
QLineEdit, QSpinBox, QProgressBar, QPushButton QLineEdit, QSpinBox, QProgressBar, QPushButton, QCheckBox
# Main app # Main app
app = QApplication([]) app = QApplication([])
@ -153,10 +153,14 @@ def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) ->
seed_input.setMaximum(2147483647) seed_input.setMaximum(2147483647)
seed_input.setValue(1337) seed_input.setValue(1337)
batch_checkbox = QCheckBox('Batch Mode')
batch_checkbox.setTristate(False)
form_layout_2.addRow(heartbeats_input_label, heartbeats_input) form_layout_2.addRow(heartbeats_input_label, heartbeats_input)
form_layout_2.addRow(projects_input_label, projects_input) form_layout_2.addRow(projects_input_label, projects_input)
form_layout_2.addRow(offset_input_label, offset_input) form_layout_2.addRow(offset_input_label, offset_input)
form_layout_2.addRow(seed_input_label, seed_input) form_layout_2.addRow(seed_input_label, seed_input)
form_layout_2.addRow(batch_checkbox)
# Bottom controls # Bottom controls
bottom_layout = QHBoxLayout() bottom_layout = QHBoxLayout()
@ -195,6 +199,7 @@ def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) ->
params.n_projects = projects_input.value() params.n_projects = projects_input.value()
params.offset = offset_input.value() params.offset = offset_input.value()
params.seed = seed_input.value() params.seed = seed_input.value()
params.batch = batch_checkbox.isChecked()
return params return params
def update_progress(inc=1): def update_progress(inc=1):
@ -231,6 +236,7 @@ def parse_arguments():
help='negative time offset in hours from now for to be used as an interval within which to generate heartbeats for') 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, parser.add_argument('-s', '--seed', type=int, default=2020,
help='a seed for initializing the pseudo-random number generator') help='a seed for initializing the pseudo-random number generator')
parser.add_argument('-b', '--batch', default=False, help='batch mode (push all heartbeats at once)', action='store_true')
return parser.parse_args() return parser.parse_args()
@ -242,6 +248,7 @@ def args_to_params(parsed_args: argparse.Namespace) -> (ConfigParams, bool):
params.seed = parsed_args.seed params.seed = parsed_args.seed
params.api_url = parsed_args.url params.api_url = parsed_args.url
params.api_key = parsed_args.apikey params.api_key = parsed_args.apikey
params.batch = parsed_args.batch
return params, not parsed_args.headless return params, not parsed_args.headless
@ -258,9 +265,14 @@ def run(params: ConfigParams, update_progress: Callable[[int], None]):
params.offset * -1 if params.offset < 0 else params.offset params.offset * -1 if params.offset < 0 else params.offset
) )
for d in data: # batch-mode won't work when using sqlite backend
post_data_sync([d], f'{params.api_url}/heartbeats', params.api_key) if params.batch:
update_progress(1) post_data_sync(data, f'{params.api_url}/heartbeats', params.api_key)
update_progress(len(data))
else:
for d in data:
post_data_sync([d], f'{params.api_url}/heartbeats', params.api_key)
update_progress(1)
if __name__ == '__main__': if __name__ == '__main__':
@ -270,5 +282,7 @@ if __name__ == '__main__':
window.show() window.show()
app.exec() app.exec()
else: else:
from tqdm import tqdm
pbar = tqdm(total=params.n) pbar = tqdm(total=params.n)
run(params, pbar.update) run(params, pbar.update)

276
scripts/sqlite2mysql.go Normal file
View File

@ -0,0 +1,276 @@
package main
/*
A script to migrate Wakapi data from SQLite to MySQL or Postgres.
Usage:
---
1. Set up an empty MySQL or Postgres database (see docker_[mysql|postgres].sh for example)
2. Create a migration config file (e.g. config.yml) as shown below
3. go run sqlite2mysql.go -config config.yml
Example: config.yml
---
source:
name: ../wakapi_db.db
# MySQL / Postgres
target:
host:
port:
user:
password:
name:
dialect:
Troubleshooting:
---
- Check https://wiki.postgresql.org/wiki/Fixing_Sequences in case of errors with Postgres
- Check https://github.com/muety/wakapi/pull/181#issue-621585477 on further details about Postgres migration
*/
import (
"flag"
"fmt"
"log"
"os"
"github.com/jinzhu/configor"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type config struct {
Source dbConfig // sqlite
Target dbConfig // mysql / postgres
}
type dbConfig struct {
Host string
Port uint
User string
Password string
Name string
Dialect string `default:"mysql"`
}
const InsertBatchSize = 100
var cfg *config
var dbSource, dbTarget *gorm.DB
var cFlag *string
func init() {
cfg = &config{}
if f := flag.Lookup("config"); f == nil {
cFlag = flag.String("config", "sqlite2mysql.yml", "config file location")
} else {
ff := f.Value.(flag.Getter).Get().(string)
cFlag = &ff
}
flag.Parse()
if err := configor.New(&configor.Config{}).Load(cfg, mustConfigPath()); err != nil {
log.Fatalln("failed to read config", err)
}
log.Println("attempting to open sqlite database as source")
if db, err := gorm.Open(sqlite.Open(cfg.Source.Name), &gorm.Config{}); err != nil {
log.Fatalln(err)
} else {
dbSource = db
}
if cfg.Target.Dialect == "postgres" {
log.Println("attempting to open postgresql database as target")
if db, err := gorm.Open(postgres.Open(fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s sslmode=disable timezone=Europe/Berlin",
cfg.Target.User,
cfg.Target.Password,
cfg.Target.Host,
cfg.Target.Port,
cfg.Target.Name,
)), &gorm.Config{}); err != nil {
log.Fatalln(err)
} else {
dbTarget = db
}
} else {
log.Println("attempting to open mysql database as target")
if db, err := gorm.Open(mysql.New(mysql.Config{
DriverName: "mysql",
DSN: fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
cfg.Target.User,
cfg.Target.Password,
cfg.Target.Host,
cfg.Target.Port,
cfg.Target.Name,
"utf8mb4",
"Local",
),
}), &gorm.Config{}); err != nil {
log.Fatalln(err)
} else {
dbTarget = db
}
}
}
func destroy() {
if db, _ := dbSource.DB(); db != nil {
db.Close()
}
if db, _ := dbTarget.DB(); db != nil {
db.Close()
}
}
func main() {
defer destroy()
if err := createSchema(); err != nil {
log.Fatalln(err)
}
keyValueSource := repositories.NewKeyValueRepository(dbSource)
keyValueTarget := repositories.NewKeyValueRepository(dbTarget)
userSource := repositories.NewUserRepository(dbSource)
userTarget := repositories.NewUserRepository(dbTarget)
languageMappingSource := repositories.NewLanguageMappingRepository(dbSource)
languageMappingTarget := repositories.NewLanguageMappingRepository(dbTarget)
aliasSource := repositories.NewAliasRepository(dbSource)
aliasTarget := repositories.NewAliasRepository(dbTarget)
summarySource := repositories.NewSummaryRepository(dbSource)
summaryTarget := repositories.NewSummaryRepository(dbTarget)
heartbeatSource := repositories.NewHeartbeatRepository(dbSource)
heartbeatTarget := repositories.NewHeartbeatRepository(dbTarget)
// TODO: things could be optimized through batch-inserts / inserts within a single transaction
log.Println("Migrating key-value pairs ...")
if data, err := keyValueSource.GetAll(); err == nil {
for _, e := range data {
if err := keyValueTarget.PutString(e); err != nil {
log.Fatalln(err)
}
}
} else {
log.Fatalln(err)
}
log.Println("Migrating users ...")
if data, err := userSource.GetAll(); err == nil {
for _, e := range data {
if _, _, err := userTarget.InsertOrGet(e); err != nil {
log.Fatalln(err)
}
}
} else {
log.Fatalln(err)
}
log.Println("Migrating language mappings ...")
if data, err := languageMappingSource.GetAll(); err == nil {
for _, e := range data {
e.ID = 0
if _, err := languageMappingTarget.Insert(e); err != nil {
log.Fatalln(err)
}
}
} else {
log.Fatalln(err)
}
log.Println("Migrating aliases ...")
if data, err := aliasSource.GetAll(); err == nil {
for _, e := range data {
e.ID = 0
if _, err := aliasTarget.Insert(e); err != nil {
log.Fatalln(err)
}
}
} else {
log.Fatalln(err)
}
log.Println("Migrating summaries ...")
if data, err := summarySource.GetAll(); err == nil {
for _, e := range data {
e.ID = 0
if err := summaryTarget.Insert(e); err != nil {
log.Fatalln(err)
}
}
} else {
log.Fatalln(err)
}
// TODO: copy in mini-batches instead of loading all heartbeats into memory (potentially millions)
log.Println("Migrating heartbeats ...")
if data, err := heartbeatSource.GetAll(); err == nil {
log.Printf("Got %d heartbeats loaded into memory. Batch-inserting them now ...\n", len(data))
var slice = make([]*models.Heartbeat, len(data))
for i, heartbeat := range data {
heartbeat = heartbeat.Hashed()
slice[i] = heartbeat
}
left, right, size := 0, InsertBatchSize, len(slice)
for right < size {
log.Printf("Inserting batch from %d", left)
if err := heartbeatTarget.InsertBatch(slice[left:right]); err != nil {
log.Fatalln(err)
}
left += InsertBatchSize
right += InsertBatchSize
}
if err := heartbeatTarget.InsertBatch(slice[left:]); err != nil {
log.Fatalln(err)
}
} else {
log.Fatalln(err)
}
}
func createSchema() error {
if err := dbTarget.AutoMigrate(&models.User{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.KeyStringValue{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.Alias{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.Heartbeat{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.Summary{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.SummaryItem{}); err != nil {
return err
}
if err := dbTarget.AutoMigrate(&models.LanguageMapping{}); err != nil {
return err
}
return nil
}
func mustConfigPath() string {
if _, err := os.Stat(*cFlag); err != nil {
log.Fatalln("failed to find config file at", *cFlag)
}
return *cFlag
}

View File

@ -1,9 +1,11 @@
package services package services
import ( import (
"errors"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"runtime" "runtime"
"sync"
"time" "time"
"github.com/go-co-op/gocron" "github.com/go-co-op/gocron"
@ -14,11 +16,14 @@ const (
aggregateIntervalDays int = 1 aggregateIntervalDays int = 1
) )
var lock = sync.Mutex{}
type AggregationService struct { type AggregationService struct {
config *config.Config config *config.Config
userService IUserService userService IUserService
summaryService ISummaryService summaryService ISummaryService
heartbeatService IHeartbeatService heartbeatService IHeartbeatService
inProgress map[string]bool
} }
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService { func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
@ -27,6 +32,7 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
userService: userService, userService: userService,
summaryService: summaryService, summaryService: summaryService,
heartbeatService: heartbeatService, heartbeatService: heartbeatService,
inProgress: map[string]bool{},
} }
} }
@ -49,6 +55,11 @@ func (srv *AggregationService) Schedule() {
} }
func (srv *AggregationService) Run(userIds map[string]bool) error { func (srv *AggregationService) Run(userIds map[string]bool) error {
if err := srv.lockUsers(userIds); err != nil {
return err
}
defer srv.unlockUsers(userIds)
jobs := make(chan *AggregationJob) jobs := make(chan *AggregationJob)
summaries := make(chan *models.Summary) summaries := make(chan *models.Summary)
@ -145,6 +156,28 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
return nil return nil
} }
func (srv *AggregationService) lockUsers(userIds map[string]bool) error {
lock.Lock()
defer lock.Unlock()
for uid := range userIds {
if _, ok := srv.inProgress[uid]; ok {
return errors.New("aggregation already in progress for at least of the request users")
}
}
for uid := range userIds {
srv.inProgress[uid] = true
}
return nil
}
func (srv *AggregationService) unlockUsers(userIds map[string]bool) {
lock.Lock()
defer lock.Unlock()
for uid := range userIds {
delete(srv.inProgress, uid)
}
}
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) { func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
var to time.Time var to time.Time

View File

@ -3,6 +3,7 @@ package services
import ( import (
"crypto/md5" "crypto/md5"
"errors" "errors"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories" "github.com/muety/wakapi/repositories"
@ -236,7 +237,15 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
Machines: make([]*models.SummaryItem, 0), Machines: make([]*models.SummaryItem, 0),
} }
var processed = map[time.Time]bool{}
for _, s := range summaries { for _, s := range summaries {
hash := s.FromTime.T()
if _, found := processed[hash]; found {
logbuch.Warn("summary from %v to %v (user '%s') was attempted to be processed more often than once", s.FromTime, s.ToTime, s.UserID)
continue
}
if s.UserID != finalSummary.UserID { if s.UserID != finalSummary.UserID {
return nil, errors.New("users don't match") return nil, errors.New("users don't match")
} }
@ -254,6 +263,8 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors) finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems) finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines) finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
processed[hash] = true
} }
finalSummary.FromTime = models.CustomTime(minTime) finalSummary.FromTime = models.CustomTime(minTime)

View File

@ -291,6 +291,52 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2+1+1) suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2+1+1)
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
var (
summaries []*models.Summary
from time.Time
to time.Time
result *models.Summary
err error
)
from, to = suite.TestStartTime.Add(-12*time.Hour), suite.TestStartTime.Add(12*time.Hour)
summaries = []*models.Summary{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(from.Add(10 * time.Minute)),
ToTime: models.CustomTime(to.Add(-10 * time.Minute)),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject1,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
}
summaries = append(summaries, &(*summaries[0])) // add same summary again -> mustn't be counted twice!
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil)
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 1)
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2)
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() { func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService) sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)

View File

@ -76,8 +76,9 @@ func (srv *UserService) Count() (int64, error) {
func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) { func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
u := &models.User{ u := &models.User{
ID: signup.Username, ID: signup.Username,
Email: signup.Email,
ApiKey: uuid.NewV4().String(), ApiKey: uuid.NewV4().String(),
Email: signup.Email,
Location: signup.Location,
Password: signup.Password, Password: signup.Password,
IsAdmin: isAdmin, IsAdmin: isAdmin,
} }

352
static/assets/timezones.js Normal file
View File

@ -0,0 +1,352 @@
// https://stackoverflow.com/a/54500197/3112139
const tzs = [
'Europe/Andorra',
'Asia/Dubai',
'Asia/Kabul',
'Europe/Tirane',
'Asia/Yerevan',
'Antarctica/Casey',
'Antarctica/Davis',
'Antarctica/DumontDUrville',
'Antarctica/Mawson',
'Antarctica/Palmer',
'Antarctica/Rothera',
'Antarctica/Syowa',
'Antarctica/Troll',
'Antarctica/Vostok',
'America/Argentina/Buenos_Aires',
'America/Argentina/Cordoba',
'America/Argentina/Salta',
'America/Argentina/Jujuy',
'America/Argentina/Tucuman',
'America/Argentina/Catamarca',
'America/Argentina/La_Rioja',
'America/Argentina/San_Juan',
'America/Argentina/Mendoza',
'America/Argentina/San_Luis',
'America/Argentina/Rio_Gallegos',
'America/Argentina/Ushuaia',
'Pacific/Pago_Pago',
'Europe/Vienna',
'Australia/Lord_Howe',
'Antarctica/Macquarie',
'Australia/Hobart',
'Australia/Currie',
'Australia/Melbourne',
'Australia/Sydney',
'Australia/Broken_Hill',
'Australia/Brisbane',
'Australia/Lindeman',
'Australia/Adelaide',
'Australia/Darwin',
'Australia/Perth',
'Australia/Eucla',
'Asia/Baku',
'America/Barbados',
'Asia/Dhaka',
'Europe/Brussels',
'Europe/Sofia',
'Atlantic/Bermuda',
'Asia/Brunei',
'America/La_Paz',
'America/Noronha',
'America/Belem',
'America/Fortaleza',
'America/Recife',
'America/Araguaina',
'America/Maceio',
'America/Bahia',
'America/Sao_Paulo',
'America/Campo_Grande',
'America/Cuiaba',
'America/Santarem',
'America/Porto_Velho',
'America/Boa_Vista',
'America/Manaus',
'America/Eirunepe',
'America/Rio_Branco',
'America/Nassau',
'Asia/Thimphu',
'Europe/Minsk',
'America/Belize',
'America/St_Johns',
'America/Halifax',
'America/Glace_Bay',
'America/Moncton',
'America/Goose_Bay',
'America/Blanc-Sablon',
'America/Toronto',
'America/Nipigon',
'America/Thunder_Bay',
'America/Iqaluit',
'America/Pangnirtung',
'America/Atikokan',
'America/Winnipeg',
'America/Rainy_River',
'America/Resolute',
'America/Rankin_Inlet',
'America/Regina',
'America/Swift_Current',
'America/Edmonton',
'America/Cambridge_Bay',
'America/Yellowknife',
'America/Inuvik',
'America/Creston',
'America/Dawson_Creek',
'America/Fort_Nelson',
'America/Vancouver',
'America/Whitehorse',
'America/Dawson',
'Indian/Cocos',
'Europe/Zurich',
'Africa/Abidjan',
'Pacific/Rarotonga',
'America/Santiago',
'America/Punta_Arenas',
'Pacific/Easter',
'Asia/Shanghai',
'Asia/Urumqi',
'America/Bogota',
'America/Costa_Rica',
'America/Havana',
'Atlantic/Cape_Verde',
'America/Curacao',
'Indian/Christmas',
'Asia/Nicosia',
'Asia/Famagusta',
'Europe/Prague',
'Europe/Berlin',
'Europe/Copenhagen',
'America/Santo_Domingo',
'Africa/Algiers',
'America/Guayaquil',
'Pacific/Galapagos',
'Europe/Tallinn',
'Africa/Cairo',
'Africa/El_Aaiun',
'Europe/Madrid',
'Africa/Ceuta',
'Atlantic/Canary',
'Europe/Helsinki',
'Pacific/Fiji',
'Atlantic/Stanley',
'Pacific/Chuuk',
'Pacific/Pohnpei',
'Pacific/Kosrae',
'Atlantic/Faroe',
'Europe/Paris',
'Europe/London',
'Asia/Tbilisi',
'America/Cayenne',
'Africa/Accra',
'Europe/Gibraltar',
'America/Godthab',
'America/Danmarkshavn',
'America/Scoresbysund',
'America/Thule',
'Europe/Athens',
'Atlantic/South_Georgia',
'America/Guatemala',
'Pacific/Guam',
'Africa/Bissau',
'America/Guyana',
'Asia/Hong_Kong',
'America/Tegucigalpa',
'America/Port-au-Prince',
'Europe/Budapest',
'Asia/Jakarta',
'Asia/Pontianak',
'Asia/Makassar',
'Asia/Jayapura',
'Europe/Dublin',
'Asia/Jerusalem',
'Asia/Kolkata',
'Indian/Chagos',
'Asia/Baghdad',
'Asia/Tehran',
'Atlantic/Reykjavik',
'Europe/Rome',
'America/Jamaica',
'Asia/Amman',
'Asia/Tokyo',
'Africa/Nairobi',
'Asia/Bishkek',
'Pacific/Tarawa',
'Pacific/Enderbury',
'Pacific/Kiritimati',
'Asia/Pyongyang',
'Asia/Seoul',
'Asia/Almaty',
'Asia/Qyzylorda',
'Asia/Qostanay',
'Asia/Aqtobe',
'Asia/Aqtau',
'Asia/Atyrau',
'Asia/Oral',
'Asia/Beirut',
'Asia/Colombo',
'Africa/Monrovia',
'Europe/Vilnius',
'Europe/Luxembourg',
'Europe/Riga',
'Africa/Tripoli',
'Africa/Casablanca',
'Europe/Monaco',
'Europe/Chisinau',
'Pacific/Majuro',
'Pacific/Kwajalein',
'Asia/Yangon',
'Asia/Ulaanbaatar',
'Asia/Hovd',
'Asia/Choibalsan',
'Asia/Macau',
'America/Martinique',
'Europe/Malta',
'Indian/Mauritius',
'Indian/Maldives',
'America/Mexico_City',
'America/Cancun',
'America/Merida',
'America/Monterrey',
'America/Matamoros',
'America/Mazatlan',
'America/Chihuahua',
'America/Ojinaga',
'America/Hermosillo',
'America/Tijuana',
'America/Bahia_Banderas',
'Asia/Kuala_Lumpur',
'Asia/Kuching',
'Africa/Maputo',
'Africa/Windhoek',
'Pacific/Noumea',
'Pacific/Norfolk',
'Africa/Lagos',
'America/Managua',
'Europe/Amsterdam',
'Europe/Oslo',
'Asia/Kathmandu',
'Pacific/Nauru',
'Pacific/Niue',
'Pacific/Auckland',
'Pacific/Chatham',
'America/Panama',
'America/Lima',
'Pacific/Tahiti',
'Pacific/Marquesas',
'Pacific/Gambier',
'Pacific/Port_Moresby',
'Pacific/Bougainville',
'Asia/Manila',
'Asia/Karachi',
'Europe/Warsaw',
'America/Miquelon',
'Pacific/Pitcairn',
'America/Puerto_Rico',
'Asia/Gaza',
'Asia/Hebron',
'Europe/Lisbon',
'Atlantic/Madeira',
'Atlantic/Azores',
'Pacific/Palau',
'America/Asuncion',
'Asia/Qatar',
'Indian/Reunion',
'Europe/Bucharest',
'Europe/Belgrade',
'Europe/Kaliningrad',
'Europe/Moscow',
'Europe/Simferopol',
'Europe/Kirov',
'Europe/Astrakhan',
'Europe/Volgograd',
'Europe/Saratov',
'Europe/Ulyanovsk',
'Europe/Samara',
'Asia/Yekaterinburg',
'Asia/Omsk',
'Asia/Novosibirsk',
'Asia/Barnaul',
'Asia/Tomsk',
'Asia/Novokuznetsk',
'Asia/Krasnoyarsk',
'Asia/Irkutsk',
'Asia/Chita',
'Asia/Yakutsk',
'Asia/Khandyga',
'Asia/Vladivostok',
'Asia/Ust-Nera',
'Asia/Magadan',
'Asia/Sakhalin',
'Asia/Srednekolymsk',
'Asia/Kamchatka',
'Asia/Anadyr',
'Asia/Riyadh',
'Pacific/Guadalcanal',
'Indian/Mahe',
'Africa/Khartoum',
'Europe/Stockholm',
'Asia/Singapore',
'America/Paramaribo',
'Africa/Juba',
'Africa/Sao_Tome',
'America/El_Salvador',
'Asia/Damascus',
'America/Grand_Turk',
'Africa/Ndjamena',
'Indian/Kerguelen',
'Asia/Bangkok',
'Asia/Dushanbe',
'Pacific/Fakaofo',
'Asia/Dili',
'Asia/Ashgabat',
'Africa/Tunis',
'Pacific/Tongatapu',
'Europe/Istanbul',
'America/Port_of_Spain',
'Pacific/Funafuti',
'Asia/Taipei',
'Europe/Kiev',
'Europe/Uzhgorod',
'Europe/Zaporozhye',
'Pacific/Wake',
'America/New_York',
'America/Detroit',
'America/Kentucky/Louisville',
'America/Kentucky/Monticello',
'America/Indiana/Indianapolis',
'America/Indiana/Vincennes',
'America/Indiana/Winamac',
'America/Indiana/Marengo',
'America/Indiana/Petersburg',
'America/Indiana/Vevay',
'America/Chicago',
'America/Indiana/Tell_City',
'America/Indiana/Knox',
'America/Menominee',
'America/North_Dakota/Center',
'America/North_Dakota/New_Salem',
'America/North_Dakota/Beulah',
'America/Denver',
'America/Boise',
'America/Phoenix',
'America/Los_Angeles',
'America/Anchorage',
'America/Juneau',
'America/Sitka',
'America/Metlakatla',
'America/Yakutat',
'America/Nome',
'America/Adak',
'Pacific/Honolulu',
'America/Montevideo',
'Asia/Samarkand',
'Asia/Tashkent',
'America/Caracas',
'Asia/Ho_Chi_Minh',
'Pacific/Efate',
'Pacific/Wallis',
'Pacific/Apia',
'Africa/Johannesburg'
]

View File

@ -7,12 +7,22 @@ import (
"time" "time"
) )
func ParseDate(date string) (time.Time, error) { // ParseDateTimeTZ attempts to parse the given date string from multiple formats.
return time.Parse(config.SimpleDateFormat, date) // First, a time-zoned date-time string (e.g. 2006-01-02T15:04:05+02:00) is tried
} // Second, a non-time-zoned date-time string (e.g. 2006-01-02 15:04:05) is tried at the given zone
// Third, a non-time-zoned date string (e.g. 2006-01-02) is tried at the given zone
func ParseDateTime(date string) (time.Time, error) { // Example:
return time.Parse(config.SimpleDateTimeFormat, date) // - Server runs in CEST (UTC+2), requesting user lives in PDT (UTC-7).
// - 2021-04-25T10:30:00Z, 2021-04-25T3:30:00-0100 and 2021-04-25T12:30:00+0200 are equivalent, they represent the same point in time
// - When user requests non-time-zoned range (e.g. 2021-04-25T00:00:00), but has their time zone properly configured, this will resolve to 2021-04-25T09:00:00
func ParseDateTimeTZ(date string, tz *time.Location) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, date); err == nil {
return t, nil
}
if t, err := time.ParseInLocation(config.SimpleDateTimeFormat, date, tz); err == nil {
return t, nil
}
return time.ParseInLocation(config.SimpleDateFormat, date, tz)
} }
func FormatDate(date time.Time) string { func FormatDate(date time.Time) string {

View File

@ -5,33 +5,42 @@ import (
"time" "time"
) )
func StartOfToday() time.Time {
return StartOfDay(time.Now())
}
func StartOfDay(date time.Time) time.Time { func StartOfDay(date time.Time) time.Time {
return FloorDate(date) return FloorDate(date)
} }
func StartOfWeek() time.Time { func StartOfToday(tz *time.Location) time.Time {
ref := time.Now() return StartOfDay(FloorDate(time.Now().In(tz)))
year, week := ref.ISOWeek()
return firstDayOfISOWeek(year, week, ref.Location())
} }
func StartOfMonth() time.Time { func StartOfThisWeek(tz *time.Location) time.Time {
ref := time.Now() return StartOfWeek(time.Now().In(tz))
return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location())
} }
func StartOfYear() time.Time { func StartOfWeek(date time.Time) time.Time {
ref := time.Now() year, week := date.ISOWeek()
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location()) return firstDayOfISOWeek(year, week, date.Location())
} }
// FloorDate rounds date down to the start of the day func StartOfThisMonth(tz *time.Location) time.Time {
return StartOfMonth(time.Now().In(tz))
}
func StartOfMonth(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
}
func StartOfThisYear(tz *time.Location) time.Time {
return StartOfYear(time.Now().In(tz))
}
func StartOfYear(date time.Time) time.Time {
return time.Date(date.Year(), time.January, 1, 0, 0, 0, 0, date.Location())
}
// FloorDate rounds date down to the start of the day and keeps the time zone
func FloorDate(date time.Time) time.Time { func FloorDate(date time.Time) time.Time {
return date.Truncate(24 * time.Hour) return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
} }
// CeilDate rounds date up to the start of next day if date is not already a start (00:00:00) // CeilDate rounds date up to the start of next day if date is not already a start (00:00:00)
@ -43,6 +52,21 @@ func CeilDate(date time.Time) time.Time {
return floored.Add(24 * time.Hour) return floored.Add(24 * time.Hour)
} }
// SetLocation resets the time zone information of a date without converting it, i.e. 19:00 UTC will result in 19:00 CET, for instance
func SetLocation(date time.Time, tz *time.Location) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, tz)
}
// WithOffset adds the time zone difference between Local and tz to a date, i.e. 19:00 UTC will result in 21:00 CET (or 22:00 CEST), for instance
func WithOffset(date time.Time, tz *time.Location) time.Time {
now := time.Now()
_, localOffset := now.Zone()
_, targetOffset := now.In(tz).Zone()
dateTz := date.Add(time.Duration((targetOffset - localOffset) * int(time.Second)))
return time.Date(dateTz.Year(), dateTz.Month(), dateTz.Day(), dateTz.Hour(), dateTz.Minute(), dateTz.Second(), dateTz.Nanosecond(), dateTz.Location()).In(tz)
}
// SplitRangeByDays creates a slice of intervals between from and to, each of which is at max of 24 hours length and has its split at midnight
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time { func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
intervals := make([][]time.Time, 0) intervals := make([][]time.Time, 0)

View File

@ -1,11 +1,26 @@
package utils package utils
import ( import (
"github.com/muety/wakapi/config"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing" "testing"
"time" "time"
) )
var (
tzLocal *time.Location
tzUtc *time.Location
tzCet *time.Location
tzPst *time.Location
)
func init() {
tzLocal = time.Local
tzUtc, _ = time.LoadLocation("UTC")
tzCet, _ = time.LoadLocation("Europe/Berlin")
tzPst, _ = time.LoadLocation("America/Los_Angeles")
}
func TestDate_Ceil(t *testing.T) { func TestDate_Ceil(t *testing.T) {
tests := []struct { tests := []struct {
in string in string
@ -28,3 +43,84 @@ func TestDate_Ceil(t *testing.T) {
assert.Equal(t, outDate, out) assert.Equal(t, outDate, out)
} }
} }
func TestDate_StartOfDay(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzCet)
assert.Equal(t, t1, StartOfDay(d1))
assert.Equal(t, t2, StartOfDay(d2))
assert.Equal(t, t3, StartOfDay(d3))
assert.Equal(t, t4, StartOfDay(d4))
assert.Equal(t, tzLocal, StartOfDay(d1).Location())
assert.Equal(t, tzUtc, StartOfDay(d2).Location())
assert.Equal(t, tzPst, StartOfDay(d3).Location())
assert.Equal(t, tzCet, StartOfDay(d4).Location())
}
func TestDate_StartOfWeek(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzCet)
assert.Equal(t, t1, StartOfWeek(d1))
assert.Equal(t, t2, StartOfWeek(d2))
assert.Equal(t, t3, StartOfWeek(d3))
assert.Equal(t, t4, StartOfWeek(d4))
assert.Equal(t, tzLocal, StartOfWeek(d1).Location())
assert.Equal(t, tzUtc, StartOfWeek(d2).Location())
assert.Equal(t, tzPst, StartOfWeek(d3).Location())
assert.Equal(t, tzCet, StartOfWeek(d4).Location())
}
func TestDate_SplitRangeByDays(t *testing.T) {
df1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-25 20:25:00")
dt1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-28 06:45:00")
df2 := df1
dt2 := CeilDate(df1)
df3 := df1
dt3 := df1.Add(10 * time.Second)
df4 := df1
dt4 := df4
result1 := SplitRangeByDays(df1, dt1)
result2 := SplitRangeByDays(df2, dt2)
result3 := SplitRangeByDays(df3, dt3)
result4 := SplitRangeByDays(df4, dt4)
assert.Len(t, result1, 4)
assert.Len(t, result1[0], 2)
assert.Equal(t, result1[0][0], df1)
assert.Equal(t, result1[3][1], dt1)
assert.Equal(t, result1[1][0].Hour()+result1[1][0].Minute()+result1[1][0].Second(), 0)
assert.Equal(t, result1[2][0].Hour()+result1[2][0].Minute()+result1[2][0].Second(), 0)
assert.Equal(t, result1[3][0].Hour()+result1[3][0].Minute()+result1[3][0].Second(), 0)
assert.Equal(t, result1[1][0], result1[0][1])
assert.Equal(t, result1[2][0], result1[1][1])
assert.Equal(t, result1[3][0], result1[2][1])
assert.Len(t, result2, 1)
assert.Equal(t, result2[0][0], df2)
assert.Equal(t, result2[0][1], dt2)
assert.Len(t, result3, 1)
assert.Equal(t, result3[0][0], df3)
assert.Equal(t, result3[0][1], dt3)
assert.Len(t, result4, 0)
}

View File

@ -6,10 +6,10 @@ import (
"net/http" "net/http"
) )
func RespondJSON(w http.ResponseWriter, status int, object interface{}) { func RespondJSON(w http.ResponseWriter, r *http.Request, status int, object interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(object); err != nil { if err := json.NewEncoder(w).Encode(object); err != nil {
config.Log().Error("error while writing json response: %v", err) config.Log().Request(r).Error("error while writing json response: %v", err)
} }
} }

View File

@ -16,51 +16,51 @@ func ParseInterval(interval string) (*models.IntervalKey, error) {
return nil, errors.New("not a valid interval") return nil, errors.New("not a valid interval")
} }
func MustResolveIntervalRaw(interval string) (from, to time.Time) { func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
_, from, to = ResolveIntervalRaw(interval) _, from, to = ResolveIntervalRawTZ(interval, tz)
return from, to return from, to
} }
func ResolveIntervalRaw(interval string) (err error, from, to time.Time) { func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
parsed, err := ParseInterval(interval) parsed, err := ParseInterval(interval)
if err != nil { if err != nil {
return err, time.Time{}, time.Time{} return err, time.Time{}, time.Time{}
} }
return ResolveInterval(parsed) return ResolveIntervalTZ(parsed, tz)
} }
func ResolveInterval(interval *models.IntervalKey) (err error, from, to time.Time) { func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
to = time.Now() to = time.Now().In(tz)
switch interval { switch interval {
case models.IntervalToday: case models.IntervalToday:
from = StartOfToday() from = StartOfToday(tz)
case models.IntervalYesterday: case models.IntervalYesterday:
from = StartOfToday().Add(-24 * time.Hour) from = StartOfToday(tz).Add(-24 * time.Hour)
to = StartOfToday() to = StartOfToday(tz)
case models.IntervalThisWeek: case models.IntervalThisWeek:
from = StartOfWeek() from = StartOfThisWeek(tz)
case models.IntervalLastWeek: case models.IntervalLastWeek:
from = StartOfWeek().AddDate(0, 0, -7) from = StartOfThisWeek(tz).AddDate(0, 0, -7)
to = StartOfWeek() to = StartOfThisWeek(tz)
case models.IntervalThisMonth: case models.IntervalThisMonth:
from = StartOfMonth() from = StartOfThisMonth(tz)
case models.IntervalLastMonth: case models.IntervalLastMonth:
from = StartOfMonth().AddDate(0, -1, 0) from = StartOfThisMonth(tz).AddDate(0, -1, 0)
to = StartOfMonth() to = StartOfThisMonth(tz)
case models.IntervalThisYear: case models.IntervalThisYear:
from = StartOfYear() from = StartOfThisYear(tz)
case models.IntervalPast7Days: case models.IntervalPast7Days:
from = StartOfToday().AddDate(0, 0, -7) from = StartOfToday(tz).AddDate(0, 0, -7)
case models.IntervalPast7DaysYesterday: case models.IntervalPast7DaysYesterday:
from = StartOfToday().AddDate(0, 0, -1).AddDate(0, 0, -7) from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = StartOfToday().AddDate(0, 0, -1) to = StartOfToday(tz).AddDate(0, 0, -1)
case models.IntervalPast14Days: case models.IntervalPast14Days:
from = StartOfToday().AddDate(0, 0, -14) from = StartOfToday(tz).AddDate(0, 0, -14)
case models.IntervalPast30Days: case models.IntervalPast30Days:
from = StartOfToday().AddDate(0, 0, -30) from = StartOfToday(tz).AddDate(0, 0, -30)
case models.IntervalPast12Months: case models.IntervalPast12Months:
from = StartOfToday().AddDate(0, -12, 0) from = StartOfToday(tz).AddDate(0, -12, 0)
case models.IntervalAny: case models.IntervalAny:
from = time.Time{} from = time.Time{}
default: default:
@ -78,24 +78,18 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
var from, to time.Time var from, to time.Time
if interval := params.Get("interval"); interval != "" { if interval := params.Get("interval"); interval != "" {
err, from, to = ResolveIntervalRaw(interval) err, from, to = ResolveIntervalRawTZ(interval, user.TZ())
} else if start := params.Get("start"); start != "" { } else if start := params.Get("start"); start != "" {
err, from, to = ResolveIntervalRaw(start) err, from, to = ResolveIntervalRawTZ(start, user.TZ())
} else { } else {
from, err = ParseDateTime(params.Get("from")) from, err = ParseDateTimeTZ(params.Get("from"), user.TZ())
if err != nil { if err != nil {
from, err = ParseDate(params.Get("from")) return nil, errors.New("missing or invalid 'from' parameter")
if err != nil {
return nil, errors.New("missing 'from' parameter")
}
} }
to, err = ParseDateTime(params.Get("to")) to, err = ParseDateTimeTZ(params.Get("to"), user.TZ())
if err != nil { if err != nil {
to, err = ParseDate(params.Get("to")) return nil, errors.New("missing or invalid 'to' parameter")
if err != nil {
return nil, errors.New("missing 'to' parameter")
}
} }
} }

View File

@ -1 +1 @@
1.26.5 1.26.7

View File

@ -70,12 +70,8 @@
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Fancy statistics and plots</li> <li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Fancy statistics and plots</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Cool badges for readmes</li> <li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Cool badges for readmes</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Intuitive REST API</li> <li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Intuitive REST API</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Compatible with <a href="https://wakatime.com" target="_blank" <li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
rel="noopener noreferrer" class="underline">Wakatime</a></li> <li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer"
class="underline">Prometheus</a> metrics via <a
href="https://github.com/MacroPower/wakatime_exporter" target="_blank"
rel="noopener noreferrer" class="underline">exporter</a></li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Lightning fast</li> <li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Lightning fast</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Self-hosted</li> <li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Self-hosted</li>
</ul> </ul>

View File

@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}
<script src="assets/timezones.js"></script>
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center"> <body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
@ -23,7 +24,7 @@
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-2xl flex-grow"> <div class="flex items-center justify-between max-w-2xl flex-grow">
<div><a href="/" class="text-gray-500 text-sm cursor-pointer">&larr; Go back</a></div> <div><a href="" class="text-gray-500 text-sm cursor-pointer">&larr; Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div> <div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</div> <div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</div>
</div> </div>
@ -37,25 +38,37 @@
<details class="my-8 pb-8 border-b border-gray-700"> <details class="my-8 pb-8 border-b border-gray-700">
<summary class="cursor-pointer"> <summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="email-heading"> id="preferences-heading">
Change E-Mail Address Account Preferences
</h2> </h2>
</summary> </summary>
<div class="w-full"> <div class="w-full">
<form class="mt-10" action="" method="post"> <form class="mt-10" action="" method="post">
<input type="hidden" name="action" value="update_user"> <input type="hidden" name="action" value="update_user">
<div class="mb-8 flex justify-between items-center space-x-4"> <div class="mb-8 flex justify-between items-center space-x-4">
<label class="inline-block text-sm text-gray-500" for="password_old">E-Mail Address</label> <label class="inline-block text-sm text-gray-500" for="select-timezone">Time Zone</label>
<select name="location" id="select-timezone"
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 flex-grow py-1 px-3 cursor-pointer">
</select>
</div>
<div class="mb-8 flex justify-between items-center space-x-4">
<label class="inline-block text-sm text-gray-500" for="email">E-Mail Address</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded flex-grow py-1 px-3" <input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded flex-grow py-1 px-3"
type="email" id="email" type="email" id="email"
name="email" placeholder="Enter your e-mail address" name="email" placeholder="Enter your e-mail address"
value="{{ .User.Email }}"> value="{{ .User.Email }}">
</div>
<div class="text-gray-300 text-sm">E-Mail address is optional, but required for some features
that you cannot use else. Also, if you do not add an e-mail address, you will not be able to
reset your password in case you forget it.
</div>
<div class="flex justify-end mt-4">
<button type="submit" <button type="submit"
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm"> class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm self-end">
Save Save
</button> </button>
</div> </div>
<div class="text-gray-300 text-sm">E-Mail address is optional, but required for some features that you cannot use else. Also, if you do not add an e-mail address, you will not be able to reset your password in case you forget it.</div>
</form> </form>
</div> </div>
</details> </details>
@ -70,7 +83,8 @@
<form class="mt-10" action="" method="post"> <form class="mt-10" action="" method="post">
<input type="hidden" name="action" value="change_password"> <input type="hidden" name="action" value="change_password">
<div class="mb-8"> <div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current Password</label> <label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current
Password</label>
<input class="shadow 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" <input class="shadow 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="password" id="password_old" type="password" id="password_old"
name="password_old" placeholder="Enter your old password" minlength="6" required> name="password_old" placeholder="Enter your old password" minlength="6" required>
@ -82,13 +96,15 @@
name="password_new" placeholder="Choose a password" minlength="6" required> name="password_new" placeholder="Choose a password" minlength="6" required>
</div> </div>
<div class="mb-8"> <div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label> <label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again
...</label>
<input class="shadow 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" <input class="shadow 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="password" id="password_repeat" type="password" id="password_repeat"
name="password_repeat" placeholder="Repeat your password" minlength="6" required> name="password_repeat" placeholder="Repeat your password" minlength="6" required>
</div> </div>
<div class="flex justify-between float-right"> <div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm"> <button type="submit"
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Save Save
</button> </button>
</div> </div>
@ -240,10 +256,14 @@
</summary> </summary>
<div> <div>
<p class="text-gray-300 text-sm mb-4 mt-6">Some features require public access to your data without authentication. This mainly includes <strong>Badges</strong> and the integration with <strong>GitHub Readme Stats</strong>, corresponding to these API endpoints:</p> <p class="text-gray-300 text-sm mb-4 mt-6">Some features require public access to your data without
authentication. This mainly includes <strong>Badges</strong> and the integration with <strong>GitHub
Readme Stats</strong>, corresponding to these API endpoints:</p>
<ul class="list-disc list-inside text-gray-300"> <ul class="list-disc list-inside text-gray-300">
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/compat/shields/v1/{user}</span></li> <li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/compat/shields/v1/{user}</span>
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/v1/users/{user}/stats/{range}</span></li> </li>
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/v1/users/{user}/stats/{range}</span>
</li>
</ul> </ul>
<form action="" method="post" class="mt-8"> <form action="" method="post" class="mt-8">
@ -252,7 +272,8 @@
<span class="mr-2">Publicly accessible data range:<br><span class="text-xs text-gray-500">(in days; 0 = not public, -1 = unlimited)</span></span> <span class="mr-2">Publicly accessible data range:<br><span class="text-xs text-gray-500">(in days; 0 = not public, -1 = unlimited)</span></span>
<div> <div>
<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 py-1 px-3" <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 py-1 px-3"
style="width: 70px;" type="number" id="max_days" name="max_days" min="-1" required value="{{ .User.ShareDataMaxDays }}"> style="width: 70px;" type="number" id="max_days" name="max_days" min="-1" required
value="{{ .User.ShareDataMaxDays }}">
</div> </div>
</div> </div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2"> <div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
@ -260,9 +281,14 @@
<span class="mr-2">Share projects: </span> <span class="mr-2">Share projects: </span>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<select autocomplete="off" name="share_projects" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"> <select autocomplete="off" name="share_projects"
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No</option> class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes</option> <option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected
{{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end
}}>Yes
</option>
</select> </select>
</div> </div>
</div> </div>
@ -271,9 +297,14 @@
<span class="mr-2">Share languages: </span> <span class="mr-2">Share languages: </span>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<select autocomplete="off" name="share_languages" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"> <select autocomplete="off" name="share_languages"
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No</option> class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes</option> <option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected
{{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{
end }}>Yes
</option>
</select> </select>
</div> </div>
</div> </div>
@ -282,9 +313,14 @@
<span class="mr-2">Share editors: </span> <span class="mr-2">Share editors: </span>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<select autocomplete="off" name="share_editors" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"> <select autocomplete="off" name="share_editors"
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No</option> class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes</option> <option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{
end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end
}}>Yes
</option>
</select> </select>
</div> </div>
</div> </div>
@ -293,9 +329,14 @@
<span class="mr-2">Share operating systems: </span> <span class="mr-2">Share operating systems: </span>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<select autocomplete="off" name="share_oss" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"> <select autocomplete="off" name="share_oss"
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No</option> class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>Yes</option> <option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end
}}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>
Yes
</option>
</select> </select>
</div> </div>
</div> </div>
@ -304,15 +345,22 @@
<span class="mr-2">Share machines: </span> <span class="mr-2">Share machines: </span>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<select autocomplete="off" name="share_machines" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"> <select autocomplete="off" name="share_machines"
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No</option> class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes</option> <option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected
{{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end
}}>Yes
</option>
</select> </select>
</div> </div>
</div> </div>
<div class="flex justify-between float-right mt-4"> <div class="flex justify-between float-right mt-4">
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm" style="width: 100px;"> <button type="submit"
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm"
style="width: 100px;">
Save Save
</button> </button>
</div> </div>
@ -394,7 +442,8 @@
</form> </form>
<p class="mt-6"> <p class="mt-6">
<span class="font-semibold"><span class="iconify inline" data-icon="emojione-v1:backhand-index-pointing-right"></span> Please note:</span> <span class="font-semibold"><span class="iconify inline"
data-icon="emojione-v1:backhand-index-pointing-right"></span> Please note:</span>
<span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a <span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94" class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94"
rel="noopener noreferrer">#94</a>) to be implemented.</span> rel="noopener noreferrer">#94</a>) to be implemented.</span>
@ -442,7 +491,8 @@
<p>You have the ability to create badges from your coding statistics using <a <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" 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 rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
access to the respective endpoint. See <a href="settings#public_data" class="underline">Public Data</a> setting.</p> access to the respective endpoint. See <a href="settings#public_data" class="underline">Public
Data</a> setting.</p>
{{ end }} {{ end }}
</div> </div>
@ -451,7 +501,10 @@
GitHub Readme Stats GitHub Readme Stats
</h3> </h3>
<p class="mb-4">Wakapi intregrates with <a href="https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats" class="underline" target="_blank" rel="noopener noreferrer">GitHub Readme Stats</a> to generate fancy cards for you.</p> <p class="mb-4">Wakapi intregrates with <a
href="https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats"
class="underline" target="_blank" rel="noopener noreferrer">GitHub Readme Stats</a> to
generate fancy cards for you.</p>
{{ if ne .User.ShareDataMaxDays 0 }} {{ if ne .User.ShareDataMaxDays 0 }}
<div class="flex space-x-1"> <div class="flex space-x-1">
@ -459,9 +512,10 @@
<span class="text-xs text-gray-500">(Only available on public instances, not on localhost)</span> <span class="text-xs text-gray-500">(Only available on public instances, not on localhost)</span>
</div> </div>
<div class="flex flex-col mb-4 mt-2"> <div class="flex flex-col mb-4 mt-2">
<img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact" class="with-url-src-no-scheme"> <img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact"
class="with-url-src-no-scheme">
<p class="mt-2"><strong>Source URL:</strong> <p class="mt-2"><strong>Source URL:</strong>
<span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme"> <span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme">
https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact
</span> </span>
</p> </p>
@ -578,11 +632,36 @@
const btnImportWakatime = document.querySelector('#btn-import-wakatime') const btnImportWakatime = document.querySelector('#btn-import-wakatime')
const formImportWakatime = document.querySelector('#form-import-wakatime') const formImportWakatime = document.querySelector('#form-import-wakatime')
btnImportWakatime.addEventListener('click', () => { if (btnImportWakatime) {
if (confirm('Are you sure? The import can not be undone.')) { btnImportWakatime.addEventListener('click', () => {
formImportWakatime.submit() if (confirm('Are you sure? The import can not be undone.')) {
} formImportWakatime.submit()
}) }
})
}
// Time zone stuff
const userTimeZone = {{ .User.Location }}
const userTzOffset = {{ .User.TZOffset.Hours }}
const selectTimezone = document.getElementById('select-timezone')
const createTzOption = (tz) => {
if (!tz) tz = 'Local'
const option = document.createElement('option')
option.setAttribute('value', tz)
option.innerText = tz
if (tz === userTimeZone) option.setAttribute('selected', 'true')
return option
}
const defaultOption = createTzOption('Local')
defaultOption.value = 'Local'
defaultOption.innerText = `Local server time (UTC+${userTzOffset})`
selectTimezone.appendChild(defaultOption)
tzs.sort()
.map(createTzOption)
.forEach(o => selectTimezone.appendChild(o))
</script> </script>
{{ template "footer.tpl.html" . }} {{ template "footer.tpl.html" . }}

View File

@ -34,6 +34,8 @@
</div> </div>
<form class="mt-10" action="signup" method="post"> <form class="mt-10" action="signup" method="post">
<input type="hidden" name="location" id="input-location">
<div class="mb-8"> <div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label> <label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow 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" <input class="shadow 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"
@ -78,6 +80,14 @@
{{ template "footer.tpl.html" . }} {{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }} {{ template "foot.tpl.html" . }}
<script type="text/javascript">
function guessTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
document.getElementById('input-location').setAttribute('value', guessTimezone())
</script>
</body> </body>
</html> </html>

View File

@ -212,7 +212,7 @@
{{ template "foot.tpl.html" . }} {{ template "foot.tpl.html" . }}
<script> <script>
document.addEventListener('load', function() { window.addEventListener('load', function() {
document.getElementById('api-key-instruction').innerHTML = document.getElementById('api-key-container').value document.getElementById('api-key-instruction').innerHTML = document.getElementById('api-key-container').value
}) })
</script> </script>