mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
31 Commits
Author | SHA1 | Date | |
---|---|---|---|
6e2f3e6731 | |||
d60dddb550 | |||
19a8c61f77 | |||
fde8c35362 | |||
8dca9f5cc0 | |||
570aeebe01 | |||
21567e7601 | |||
a8009e107d | |||
84e9559860 | |||
7c8ea86d4e | |||
587ac6a330 | |||
97cb29ee4d | |||
cecb5e113c | |||
75b33d5e42 | |||
50b7a9ec3d | |||
82ed386359 | |||
12cc4cd9cf | |||
2eccb7a468 | |||
08a83af8da | |||
c0d6855546 | |||
0af7d2f8ef | |||
11d1d5bc99 | |||
ada0863f7c | |||
7818f6b094 | |||
f86eb7668d | |||
24469e4922 | |||
4f035b3a63 | |||
0eac9a8854 | |||
0294425de0 | |||
a7c83252ef | |||
07a03ce3ac |
@ -9,7 +9,7 @@ RUN cd /src && go build -o wakapi
|
||||
|
||||
# When running the application using `docker run`, you can pass environment variables
|
||||
# to override config values from .env using `-e` syntax.
|
||||
# Available options are:
|
||||
# Available options are:
|
||||
# – WAKAPI_DB_TYPE
|
||||
# – WAKAPI_DB_USER
|
||||
# – WAKAPI_DB_PASSWORD
|
||||
@ -17,8 +17,7 @@ RUN cd /src && go build -o wakapi
|
||||
# – WAKAPI_DB_PORT
|
||||
# – WAKAPI_DB_NAME
|
||||
# – WAKAPI_PASSWORD_SALT
|
||||
# – WAKAPI_DEFAULT_USER_NAME
|
||||
# – WAKAPI_DEFAULT_USER_PASSWORD
|
||||
# – WAKAPI_BASE_PATH
|
||||
|
||||
FROM debian
|
||||
WORKDIR /app
|
||||
@ -30,8 +29,6 @@ ENV WAKAPI_DB_PASSWORD ''
|
||||
ENV WAKAPI_DB_HOST ''
|
||||
ENV WAKAPI_DB_NAME=/data/wakapi.db
|
||||
ENV WAKAPI_PASSWORD_SALT ''
|
||||
ENV WAKAPI_DEFAULT_USER_NAME admin
|
||||
ENV WAKAPI_DEFAULT_USER_PASSWORD admin
|
||||
|
||||
COPY --from=build-env /src/wakapi /app/
|
||||
COPY --from=build-env /src/config.ini /app/
|
||||
|
27
README.md
27
README.md
@ -4,9 +4,7 @@
|
||||
[](https://saythanks.io/to/n1try)
|
||||

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

|
||||
---
|
||||
|
||||
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
|
||||
@ -80,8 +78,29 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam
|
||||
* Language ~ type **1**
|
||||
* Editor ~ type **2**
|
||||
* OS ~ type **3**
|
||||
* Machine ~ type **4**
|
||||
|
||||
## API Endpoints
|
||||
The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)).
|
||||
|
||||
* `POST /api/heartbeat`
|
||||
* `GET /api/summary`
|
||||
* `string` parameter `interval`: One of `today`, `day`, `week`, `month`, `year`, `any`
|
||||
* `GET /api/compat/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
|
||||
* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
|
||||
* `GET /api/health`
|
||||
|
||||
## Prometheus Export
|
||||
If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**.
|
||||
|
||||
[](https://github.com/MacroPower/wakatime_exporter)
|
||||
|
||||
It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible.
|
||||
|
||||
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly.
|
||||
|
||||
## Badges
|
||||
|
||||
**NOTE:** In order for the aliases to take effect for non-live statistics, you would either have to wait 24 hours for the cache to be invalidated or restart Wakapi.
|
||||
|
||||
## Best Practices
|
||||
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
|
||||
|
@ -11,4 +11,5 @@ cleanup = false
|
||||
max_connections = 2
|
||||
|
||||
[languages]
|
||||
vue = Vue
|
||||
vue = Vue
|
||||
jsx = JSX
|
2
go.mod
2
go.mod
@ -14,7 +14,7 @@ require (
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f
|
||||
github.com/t-tiger/gorm-bulk-insert v1.3.0
|
||||
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c
|
||||
gopkg.in/ini.v1 v1.50.0
|
||||
)
|
||||
|
2
go.sum
2
go.sum
@ -318,6 +318,8 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f h1:Op5lFYUNE7tPxu6gJfwkgY8HMIWpLqiLApBJfGs71U8=
|
||||
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f/go.mod h1:SK1RZT4TR1aMUNGtbk6YxTPgx2D/gfbxB571QGnAV+c=
|
||||
github.com/t-tiger/gorm-bulk-insert v1.3.0 h1:9k7BaVEhw/3fsvh6GTOBwJ2RXk3asc5xs5m6hwozq20=
|
||||
github.com/t-tiger/gorm-bulk-insert v1.3.0/go.mod h1:ruDlk8xDl+8sX4bA7PQuYly9YEb3pbp1eP2LCyeRrFY=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
|
54
main.go
54
main.go
@ -15,6 +15,8 @@ import (
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/routes"
|
||||
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
|
||||
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
@ -43,6 +45,11 @@ func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
}
|
||||
|
||||
// Show data loss warning
|
||||
if config.CleanUp {
|
||||
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
var err error
|
||||
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
|
||||
@ -81,17 +88,27 @@ func main() {
|
||||
go heartbeatService.ScheduleCleanUp()
|
||||
}
|
||||
|
||||
// TODO: move endpoint registration to the respective routes files
|
||||
|
||||
// Handlers
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||
healthHandler := routes.NewHealthHandler(db)
|
||||
settingsHandler := routes.NewSettingsHandler(userService)
|
||||
publicHandler := routes.NewIndexHandler(userService, keyValueService)
|
||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
|
||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
|
||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||
|
||||
// Setup Routers
|
||||
router := mux.NewRouter()
|
||||
publicRouter := router.PathPrefix("/").Subrouter()
|
||||
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
|
||||
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
|
||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||
compatRouter := apiRouter.PathPrefix("/compat").Subrouter()
|
||||
wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1").Subrouter()
|
||||
shieldsV1Router := compatRouter.PathPrefix("/shields/v1").Subrouter()
|
||||
|
||||
// Middlewares
|
||||
recoveryMiddleware := handlers.RecoveryHandler()
|
||||
@ -99,29 +116,44 @@ func main() {
|
||||
corsMiddleware := handlers.CORS()
|
||||
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
|
||||
userService,
|
||||
[]string{"/api/health"},
|
||||
[]string{"/api/health", "/api/compat/shields/v1"},
|
||||
).Handler
|
||||
|
||||
// Router configs
|
||||
router.Use(loggingMiddleware, recoveryMiddleware)
|
||||
summaryRouter.Use(authenticateMiddleware)
|
||||
settingsRouter.Use(authenticateMiddleware)
|
||||
apiRouter.Use(corsMiddleware, authenticateMiddleware)
|
||||
|
||||
// Public Routes
|
||||
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.Index)
|
||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.Login)
|
||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.Logout)
|
||||
publicRouter.Path("/signup").Methods(http.MethodGet, http.MethodPost).HandlerFunc(publicHandler.Signup)
|
||||
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.Imprint)
|
||||
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
|
||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
|
||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
|
||||
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
|
||||
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
|
||||
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
|
||||
|
||||
// Summary Routes
|
||||
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.Index)
|
||||
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
|
||||
|
||||
// Settings Routes
|
||||
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
|
||||
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
|
||||
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
|
||||
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
|
||||
|
||||
// API Routes
|
||||
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
|
||||
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet)
|
||||
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
|
||||
|
||||
// Wakatime compat V1 API Routes
|
||||
wakatimeV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(wakatimeV1AllHandler.ApiGet)
|
||||
wakatimeV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(wakatimeV1SummariesHandler.ApiGet)
|
||||
|
||||
// Shields.io compat API Routes
|
||||
shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet)
|
||||
|
||||
// Static Routes
|
||||
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
|
||||
|
||||
@ -163,3 +195,11 @@ func migrateLanguages() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func promptAbort(message string, timeoutSec int) {
|
||||
log.Printf("[WARNING] %s.\nTo abort server startup, press Ctrl+C.\n", message)
|
||||
for i := timeoutSec; i > 0; i-- {
|
||||
log.Printf("Starting in %d seconds ...\n", i)
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
insert into key_string_values (`key`, `value`) values ('imprint', 'no content here');
|
||||
insert into key_string_values ("key", "value") values ('imprint', 'no content here');
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
delete from key_string_values where `key` = 'imprint';
|
||||
SET SQL_MODE=ANSI_QUOTES;
|
||||
delete from key_string_values where key = 'imprint';
|
11
migrations/sqlite3/4_machine_column.sql
Normal file
11
migrations/sqlite3/4_machine_column.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
|
||||
alter table heartbeats
|
||||
add column `machine` varchar(255);
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
alter table heartbeats
|
||||
drop column `machine`;
|
11
migrations/sqlite3/5_badges_column.sql
Normal file
11
migrations/sqlite3/5_badges_column.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
|
||||
alter table users
|
||||
add column `badges_enabled` tinyint(1) default 0 not null;
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
alter table users
|
||||
drop column `badges_enabled`;
|
37
models/compat/shields/v1/badge.go
Normal file
37
models/compat/shields/v1/badge.go
Normal file
@ -0,0 +1,37 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://shields.io/endpoint
|
||||
|
||||
const (
|
||||
defaultLabel = "coding time"
|
||||
defaultColor = "#2D3748" // not working
|
||||
)
|
||||
|
||||
type BadgeData struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Label string `json:"label"`
|
||||
Message string `json:"message"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData {
|
||||
var total time.Duration
|
||||
if hasFilter, filterType, filterKey := filters.First(); hasFilter {
|
||||
total = summary.TotalTimeByKey(filterType, filterKey)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
||||
return &BadgeData{
|
||||
SchemaVersion: 1,
|
||||
Label: defaultLabel,
|
||||
Message: utils.FmtWakatimeDuration(total),
|
||||
Color: defaultColor,
|
||||
}
|
||||
}
|
36
models/compat/wakatime/v1/all_time.go
Normal file
36
models/compat/wakatime/v1/all_time.go
Normal file
@ -0,0 +1,36 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/developers#all_time_since_today
|
||||
|
||||
type AllTimeViewModel struct {
|
||||
Data *allTimeData `json:"data"`
|
||||
}
|
||||
|
||||
type allTimeData struct {
|
||||
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
|
||||
Text string `json:"text"` // total time logged since account created as human readable string>
|
||||
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
|
||||
}
|
||||
|
||||
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
|
||||
var total time.Duration
|
||||
if key := filters.Project; key != "" {
|
||||
total = summary.TotalTimeByKey(models.SummaryProject, key)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
||||
return &AllTimeViewModel{
|
||||
Data: &allTimeData{
|
||||
TotalSeconds: float32(total.Seconds()),
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
IsUpToDate: true,
|
||||
},
|
||||
}
|
||||
}
|
172
models/compat/wakatime/v1/summaries.go
Normal file
172
models/compat/wakatime/v1/summaries.go
Normal file
@ -0,0 +1,172 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/developers#summaries
|
||||
// https://pastr.de/v/736450
|
||||
|
||||
type SummariesViewModel struct {
|
||||
Data []*summariesData `json:"data"`
|
||||
End time.Time `json:"end"`
|
||||
Start time.Time `json:"start"`
|
||||
}
|
||||
|
||||
type summariesData struct {
|
||||
Categories []*summariesEntry `json:"categories"`
|
||||
Dependencies []*summariesEntry `json:"dependencies"`
|
||||
Editors []*summariesEntry `json:"editors"`
|
||||
Languages []*summariesEntry `json:"languages"`
|
||||
Machines []*summariesEntry `json:"machines"`
|
||||
OperatingSystems []*summariesEntry `json:"operating_systems"`
|
||||
Projects []*summariesEntry `json:"projects"`
|
||||
GrandTotal *summariesGrandTotal `json:"grand_total"`
|
||||
Range *summariesRange `json:"range"`
|
||||
}
|
||||
|
||||
type summariesEntry struct {
|
||||
Digital string `json:"digital"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
Name string `json:"name"`
|
||||
Percent float64 `json:"percent"`
|
||||
Seconds int `json:"seconds"`
|
||||
Text string `json:"text"`
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
}
|
||||
|
||||
type summariesGrandTotal struct {
|
||||
Digital string `json:"digital"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
Text string `json:"text"`
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
}
|
||||
|
||||
type summariesRange struct {
|
||||
Date string `json:"date"`
|
||||
End time.Time `json:"end"`
|
||||
Start time.Time `json:"start"`
|
||||
Text string `json:"text"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
|
||||
data := make([]*summariesData, len(summaries))
|
||||
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
|
||||
|
||||
for i, s := range summaries {
|
||||
data[i] = newDataFrom(s)
|
||||
|
||||
if s.FromTime.Before(minDate) {
|
||||
minDate = s.FromTime
|
||||
}
|
||||
if s.ToTime.After(maxDate) {
|
||||
maxDate = s.ToTime
|
||||
}
|
||||
}
|
||||
|
||||
return &SummariesViewModel{
|
||||
Data: data,
|
||||
End: maxDate,
|
||||
Start: minDate,
|
||||
}
|
||||
}
|
||||
|
||||
func newDataFrom(s *models.Summary) *summariesData {
|
||||
zone, _ := time.Now().Zone()
|
||||
total := s.TotalTime()
|
||||
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
|
||||
|
||||
data := &summariesData{
|
||||
Categories: make([]*summariesEntry, 0),
|
||||
Dependencies: make([]*summariesEntry, 0),
|
||||
Editors: make([]*summariesEntry, len(s.Editors)),
|
||||
Languages: make([]*summariesEntry, len(s.Languages)),
|
||||
Machines: make([]*summariesEntry, len(s.Machines)),
|
||||
OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)),
|
||||
Projects: make([]*summariesEntry, len(s.Projects)),
|
||||
GrandTotal: &summariesGrandTotal{
|
||||
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
||||
Hours: totalHrs,
|
||||
Minutes: totalMins,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
},
|
||||
Range: &summariesRange{
|
||||
Date: time.Now().Format(time.RFC3339),
|
||||
End: s.ToTime,
|
||||
Start: s.FromTime,
|
||||
Text: "",
|
||||
Timezone: zone,
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(5)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Projects {
|
||||
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Editors {
|
||||
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Languages {
|
||||
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
||||
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.OperatingSystems {
|
||||
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Machines {
|
||||
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
|
||||
}
|
||||
}(data)
|
||||
|
||||
wg.Wait()
|
||||
return data
|
||||
}
|
||||
|
||||
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry {
|
||||
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
||||
// TODO: fix some day, while migrating persisted summary items
|
||||
total := e.Total * time.Second
|
||||
hrs := int(total.Hours())
|
||||
mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
|
||||
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())
|
||||
|
||||
return &summariesEntry{
|
||||
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
|
||||
Hours: hrs,
|
||||
Minutes: mins,
|
||||
Name: e.Key,
|
||||
Percent: math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100,
|
||||
Seconds: secs,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
}
|
||||
}
|
@ -161,7 +161,11 @@ func readConfig() *Config {
|
||||
port = cfg.Section("server").Key("port").MustInt()
|
||||
}
|
||||
|
||||
basePathEnv, basePathEnvExists := os.LookupEnv("WAKAPI_BASE_PATH")
|
||||
basePath := cfg.Section("server").Key("base_path").MustString("/")
|
||||
if basePathEnvExists {
|
||||
basePath = basePathEnv
|
||||
}
|
||||
if strings.HasSuffix(basePath, "/") {
|
||||
basePath = basePath[:len(basePath)-1]
|
||||
}
|
||||
|
@ -5,8 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type CustomTime time.Time
|
||||
|
||||
type Heartbeat struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
@ -20,7 +18,8 @@ type Heartbeat struct {
|
||||
IsWrite bool `json:"is_write"`
|
||||
Editor string `json:"editor"`
|
||||
OperatingSystem string `json:"operating_system"`
|
||||
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
|
||||
Machine string `json:"machine"`
|
||||
Time CustomTime `json:"time" gorm:"type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time,idx_time_user"`
|
||||
languageRegex *regexp.Regexp
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jinzhu/gorm"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -23,13 +24,54 @@ type KeyStringValue struct {
|
||||
Value string `gorm:"type:text"`
|
||||
}
|
||||
|
||||
type Filters struct {
|
||||
Project string
|
||||
OS string
|
||||
Language string
|
||||
Editor string
|
||||
Machine string
|
||||
}
|
||||
|
||||
func NewFiltersWith(entity uint8, key string) *Filters {
|
||||
switch entity {
|
||||
case SummaryProject:
|
||||
return &Filters{Project: key}
|
||||
case SummaryOS:
|
||||
return &Filters{Project: key}
|
||||
case SummaryLanguage:
|
||||
return &Filters{Project: key}
|
||||
case SummaryEditor:
|
||||
return &Filters{Project: key}
|
||||
case SummaryMachine:
|
||||
return &Filters{Project: key}
|
||||
}
|
||||
return &Filters{}
|
||||
}
|
||||
|
||||
func (f *Filters) First() (bool, uint8, string) {
|
||||
if f.Project != "" {
|
||||
return true, SummaryProject, f.Project
|
||||
} else if f.OS != "" {
|
||||
return true, SummaryOS, f.OS
|
||||
} else if f.Language != "" {
|
||||
return true, SummaryLanguage, f.Language
|
||||
} else if f.Editor != "" {
|
||||
return true, SummaryEditor, f.Editor
|
||||
} else if f.Machine != "" {
|
||||
return true, SummaryMachine, f.Machine
|
||||
}
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
type CustomTime time.Time
|
||||
|
||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Split(strings.Trim(string(b), "\""), ".")[0]
|
||||
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
|
||||
i, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := time.Unix(i, 0)
|
||||
t := time.Unix(0, i*int64(math.Pow10(19-len(s))))
|
||||
*j = CustomTime(t)
|
||||
return nil
|
||||
}
|
||||
@ -60,7 +102,7 @@ func (j CustomTime) Value() (driver.Value, error) {
|
||||
|
||||
func (j CustomTime) String() string {
|
||||
t := time.Time(j)
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
return t.Format("2006-01-02 15:04:05.000")
|
||||
}
|
||||
|
||||
func (j CustomTime) Time() time.Time {
|
||||
|
@ -5,22 +5,44 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
NSummaryTypes uint8 = 4
|
||||
NSummaryTypes uint8 = 99
|
||||
SummaryProject uint8 = 0
|
||||
SummaryLanguage uint8 = 1
|
||||
SummaryEditor uint8 = 2
|
||||
SummaryOS uint8 = 3
|
||||
SummaryMachine uint8 = 4
|
||||
)
|
||||
|
||||
const (
|
||||
IntervalToday string = "today"
|
||||
IntervalYesterday string = "day"
|
||||
IntervalThisWeek string = "week"
|
||||
IntervalThisMonth string = "month"
|
||||
IntervalThisYear string = "year"
|
||||
IntervalPast7Days string = "7_days"
|
||||
IntervalPast30Days string = "30_days"
|
||||
IntervalPast12Months string = "12_months"
|
||||
IntervalAny string = "any"
|
||||
)
|
||||
|
||||
func Intervals() []string {
|
||||
return []string{
|
||||
IntervalToday, IntervalYesterday, IntervalThisWeek, IntervalThisMonth, IntervalThisYear, IntervalPast7Days, IntervalPast30Days, IntervalPast12Months, IntervalAny,
|
||||
}
|
||||
}
|
||||
|
||||
const UnknownSummaryKey = "unknown"
|
||||
|
||||
type Summary struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime time.Time `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
ToTime time.Time `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
FromTime time.Time `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
|
||||
ToTime time.Time `json:"to" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
|
||||
Projects []*SummaryItem `json:"projects"`
|
||||
Languages []*SummaryItem `json:"languages"`
|
||||
Editors []*SummaryItem `json:"editors"`
|
||||
OperatingSystems []*SummaryItem `json:"operating_systems"`
|
||||
Machines []*SummaryItem `json:"machines"`
|
||||
}
|
||||
|
||||
type SummaryItem struct {
|
||||
@ -43,3 +65,113 @@ type SummaryViewModel struct {
|
||||
Success string
|
||||
ApiKey string
|
||||
}
|
||||
|
||||
type SummaryParams struct {
|
||||
From time.Time
|
||||
To time.Time
|
||||
User *User
|
||||
Recompute bool
|
||||
}
|
||||
|
||||
func SummaryTypes() []uint8 {
|
||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
}
|
||||
|
||||
func (s *Summary) Types() []uint8 {
|
||||
return SummaryTypes()
|
||||
}
|
||||
|
||||
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
|
||||
return map[uint8]*[]*SummaryItem{
|
||||
SummaryProject: &s.Projects,
|
||||
SummaryLanguage: &s.Languages,
|
||||
SummaryEditor: &s.Editors,
|
||||
SummaryOS: &s.OperatingSystems,
|
||||
SummaryMachine: &s.Machines,
|
||||
}
|
||||
}
|
||||
|
||||
/* Augments the summary in a way that at least one item is present for every type.
|
||||
If a summary has zero items for a given type, but one or more for any of the other types,
|
||||
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
|
||||
for the missing type.
|
||||
For instance, the machine type was introduced post hoc. Accordingly, no "machine"-information is present in
|
||||
the data for old heartbeats and summaries. If a user has two years of data without machine information and
|
||||
one day with such, a "machine"-chart plotted from that data will reference a way smaller absolute total amount
|
||||
of time than the other ones.
|
||||
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
|
||||
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
||||
*/
|
||||
func (s *Summary) FillUnknown() {
|
||||
types := s.Types()
|
||||
typeItems := s.MappedItems()
|
||||
missingTypes := make([]uint8, 0)
|
||||
|
||||
for _, t := range types {
|
||||
if len(*typeItems[t]) == 0 {
|
||||
missingTypes = append(missingTypes, t)
|
||||
}
|
||||
}
|
||||
|
||||
// can't proceed if entire summary is empty
|
||||
if len(missingTypes) == len(types) {
|
||||
return
|
||||
}
|
||||
|
||||
timeSum := s.TotalTime()
|
||||
|
||||
// construct dummy item for all missing types
|
||||
for _, t := range missingTypes {
|
||||
*typeItems[t] = append(*typeItems[t], &SummaryItem{
|
||||
Type: t,
|
||||
Key: UnknownSummaryKey,
|
||||
Total: timeSum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTime() time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
mappedItems := s.MappedItems()
|
||||
// calculate total duration from any of the present sets of items
|
||||
for _, t := range s.Types() {
|
||||
if items := mappedItems[t]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
timeSum += item.Total
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeBy(entityType uint8) time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
mappedItems := s.MappedItems()
|
||||
if items := mappedItems[entityType]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
timeSum += item.Total
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeByKey(entityType uint8, key string) time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
mappedItems := s.MappedItems()
|
||||
if items := mappedItems[entityType]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
if item.Key != key {
|
||||
continue
|
||||
}
|
||||
timeSum += item.Total
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ type User struct {
|
||||
Password string `json:"-"`
|
||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
||||
BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
@ -19,8 +20,27 @@ type Signup struct {
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
}
|
||||
|
||||
type CredentialsReset struct {
|
||||
PasswordOld string `schema:"password_old"`
|
||||
PasswordNew string `schema:"password_new"`
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
}
|
||||
|
||||
func (c *CredentialsReset) IsValid() bool {
|
||||
return validatePassword(c.PasswordNew) &&
|
||||
c.PasswordNew == c.PasswordRepeat
|
||||
}
|
||||
|
||||
func (s *Signup) IsValid() bool {
|
||||
return len(s.Username) >= 3 &&
|
||||
len(s.Password) >= 6 &&
|
||||
return validateUsername(s.Username) &&
|
||||
validatePassword(s.Password) &&
|
||||
s.Password == s.PasswordRepeat
|
||||
}
|
||||
|
||||
func validateUsername(username string) bool {
|
||||
return len(username) >= 3 && username != "current"
|
||||
}
|
||||
|
||||
func validatePassword(password string) bool {
|
||||
return len(password) >= 6
|
||||
}
|
||||
|
96
routes/compat/shields/v1/badge.go
Normal file
96
routes/compat/shields/v1/badge.go
Normal file
@ -0,0 +1,96 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/shields/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
intervalPattern = `interval:([a-z0-9_]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)`
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
userSrvc *services.UserService
|
||||
summarySrvc *services.SummaryService
|
||||
config *models.Config
|
||||
}
|
||||
|
||||
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
|
||||
return &BadgeHandler{
|
||||
summarySrvc: summaryService,
|
||||
userSrvc: userService,
|
||||
config: models.GetConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
intervalReg := regexp.MustCompile(intervalPattern)
|
||||
entityFilterReg := regexp.MustCompile(entityFilterPattern)
|
||||
|
||||
requestedUserId := mux.Vars(r)["user"]
|
||||
user, err := h.userSrvc.GetUserById(requestedUserId)
|
||||
if err != nil || !user.BadgesEnabled {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var filterEntity, filterKey string
|
||||
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
|
||||
filterEntity, filterKey = groups[1], groups[2]
|
||||
}
|
||||
|
||||
var interval = models.IntervalPast30Days
|
||||
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
|
||||
interval = groups[1]
|
||||
}
|
||||
|
||||
filters := &models.Filters{}
|
||||
switch filterEntity {
|
||||
case "project":
|
||||
filters.Project = filterKey
|
||||
case "os":
|
||||
filters.OS = filterKey
|
||||
case "editor":
|
||||
filters.Editor = filterKey
|
||||
case "language":
|
||||
filters.Language = filterKey
|
||||
case "machine":
|
||||
filters.Machine = filterKey
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(user, interval)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewBadgeDataFrom(summary, filters)
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) {
|
||||
err, from, to := utils.ResolveInterval(interval)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
summaryParams := &models.SummaryParams{
|
||||
From: from,
|
||||
To: to,
|
||||
User: user,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
63
routes/compat/wakatime/v1/all_time.go
Normal file
63
routes/compat/wakatime/v1/all_time.go
Normal file
@ -0,0 +1,63 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AllTimeHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *models.Config
|
||||
}
|
||||
|
||||
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
|
||||
return &AllTimeHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: models.GetConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
values, _ := url.ParseQuery(r.URL.RawQuery)
|
||||
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(authorizedUser)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")})
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
|
||||
summaryParams := &models.SummaryParams{
|
||||
From: time.Time{},
|
||||
To: time.Now(),
|
||||
User: user,
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
96
routes/compat/wakatime/v1/summaries.go
Normal file
96
routes/compat/wakatime/v1/summaries.go
Normal file
@ -0,0 +1,96 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SummariesHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *models.Config
|
||||
}
|
||||
|
||||
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
|
||||
return &SummariesHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: models.GetConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: support parameters: project, branches, timeout, writes_only, timezone
|
||||
https://wakatime.com/developers#summaries
|
||||
timezone can be specified via an offset suffix (e.g. +02:00) in date strings
|
||||
*/
|
||||
|
||||
func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
summaries, err, status := h.loadUserSummaries(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewSummariesFrom(summaries, &models.Filters{})
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
|
||||
var start, end time.Time
|
||||
// TODO: find out what other special dates are supported by wakatime (e.g. tomorrow, yesterday, ...?)
|
||||
if startKey, endKey := params.Get("start"), params.Get("end"); startKey == "today" && startKey == endKey {
|
||||
start = utils.StartOfToday()
|
||||
end = time.Now()
|
||||
} else {
|
||||
var err error
|
||||
|
||||
start, err = time.Parse(time.RFC3339, strings.Replace(startKey, " ", "+", 1))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
|
||||
}
|
||||
|
||||
end, err = time.Parse(time.RFC3339, strings.Replace(endKey, " ", "+", 1))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
overallParams := &models.SummaryParams{
|
||||
From: start,
|
||||
To: end,
|
||||
User: user,
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
|
||||
summaries := make([]*models.Summary, len(intervals))
|
||||
|
||||
for i, interval := range intervals {
|
||||
summary, err := h.summarySrvc.Construct(interval[0], interval[1], user, false) // 'to' is always constant
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
summaries[i] = summary
|
||||
}
|
||||
|
||||
return summaries, nil, http.StatusOK
|
||||
}
|
@ -23,10 +23,15 @@ func NewHeartbeatHandler(heartbeatService *services.HeartbeatService) *Heartbeat
|
||||
}
|
||||
}
|
||||
|
||||
type heartbeatResponseVm struct {
|
||||
Responses [][]interface{} `json:"responses"`
|
||||
}
|
||||
|
||||
func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
|
||||
machineName := r.Header.Get("X-Machine-Name")
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
if err := dec.Decode(&heartbeats); err != nil {
|
||||
@ -38,6 +43,7 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
for _, hb := range heartbeats {
|
||||
hb.OperatingSystem = opSys
|
||||
hb.Editor = editor
|
||||
hb.Machine = machineName
|
||||
hb.User = user
|
||||
hb.UserID = user.ID
|
||||
hb.Augment(h.config.CustomLanguages)
|
||||
@ -55,5 +61,23 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
||||
}
|
||||
|
||||
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
|
||||
// to make the cli consider all heartbeats to having been successfully saved
|
||||
// response looks like: { "responses": [ [ { "data": {...} }, 201 ], ... ] }
|
||||
func constructSuccessResponse(n int) *heartbeatResponseVm {
|
||||
responses := make([][]interface{}, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
r := make([]interface{}, 2)
|
||||
r[0] = nil
|
||||
r[1] = http.StatusCreated
|
||||
responses[i] = r
|
||||
}
|
||||
|
||||
return &heartbeatResponseVm{
|
||||
Responses: responses,
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ func NewIndexHandler(userService *services.UserService, keyValueService *service
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -43,18 +43,10 @@ func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: make this more generic and reusable
|
||||
if success := r.URL.Query().Get("success"); success != "" {
|
||||
templates["index.tpl.html"].Execute(w, struct {
|
||||
Success string
|
||||
Error string
|
||||
}{Success: success})
|
||||
return
|
||||
}
|
||||
templates["index.tpl.html"].Execute(w, nil)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) Imprint(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -69,7 +61,7 @@ func (h *IndexHandler) Imprint(w http.ResponseWriter, r *http.Request) {
|
||||
}{HtmlText: text})
|
||||
}
|
||||
|
||||
func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -121,7 +113,7 @@ func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -130,27 +122,7 @@ func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handlePostSignup(w, r)
|
||||
return
|
||||
default:
|
||||
h.handleGetSignup(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -167,7 +139,7 @@ func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
templates["signup.tpl.html"].Execute(w, nil)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) handlePostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
@ -64,7 +64,7 @@ func respondAlert(w http.ResponseWriter, error, success, tplName string, status
|
||||
templates[tplName].Execute(w, struct {
|
||||
Error string
|
||||
Success string
|
||||
}{Error: error})
|
||||
}{Error: error, Success: success})
|
||||
}
|
||||
|
||||
// TODO: do better
|
||||
|
133
routes/settings.go
Normal file
133
routes/settings.go
Normal file
@ -0,0 +1,133 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/schema"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
config *models.Config
|
||||
userSrvc *services.UserService
|
||||
}
|
||||
|
||||
var credentialsDecoder = schema.NewDecoder()
|
||||
|
||||
func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
config: models.GetConfig(),
|
||||
userSrvc: userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
data := map[string]interface{}{
|
||||
"User": user,
|
||||
}
|
||||
|
||||
// TODO: when alerts are present, other data will not be passed to the template
|
||||
if handleAlerts(w, r, "settings.tpl.html") {
|
||||
return
|
||||
}
|
||||
templates["settings.tpl.html"].Execute(w, data)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
var credentials models.CredentialsReset
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.PasswordSalt) {
|
||||
respondAlert(w, "invalid credentials", "", "settings.tpl.html", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !credentials.IsValid() {
|
||||
respondAlert(w, "invalid parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = credentials.PasswordNew
|
||||
if err := utils.HashPassword(user, h.config.PasswordSalt); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
login := &models.Login{
|
||||
Username: user.ID,
|
||||
Password: user.Password,
|
||||
}
|
||||
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
if err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: models.AuthCookieKey,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
Secure: !h.config.InsecureCookies,
|
||||
HttpOnly: true,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
msg := url.QueryEscape("password was updated successfully")
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.BasePath), http.StatusFound)
|
||||
}
|
@ -1,22 +1,10 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
IntervalToday string = "today"
|
||||
IntervalLastDay string = "day"
|
||||
IntervalLastWeek string = "week"
|
||||
IntervalLastMonth string = "month"
|
||||
IntervalLastYear string = "year"
|
||||
IntervalAny string = "any"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SummaryHandler struct {
|
||||
@ -32,7 +20,7 @@ func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err, status := loadUserSummary(r, h.summarySrvc)
|
||||
summary, err, status := h.loadUserSummary(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
@ -42,7 +30,7 @@ func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
utils.RespondJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -53,7 +41,7 @@ func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
summary, err, status := loadUserSummary(r, h.summarySrvc)
|
||||
summary, err, status := h.loadUserSummary(r)
|
||||
if err != nil {
|
||||
respondAlert(w, err.Error(), "", "summary.tpl.html", status)
|
||||
return
|
||||
@ -74,39 +62,13 @@ func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
templates["summary.tpl.html"].Execute(w, vm)
|
||||
}
|
||||
|
||||
func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
interval := params.Get("interval")
|
||||
from, err := utils.ParseDate(params.Get("from"))
|
||||
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
|
||||
summaryParams, err := utils.ParseSummaryParams(r)
|
||||
if err != nil {
|
||||
switch interval {
|
||||
case IntervalToday:
|
||||
from = utils.StartOfDay()
|
||||
case IntervalLastDay:
|
||||
from = utils.StartOfDay().Add(-24 * time.Hour)
|
||||
case IntervalLastWeek:
|
||||
from = utils.StartOfWeek()
|
||||
case IntervalLastMonth:
|
||||
from = utils.StartOfMonth()
|
||||
case IntervalLastYear:
|
||||
from = utils.StartOfYear()
|
||||
case IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
return nil, errors.New("missing 'from' parameter"), http.StatusBadRequest
|
||||
}
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
live := (params.Get("live") != "" && params.Get("live") != "false") || interval == IntervalToday
|
||||
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
|
||||
to := utils.StartOfDay()
|
||||
if live {
|
||||
to = time.Now()
|
||||
}
|
||||
|
||||
var summary *models.Summary
|
||||
summary, err = summaryService.Construct(from, to, user, recompute) // 'to' is always constant
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -77,7 +77,7 @@ func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) CleanUp() error {
|
||||
refTime := utils.StartOfDay().Add(-cleanUpInterval)
|
||||
refTime := utils.StartOfToday().Add(-cleanUpInterval)
|
||||
if err := srv.DeleteBefore(refTime); err != nil {
|
||||
log.Printf("Failed to clean up heartbeats older than %v – %v\n", refTime, err)
|
||||
return err
|
||||
|
@ -65,12 +65,13 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
heartbeats = append(heartbeats, hb...)
|
||||
}
|
||||
|
||||
types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS}
|
||||
types := models.SummaryTypes()
|
||||
|
||||
var projectItems []*models.SummaryItem
|
||||
var languageItems []*models.SummaryItem
|
||||
var editorItems []*models.SummaryItem
|
||||
var osItems []*models.SummaryItem
|
||||
var machineItems []*models.SummaryItem
|
||||
|
||||
if err := srv.AliasService.LoadUserAliases(user.ID); err != nil {
|
||||
return nil, err
|
||||
@ -92,18 +93,40 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
editorItems = item.Items
|
||||
case models.SummaryOS:
|
||||
osItems = item.Items
|
||||
case models.SummaryMachine:
|
||||
machineItems = item.Items
|
||||
}
|
||||
}
|
||||
close(c)
|
||||
|
||||
realFrom, realTo := from, to
|
||||
if len(existingSummaries) > 0 {
|
||||
realFrom = existingSummaries[0].FromTime
|
||||
realTo = existingSummaries[len(existingSummaries)-1].ToTime
|
||||
|
||||
for _, summary := range existingSummaries {
|
||||
summary.FillUnknown()
|
||||
}
|
||||
}
|
||||
if len(heartbeats) > 0 {
|
||||
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
|
||||
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
|
||||
realFrom = t1
|
||||
}
|
||||
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
|
||||
realTo = t2
|
||||
}
|
||||
}
|
||||
|
||||
aggregatedSummary := &models.Summary{
|
||||
UserID: user.ID,
|
||||
FromTime: from,
|
||||
ToTime: to,
|
||||
FromTime: realFrom,
|
||||
ToTime: realTo,
|
||||
Projects: projectItems,
|
||||
Languages: languageItems,
|
||||
Editors: editorItems,
|
||||
OperatingSystems: osItems,
|
||||
Machines: machineItems,
|
||||
}
|
||||
|
||||
allSummaries := []*models.Summary{aggregatedSummary}
|
||||
@ -134,10 +157,12 @@ func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time
|
||||
Where(&models.Summary{UserID: user.ID}).
|
||||
Where("from_time >= ?", from).
|
||||
Where("to_time <= ?", to).
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -171,10 +196,12 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
||||
key = h.Language
|
||||
case models.SummaryOS:
|
||||
key = h.OperatingSystem
|
||||
case models.SummaryMachine:
|
||||
key = h.Machine
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
key = models.UnknownSummaryKey
|
||||
}
|
||||
|
||||
if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil {
|
||||
@ -224,7 +251,16 @@ func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary
|
||||
|
||||
// Between
|
||||
for i := 0; i < len(existingSummaries)-1; i++ {
|
||||
if existingSummaries[i].ToTime.Before(existingSummaries[i+1].FromTime) {
|
||||
t1, t2 := existingSummaries[i].ToTime, existingSummaries[i+1].FromTime
|
||||
if t1.Equal(t2) {
|
||||
continue
|
||||
}
|
||||
|
||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||
// one or more day missing in between?
|
||||
if td1.Before(td2) {
|
||||
intervals = append(intervals, &Interval{existingSummaries[i].ToTime, existingSummaries[i+1].FromTime})
|
||||
}
|
||||
}
|
||||
@ -251,6 +287,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
Languages: make([]*models.SummaryItem, 0),
|
||||
Editors: make([]*models.SummaryItem, 0),
|
||||
OperatingSystems: make([]*models.SummaryItem, 0),
|
||||
Machines: make([]*models.SummaryItem, 0),
|
||||
}
|
||||
|
||||
for _, s := range summaries {
|
||||
@ -270,6 +307,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages)
|
||||
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||
}
|
||||
|
||||
finalSummary.FromTime = minTime
|
||||
|
@ -82,6 +82,24 @@ func (srv *UserService) Update(user *models.User) (*models.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
user.ApiKey = uuid.NewV4().String()
|
||||
return srv.Update(user)
|
||||
}
|
||||
|
||||
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
|
||||
result := srv.Db.Model(user).Update("badges_enabled", !user.BadgesEnabled)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
||||
user.Password = login.Password
|
||||
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {
|
||||
|
@ -1,10 +1,21 @@
|
||||
const SHOW_TOP_N = 10
|
||||
const CHART_TARGET_SIZE = 170
|
||||
const CHART_TARGET_SIZE = 200
|
||||
|
||||
const projectsCanvas = document.getElementById('chart-projects')
|
||||
const osCanvas = document.getElementById('chart-os')
|
||||
const editorsCanvas = document.getElementById('chart-editor')
|
||||
const languagesCanvas = document.getElementById('chart-language')
|
||||
const machinesCanvas = document.getElementById('chart-machine')
|
||||
|
||||
const projectContainer = document.getElementById('project-container')
|
||||
const osContainer = document.getElementById('os-container')
|
||||
const editorContainer = document.getElementById('editor-container')
|
||||
const languageContainer = document.getElementById('language-container')
|
||||
const machineContainer = document.getElementById('machine-container')
|
||||
|
||||
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer]
|
||||
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
|
||||
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
|
||||
|
||||
let charts = []
|
||||
let resizeCount = 0
|
||||
@ -28,11 +39,6 @@ String.prototype.toHHMMSS = function () {
|
||||
}
|
||||
|
||||
function draw() {
|
||||
let titleOptions = {
|
||||
display: true,
|
||||
fontSize: 16
|
||||
}
|
||||
|
||||
function getTooltipOptions(key, type) {
|
||||
return {
|
||||
mode: 'single',
|
||||
@ -48,102 +54,158 @@ function draw() {
|
||||
|
||||
charts.forEach(c => c.destroy())
|
||||
|
||||
let projectChart = new Chart(projectsCanvas.getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: wakapiData.projects
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length))
|
||||
.map(p => {
|
||||
return {
|
||||
label: p.key,
|
||||
data: [parseInt(p.total)],
|
||||
backgroundColor: getRandomColor(p.key)
|
||||
}
|
||||
})
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Projects (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('projects', 'bar'),
|
||||
legend: {
|
||||
display: false
|
||||
let projectChart = !projectsCanvas.classList.contains('hidden')
|
||||
? new Chart(projectsCanvas.getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: wakapiData.projects
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length))
|
||||
.map(p => {
|
||||
return {
|
||||
label: p.key,
|
||||
data: [parseInt(p.total) / 60],
|
||||
backgroundColor: getRandomColor(p.key)
|
||||
}
|
||||
})
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
options: {
|
||||
tooltips: getTooltipOptions('projects', 'bar'),
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
scaleLabel: {
|
||||
display: true,
|
||||
labelString: 'Minutes'
|
||||
}
|
||||
}]
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let osChart = new Chart(osCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.operatingSystems
|
||||
let osChart = !osCanvas.classList.contains('hidden')
|
||||
? new Chart(osCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Operating Systems (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('operatingSystems', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('operatingSystems', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let editorChart = new Chart(editorsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.editors
|
||||
let editorChart = !editorsCanvas.classList.contains('hidden')
|
||||
? new Chart(editorsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Editors (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('editors', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('editors', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let languageChart = new Chart(languagesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.languages
|
||||
let languageChart = !languagesCanvas.classList.contains('hidden')
|
||||
? new Chart(languagesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Languages (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('languages', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('languages', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let machineChart = !machinesCanvas.classList.contains('hidden')
|
||||
? new Chart(machinesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.machines
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.machines
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('machines', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
getTotal(wakapiData.operatingSystems)
|
||||
document.getElementById('grid-container').style.visibility = 'visible'
|
||||
|
||||
charts = [projectChart, osChart, editorChart, languageChart]
|
||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
|
||||
|
||||
charts.forEach(c => c.options.onResize(c.chart))
|
||||
equalizeHeights()
|
||||
}
|
||||
|
||||
function setTopLabels() {
|
||||
[...document.getElementsByClassName('top-label')]
|
||||
.forEach(e => e.innerText = `(top ${SHOW_TOP_N})`)
|
||||
}
|
||||
|
||||
function togglePlaceholders(mask) {
|
||||
const placeholderElements = containers.map(c => c.querySelector('.placeholder-container'))
|
||||
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
if (!mask[i]) {
|
||||
canvases[i].classList.add('hidden')
|
||||
placeholderElements[i].classList.remove('hidden')
|
||||
} else {
|
||||
canvases[i].classList.remove('hidden')
|
||||
placeholderElements[i].classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPresentDataMask() {
|
||||
return data.map(list => list.reduce((acc, e) => acc + e.total, 0) > 0)
|
||||
}
|
||||
|
||||
function getContainer(chart) {
|
||||
return chart.canvas.parentNode
|
||||
}
|
||||
@ -227,7 +289,7 @@ if (favicon) {
|
||||
}
|
||||
|
||||
// Click outside
|
||||
window.addEventListener('click', function(event) {
|
||||
window.addEventListener('click', function (event) {
|
||||
if (event.target.classList.contains('popup')) {
|
||||
return
|
||||
}
|
||||
@ -238,5 +300,7 @@ window.addEventListener('click', function(event) {
|
||||
})
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
setTopLabels()
|
||||
togglePlaceholders(getPresentDataMask())
|
||||
draw()
|
||||
})
|
1
static/assets/images/no_data.svg
Normal file
1
static/assets/images/no_data.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.9 KiB |
@ -43,15 +43,14 @@ func MakeConnectionString(config *models.Config) string {
|
||||
}
|
||||
|
||||
func mySqlConnectionString(config *models.Config) string {
|
||||
location, _ := time.LoadLocation("Local")
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s",
|
||||
//location, _ := time.LoadLocation("Local")
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
config.DbUser,
|
||||
config.DbPassword,
|
||||
config.DbHost,
|
||||
config.DbPort,
|
||||
config.DbName,
|
||||
location.String(),
|
||||
"Local",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,16 @@
|
||||
package utils
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartOfDay() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, ref.Location())
|
||||
func StartOfToday() time.Time {
|
||||
return StartOfDay(time.Now())
|
||||
}
|
||||
|
||||
func StartOfDay(date time.Time) time.Time {
|
||||
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
}
|
||||
|
||||
func StartOfWeek() time.Time {
|
||||
@ -15,12 +21,35 @@ func StartOfWeek() time.Time {
|
||||
|
||||
func StartOfMonth() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), ref.Month(), 0, 0, 0, 0, 0, ref.Location())
|
||||
return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location())
|
||||
}
|
||||
|
||||
func StartOfYear() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), time.January, 0, 0, 0, 0, 0, ref.Location())
|
||||
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
|
||||
}
|
||||
|
||||
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
|
||||
intervals := make([][]time.Time, 0)
|
||||
|
||||
for t1 := from; t1.Before(to); {
|
||||
t2 := StartOfDay(t1).Add(24 * time.Hour)
|
||||
if t2.After(to) {
|
||||
t2 = to
|
||||
}
|
||||
intervals = append(intervals, []time.Time{t1, t2})
|
||||
t1 = t2
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
func FmtWakatimeDuration(d time.Duration) string {
|
||||
d = d.Round(time.Minute)
|
||||
h := d / time.Hour
|
||||
d -= h * time.Hour
|
||||
m := d / time.Minute
|
||||
return fmt.Sprintf("%d hrs %d mins", h, m)
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/18632496
|
||||
|
@ -8,3 +8,12 @@ import (
|
||||
func Capitalize(s string) string {
|
||||
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
|
||||
}
|
||||
|
||||
func FindString(needle string, haystack []string, defaultVal string) string {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
69
utils/summary.go
Normal file
69
utils/summary.go
Normal file
@ -0,0 +1,69 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ResolveInterval(interval string) (err error, from, to time.Time) {
|
||||
to = time.Now()
|
||||
|
||||
switch interval {
|
||||
case models.IntervalToday:
|
||||
from = StartOfToday()
|
||||
case models.IntervalYesterday:
|
||||
from = StartOfToday().Add(-24 * time.Hour)
|
||||
to = StartOfToday()
|
||||
case models.IntervalThisWeek:
|
||||
from = StartOfWeek()
|
||||
case models.IntervalThisMonth:
|
||||
from = StartOfMonth()
|
||||
case models.IntervalThisYear:
|
||||
from = StartOfYear()
|
||||
case models.IntervalPast7Days:
|
||||
from = StartOfToday().AddDate(0, 0, -7)
|
||||
case models.IntervalPast30Days:
|
||||
from = StartOfToday().AddDate(0, 0, -30)
|
||||
case models.IntervalPast12Months:
|
||||
from = StartOfToday().AddDate(0, -12, 0)
|
||||
case models.IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
err = errors.New("invalid interval")
|
||||
}
|
||||
|
||||
return err, from, to
|
||||
}
|
||||
|
||||
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
|
||||
var err error
|
||||
var from, to time.Time
|
||||
|
||||
if interval := params.Get("interval"); interval != "" {
|
||||
err, from, to = ResolveInterval(interval)
|
||||
} else {
|
||||
from, err = ParseDate(params.Get("from"))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing 'from' parameter")
|
||||
}
|
||||
|
||||
to, err = ParseDate(params.Get("to"))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing 'to' parameter")
|
||||
}
|
||||
}
|
||||
|
||||
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
|
||||
|
||||
return &models.SummaryParams{
|
||||
From: from,
|
||||
To: to,
|
||||
User: user,
|
||||
Recompute: recompute,
|
||||
}, nil
|
||||
}
|
@ -1 +1 @@
|
||||
1.6.3
|
||||
1.11.1
|
@ -2,11 +2,13 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
const languageColors = {{ .LanguageColors | json }}
|
||||
|
||||
let wakapiData = {}
|
||||
let languageColors = {{ .LanguageColors | json }}
|
||||
wakapiData.projects = {{ .Projects | json }}
|
||||
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
||||
wakapiData.editors = {{ .Editors | json }}
|
||||
wakapiData.languages = {{ .Languages | json }}
|
||||
wakapiData.projects = {{ .Projects | json }}
|
||||
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
||||
wakapiData.editors = {{ .Editors | json }}
|
||||
wakapiData.languages = {{ .Languages | json }}
|
||||
wakapiData.machines = {{ .Machines | json }}
|
||||
</script>
|
||||
<script src="assets/app.js"></script>
|
@ -5,7 +5,7 @@
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||
<div><a href="" class="text-gray-500 text-sm">🠐 Go back</a></div>
|
||||
<div><a href="" class="text-gray-500 text-sm">← Go back</a></div>
|
||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Imprint & Data Privacy</h1>
|
||||
</div>
|
||||
<div></div>
|
||||
|
138
views/settings.tpl.html
Normal file
138
views/settings.tpl.html
Normal file
@ -0,0 +1,138 @@
|
||||
<html>
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||
<div><a href="" class="text-gray-500 text-sm">← 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>
|
||||
</div>
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-4 flex-grow flex justify-center w-full">
|
||||
<div class="flex flex-col flex-grow max-w-lg mt-8">
|
||||
<div class="w-full my-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Change Password
|
||||
</div>
|
||||
|
||||
<form class="mt-10" action="settings/credentials" method="post">
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password_new">New 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_new"
|
||||
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>
|
||||
<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">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Reset API Key
|
||||
</div>
|
||||
|
||||
<form class="mt-6" action="settings/reset" method="post">
|
||||
<div class="text-gray-300 text-sm mb-4">
|
||||
<strong>⚠️ Caution:</strong> Resetting your API key requires you to update your <span class="font-mono">.wakatime.cfg</span> files on all of your computers to make the WakaTime client send heartbeats again.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between float-right">
|
||||
<button type="submit" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Badges
|
||||
</div>
|
||||
|
||||
<form class="mt-6" action="settings/badges" method="post">
|
||||
<div class="text-gray-300 text-sm mb-4">
|
||||
{{ if .User.BadgesEnabled }}
|
||||
<p>Badges are currently enabled. You can disable the feature by deactivating the respective API endpoint.</p>
|
||||
|
||||
<div class="flex justify-around mt-4">
|
||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
||||
<button type="submit" class="py-1 px-2 rounded bg-orange-700 hover:bg-orange-800 text-white text-xs" title="Disable support for badges to secure endpoint">
|
||||
Status: public
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="font-semibold mb-2 mt-8">Examples</h3>
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="flex justify-between my-2">
|
||||
<div>
|
||||
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today"/>
|
||||
</div>
|
||||
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
|
||||
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between my-2">
|
||||
<div>
|
||||
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d"/>
|
||||
</div>
|
||||
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
|
||||
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span> to the URL to filter by project.</p>
|
||||
{{ else }}
|
||||
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
|
||||
<div class="flex justify-around mt-4">
|
||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
||||
<button type="submit" class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs" title="Make endpoint public to enable badges">
|
||||
Status: protected
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="text/javascript">
|
||||
const baseUrl = location.href.substring(0, location.href.indexOf('/settings'))
|
||||
document.querySelectorAll('.with-url-src').forEach(e => {
|
||||
e.setAttribute('src', e.getAttribute('src').replace('%s', baseUrl))
|
||||
e.classList.remove('hidden')
|
||||
})
|
||||
document.querySelectorAll('.with-url-inner').forEach(e => {
|
||||
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
|
||||
e.classList.remove('hidden')
|
||||
})
|
||||
</script>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
||||
{{ template "foot.tpl.html" . }}
|
||||
</body>
|
||||
|
||||
</html>
|
@ -5,7 +5,7 @@
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||
<div><a href="" class="text-gray-500 text-sm">🠐 Go back</a></div>
|
||||
<div><a href="" class="text-gray-500 text-sm">← Go back</a></div>
|
||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1></div>
|
||||
<div></div>
|
||||
</div>
|
||||
@ -41,7 +41,7 @@
|
||||
name="password" 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">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>
|
||||
|
@ -4,34 +4,48 @@
|
||||
|
||||
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
|
||||
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup" id="api-key-popup">
|
||||
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup"
|
||||
id="api-key-popup">
|
||||
<div class="flex-grow flex flex-col px-2">
|
||||
<span class="text-xs text-gray-500 mx-1">API Key</span>
|
||||
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly value="{{ .ApiKey }}" style="min-width: 330px">
|
||||
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly
|
||||
value="{{ .ApiKey }}" style="min-width: 330px">
|
||||
</div>
|
||||
<div class="flex items-center px-2 border-l border-gray-700">
|
||||
<button title="Copy to clipboard" onclick="copyApiKey(event)">📋</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-0 right-0 mr-8 mt-10 py-2">
|
||||
<form action="logout" method="post">
|
||||
<button type="button" class="mx-1 py-1 px-3 rounded border border-green-700 text-white text-sm" onclick="showApiKeyPopup(event)">🔐</button>
|
||||
<button type="submit" class="mx-1 py-1 px-3 rounded border border-green-700 text-white text-sm">Logout</button>
|
||||
</form>
|
||||
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
|
||||
<div class="mx-1">
|
||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm"
|
||||
onclick="showApiKeyPopup(event)">🔐
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-1">
|
||||
<a href="settings" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">⚙️</a>
|
||||
</div>
|
||||
<div class="mx-1">
|
||||
<form action="logout" method="post">
|
||||
<button type="submit" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Your Coding Statistics 🤓</h1>
|
||||
</div>
|
||||
|
||||
<div class="text-white text-sm flex items-center justify-center mt-4">
|
||||
<a href="summary?interval=today" class="m-1 border-b border-green-700">Today (live)</a>
|
||||
<a href="summary?interval=day" class="m-1 border-b border-green-700">Yesterday</a>
|
||||
<a href="summary?interval=week" class="m-1 border-b border-green-700">This Week</a>
|
||||
<a href="summary?interval=month" class="m-1 border-b border-green-700">This Month</a>
|
||||
<a href="summary?interval=year" class="m-1 border-b border-green-700">This Year</a>
|
||||
<a href="summary?interval=any" class="m-1 border-b border-green-700">All Time</a>
|
||||
<div class="text-white text-sm flex items-center justify-center mt-4 self-center max-w-lg flex-wrap">
|
||||
<a href="summary?interval=today" class="mx-2 my-1 border-b border-green-700">Today</a>
|
||||
<a href="summary?interval=day" class="mx-2 my-1 border-b border-green-700">Yesterday</a>
|
||||
<a href="summary?interval=week" class="mx-2 my-1 border-b border-green-700">This Week</a>
|
||||
<a href="summary?interval=month" class="mx-2 my-1 border-b border-green-700">This Month</a>
|
||||
<a href="summary?interval=year" class="mx-2 my-1 border-b border-green-700">This Year</a>
|
||||
<a href="summary?interval=7_days" class="mx-2 my-1 border-b border-green-700">Past 7 Days</a>
|
||||
<a href="summary?interval=30_days" class="mx-2 my-1 border-b border-green-700">Past 30 Days</a>
|
||||
<a href="summary?interval=12_months" class="mx-2 my-1 border-b border-green-700">Past 12 Months</a>
|
||||
<a href="summary?interval=any" class="mx-2 my-1 border-b border-green-700">All Time</a>
|
||||
</div>
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
@ -48,23 +62,68 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="projects-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="project-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Projects</span>
|
||||
<span id="project-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<canvas id="chart-projects"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="os-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="os-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Operating Systems</span>
|
||||
<span id="os-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<canvas id="chart-os"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2 relative" id="language-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col relative" id="language-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Languages</span>
|
||||
<span id="language-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<canvas id="chart-language"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="editor-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="editor-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Editors</span>
|
||||
<span id="editor-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<canvas id="chart-editor"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="machine-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Machines</span>
|
||||
<span id="machine-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<canvas id="chart-machine"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user