mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
8231d76200 | |||
c6fd43a964 | |||
4ab657ebd5 | |||
0a07ac1dd4 | |||
a64201c93b | |||
b105b0fe1c | |||
649c658923 | |||
bc9191a514 | |||
04690d287d | |||
c142b525a4 | |||
304fa3b03f | |||
e01e6575db | |||
75e61c0dc3 | |||
6973743f41 | |||
26ef93c1af | |||
0556efd39a | |||
030181fb2f | |||
8b9a9a1a42 | |||
6576837396 | |||
1a10a4fb21 | |||
0e3ce1e9e4 | |||
50a54bde22 | |||
53f3a9d685 | |||
c37278e660 | |||
e2deadfd44 |
10
README.md
10
README.md
@ -117,13 +117,14 @@ $ ./wakapi
|
||||
|
||||
#### Compile & Run
|
||||
```bash
|
||||
# Adapt config to your needs
|
||||
$ cp config.default.yml config.yml
|
||||
$ vi config.yml
|
||||
|
||||
# Build the executable
|
||||
$ go build -o wakapi
|
||||
|
||||
# Adapt config to your needs
|
||||
$ cp config.default.yml config.yml
|
||||
$ vi config.yml
|
||||
|
||||
# Run it
|
||||
$ ./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.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.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.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 |
|
||||
@ -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.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_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
|
||||
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
|
||||
|
@ -18,15 +18,16 @@ app:
|
||||
svelte: Svelte
|
||||
|
||||
db:
|
||||
host: # leave blank when using sqlite3
|
||||
port: # leave blank when using sqlite3
|
||||
user: # 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)
|
||||
dialect: sqlite3 # mysql, postgres, sqlite3
|
||||
charset: utf8mb4 # only used for mysql connections
|
||||
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)
|
||||
host: # leave blank when using sqlite3
|
||||
port: # leave blank when using sqlite3
|
||||
user: # 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)
|
||||
dialect: sqlite3 # mysql, postgres, sqlite3
|
||||
charset: utf8mb4 # only used for mysql connections
|
||||
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)
|
||||
automgirate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
|
||||
|
||||
security:
|
||||
password_salt: # CHANGE !
|
||||
|
@ -14,9 +14,6 @@ import (
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/muety/wakapi/data"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@ -82,16 +79,17 @@ type securityConfig struct {
|
||||
}
|
||||
|
||||
type dbConfig struct {
|
||||
Host string `env:"WAKAPI_DB_HOST"`
|
||||
Port uint `env:"WAKAPI_DB_PORT"`
|
||||
User string `env:"WAKAPI_DB_USER"`
|
||||
Password string `env:"WAKAPI_DB_PASSWORD"`
|
||||
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
|
||||
Dialect string `yaml:"-"`
|
||||
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
|
||||
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
||||
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
||||
Host string `env:"WAKAPI_DB_HOST"`
|
||||
Port uint `env:"WAKAPI_DB_PORT"`
|
||||
User string `env:"WAKAPI_DB_USER"`
|
||||
Password string `env:"WAKAPI_DB_PASSWORD"`
|
||||
Name string `default:"wakapi_db.db" env:"WAKAPI_DB_NAME"`
|
||||
Dialect string `yaml:"-"`
|
||||
Charset string `default:"utf8mb4" env:"WAKAPI_DB_CHARSET"`
|
||||
Type string `yaml:"dialect" default:"sqlite3" env:"WAKAPI_DB_TYPE"`
|
||||
MaxConn uint `yaml:"max_conn" default:"2" env:"WAKAPI_DB_MAX_CONNECTIONS"`
|
||||
Ssl bool `default:"false" env:"WAKAPI_DB_SSL"`
|
||||
AutoMigrateFailSilently bool `yaml:"automigrate_fail_silently" default:"false" env:"WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY"`
|
||||
}
|
||||
|
||||
type serverConfig struct {
|
||||
@ -176,25 +174,25 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
||||
switch dbDialect {
|
||||
default:
|
||||
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
|
||||
}
|
||||
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.KeyStringValue{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Alias{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Alias{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Heartbeat{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Summary{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Summary{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.SummaryItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return cloneStringMap(c.CustomLanguages, false)
|
||||
}
|
||||
|
85
config/db.go
Normal file
85
config/db.go
Normal 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
@ -9,6 +9,11 @@ type AliasRepositoryMock struct {
|
||||
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) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
|
@ -15,6 +15,11 @@ func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
|
||||
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) {
|
||||
args := m.Called(user, time, time2)
|
||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||
|
@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -30,6 +31,9 @@ type StatsData struct {
|
||||
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
|
||||
totalTime := summary.TotalTime()
|
||||
numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24)
|
||||
if math.IsInf(float64(numDays), 0) {
|
||||
numDays = 0
|
||||
}
|
||||
|
||||
data := &StatsData{
|
||||
Username: summary.UserID,
|
||||
|
@ -129,7 +129,6 @@ func newDataFrom(s *models.Summary) *SummariesData {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Languages {
|
||||
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
||||
|
||||
}
|
||||
}(data)
|
||||
|
||||
|
@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -30,24 +29,24 @@ type Interval struct {
|
||||
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
|
||||
|
||||
func (j *CustomTime) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(j.String())
|
||||
return json.Marshal(j.T())
|
||||
}
|
||||
|
||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
|
||||
i, err := strconv.ParseInt(s, 10, 64)
|
||||
s := strings.Trim(string(b), "\"")
|
||||
ts, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
// heartbeat timestamps arrive as strings for sqlite and as time.Time for postgres
|
||||
func (j *CustomTime) Scan(value interface{}) error {
|
||||
var (
|
||||
t time.Time
|
||||
@ -56,13 +55,12 @@ func (j *CustomTime) Scan(value interface{}) error {
|
||||
|
||||
switch value.(type) {
|
||||
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))
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("unsupported date time format: %s", value))
|
||||
}
|
||||
case int64:
|
||||
t = time.Unix(0, value.(int64))
|
||||
break
|
||||
case time.Time:
|
||||
t = value.(time.Time)
|
||||
break
|
||||
@ -76,18 +74,17 @@ func (j *CustomTime) Scan(value interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *CustomTime) Hash() (uint64, error) {
|
||||
return uint64((j.T().UnixNano() / 1000) / 1000), nil
|
||||
}
|
||||
|
||||
func (j CustomTime) Value() (driver.Value, error) {
|
||||
t := time.Unix(0, j.T().UnixNano()/int64(time.Millisecond)*int64(time.Millisecond)) // round to millisecond precision
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (j *CustomTime) Hash() (uint64, error) {
|
||||
return uint64((j.T().UnixNano() / 1000) / 1000), nil
|
||||
}
|
||||
|
||||
func (j CustomTime) String() string {
|
||||
t := time.Time(j)
|
||||
return t.Format("2006-01-02 15:04:05.000")
|
||||
return j.T().String()
|
||||
}
|
||||
|
||||
func (j CustomTime) T() time.Time {
|
||||
|
@ -1,6 +1,9 @@
|
||||
package models
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
mailRegex = regexp.MustCompile(MailPattern)
|
||||
@ -10,6 +13,7 @@ type User struct {
|
||||
ID string `json:"id" gorm:"primary_key"`
|
||||
ApiKey string `json:"api_key" gorm:"unique"`
|
||||
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
||||
Location string `json:"location"`
|
||||
Password string `json:"-"`
|
||||
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"`
|
||||
@ -35,6 +39,7 @@ type Signup struct {
|
||||
Email string `schema:"email"`
|
||||
Password string `schema:"password"`
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
Location string `schema:"location"`
|
||||
}
|
||||
|
||||
type SetPasswordRequest struct {
|
||||
@ -54,7 +59,8 @@ type CredentialsReset struct {
|
||||
}
|
||||
|
||||
type UserDataUpdate struct {
|
||||
Email string `schema:"email"`
|
||||
Email string `schema:"email"`
|
||||
Location string `schema:"location"`
|
||||
}
|
||||
|
||||
type TimeByUser struct {
|
||||
@ -67,6 +73,22 @@ type CountByUser struct {
|
||||
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 {
|
||||
return ValidatePassword(c.PasswordNew) &&
|
||||
c.PasswordNew == c.PasswordRepeat
|
||||
@ -85,7 +107,7 @@ func (s *Signup) IsValid() bool {
|
||||
}
|
||||
|
||||
func (r *UserDataUpdate) IsValid() bool {
|
||||
return ValidateEmail(r.Email)
|
||||
return ValidateEmail(r.Email) && ValidateTimezone(r.Location)
|
||||
}
|
||||
|
||||
func ValidateUsername(username string) bool {
|
||||
@ -99,3 +121,8 @@ func ValidatePassword(password string) bool {
|
||||
func ValidateEmail(email string) bool {
|
||||
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
19
models/user_test.go
Normal 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))
|
||||
}
|
@ -14,6 +14,14 @@ func NewAliasRepository(db *gorm.DB) *AliasRepository {
|
||||
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) {
|
||||
var aliases []*models.Alias
|
||||
if err := r.db.
|
||||
|
@ -15,6 +15,15 @@ func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
|
||||
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 {
|
||||
if err := r.db.
|
||||
Clauses(clause.OnConflict{
|
||||
@ -45,8 +54,8 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := r.db.
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Where("time >= ?", from).
|
||||
Where("time < ?", to).
|
||||
Where("time >= ?", from.Local()).
|
||||
Where("time < ?", to.Local()).
|
||||
Order("time asc").
|
||||
Find(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
@ -117,7 +126,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
||||
|
||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||
if err := r.db.
|
||||
Where("time <= ?", t).
|
||||
Where("time <= ?", t.Local()).
|
||||
Delete(models.Heartbeat{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -15,6 +15,14 @@ func NewKeyValueRepository(db *gorm.DB) *KeyValueRepository {
|
||||
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) {
|
||||
kv := &models.KeyStringValue{}
|
||||
if err := r.db.
|
||||
|
@ -16,6 +16,14 @@ func NewLanguageMappingRepository(db *gorm.DB) *LanguageMappingRepository {
|
||||
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) {
|
||||
mapping := &models.LanguageMapping{}
|
||||
if err := r.db.Where(&models.LanguageMapping{ID: id}).First(mapping).Error; err != nil {
|
||||
|
@ -9,6 +9,7 @@ type IAliasRepository interface {
|
||||
Insert(*models.Alias) (*models.Alias, error)
|
||||
Delete(uint) error
|
||||
DeleteBatch([]uint) error
|
||||
GetAll() ([]*models.Alias, error)
|
||||
GetByUser(string) ([]*models.Alias, error)
|
||||
GetByUserAndKey(string, string) ([]*models.Alias, error)
|
||||
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
|
||||
@ -17,6 +18,7 @@ type IAliasRepository interface {
|
||||
|
||||
type IHeartbeatRepository interface {
|
||||
InsertBatch([]*models.Heartbeat) error
|
||||
GetAll() ([]*models.Heartbeat, error)
|
||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||
GetLastByUsers() ([]*models.TimeByUser, error)
|
||||
@ -28,12 +30,14 @@ type IHeartbeatRepository interface {
|
||||
}
|
||||
|
||||
type IKeyValueRepository interface {
|
||||
GetAll() ([]*models.KeyStringValue, error)
|
||||
GetString(string) (*models.KeyStringValue, error)
|
||||
PutString(*models.KeyStringValue) error
|
||||
DeleteString(string) error
|
||||
}
|
||||
|
||||
type ILanguageMappingRepository interface {
|
||||
GetAll() ([]*models.LanguageMapping, error)
|
||||
GetById(uint) (*models.LanguageMapping, error)
|
||||
GetByUser(string) ([]*models.LanguageMapping, error)
|
||||
Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
|
||||
@ -42,6 +46,7 @@ type ILanguageMappingRepository interface {
|
||||
|
||||
type ISummaryRepository interface {
|
||||
Insert(*models.Summary) error
|
||||
GetAll() ([]*models.Summary, error)
|
||||
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
|
||||
GetLastByUser() ([]*models.TimeByUser, error)
|
||||
DeleteByUser(string) error
|
||||
|
@ -14,6 +14,21 @@ func NewSummaryRepository(db *gorm.DB) *SummaryRepository {
|
||||
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 {
|
||||
if err := r.db.Create(summary).Error; err != nil {
|
||||
return err
|
||||
@ -25,8 +40,8 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
|
||||
var summaries []*models.Summary
|
||||
if err := r.db.
|
||||
Where(&models.Summary{UserID: user.ID}).
|
||||
Where("from_time >= ?", from).
|
||||
Where("to_time <= ?", to).
|
||||
Where("from_time >= ?", from.Local()).
|
||||
Where("to_time <= ?", to.Local()).
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
|
@ -77,7 +77,7 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
|
||||
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
Where("last_logged_in_at >= ?", t).
|
||||
Where("last_logged_in_at >= ?", t.Local()).
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -96,7 +96,7 @@ func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, erro
|
||||
if err := r.db.
|
||||
Select("user as id").
|
||||
Table("(?) as q", subQuery1).
|
||||
Where("time >= ?", t).
|
||||
Where("time >= ?", t.Local()).
|
||||
Scan(&userIds).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -142,6 +142,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
"wakatime_api_key": user.WakatimeApiKey,
|
||||
"has_data": user.HasData,
|
||||
"reset_token": user.ResetToken,
|
||||
"location": user.Location,
|
||||
}
|
||||
|
||||
result := r.db.Model(user).Updates(updateMap)
|
||||
|
@ -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)
|
||||
|
@ -116,7 +116,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
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)
|
||||
if err != nil {
|
||||
|
@ -51,5 +51,5 @@ func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, http.StatusOK, summary)
|
||||
utils.RespondJSON(w, r, http.StatusOK, summary)
|
||||
}
|
||||
|
@ -74,7 +74,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
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)))
|
||||
// negative value means no limit
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@ -114,11 +114,11 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
vm := v1.NewBadgeDataFrom(summary, filters)
|
||||
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) {
|
||||
err, from, to := utils.ResolveInterval(interval)
|
||||
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
@ -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")))
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
|
||||
|
@ -62,7 +62,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
rangeParam = (*models.IntervalPast7Days)[0]
|
||||
}
|
||||
|
||||
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(rangeParam)
|
||||
err, rangeFrom, rangeTo := utils.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid range"))
|
||||
@ -103,7 +103,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
|
@ -76,7 +76,7 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -87,12 +87,12 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
||||
var start, end time.Time
|
||||
if rangeParam != "" {
|
||||
// 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
|
||||
} else {
|
||||
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
|
||||
start, end = parsedFrom, parsedTo
|
||||
} else {
|
||||
|
@ -166,6 +166,7 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
user.Email = payload.Email
|
||||
user.Location = payload.Location
|
||||
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
return http.StatusInternalServerError, "", conf.ErrInternalServerError
|
||||
|
@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
@ -8,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
|
||||
user := middlewares.GetPrincipal(r)
|
||||
summaryParams, err := utils.ParseSummaryParams(r)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
@ -23,5 +25,8 @@ func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summ
|
||||
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
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker run -d -p 5432:5432 -e POSTGRES_DATABASE=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres
|
||||
docker run -d -p 5432:5432 -e POSTGRES_DB=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres
|
@ -9,7 +9,6 @@ from datetime import datetime, timedelta
|
||||
from typing import List, Union, Callable
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
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'
|
||||
@ -53,6 +52,7 @@ class ConfigParams:
|
||||
self.n_projects = 0
|
||||
self.offset = 0
|
||||
self.seed = 0
|
||||
self.batch = False
|
||||
|
||||
|
||||
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):
|
||||
encoded_key: str = str(base64.b64encode(api_key.encode('utf-8')), 'utf-8')
|
||||
|
||||
for h in data:
|
||||
r = requests.post(url, json=[h.__dict__], headers={
|
||||
'User-Agent': UA,
|
||||
'Authorization': f'Basic {encoded_key}',
|
||||
'X-Machine-Name': MACHINE,
|
||||
})
|
||||
if r.status_code != 201:
|
||||
print(r.text)
|
||||
sys.exit(1)
|
||||
r = requests.post(url, json=[h.__dict__ for h in data], headers={
|
||||
'User-Agent': UA,
|
||||
'Authorization': f'Basic {encoded_key}',
|
||||
'X-Machine-Name': MACHINE,
|
||||
})
|
||||
if r.status_code != 201:
|
||||
print(r.text)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
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.QtWidgets import QApplication, QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QLabel, \
|
||||
QLineEdit, QSpinBox, QProgressBar, QPushButton
|
||||
QLineEdit, QSpinBox, QProgressBar, QPushButton, QCheckBox
|
||||
|
||||
# Main app
|
||||
app = QApplication([])
|
||||
@ -153,10 +153,14 @@ def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) ->
|
||||
seed_input.setMaximum(2147483647)
|
||||
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(projects_input_label, projects_input)
|
||||
form_layout_2.addRow(offset_input_label, offset_input)
|
||||
form_layout_2.addRow(seed_input_label, seed_input)
|
||||
form_layout_2.addRow(batch_checkbox)
|
||||
|
||||
# Bottom controls
|
||||
bottom_layout = QHBoxLayout()
|
||||
@ -195,6 +199,7 @@ def make_gui(callback: Callable[[ConfigParams, Callable[[int], None]], None]) ->
|
||||
params.n_projects = projects_input.value()
|
||||
params.offset = offset_input.value()
|
||||
params.seed = seed_input.value()
|
||||
params.batch = batch_checkbox.isChecked()
|
||||
return params
|
||||
|
||||
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')
|
||||
parser.add_argument('-s', '--seed', type=int, default=2020,
|
||||
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()
|
||||
|
||||
|
||||
@ -242,6 +248,7 @@ def args_to_params(parsed_args: argparse.Namespace) -> (ConfigParams, bool):
|
||||
params.seed = parsed_args.seed
|
||||
params.api_url = parsed_args.url
|
||||
params.api_key = parsed_args.apikey
|
||||
params.batch = parsed_args.batch
|
||||
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
|
||||
)
|
||||
|
||||
for d in data:
|
||||
post_data_sync([d], f'{params.api_url}/heartbeats', params.api_key)
|
||||
update_progress(1)
|
||||
# batch-mode won't work when using sqlite backend
|
||||
if params.batch:
|
||||
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__':
|
||||
@ -270,5 +282,7 @@ if __name__ == '__main__':
|
||||
window.show()
|
||||
app.exec()
|
||||
else:
|
||||
from tqdm import tqdm
|
||||
|
||||
pbar = tqdm(total=params.n)
|
||||
run(params, pbar.update)
|
||||
|
276
scripts/sqlite2mysql.go
Normal file
276
scripts/sqlite2mysql.go
Normal 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
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron"
|
||||
@ -14,11 +16,14 @@ const (
|
||||
aggregateIntervalDays int = 1
|
||||
)
|
||||
|
||||
var lock = sync.Mutex{}
|
||||
|
||||
type AggregationService struct {
|
||||
config *config.Config
|
||||
userService IUserService
|
||||
summaryService ISummaryService
|
||||
heartbeatService IHeartbeatService
|
||||
inProgress map[string]bool
|
||||
}
|
||||
|
||||
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
|
||||
@ -27,6 +32,7 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
|
||||
userService: userService,
|
||||
summaryService: summaryService,
|
||||
heartbeatService: heartbeatService,
|
||||
inProgress: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +55,11 @@ func (srv *AggregationService) Schedule() {
|
||||
}
|
||||
|
||||
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)
|
||||
summaries := make(chan *models.Summary)
|
||||
|
||||
@ -145,6 +156,28 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
|
||||
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) {
|
||||
var to time.Time
|
||||
|
||||
|
@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
@ -236,7 +237,15 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
|
||||
Machines: make([]*models.SummaryItem, 0),
|
||||
}
|
||||
|
||||
var processed = map[time.Time]bool{}
|
||||
|
||||
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 {
|
||||
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.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||
|
||||
processed[hash] = true
|
||||
}
|
||||
|
||||
finalSummary.FromTime = models.CustomTime(minTime)
|
||||
|
@ -291,6 +291,52 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
||||
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() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
|
||||
|
@ -76,8 +76,9 @@ func (srv *UserService) Count() (int64, error) {
|
||||
func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
|
||||
u := &models.User{
|
||||
ID: signup.Username,
|
||||
Email: signup.Email,
|
||||
ApiKey: uuid.NewV4().String(),
|
||||
Email: signup.Email,
|
||||
Location: signup.Location,
|
||||
Password: signup.Password,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
352
static/assets/timezones.js
Normal file
352
static/assets/timezones.js
Normal 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'
|
||||
]
|
@ -7,12 +7,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func ParseDate(date string) (time.Time, error) {
|
||||
return time.Parse(config.SimpleDateFormat, date)
|
||||
}
|
||||
|
||||
func ParseDateTime(date string) (time.Time, error) {
|
||||
return time.Parse(config.SimpleDateTimeFormat, date)
|
||||
// ParseDateTimeTZ attempts to parse the given date string from multiple formats.
|
||||
// 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
|
||||
// Example:
|
||||
// - 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 {
|
||||
|
@ -5,33 +5,42 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartOfToday() time.Time {
|
||||
return StartOfDay(time.Now())
|
||||
}
|
||||
|
||||
func StartOfDay(date time.Time) time.Time {
|
||||
return FloorDate(date)
|
||||
}
|
||||
|
||||
func StartOfWeek() time.Time {
|
||||
ref := time.Now()
|
||||
year, week := ref.ISOWeek()
|
||||
return firstDayOfISOWeek(year, week, ref.Location())
|
||||
func StartOfToday(tz *time.Location) time.Time {
|
||||
return StartOfDay(FloorDate(time.Now().In(tz)))
|
||||
}
|
||||
|
||||
func StartOfMonth() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location())
|
||||
func StartOfThisWeek(tz *time.Location) time.Time {
|
||||
return StartOfWeek(time.Now().In(tz))
|
||||
}
|
||||
|
||||
func StartOfYear() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
|
||||
func StartOfWeek(date time.Time) time.Time {
|
||||
year, week := date.ISOWeek()
|
||||
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 {
|
||||
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)
|
||||
@ -43,6 +52,21 @@ func CeilDate(date time.Time) time.Time {
|
||||
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 {
|
||||
intervals := make([][]time.Time, 0)
|
||||
|
||||
|
@ -1,11 +1,26 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"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) {
|
||||
tests := []struct {
|
||||
in string
|
||||
@ -28,3 +43,84 @@ func TestDate_Ceil(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
@ -6,10 +6,10 @@ import (
|
||||
"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.WriteHeader(status)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -16,51 +16,51 @@ func ParseInterval(interval string) (*models.IntervalKey, error) {
|
||||
return nil, errors.New("not a valid interval")
|
||||
}
|
||||
|
||||
func MustResolveIntervalRaw(interval string) (from, to time.Time) {
|
||||
_, from, to = ResolveIntervalRaw(interval)
|
||||
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
|
||||
_, from, to = ResolveIntervalRawTZ(interval, tz)
|
||||
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)
|
||||
if err != nil {
|
||||
return err, time.Time{}, time.Time{}
|
||||
}
|
||||
return ResolveInterval(parsed)
|
||||
return ResolveIntervalTZ(parsed, tz)
|
||||
}
|
||||
|
||||
func ResolveInterval(interval *models.IntervalKey) (err error, from, to time.Time) {
|
||||
to = time.Now()
|
||||
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
||||
to = time.Now().In(tz)
|
||||
|
||||
switch interval {
|
||||
case models.IntervalToday:
|
||||
from = StartOfToday()
|
||||
from = StartOfToday(tz)
|
||||
case models.IntervalYesterday:
|
||||
from = StartOfToday().Add(-24 * time.Hour)
|
||||
to = StartOfToday()
|
||||
from = StartOfToday(tz).Add(-24 * time.Hour)
|
||||
to = StartOfToday(tz)
|
||||
case models.IntervalThisWeek:
|
||||
from = StartOfWeek()
|
||||
from = StartOfThisWeek(tz)
|
||||
case models.IntervalLastWeek:
|
||||
from = StartOfWeek().AddDate(0, 0, -7)
|
||||
to = StartOfWeek()
|
||||
from = StartOfThisWeek(tz).AddDate(0, 0, -7)
|
||||
to = StartOfThisWeek(tz)
|
||||
case models.IntervalThisMonth:
|
||||
from = StartOfMonth()
|
||||
from = StartOfThisMonth(tz)
|
||||
case models.IntervalLastMonth:
|
||||
from = StartOfMonth().AddDate(0, -1, 0)
|
||||
to = StartOfMonth()
|
||||
from = StartOfThisMonth(tz).AddDate(0, -1, 0)
|
||||
to = StartOfThisMonth(tz)
|
||||
case models.IntervalThisYear:
|
||||
from = StartOfYear()
|
||||
from = StartOfThisYear(tz)
|
||||
case models.IntervalPast7Days:
|
||||
from = StartOfToday().AddDate(0, 0, -7)
|
||||
from = StartOfToday(tz).AddDate(0, 0, -7)
|
||||
case models.IntervalPast7DaysYesterday:
|
||||
from = StartOfToday().AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||
to = StartOfToday().AddDate(0, 0, -1)
|
||||
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||
to = StartOfToday(tz).AddDate(0, 0, -1)
|
||||
case models.IntervalPast14Days:
|
||||
from = StartOfToday().AddDate(0, 0, -14)
|
||||
from = StartOfToday(tz).AddDate(0, 0, -14)
|
||||
case models.IntervalPast30Days:
|
||||
from = StartOfToday().AddDate(0, 0, -30)
|
||||
from = StartOfToday(tz).AddDate(0, 0, -30)
|
||||
case models.IntervalPast12Months:
|
||||
from = StartOfToday().AddDate(0, -12, 0)
|
||||
from = StartOfToday(tz).AddDate(0, -12, 0)
|
||||
case models.IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
@ -78,24 +78,18 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
|
||||
var from, to time.Time
|
||||
|
||||
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 != "" {
|
||||
err, from, to = ResolveIntervalRaw(start)
|
||||
err, from, to = ResolveIntervalRawTZ(start, user.TZ())
|
||||
} else {
|
||||
from, err = ParseDateTime(params.Get("from"))
|
||||
from, err = ParseDateTimeTZ(params.Get("from"), user.TZ())
|
||||
if err != nil {
|
||||
from, err = ParseDate(params.Get("from"))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing 'from' parameter")
|
||||
}
|
||||
return nil, errors.New("missing or invalid 'from' parameter")
|
||||
}
|
||||
|
||||
to, err = ParseDateTime(params.Get("to"))
|
||||
to, err = ParseDateTimeTZ(params.Get("to"), user.TZ())
|
||||
if err != nil {
|
||||
to, err = ParseDate(params.Get("to"))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing 'to' parameter")
|
||||
}
|
||||
return nil, errors.New("missing or invalid 'to' parameter")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1 +1 @@
|
||||
1.26.5
|
||||
1.26.7
|
||||
|
@ -70,12 +70,8 @@
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Fancy statistics and plots</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Cool badges for readmes</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Intuitive REST API</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Compatible with <a href="https://wakatime.com" target="_blank"
|
||||
rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> <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> Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> <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> Lightning fast</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Self-hosted</li>
|
||||
</ul>
|
||||
|
@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
|
||||
{{ 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">
|
||||
|
||||
@ -23,7 +24,7 @@
|
||||
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-2xl flex-grow">
|
||||
<div><a href="/" class="text-gray-500 text-sm cursor-pointer">← Go back</a></div>
|
||||
<div><a href="" class="text-gray-500 text-sm cursor-pointer">← 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> </div>
|
||||
</div>
|
||||
@ -37,25 +38,37 @@
|
||||
<details class="my-8 pb-8 border-b border-gray-700">
|
||||
<summary class="cursor-pointer">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||
id="email-heading">
|
||||
Change E-Mail Address
|
||||
id="preferences-heading">
|
||||
Account Preferences
|
||||
</h2>
|
||||
</summary>
|
||||
<div class="w-full">
|
||||
<form class="mt-10" action="" method="post">
|
||||
<input type="hidden" name="action" value="update_user">
|
||||
<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"
|
||||
type="email" id="email"
|
||||
name="email" placeholder="Enter your e-mail address"
|
||||
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"
|
||||
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
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
</details>
|
||||
@ -70,7 +83,8 @@
|
||||
<form class="mt-10" action="" method="post">
|
||||
<input type="hidden" name="action" value="change_password">
|
||||
<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"
|
||||
type="password" id="password_old"
|
||||
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>
|
||||
</div>
|
||||
<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"
|
||||
type="password" id="password_repeat"
|
||||
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@ -240,10 +256,14 @@
|
||||
</summary>
|
||||
|
||||
<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">
|
||||
<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/v1/users/{user}/stats/{range}</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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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"
|
||||
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 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>
|
||||
</div>
|
||||
<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">
|
||||
<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 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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -271,9 +297,14 @@
|
||||
<span class="mr-2">Share languages: </span>
|
||||
</div>
|
||||
<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">
|
||||
<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 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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -282,9 +313,14 @@
|
||||
<span class="mr-2">Share editors: </span>
|
||||
</div>
|
||||
<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">
|
||||
<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 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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -293,9 +329,14 @@
|
||||
<span class="mr-2">Share operating systems: </span>
|
||||
</div>
|
||||
<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">
|
||||
<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 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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -304,15 +345,22 @@
|
||||
<span class="mr-2">Share machines: </span>
|
||||
</div>
|
||||
<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">
|
||||
<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 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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@ -394,7 +442,8 @@
|
||||
</form>
|
||||
|
||||
<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
|
||||
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94"
|
||||
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
|
||||
href="https://shields.io" target="_blank" class="border-b border-green-800"
|
||||
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
|
||||
access to the respective endpoint. 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 }}
|
||||
</div>
|
||||
|
||||
@ -451,7 +501,10 @@
|
||||
GitHub Readme Stats
|
||||
</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 }}
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<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
|
||||
</span>
|
||||
</p>
|
||||
@ -578,11 +632,36 @@
|
||||
|
||||
const btnImportWakatime = document.querySelector('#btn-import-wakatime')
|
||||
const formImportWakatime = document.querySelector('#form-import-wakatime')
|
||||
btnImportWakatime.addEventListener('click', () => {
|
||||
if (confirm('Are you sure? The import can not be undone.')) {
|
||||
formImportWakatime.submit()
|
||||
}
|
||||
})
|
||||
if (btnImportWakatime) {
|
||||
btnImportWakatime.addEventListener('click', () => {
|
||||
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>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
@ -34,6 +34,8 @@
|
||||
</div>
|
||||
|
||||
<form class="mt-10" action="signup" method="post">
|
||||
<input type="hidden" name="location" id="input-location">
|
||||
|
||||
<div class="mb-8">
|
||||
<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"
|
||||
@ -78,6 +80,14 @@
|
||||
{{ template "footer.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>
|
||||
|
||||
</html>
|
@ -212,7 +212,7 @@
|
||||
{{ template "foot.tpl.html" . }}
|
||||
|
||||
<script>
|
||||
document.addEventListener('load', function() {
|
||||
window.addEventListener('load', function() {
|
||||
document.getElementById('api-key-instruction').innerHTML = document.getElementById('api-key-container').value
|
||||
})
|
||||
</script>
|
||||
|
Reference in New Issue
Block a user