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

Compare commits

..

25 Commits

Author SHA1 Message Date
82b8951437 fix: attempt to fix failing sqlite migrations (resolve #210) 2021-06-13 11:43:24 +02:00
25464e9519 chore: code smells 2021-06-13 10:14:15 +02:00
650fffa344 fix: exclude zero entries again 2021-06-12 12:06:24 +02:00
69627fbe11 fix: exclude zero entries 2021-06-12 12:04:38 +02:00
561198b203 chore: minor ui improvements 2021-06-12 12:01:20 +02:00
7c4a2024b6 chore: link to labels settings 2021-06-12 11:40:13 +02:00
7bcd6890d1 chore: adapt tests and bump version 2021-06-12 11:26:15 +02:00
1e4e530c21 chore: adapt tests 2021-06-12 11:09:24 +02:00
490cca05eb feat: ui for managing project labels 2021-06-12 10:44:19 +02:00
3780ae4255 fix: invalidate user summary cache (fix #209) 2021-06-12 10:43:56 +02:00
628ea0b9dd fix: nil pointer dereference
chore: allow to share labels publicly on settings page
2021-06-12 09:12:28 +02:00
0d64858721 feat: implement project labels (resolve #204) 2021-06-11 20:59:34 +02:00
c1c78d8d5b test: add more api tests 2021-06-11 17:47:33 +02:00
538b9d2463 fix: permissions for stats endpoint 2021-06-11 17:41:45 +02:00
f4612fd542 fix: badge endpoint permission fixes (resolve #205)
fix: reference past x days intervals from now instead of start of day
2021-06-11 16:02:28 +02:00
fb643571d2 Merge remote-tracking branch 'origin/master' 2021-06-10 23:22:58 +02:00
101fdfb957 chore: adapt default insert batch size (fix #206)
fix: set user data flag after import (fix #207)
2021-06-10 23:22:04 +02:00
a4d47fb566 test: more api tests [ci skip] 2021-05-29 09:52:26 +02:00
1a808f9197 feat: basic integration / api tests (wip) (resolve #9) 2021-05-28 17:14:16 +02:00
ee31212cdd fix: hotfix for invalid api base url prefix (#203) 2021-05-19 10:18:18 +02:00
712949afc7 chore: minor optimization to heartbeats by multi-user query 2021-05-14 09:38:31 +02:00
9dbc2039fc chore: add random time offset to scheduled reports jobs 2021-05-04 21:04:11 +02:00
f3b738b250 fix: empty projects (resolve #197)
fix: potential division by zero (see #199)
2021-05-03 21:32:26 +02:00
cf3d293688 feat: implement wakatime projects endpoint (resolve #196) 2021-05-01 13:52:03 +02:00
0fbb554fc3 fix: respect timezone parameter for wakatime summary endpoint (resolve #195) 2021-05-01 12:46:53 +02:00
57 changed files with 4743 additions and 734 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@ build
*.db
config*.yml
!config.default.yml
!testing/config.testing.yml
pkged.go
package.json
yarn.lock

View File

@ -4,14 +4,13 @@
<p align="center">
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
<a href="https://saythanks.io/to/n1try"><img src="https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg"></a>
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
</p>
<p align="center">
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
</p>
@ -24,7 +23,7 @@
<span> | </span>
<a href="#-features">Features</a>
<span> | </span>
<a href="#-how-to-use">How to use</a>
<a href="#%EF%B8%8F-how-to-use">How to use</a>
<span> | </span>
<a href="https://github.com/muety/wakapi/issues">Issues</a>
<span> | </span>
@ -45,6 +44,7 @@
* [API Endpoints](#-api-endpoints)
* [Integrations](#-integrations)
* [Best Practices](#-best-practices)
* [Tests](#-tests)
* [Developer Notes](#-developer-notes)
* [Support](#-support)
* [FAQs](#-faqs)
@ -59,6 +59,7 @@ I'd love to get some community feedback from active Wakapi users. If you want, p
* ✅ Built by developers for developers
* ✅ Statistics for projects, languages, editors, hosts and operating systems
* ✅ Badges
* ✅ Weekly E-Mail Reports
* ✅ REST API
* ✅ Partially compatible with WakaTime
* ✅ WakaTime integration
@ -131,8 +132,8 @@ Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime
```ini
[settings]
# Your Wakapi server URL or 'https://wakapi.dev/api/heartbeat' when using the cloud server
api_url = http://localhost:3000/api/heartbeat
# Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
api_url = http://localhost:3000/api
# Your Wakapi API key (get it from the web interface after having created an account)
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
@ -282,12 +283,40 @@ Preview:
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
## 🤓 Developer Notes
### Running tests
## 🧪 Tests
### Unit Tests
Unit tests are supposed to test business logic on a fine-grained level. They are implemented as part of the application, using Go's [testing](https://pkg.go.dev/testing?utm_source=godoc) package alongside [stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify).
#### How to run
```bash
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
$ CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
```
### API Tests
API tests are implemented as black box tests, which interact with a fully-fledged, standalone Wakapi through HTTP requests. They are supposed to check Wakapi's web stack and endpoints, including response codes, headers and data on a syntactical level, rather than checking the actual content that is returned.
Our API (or end-to-end, in some way) tests are implemented as a [Postman](https://www.postman.com/) collection and can be run either from inside Postman, or using [newman](https://www.npmjs.com/package/newman) as a command-line runner.
To get a predictable environment, tests are run against a fresh and clean Wakapi instance with a SQLite database that is populated with nothing but some seed data (see [data.sql](testing/data.sql)). It is usually recommended for software tests to be [safe](https://www.restapitutorial.com/lessons/idempotency.html), stateless and without side effects. In contrary to that paradigm, our API tests strictly require a fixed execution order (which Postman assures) and their assertions may rely on specific previous tests having succeeded.
#### Prerequisites (Linux only)
```bash
# 1. sqlite (cli)
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
# 2. screen
$ sudo apt install screen # Fedora: sudo dnf install screen
# 3. newman
$ npm install -g newman
```
#### How to run (Linux only)
```bash
$ ./testing/run_api_tests.sh
```
## 🤓 Developer Notes
### Building web assets
To keep things minimal, Wakapi does not contain a `package.json`, `node_modules` or any sort of frontend build step. Instead, all JS and CSS assets are included as static files and checked in to Git. This way we can avoid requiring NodeJS to build Wakapi. However, for [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) it makes sense to run it through a "build" step to benefit from purging and significantly reduce it in size. To only require this at the time of development, the compiled asset is checked in to Git as well. Similarly, [Iconify](https://iconify.design/docs/icon-bundles/) bundles are also created at development time and checked in to the repo.

View File

@ -13,6 +13,7 @@ app:
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>')
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
custom_languages:
vue: Vue
jsx: JSX

View File

@ -64,7 +64,7 @@ type appConfig struct {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"`
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
@ -197,6 +197,9 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
return nil
}
}
@ -341,22 +344,27 @@ func Load(version string) *Config {
}
}
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
}
if config.Db.MaxConn <= 0 {
logbuch.Fatal("you must allow at least one database connection")
}
if config.Sentry.Dsn != "" {
logbuch.Info("enabling sentry integration")
initSentry(config.Sentry, config.IsDev())
}
// some validation checks
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
}
if config.Db.MaxConn <= 0 {
logbuch.Fatal("you must allow at least one database connection")
}
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
}
if _, err := time.Parse("15:04", config.App.GetWeeklyReportTime()); err != nil {
logbuch.Fatal("invalid interval set for report_time_weekly")
}
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
logbuch.Fatal("invalid interval set for aggregation_time")
}
Set(config)
return Get()

File diff suppressed because it is too large Load Diff

11
main.go
View File

@ -49,6 +49,7 @@ var (
heartbeatRepository repositories.IHeartbeatRepository
userRepository repositories.IUserRepository
languageMappingRepository repositories.ILanguageMappingRepository
projectLabelRepository repositories.IProjectLabelRepository
summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository
)
@ -58,6 +59,7 @@ var (
heartbeatService services.IHeartbeatService
userService services.IUserService
languageMappingService services.ILanguageMappingService
projectLabelService services.IProjectLabelService
summaryService services.ISummaryService
aggregationService services.IAggregationService
mailService services.IMailService
@ -113,6 +115,7 @@ func main() {
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
if config.Db.Dialect == "sqlite3" {
db.Raw("PRAGMA foreign_keys = ON;")
db.DisableForeignKeyConstraintWhenMigrating = true
}
if config.IsDev() {
@ -135,6 +138,7 @@ func main() {
heartbeatRepository = repositories.NewHeartbeatRepository(db)
userRepository = repositories.NewUserRepository(db)
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
projectLabelRepository = repositories.NewProjectLabelRepository(db)
summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db)
@ -142,8 +146,9 @@ func main() {
aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
mailService = mail.NewMailService()
keyValueService = services.NewKeyValueService(keyValueRepository)
@ -168,11 +173,12 @@ func main() {
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService)
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
wakatimeV1UsersHandler := wtV1Routes.NewUsersHandler(userService, heartbeatService)
wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService, mailService)
imprintHandler := routes.NewImprintHandler(keyValueService)
@ -207,6 +213,7 @@ func main() {
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
wakatimeV1UsersHandler.RegisterRoutes(apiRouter)
wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter)
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
// Static Routes

View File

@ -55,6 +55,11 @@ func (m *HeartbeatServiceMock) GetLatestByOriginAndUser(s string, user *models.U
return args.Get(0).(*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) GetEntitySetByUser(u uint8, user *models.User) ([]string, error) {
args := m.Called(u, user)
return args.Get(0).([]string), args.Error(1)
}
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
args := m.Called(time)
return args.Error(0)

View File

@ -0,0 +1,35 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type ProjectLabelServiceMock struct {
mock.Mock
}
func (p *ProjectLabelServiceMock) GetById(u uint) (*models.ProjectLabel, error) {
args := p.Called(u)
return args.Get(0).(*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) GetByUser(s string) ([]*models.ProjectLabel, error) {
args := p.Called(s)
return args.Get(0).([]*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) GetByUserGrouped(s string) (map[string][]*models.ProjectLabel, error) {
args := p.Called(s)
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) Create(l *models.ProjectLabel) (*models.ProjectLabel, error) {
args := p.Called(l)
return args.Get(0).(*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) Delete(l *models.ProjectLabel) error {
args := p.Called(l)
return args.Error(0)
}

View File

@ -0,0 +1,11 @@
package v1
type ProjectsViewModel struct {
Data []*Project `json:"data"`
}
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Repository string `json:"repository"`
}

View File

@ -31,9 +31,6 @@ type StatsData struct {
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
totalTime := summary.TotalTime()
numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24)
if math.IsInf(float64(numDays), 0) {
numDays = 0
}
data := &StatsData{
Username: summary.UserID,
@ -45,6 +42,10 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
DaysIncludingHolidays: numDays,
}
if math.IsInf(data.DailyAverage, 0) || math.IsNaN(data.DailyAverage) {
data.DailyAverage = 0
}
editors := make([]*SummariesEntry, len(summary.Editors))
for i, e := range summary.Editors {
editors[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryEditor))

View File

@ -6,6 +6,7 @@ type Filters struct {
Language string
Editor string
Machine string
Label string
}
type FilterElement struct {
@ -25,6 +26,8 @@ func NewFiltersWith(entity uint8, key string) *Filters {
return &Filters{Editor: key}
case SummaryMachine:
return &Filters{Machine: key}
case SummaryLabel:
return &Filters{Label: key}
}
return &Filters{}
}
@ -40,6 +43,8 @@ func (f *Filters) One() (bool, uint8, string) {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
return true, SummaryMachine, f.Machine
} else if f.Label != "" {
return true, SummaryLabel, f.Label
}
return false, 0, ""
}

13
models/project_label.go Normal file
View File

@ -0,0 +1,13 @@
package models
type ProjectLabel struct {
ID uint `json:"id" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_project_label_user"`
ProjectKey string `json:"project"`
Label string `json:"label" gorm:"type:varchar(64)"`
}
func (l *ProjectLabel) IsValid() bool {
return l.ProjectKey != "" && l.Label != ""
}

View File

@ -1,6 +1,7 @@
package models
import (
"errors"
"sort"
"time"
)
@ -12,9 +13,11 @@ const (
SummaryEditor uint8 = 2
SummaryOS uint8 = 3
SummaryMachine uint8 = 4
SummaryLabel uint8 = 5
)
const UnknownSummaryKey = "unknown"
const DefaultProjectLabel = "default"
type Summary struct {
ID uint `json:"-" gorm:"primary_key"`
@ -27,6 +30,7 @@ type Summary struct {
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Labels SummaryItems `json:"labels" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
type SummaryItems []*SummaryItem
@ -68,6 +72,10 @@ type SummaryParams struct {
type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel}
}
func NativeSummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
}
@ -77,6 +85,7 @@ func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.OperatingSystems))
sort.Sort(sort.Reverse(s.Languages))
sort.Sort(sort.Reverse(s.Editors))
sort.Sort(sort.Reverse(s.Labels))
return s
}
@ -91,6 +100,7 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
SummaryEditor: &s.Editors,
SummaryOS: &s.OperatingSystems,
SummaryMachine: &s.Machines,
SummaryLabel: &s.Labels,
}
}
@ -109,7 +119,7 @@ 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() {
func (s *Summary) FillMissing() {
types := s.Types()
typeItems := s.MappedItems()
missingTypes := make([]uint8, 0)
@ -125,15 +135,46 @@ func (s *Summary) FillUnknown() {
return
}
timeSum := s.TotalTime()
// construct dummy item for all missing types
presentType, err := s.findFirstPresentType()
if err != nil {
return // all types are either zero or missing entirely, nothing to fill
}
for _, t := range missingTypes {
*typeItems[t] = append(*typeItems[t], &SummaryItem{
Type: t,
Key: UnknownSummaryKey,
Total: timeSum,
})
s.FillBy(presentType, t)
}
}
// inplace!
func (s *Summary) FillBy(fromType uint8, toType uint8) {
typeItems := s.MappedItems()
totalWanted := s.TotalTimeBy(fromType)
totalActual := s.TotalTimeBy(toType)
key := UnknownSummaryKey
if toType == SummaryLabel {
key = DefaultProjectLabel
}
existingEntryIdx := -1
for i, item := range *typeItems[toType] {
if item.Key == key {
existingEntryIdx = i
break
}
}
total := (totalWanted - totalActual) / time.Second // workaround
if total > 0 {
if existingEntryIdx >= 0 {
(*typeItems[toType])[existingEntryIdx].Total = total
} else {
*typeItems[toType] = append(*typeItems[toType], &SummaryItem{
Type: toType,
Key: key,
Total: total,
})
}
}
}
@ -141,14 +182,12 @@ 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
}
t, err := s.findFirstPresentType()
if err != nil {
return 0
}
for _, item := range *mappedItems[t] {
timeSum += item.Total
}
return timeSum * time.Second
@ -231,10 +270,20 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
s.Languages = processAliases(s.Languages)
s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines)
s.Labels = processAliases(s.Labels)
return s
}
func (s *Summary) findFirstPresentType() (uint8, error) {
for _, t := range s.Types() {
if s.TotalTimeBy(t) != 0 {
return t, nil
}
}
return 127, errors.New("no type present")
}
func (s *SummaryItem) TotalFixed() time.Duration {
// 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

View File

@ -6,7 +6,7 @@ import (
"time"
)
func TestSummary_FillUnknown(t *testing.T) {
func TestSummary_FillMissing(t *testing.T) {
testDuration := 10 * time.Minute
sut := &Summary{
@ -20,7 +20,7 @@ func TestSummary_FillUnknown(t *testing.T) {
},
}
sut.FillUnknown()
sut.FillMissing()
itemLists := [][]*SummaryItem{
sut.Machines,
@ -31,8 +31,12 @@ func TestSummary_FillUnknown(t *testing.T) {
for _, l := range itemLists {
assert.Len(t, l, 1)
assert.Equal(t, UnknownSummaryKey, l[0].Key)
assert.Equal(t, testDuration, l[0].Total)
assert.Equal(t, testDuration, l[0].TotalFixed())
}
assert.Len(t, sut.Labels, 1)
assert.Equal(t, DefaultProjectLabel, sut.Labels[0].Key)
assert.Equal(t, testDuration, sut.Labels[0].TotalFixed())
}
func TestSummary_TotalTimeBy(t *testing.T) {

View File

@ -23,6 +23,7 @@ type User struct {
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
HasData bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"`

View File

@ -6,6 +6,8 @@ type SettingsViewModel struct {
User *models.User
LanguageMappings []*models.LanguageMapping
Aliases []*SettingsVMCombinedAlias
Labels []*SettingsVMCombinedLabel
Projects []string
Success string
Error string
}
@ -16,6 +18,11 @@ type SettingsVMCombinedAlias struct {
Values []string
}
type SettingsVMCombinedLabel struct {
Key string
Values []string
}
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
s.Success = m
return s

View File

@ -1,6 +1,7 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@ -63,6 +64,7 @@ func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *mode
}
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
// https://stackoverflow.com/a/20765152/3112139
var heartbeats []*models.Heartbeat
if err := r.db.
Where(&models.Heartbeat{UserID: user.ID}).
@ -125,9 +127,8 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
}
if err := r.db.
Model(&models.User{}).
Select("users.id as user, count(heartbeats.id) as count").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Model(&models.Heartbeat{}).
Select("user_id as user, count(id) as count").
Where("user_id in ?", userIds).
Group("user").
Find(&counts).Error; err != nil {
@ -136,6 +137,24 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
return counts, nil
}
func (r HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
columns := []string{"project", "language", "editor", "operating_system", "machine"}
if int(entityType) >= len(columns) {
// invalid entity type
return nil, errors.New("invalid entity type")
}
var results []string
if err := r.db.
Model(&models.Heartbeat{}).
Distinct(columns[entityType]).
Where(&models.Heartbeat{UserID: user.ID}).
Find(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
if err := r.db.
Where("time <= ?", t.Local()).

View File

@ -0,0 +1,60 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type ProjectLabelRepository struct {
config *config.Config
db *gorm.DB
}
func NewProjectLabelRepository(db *gorm.DB) *ProjectLabelRepository {
return &ProjectLabelRepository{config: config.Get(), db: db}
}
func (r *ProjectLabelRepository) GetAll() ([]*models.ProjectLabel, error) {
var labels []*models.ProjectLabel
if err := r.db.Find(&labels).Error; err != nil {
return nil, err
}
return labels, nil
}
func (r *ProjectLabelRepository) GetById(id uint) (*models.ProjectLabel, error) {
label := &models.ProjectLabel{}
if err := r.db.Where(&models.ProjectLabel{ID: id}).First(label).Error; err != nil {
return label, err
}
return label, nil
}
func (r *ProjectLabelRepository) GetByUser(userId string) ([]*models.ProjectLabel, error) {
var labels []*models.ProjectLabel
if err := r.db.
Where(&models.ProjectLabel{UserID: userId}).
Find(&labels).Error; err != nil {
return labels, err
}
return labels, nil
}
func (r *ProjectLabelRepository) Insert(label *models.ProjectLabel) (*models.ProjectLabel, error) {
if !label.IsValid() {
return nil, errors.New("invalid label")
}
result := r.db.Create(label)
if err := result.Error; err != nil {
return nil, err
}
return label, nil
}
func (r *ProjectLabelRepository) Delete(id uint) error {
return r.db.
Where("id = ?", id).
Delete(models.ProjectLabel{}).Error
}

View File

@ -27,6 +27,7 @@ type IHeartbeatRepository interface {
Count() (int64, error)
CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error
}
@ -45,6 +46,14 @@ type ILanguageMappingRepository interface {
Delete(uint) error
}
type IProjectLabelRepository interface {
GetAll() ([]*models.ProjectLabel, error)
GetById(uint) (*models.ProjectLabel, error)
GetByUser(string) ([]*models.ProjectLabel, error)
Insert(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(uint) error
}
type ISummaryRepository interface {
Insert(*models.Summary) error
GetAll() ([]*models.Summary, error)

View File

@ -23,6 +23,7 @@ func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Preload("Labels", "type = ?", models.SummaryLabel).
Find(&summaries).Error; err != nil {
return nil, err
}
@ -48,6 +49,7 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Preload("Labels", "type = ?", models.SummaryLabel).
Find(&summaries).Error; err != nil {
return nil, err
}

View File

@ -147,6 +147,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"share_oss": user.ShareOSs,
"share_projects": user.ShareProjects,
"share_machines": user.ShareMachines,
"share_labels": user.ShareLabels,
"wakatime_api_key": user.WakatimeApiKey,
"has_data": user.HasData,
"reset_token": user.ResetToken,

View File

@ -6,6 +6,7 @@ import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
customMiddleware "github.com/muety/wakapi/middlewares/custom"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
@ -34,12 +35,14 @@ type heartbeatResponseVm struct {
}
func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/heartbeat").Subrouter()
r := router.PathPrefix("").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
customMiddleware.NewWakatimeRelayMiddleware().Handler,
)
r.Path("").Methods(http.MethodPost).HandlerFunc(h.Post)
r.PathPrefix("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post)
r.PathPrefix("/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
r.PathPrefix("/compat/wakatime/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
}
// @Summary Push a new heartbeat
@ -51,8 +54,12 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
// @Success 201
// @Router /heartbeat [post]
func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
var heartbeats []*models.Heartbeat
user := middlewares.GetPrincipal(r)
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
machineName := r.Header.Get("X-Machine-Name")

View File

@ -27,6 +27,7 @@ const (
DescLanguages = "Total seconds for each language."
DescOperatingSystems = "Total seconds for each operating system."
DescMachines = "Total seconds for each machine."
DescLabels = "Total seconds for each project label."
DescAdminTotalTime = "Total seconds (all users, all time)."
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
@ -198,6 +199,15 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
})
}
for _, m := range summaryToday.Labels {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_label_seconds_total",
Desc: DescLabels,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}},
})
}
return &metrics, nil
}

View File

@ -16,7 +16,7 @@ import (
const (
intervalPattern = `interval:([a-z0-9_]+)`
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)`
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-\s]+)`
)
type BadgeHandler struct {
@ -75,7 +75,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
}
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))
// negative value means no limit
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden)
@ -83,22 +83,38 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
var permitEntity bool
var filters *models.Filters
switch filterEntity {
case "project":
permitEntity = user.ShareProjects
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
case "os":
permitEntity = user.ShareOSs
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
case "editor":
permitEntity = user.ShareEditors
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
case "language":
permitEntity = user.ShareLanguages
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
case "machine":
permitEntity = user.ShareMachines
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
case "label":
permitEntity = user.ShareLabels
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
default:
permitEntity = true
filters = &models.Filters{}
}
if !permitEntity {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("user did not opt in to share entity-specific data"))
return
}
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
if cacheResult, ok := h.cache.Get(cacheKey); ok {
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))

View File

@ -6,6 +6,7 @@ import (
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
@ -45,18 +46,14 @@ func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.AllTimeViewModel
// @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get]
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
values, _ := url.ParseQuery(r.URL.RawQuery)
requestedUser := vars["user"]
authorizedUser := middlewares.GetPrincipal(r)
if requestedUser != authorizedUser.ID && requestedUser != "current" {
w.WriteHeader(http.StatusForbidden)
return
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
summary, err, status := h.loadUserSummary(authorizedUser)
summary, err, status := h.loadUserSummary(user)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))

View File

@ -0,0 +1,73 @@
package v1
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"strings"
)
type ProjectsHandler struct {
config *conf.Config
userSrvc services.IUserService
heartbeatSrvc services.IHeartbeatService
}
func NewProjectsHandler(userService services.IUserService, heartbeatsService services.IHeartbeatService) *ProjectsHandler {
return &ProjectsHandler{
userSrvc: userService,
heartbeatSrvc: heartbeatsService,
config: conf.Get(),
}
}
func (h *ProjectsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/compat/wakatime/v1/users/{user}/projects").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
}
// @Summary Retrieve and fitler the user's projects
// @Description Mimics https://wakatime.com/developers#projects
// @ID get-wakatime-projects
// @Tags wakatime
// @Produce json
// @Param user path string true "User ID to fetch data for (or 'current')"
// @Param q query string true "Query to filter projects by"
// @Security ApiKeyAuth
// @Success 200 {object} v1.ProjectsViewModel
// @Router /compat/wakatime/v1/users/{user}/projects [get]
func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) {
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
results, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("something went wrong"))
conf.Log().Request(r).Error(err.Error())
return
}
q := r.URL.Query().Get("q")
projects := make([]*v1.Project, 0, len(results))
for _, p := range results {
if strings.HasPrefix(p, q) {
projects = append(projects, &v1.Project{ID: p, Name: p})
}
}
vm := &v1.ProjectsViewModel{Data: projects}
utils.RespondJSON(w, r, http.StatusOK, vm)
}

View File

@ -79,7 +79,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)))
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden)

View File

@ -7,6 +7,7 @@ import (
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
@ -36,7 +37,7 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
}
// TODO: Support parameters: project, branches, timeout, writes_only, timezone
// TODO: Support parameters: project, branches, timeout, writes_only
// See https://wakatime.com/developers#summaries.
// Timezone can be specified via an offset suffix (e.g. +02:00) in date strings.
// Requires https://github.com/muety/wakapi/issues/108.
@ -54,13 +55,9 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.SummariesViewModel
// @Router /compat/wakatime/v1/users/{user}/summaries [get]
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
requestedUser := vars["user"]
authorizedUser := middlewares.GetPrincipal(r)
if requestedUser != authorizedUser.ID && requestedUser != "current" {
w.WriteHeader(http.StatusForbidden)
return
_, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
summaries, err, status := h.loadUserSummaries(r)
@ -82,35 +79,42 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
user := middlewares.GetPrincipal(r)
params := r.URL.Query()
rangeParam, startParam, endParam := params.Get("range"), params.Get("start"), params.Get("end")
rangeParam, startParam, endParam, tzParam := params.Get("range"), params.Get("start"), params.Get("end"), params.Get("timezone")
timezone := user.TZ()
if tzParam != "" {
if tz, err := time.LoadLocation(tzParam); err == nil {
timezone = tz
}
}
var start, end time.Time
if rangeParam != "" {
// range param takes precedence
if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, user.TZ()); err == nil {
if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, timezone); err == nil {
start, end = parsedFrom, parsedTo
} else {
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
}
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, user.TZ()); err == nil && startParam == endParam {
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, timezone); err == nil && startParam == endParam {
// also accept start param to be a range param
start, end = parsedFrom, parsedTo
} else {
// eventually, consider start and end params a date
var err error
start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), user.TZ())
start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
if err != nil {
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
}
end, err = utils.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), user.TZ())
end, err = utils.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), timezone)
if err != nil {
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
}
}
// wakatime iterprets end date as "inclusive", wakapi usually as "exclusive"
// wakatime interprets end date as "inclusive", wakapi usually as "exclusive"
// i.e. for wakatime, an interval 2021-04-29 - 2021-04-29 is actually 2021-04-29 - 2021-04-30,
// while for wakapi it would be empty
// see https://github.com/muety/wakapi/issues/192

View File

@ -5,6 +5,7 @@ import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
@ -42,18 +43,13 @@ func (h *UsersHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.UserViewModel
// @Router /compat/wakatime/v1/users/{user} [get]
func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
requestedUser := vars["user"]
authorizedUser := middlewares.GetPrincipal(r)
if requestedUser != authorizedUser.ID && requestedUser != "current" {
w.WriteHeader(http.StatusForbidden)
return
wakapiUser, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
user := v1.NewFromUser(authorizedUser)
if hb, err := h.heartbeatSrvc.GetLatestByUser(authorizedUser); err == nil {
user := v1.NewFromUser(wakapiUser)
if hb, err := h.heartbeatSrvc.GetLatestByUser(wakapiUser); err == nil {
user = user.WithLatestHeartbeat(hb)
} else {
conf.Log().Request(r).Error("%v", err)

View File

@ -112,6 +112,9 @@ func typeName(t uint8) string {
if t == models.SummaryMachine {
return "machine"
}
if t == models.SummaryLabel {
return "label"
}
return "unknown"
}

View File

@ -14,10 +14,14 @@ import (
"github.com/muety/wakapi/services/imports"
"github.com/muety/wakapi/utils"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
const criticalError = "a critical error has occurred, sorry"
type SettingsHandler struct {
config *conf.Config
userSrvc services.IUserService
@ -26,6 +30,7 @@ type SettingsHandler struct {
aliasSrvc services.IAliasService
aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService
projectLabelSrvc services.IProjectLabelService
keyValueSrvc services.IKeyValueService
mailSrvc services.IMailService
httpClient *http.Client
@ -40,6 +45,7 @@ func NewSettingsHandler(
aliasService services.IAliasService,
aggregationService services.IAggregationService,
languageMappingService services.ILanguageMappingService,
projectLabelService services.IProjectLabelService,
keyValueService services.IKeyValueService,
mailService services.IMailService,
) *SettingsHandler {
@ -49,6 +55,7 @@ func NewSettingsHandler(
aliasSrvc: aliasService,
aggregationSrvc: aggregationService,
languageMappingSrvc: languageMappingService,
projectLabelSrvc: projectLabelService,
userSrvc: userService,
heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService,
@ -70,7 +77,6 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
}
@ -128,6 +134,10 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return h.actionDeleteAlias
case "add_alias":
return h.actionAddAlias
case "add_label":
return h.actionAddLabel
case "delete_label":
return h.actionDeleteLabel
case "delete_mapping":
return h.actionDeleteLanguageMapping
case "add_mapping":
@ -252,6 +262,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
user.ShareLabels, err = strconv.ParseBool(r.PostFormValue("share_labels"))
user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days"))
if err != nil {
@ -313,6 +324,59 @@ func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request)
return http.StatusOK, "alias added successfully", ""
}
func (h *SettingsHandler) actionAddLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
label := &models.ProjectLabel{
UserID: user.ID,
ProjectKey: r.PostFormValue("key"),
Label: r.PostFormValue("value"),
}
if !label.IsValid() {
return http.StatusBadRequest, "", "invalid input"
}
if _, err := h.projectLabelSrvc.Create(label); err != nil {
// TODO: distinguish between bad request, conflict and server error
return http.StatusBadRequest, "", "invalid input"
}
return http.StatusOK, "label added successfully", ""
}
func (h *SettingsHandler) actionDeleteLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
labelKey := r.PostFormValue("key")
labelValue := r.PostFormValue("value")
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
if err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
if projectLabels, ok := labelMap[labelKey]; ok {
for _, l := range projectLabels {
if l.Label == labelValue {
if err := h.projectLabelSrvc.Delete(l); err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
return http.StatusOK, "label deleted successfully", ""
}
}
return http.StatusNotFound, "", "label not found"
} else {
return http.StatusNotFound, "", "project not found"
}
}
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
@ -450,6 +514,13 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req
h.regenerateSummaries(user)
if !user.HasData {
user.HasData = true
if _, err := h.userSrvc.Update(user); err != nil {
conf.Log().Request(r).Error("failed to set 'has_data' flag for user %s %v", user.ID, err)
}
}
if user.Email != "" {
if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil {
conf.Log().Request(r).Error("failed to send import notification mail to %s %v", user.ID, err)
@ -546,8 +617,16 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
user := middlewares.GetPrincipal(r)
// mappings
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
// aliases
aliases, err := h.aliasSrvc.GetByUser(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building alias map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
aliasMap := make(map[string][]*models.Alias)
for _, a := range aliases {
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
@ -571,10 +650,42 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
combinedAliases = append(combinedAliases, ca)
}
// labels
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
for _, l := range labelMap {
cl := &view.SettingsVMCombinedLabel{
Key: l[0].ProjectKey,
Values: make([]string, len(l)),
}
for i, l1 := range l {
cl.Values[i] = l1.Label
}
combinedLabels = append(combinedLabels, cl)
}
sort.Slice(combinedLabels, func(i, j int) bool {
return strings.Compare(combinedLabels[i].Key, combinedLabels[j].Key) < 0
})
// projects
projects, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
if err != nil {
conf.Log().Request(r).Error("error while fetching projects - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
sort.Strings(projects)
return &view.SettingsViewModel{
User: user,
LanguageMappings: mappings,
Aliases: combinedAliases,
Labels: combinedLabels,
Projects: projects,
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}

View File

@ -0,0 +1,46 @@
package utils
import (
"errors"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"net/http"
)
// CheckEffectiveUser extracts the requested user from a URL (like '/users/{user}'), compares it with the currently authorized user and writes an HTTP error if they differ.
// Fallback can be used to manually set a value for '{user}' if none is present.
func CheckEffectiveUser(w http.ResponseWriter, r *http.Request, userService services.IUserService, fallback string) (*models.User, error) {
var vars = mux.Vars(r)
var authorizedUser, requestedUser *models.User
if vars["user"] == "" {
vars["user"] = fallback
}
authorizedUser = middlewares.GetPrincipal(r)
if authorizedUser != nil {
if vars["user"] == "current" {
vars["user"] = authorizedUser.ID
}
}
requestedUser, err := userService.GetUserById(vars["user"])
if err != nil {
err := errors.New("user not found")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(err.Error()))
return nil, err
}
if authorizedUser == nil || authorizedUser.ID != requestedUser.ID {
err := errors.New(conf.ErrUnauthorized)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(err.Error()))
return nil, err
}
return authorizedUser, nil
}

View File

@ -1,3 +1,3 @@
#!/bin/bash
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secretpassword -e MYSQL_DATABASE=wakapi_local -e MYSQL_USER=wakapi_user -e MYSQL_PASSWORD=wakapi --name wakapi-mysql mysql:5
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secretpassword -e MYSQL_DATABASE=wakapi_local -e MYSQL_USER=wakapi_user -e MYSQL_PASSWORD=wakapi --name wakapi-mysql mysql:8

View File

@ -1,8 +1,12 @@
package services
import (
"fmt"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"strings"
"time"
"github.com/muety/wakapi/models"
@ -10,6 +14,7 @@ import (
type HeartbeatService struct {
config *config.Config
cache *cache.Cache
repository repositories.IHeartbeatRepository
languageMappingSrvc ILanguageMappingService
}
@ -17,12 +22,14 @@ type HeartbeatService struct {
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
return &HeartbeatService{
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
repository: heartbeatRepo,
languageMappingSrvc: languageMappingService,
}
}
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
srv.updateEntityUserCacheByHeartbeat(heartbeat)
return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat})
}
@ -36,6 +43,7 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
filteredHeartbeats = append(filteredHeartbeats, hb)
hashes[hb.Hash] = true
}
srv.updateEntityUserCacheByHeartbeat(hb)
}
return srv.repository.InsertBatch(filteredHeartbeats)
@ -73,6 +81,28 @@ func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
return srv.repository.GetFirstByUsers()
}
func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
cacheKey := srv.getEntityUserCacheKey(entityType, user)
if results, found := srv.cache.Get(cacheKey); found {
return utils.SetToStrings(results.(map[string]bool)), nil
}
results, err := srv.repository.GetEntitySetByUser(entityType, user)
if err != nil {
return nil, err
}
filtered := make([]string, 0, len(results))
for _, r := range results {
if strings.TrimSpace(r) != "" {
filtered = append(filtered, r)
}
}
srv.cache.Set(cacheKey, utils.StringsToSet(filtered), cache.DefaultExpiration)
return filtered, nil
}
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
return srv.repository.DeleteBefore(t)
}
@ -89,3 +119,26 @@ func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId st
return heartbeats, nil
}
func (srv *HeartbeatService) getEntityUserCacheKey(entityType uint8, user *models.User) string {
return fmt.Sprintf("entity_set_%d_%s", entityType, user.ID)
}
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
cacheKey := srv.getEntityUserCacheKey(entityType, user)
if entities, found := srv.cache.Get(cacheKey); found {
if _, ok := entities.(map[string]bool)[entityKey]; !ok {
// new project / language / ..., which is not yet present in cache, arrived as part of a heartbeats
// -> invalidate cache
srv.cache.Delete(cacheKey)
}
}
}
func (srv *HeartbeatService) updateEntityUserCacheByHeartbeat(hb *models.Heartbeat) {
srv.updateEntityUserCache(models.SummaryProject, hb.Project, hb.User)
srv.updateEntityUserCache(models.SummaryLanguage, hb.Language, hb.User)
srv.updateEntityUserCache(models.SummaryEditor, hb.Editor, hb.User)
srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
srv.updateEntityUserCache(models.SummaryMachine, hb.Machine, hb.User)
}

78
services/project_label.go Normal file
View File

@ -0,0 +1,78 @@
package services
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"time"
)
type ProjectLabelService struct {
config *config.Config
cache *cache.Cache
repository repositories.IProjectLabelRepository
}
func NewProjectLabelService(projectLabelRepository repositories.IProjectLabelRepository) *ProjectLabelService {
return &ProjectLabelService{
config: config.Get(),
repository: projectLabelRepository,
cache: cache.New(24*time.Hour, 24*time.Hour),
}
}
func (srv *ProjectLabelService) GetById(id uint) (*models.ProjectLabel, error) {
return srv.repository.GetById(id)
}
func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel, error) {
if labels, found := srv.cache.Get(userId); found {
return labels.([]*models.ProjectLabel), nil
}
labels, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
}
srv.cache.Set(userId, labels, cache.DefaultExpiration)
return labels, nil
}
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
labels := make(map[string][]*models.ProjectLabel)
userLabels, err := srv.GetByUser(userId)
if err != nil {
return nil, err
}
for _, l := range userLabels {
if _, ok := labels[l.ProjectKey]; !ok {
labels[l.ProjectKey] = []*models.ProjectLabel{l}
} else {
labels[l.ProjectKey] = append(labels[l.ProjectKey], l)
}
}
return labels, nil
}
func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.ProjectLabel, error) {
result, err := srv.repository.Insert(label)
if err != nil {
return nil, err
}
srv.cache.Delete(result.UserID)
return result, nil
}
func (srv *ProjectLabelService) Delete(label *models.ProjectLabel) error {
if label.UserID == "" {
return errors.New("no user id specified")
}
err := srv.repository.Delete(label.ID)
srv.cache.Delete(label.UserID)
return err
}

View File

@ -6,31 +6,40 @@ import (
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"math/rand"
"sync"
"time"
)
var reportLock = sync.Mutex{}
// range for random offset to add / subtract when scheduling a new job
// to avoid all mails being sent at once, but distributed over 2*offsetIntervalMin minutes
const offsetIntervalMin = 15
type ReportService struct {
config *config.Config
eventBus *hub.Hub
summaryService ISummaryService
userService IUserService
mailService IMailService
schedulersWeekly map[string]*gocron.Scheduler // user id -> scheduler
config *config.Config
eventBus *hub.Hub
summaryService ISummaryService
userService IUserService
mailService IMailService
scheduler *gocron.Scheduler
rand *rand.Rand
}
func NewReportService(summaryService ISummaryService, userService IUserService, mailService IMailService) *ReportService {
srv := &ReportService{
config: config.Get(),
eventBus: config.EventBus(),
summaryService: summaryService,
userService: userService,
mailService: mailService,
schedulersWeekly: map[string]*gocron.Scheduler{},
config: config.Get(),
eventBus: config.EventBus(),
summaryService: summaryService,
userService: userService,
mailService: mailService,
scheduler: gocron.NewScheduler(time.Local),
rand: rand.New(rand.NewSource(time.Now().Unix())),
}
srv.scheduler.StartAsync()
sub := srv.eventBus.Subscribe(0, config.EventUserUpdate)
go func(sub *hub.Subscription) {
for m := range sub.Receiver {
@ -62,24 +71,24 @@ func (srv *ReportService) SyncSchedule(u *models.User) bool {
defer reportLock.Unlock()
// unschedule
if s, ok := srv.schedulersWeekly[u.ID]; ok && !u.ReportsWeekly {
s.Stop()
s.Clear()
delete(srv.schedulersWeekly, u.ID)
if !u.ReportsWeekly {
_ = srv.scheduler.RemoveByTag(u.ID)
return false
}
// schedule
if _, ok := srv.schedulersWeekly[u.ID]; !ok && u.ReportsWeekly {
s := gocron.NewScheduler(u.TZ())
s.
if j := srv.getJobByTag(u.ID); j == nil && u.ReportsWeekly {
t, _ := time.ParseInLocation("15:04", srv.config.App.GetWeeklyReportTime(), u.TZ())
t = t.Add(time.Duration(srv.rand.Intn(offsetIntervalMin)*srv.rand.Intn(2)) * time.Minute)
if _, err := srv.scheduler.
Every(1).
Week().
Weekday(srv.config.App.GetWeeklyReportDay()).
At(srv.config.App.GetWeeklyReportTime()).
Do(srv.Run, u, 7*24*time.Hour)
s.StartAsync()
srv.schedulersWeekly[u.ID] = s
At(t).
Tag(u.ID).
Do(srv.Run, u, 7*24*time.Hour); err != nil {
config.Log().Error("failed to schedule report job for user '%s' %v", u.ID, err)
}
}
return u.ReportsWeekly
@ -119,3 +128,14 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
logbuch.Info("sent report to user '%s'", user.ID)
return nil
}
func (srv *ReportService) getJobByTag(tag string) *gocron.Job {
for _, j := range srv.scheduler.Jobs() {
for _, t := range j.Tags() {
if t == tag {
return j
}
}
}
return nil
}

View File

@ -35,6 +35,7 @@ type IHeartbeatService interface {
GetFirstByUsers() ([]*models.TimeByUser, error)
GetLatestByUser(*models.User) (*models.Heartbeat, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error
}
@ -53,6 +54,14 @@ type ILanguageMappingService interface {
Delete(mapping *models.LanguageMapping) error
}
type IProjectLabelService interface {
GetById(uint) (*models.ProjectLabel, error)
GetByUser(string) ([]*models.ProjectLabel, error)
GetByUserGrouped(string) (map[string][]*models.ProjectLabel, error)
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(*models.ProjectLabel) error
}
type IMailService interface {
SendPasswordReset(*models.User, string) error
SendImportNotification(*models.User, time.Duration, int) error

View File

@ -1,7 +1,6 @@
package services
import (
"crypto/md5"
"errors"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
@ -10,28 +9,31 @@ import (
"github.com/patrickmn/go-cache"
"math"
"sort"
"strings"
"time"
)
const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct {
config *config.Config
cache *cache.Cache
repository repositories.ISummaryRepository
heartbeatService IHeartbeatService
aliasService IAliasService
config *config.Config
cache *cache.Cache
repository repositories.ISummaryRepository
heartbeatService IHeartbeatService
aliasService IAliasService
projectLabelService IProjectLabelService
}
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
return &SummaryService{
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
repository: summaryRepo,
heartbeatService: heartbeatService,
aliasService: aliasService,
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
repository: summaryRepo,
heartbeatService: heartbeatService,
aliasService: aliasService,
projectLabelService: projectLabelService,
}
}
@ -63,6 +65,9 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
// Post-process summary and cache it
summary := s.WithResolvedAliases(resolve)
summary.FillBy(models.SummaryProject, models.SummaryLabel) // first fill up labels from projects
summary.FillMissing() // then, full up types which are entirely missing
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil
}
@ -110,7 +115,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
return nil, err
}
types := models.SummaryTypes()
types := models.NativeSummaryTypes()
typedAggregations := make(chan models.SummaryItemContainer)
defer close(typedAggregations)
@ -156,8 +161,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
OperatingSystems: osItems,
Machines: machineItems,
}
//summary.FillUnknown()
summary = srv.withProjectLabels(summary)
return summary.Sorted(), nil
}
@ -169,10 +173,12 @@ func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
}
func (srv *SummaryService) DeleteByUser(userId string) error {
srv.invalidateUserCache(userId)
return srv.repository.DeleteByUser(userId)
}
func (srv *SummaryService) Insert(summary *models.Summary) error {
srv.invalidateUserCache(summary.UserID)
return srv.repository.Insert(summary)
}
@ -220,6 +226,49 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
}
func (srv *SummaryService) withProjectLabels(summary *models.Summary) *models.Summary {
newEntry := func(key string, total time.Duration) *models.SummaryItem {
return &models.SummaryItem{
Type: models.SummaryLabel,
Key: key,
Total: total,
}
}
allLabels, err := srv.projectLabelService.GetByUser(summary.UserID)
if err != nil {
logbuch.Error("failed to retrieve project labels for user summary ('%s', '%s', '%s')", summary.UserID, summary.FromTime.String(), summary.ToTime.String())
return summary
}
mappedProjects := make(map[string]*models.SummaryItem, len(summary.Projects))
for _, p := range summary.Projects {
mappedProjects[p.Key] = p
}
var totalLabelTime time.Duration
labelMap := make(map[string]*models.SummaryItem, 0)
for _, l := range allLabels {
if p, ok := mappedProjects[l.ProjectKey]; ok {
if _, ok2 := labelMap[l.Label]; !ok2 {
labelMap[l.Label] = newEntry(l.Label, 0)
}
labelMap[l.Label].Total += p.Total
totalLabelTime += p.Total
}
}
//labelMap[models.DefaultProjectLabel] = newEntry(models.DefaultProjectLabel, summary.TotalTimeBy(models.SummaryProject) / time.Second-totalLabelTime)
labels := make([]*models.SummaryItem, 0, len(labelMap))
for _, v := range labelMap {
if v.Total > 0 {
labels = append(labels, v)
}
}
summary.Labels = labels
return summary
}
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
if len(summaries) < 1 {
return nil, errors.New("no summaries given")
@ -235,6 +284,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
Editors: make([]*models.SummaryItem, 0),
OperatingSystems: make([]*models.SummaryItem, 0),
Machines: make([]*models.SummaryItem, 0),
Labels: make([]*models.SummaryItem, 0),
}
var processed = map[time.Time]bool{}
@ -263,6 +313,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
processed[hash] = true
}
@ -349,9 +400,13 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
}
func (srv *SummaryService) getHash(args ...string) string {
digest := md5.New()
for _, a := range args {
digest.Write([]byte(a))
}
return string(digest.Sum(nil))
return strings.Join(args, "__")
}
func (srv *SummaryService) invalidateUserCache(userId string) {
for key := range srv.cache.Items() {
if strings.Contains(key, userId) {
srv.cache.Delete(key)
}
}
}

View File

@ -16,6 +16,9 @@ const (
TestUserId = "muety"
TestProject1 = "test-project-1"
TestProject2 = "test-project-2"
TestProjectLabel1 = "private"
TestProjectLabel2 = "work"
TestProjectLabel3 = "non-existing"
TestLanguageGo = "Go"
TestLanguageJava = "Java"
TestLanguagePython = "Python"
@ -31,12 +34,14 @@ const (
type SummaryServiceTestSuite struct {
suite.Suite
TestUser *models.User
TestStartTime time.Time
TestHeartbeats []*models.Heartbeat
SummaryRepository *mocks.SummaryRepositoryMock
HeartbeatService *mocks.HeartbeatServiceMock
AliasService *mocks.AliasServiceMock
TestUser *models.User
TestStartTime time.Time
TestHeartbeats []*models.Heartbeat
TestLabels []*models.ProjectLabel
SummaryRepository *mocks.SummaryRepositoryMock
HeartbeatService *mocks.HeartbeatServiceMock
AliasService *mocks.AliasServiceMock
ProjectLabelService *mocks.ProjectLabelServiceMock
}
func (suite *SummaryServiceTestSuite) SetupSuite() {
@ -75,12 +80,27 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
},
}
suite.TestLabels = []*models.ProjectLabel{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
ProjectKey: TestProject1,
Label: TestProjectLabel1,
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
ProjectKey: TestProjectLabel3,
Label: "blaahh",
},
}
}
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
suite.AliasService = new(mocks.AliasServiceMock)
suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock)
}
func TestSummaryServiceTestSuite(t *testing.T) {
@ -88,7 +108,7 @@ func TestSummaryServiceTestSuite(t *testing.T) {
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
var (
from time.Time
@ -100,6 +120,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 1 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser)
@ -113,6 +134,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 2 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser)
@ -126,6 +148,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 3 */
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser)
@ -136,12 +159,16 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
assert.Equal(suite.T(), 150*time.Second, result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel1))
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel3))
assert.Len(suite.T(), result.Editors, 2)
assertNumAllItems(suite.T(), 1, result, "e")
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var (
summaries []*models.Summary
@ -292,7 +319,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var (
summaries []*models.Summary
@ -338,7 +367,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var (
from time.Time

View File

@ -1,3 +1,3 @@
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/**
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/**,views/mail/**
sonar.tests=.
sonar.go.coverage.reportPaths=coverage/coverage.out

View File

@ -5,16 +5,18 @@ 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 labelsCanvas = document.getElementById('chart-label')
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 labelContainer = document.getElementById('label-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]
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels]
let topNPickers = [...document.getElementsByClassName('top-picker')]
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
@ -255,9 +257,42 @@ function draw(subselection) {
})
: null
let labelChart = !labelsCanvas.classList.contains('hidden') && shouldUpdate(5)
? new Chart(labelsCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
}],
labels: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => p.key)
},
options: {
tooltips: getTooltipOptions('labels'),
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
getTotal(wakapiData.operatingSystems)
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
charts = [projectChart, osChart, editorChart, languageChart, machineChart, labelChart].filter(c => !!c)
if (!subselection) {
charts.forEach(c => c.options.onResize(c.chart))

View File

@ -641,6 +641,12 @@ video {
background-color: rgba(47, 133, 90, var(--bg-opacity));
}
.hover\:bg-gray-700:hover {
--bg-opacity: 1;
background-color: #4a5568;
background-color: rgba(74, 85, 104, var(--bg-opacity));
}
.hover\:bg-red-600:hover {
--bg-opacity: 1;
background-color: #e53e3e;
@ -713,6 +719,10 @@ video {
border-radius: 0.375rem;
}
.rounded-full {
border-radius: 9999px;
}
.border {
border-width: 1px;
}
@ -753,6 +763,10 @@ video {
display: flex;
}
.inline-flex {
display: inline-flex;
}
.table {
display: table;
}
@ -821,6 +835,10 @@ video {
font-weight: 600;
}
.h-4 {
height: 1rem;
}
.h-8 {
height: 2rem;
}
@ -853,6 +871,10 @@ video {
font-size: 2.25rem;
}
.leading-none {
line-height: 1;
}
.list-inside {
list-style-position: inside;
}
@ -1186,6 +1208,10 @@ video {
overflow-wrap: break-word;
}
.w-4 {
width: 1rem;
}
.w-1\/2 {
width: 50%;
}

View File

@ -160,6 +160,48 @@ var doc = `{
}
}
},
"/compat/wakatime/v1/users/{user}/projects": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#projects",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve and fitler the user's projects",
"operationId": "get-wakatime-projects",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Query to filter projects by",
"name": "q",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.ProjectsViewModel"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/stats/{range}": {
"get": {
"security": [
@ -574,6 +616,31 @@ var doc = `{
}
}
},
"v1.Project": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"repository": {
"type": "string"
}
}
},
"v1.ProjectsViewModel": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.Project"
}
}
}
},
"v1.StatsData": {
"type": "object",
"properties": {

View File

@ -144,6 +144,48 @@
}
}
},
"/compat/wakatime/v1/users/{user}/projects": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Mimics https://wakatime.com/developers#projects",
"produces": [
"application/json"
],
"tags": [
"wakatime"
],
"summary": "Retrieve and fitler the user's projects",
"operationId": "get-wakatime-projects",
"parameters": [
{
"type": "string",
"description": "User ID to fetch data for (or 'current')",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Query to filter projects by",
"name": "q",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.ProjectsViewModel"
}
}
}
}
},
"/compat/wakatime/v1/users/{user}/stats/{range}": {
"get": {
"security": [
@ -558,6 +600,31 @@
}
}
},
"v1.Project": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"repository": {
"type": "string"
}
}
},
"v1.ProjectsViewModel": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.Project"
}
}
}
},
"v1.StatsData": {
"type": "object",
"properties": {

View File

@ -117,6 +117,22 @@ definitions:
schemaVersion:
type: integer
type: object
v1.Project:
properties:
id:
type: string
name:
type: string
repository:
type: string
type: object
v1.ProjectsViewModel:
properties:
data:
items:
$ref: '#/definitions/v1.Project'
type: array
type: object
v1.StatsData:
properties:
daily_average:
@ -392,6 +408,33 @@ paths:
summary: Retrieve summary for all time
tags:
- wakatime
/compat/wakatime/v1/users/{user}/projects:
get:
description: Mimics https://wakatime.com/developers#projects
operationId: get-wakatime-projects
parameters:
- description: User ID to fetch data for (or 'current')
in: path
name: user
required: true
type: string
- description: Query to filter projects by
in: query
name: q
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.ProjectsViewModel'
security:
- ApiKeyAuth: []
summary: Retrieve and fitler the user's projects
tags:
- wakatime
/compat/wakatime/v1/users/{user}/stats/{range}:
get:
description: Mimics https://wakatime.com/developers#stats

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
env: production
server:
listen_ipv4: 127.0.0.1
listen_ipv6:
tls_cert_path:
tls_key_path:
port: 3000
base_path: /
public_url: http://localhost:3000
app:
aggregation_time: '02:15'
report_time_weekly: 'fri,18:00'
inactive_days: 7
custom_languages:
vue: Vue
jsx: JSX
svelte: Svelte
db:
host:
port:
user:
password:
name: wakapi_testing.db
dialect: sqlite3
charset:
max_conn: 2
ssl: false
automgirate_fail_silently: false
security:
password_salt:
insecure_cookies: true
cookie_max_age: 172800
allow_signup: true
expose_metrics: true
sentry:
dsn:
enable_tracing: false
sample_rate:
sample_rate_heartbeats:
mail:
enabled: false
provider: smtp
sender: Wakapi <noreply@wakapi.dev>
smtp:
host:
port:
username:
password:
tls:
mailwhale:
url:
client_id:
client_secret:

14
testing/data.sql Normal file
View File

@ -0,0 +1,14 @@
BEGIN TRANSACTION;
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
VALUES ('readuser', '33e7f538-0dce-4eba-8ffe-53db6814ed42', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:25',
'2021-05-28 14:34:34.178+02:00', 0, 0, 0, 0, 0, 0, 1, 0, '', '', 0);
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
VALUES ('writeuser', 'f7aa255c-8647-4d0b-b90f-621c58fd580f', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:56',
'2021-05-28 14:35:05.118+02:00', 7, 0, 0, 1, 0, 0, 0, 1, '', '', 0);
COMMIT;

40
testing/run_api_tests.sh Executable file
View File

@ -0,0 +1,40 @@
#!/bin/bash
if [ ! -f "wakapi" ]; then
echo "Wakapi executable not found. Compiling."
go build
fi
if ! command -v newman &> /dev/null
then
echo "Newman could not be found. Run 'npm install -g newman' first."
exit 1
fi
cd "$(dirname "$0")"
echo "Creating database and schema ..."
sqlite3 wakapi_testing.db < schema.sql
echo "Importing seed data ..."
sqlite3 wakapi_testing.db < data.sql
echo "Running Wakapi testing instance in background ..."
screen -S wakapi_testing -dm bash -c "../wakapi -config config.testing.yml"
echo "Waiting for Wakapi to come up ..."
until $(curl --output /dev/null --silent --get --fail http://localhost:3000/api/health); do
printf '.'
sleep 1
done
echo ""
echo "Running test collection ..."
newman run "Wakapi API Tests.postman_collection.json"
echo "Shutting down Wakapi ..."
screen -S wakapi_testing -X quit
echo "Deleting database ..."
rm wakapi_testing.db

147
testing/schema.sql Normal file
View File

@ -0,0 +1,147 @@
BEGIN TRANSACTION;
DROP TABLE IF EXISTS "users";
CREATE TABLE IF NOT EXISTS "users" (
"id" text,
"api_key" text UNIQUE,
"email" text,
"password" text,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"last_logged_in_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"share_data_max_days" integer DEFAULT 0,
"share_editors" numeric DEFAULT false,
"share_languages" numeric DEFAULT false,
"share_projects" numeric DEFAULT false,
"share_oss" numeric DEFAULT false,
"share_machines" numeric DEFAULT false,
"is_admin" numeric DEFAULT false,
"has_data" numeric DEFAULT false,
"wakatime_api_key" text,
"reset_token" text,
"location" text,
"reports_weekly" numeric DEFAULT false,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "key_string_values";
CREATE TABLE IF NOT EXISTS "key_string_values" (
"key" text,
"value" text,
PRIMARY KEY("key")
);
DROP TABLE IF EXISTS "summary_items";
CREATE TABLE IF NOT EXISTS "summary_items" (
"id" integer,
"summary_id" integer,
"type" integer,
"key" text,
"total" integer,
CONSTRAINT "fk_summaries_languages" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summary_items_summary" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_machines" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_projects" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_operating_systems" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fk_summaries_editors" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "aliases";
CREATE TABLE IF NOT EXISTS "aliases" (
"id" integer,
"type" integer NOT NULL,
"user_id" text NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
CONSTRAINT "fk_aliases_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "heartbeats";
CREATE TABLE IF NOT EXISTS "heartbeats" (
"id" integer,
"user_id" text NOT NULL,
"entity" text NOT NULL,
"type" text,
"category" text,
"project" text,
"branch" text,
"language" text,
"is_write" numeric,
"editor" text,
"operating_system" text,
"machine" text,
"time" timestamp,
"hash" varchar(17),
"origin" text,
"origin_id" text,
"created_at" timestamp,
CONSTRAINT "fk_heartbeats_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "summaries";
CREATE TABLE IF NOT EXISTS "summaries" (
"id" integer,
"user_id" text NOT NULL,
"from_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"to_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fk_summaries_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP TABLE IF EXISTS "language_mappings";
CREATE TABLE IF NOT EXISTS "language_mappings" (
"id" integer,
"user_id" text NOT NULL,
"extension" varchar(16),
"language" varchar(64),
CONSTRAINT "fk_language_mappings_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY("id")
);
DROP INDEX IF EXISTS "idx_user_email";
CREATE INDEX IF NOT EXISTS "idx_user_email" ON "users" (
"email"
);
DROP INDEX IF EXISTS "idx_type";
CREATE INDEX IF NOT EXISTS "idx_type" ON "summary_items" (
"type"
);
DROP INDEX IF EXISTS "idx_alias_type_key";
CREATE INDEX IF NOT EXISTS "idx_alias_type_key" ON "aliases" (
"type",
"key"
);
DROP INDEX IF EXISTS "idx_alias_user";
CREATE INDEX IF NOT EXISTS "idx_alias_user" ON "aliases" (
"user_id"
);
DROP INDEX IF EXISTS "idx_time";
CREATE INDEX IF NOT EXISTS "idx_time" ON "heartbeats" (
"time"
);
DROP INDEX IF EXISTS "idx_heartbeats_hash";
CREATE UNIQUE INDEX IF NOT EXISTS "idx_heartbeats_hash" ON "heartbeats" (
"hash"
);
DROP INDEX IF EXISTS "idx_time_user";
CREATE INDEX IF NOT EXISTS "idx_time_user" ON "heartbeats" (
"user_id"
);
DROP INDEX IF EXISTS "idx_entity";
CREATE INDEX IF NOT EXISTS "idx_entity" ON "heartbeats" (
"entity"
);
DROP INDEX IF EXISTS "idx_language";
CREATE INDEX IF NOT EXISTS "idx_language" ON "heartbeats" (
"language"
);
DROP INDEX IF EXISTS "idx_time_summary_user";
CREATE INDEX IF NOT EXISTS "idx_time_summary_user" ON "summaries" (
"user_id",
"from_time",
"to_time"
);
DROP INDEX IF EXISTS "idx_language_mapping_composite";
CREATE UNIQUE INDEX IF NOT EXISTS "idx_language_mapping_composite" ON "language_mappings" (
"user_id",
"extension"
);
DROP INDEX IF EXISTS "idx_language_mapping_user";
CREATE INDEX IF NOT EXISTS "idx_language_mapping_user" ON "language_mappings" (
"user_id"
);
COMMIT;

9
utils/collection.go Normal file
View File

@ -0,0 +1,9 @@
package utils
func GetMapValues(m map[string]interface{}) []interface{} {
values := make([]interface{}, 0, len(m))
for _, v := range m {
values = append(values, v)
}
return values
}

17
utils/set.go Normal file
View File

@ -0,0 +1,17 @@
package utils
func StringsToSet(slice []string) map[string]bool {
set := make(map[string]bool, len(slice))
for _, e := range slice {
set[e] = true
}
return set
}
func SetToStrings(set map[string]bool) []string {
slice := make([]string, 0, len(set))
for k := range set {
slice = append(slice, k)
}
return slice
}

View File

@ -30,7 +30,8 @@ func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from,
}
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
to = time.Now().In(tz)
now := time.Now().In(tz)
to = now
switch interval {
case models.IntervalToday:
@ -51,16 +52,16 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
case models.IntervalThisYear:
from = StartOfThisYear(tz)
case models.IntervalPast7Days:
from = StartOfToday(tz).AddDate(0, 0, -7)
from = now.AddDate(0, 0, -7)
case models.IntervalPast7DaysYesterday:
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = StartOfToday(tz).AddDate(0, 0, -1)
case models.IntervalPast14Days:
from = StartOfToday(tz).AddDate(0, 0, -14)
from = now.AddDate(0, 0, -14)
case models.IntervalPast30Days:
from = StartOfToday(tz).AddDate(0, 0, -30)
from = now.AddDate(0, 0, -30)
case models.IntervalPast12Months:
from = StartOfToday(tz).AddDate(0, -12, 0)
from = now.AddDate(0, -12, 0)
case models.IntervalAny:
from = time.Time{}
default:

View File

@ -1 +1 @@
1.27.0
1.28.1

View File

@ -69,6 +69,7 @@
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Built by developers for developers</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Fancy statistics and plots</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Cool badges for readmes</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Weekly e-mail reports</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Intuitive REST API</li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> &nbsp; <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics</li>

View File

@ -35,7 +35,7 @@
<main class="mt-4 flex-grow flex justify-center w-full">
<div class="flex flex-col flex-grow max-w-2xl mt-8">
<details class="my-8 pb-8 border-b border-gray-700">
<details class="my-8 pb-8 border-b border-gray-700" id="details-account">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="preferences-heading">
@ -88,7 +88,7 @@
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700">
<details class="mb-8 pb-8 border-b border-gray-700" id="details-password">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
Change Password
@ -127,7 +127,7 @@
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700">
<details class="mb-8 pb-8 border-b border-gray-700" id="details-aliases">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Aliases
@ -203,7 +203,80 @@
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700">
<details class="mb-8 pb-8 border-b border-gray-700" id="details-labels">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Project Labels
</h2>
</summary>
<div class="w-full" id="project-labels">
<div class="text-gray-300 text-sm mb-4 mt-6">
You can assign labels (aka. tags) to projects to group them together, e.g. by <span class="inline-block mb-1 text-gray-500 italic">private</span> and <span
class="inline-block mb-1 text-gray-500 italic">work</span>. Please note that labels are only applied to new data, not to existing summaries in retrospect. To label historic data, you will need to clear and regenerate your summaries (see down below).
</div>
{{ if .Labels }}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Labels</h3>
{{ range $i, $label := .Labels }}
<div class="flex items-center" action="" method="post">
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
style="line-height: 1.8">
&#9656;&nbsp;<span class="font-semibold text-white">{{ $label.Key }}:</span>
{{ range $j, $value := $label.Values }}
<form action="" method="post" class="text-white text-xs bg-gray-900 rounded-full py-1 px-2 font-mono inline-flex justify-between items-center space-x-2">
<input type="hidden" name="action" value="delete_label">
<input type="hidden" name="key" value="{{ $label.Key }}">
<input type="hidden" name="value" value="{{ $value }}">
<span>{{- $value -}}</span>
<button type="submit" class="bg-gray-800 text-center hover:bg-gray-700 rounded-full w-4 h-4 leading-none" title="Delete label">x</button>
</form>
{{ if lt $j (add (len $label.Values) -1) }}
<span class="-ml-1">{{- ", " | capitalize -}}</span>
{{ end }}
{{ end }}
</div>
</div>
{{end}}
<div class="mb-8"></div>
{{end}}
{{ if .Projects }}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Label</h3>
<form action="" method="post">
<input type="hidden" name="action" value="add_label">
<div class="flex flex-col space-y-4">
<div class="flex justify-between items-center mt-2 w-full text-gray-500 text-sm space-x-4">
<div class="w-1/2 flex flex-col flex-grow">
<span>Project</span>
<select name="key" id="select-project"
class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3 cursor-pointer">
{{ range $i, $p := .Projects }}
<option value="{{ $p }}">{{ $p }}</option>
{{ end }}
</select>
</div>
<div class="w-1/2 flex flex-col flex-grow">
<span>Label</span>
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
type="text" id="label-value"
name="value" placeholder="work" minlength="1" required>
</div>
</div>
<div class="flex-grow flex justify-end">
<button type="submit"
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Add
</button>
</div>
</div>
</form>
{{ else }}
<div class="text-gray-300 text-sm mb-4 mt-6">You don't have any projects, yet. Start out by sending a few heartbeats before you can then assign labels.</div>
{{ end }}
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700" id="details-mappings">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="languages">
@ -263,7 +336,7 @@
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700" id="public_data">
<details class="mb-8 pb-8 border-b border-gray-700" id="details-public-data">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Public Data
@ -298,11 +371,9 @@
<div class="flex justify-end">
<select autocomplete="off" name="share_projects"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected
{{ end }}>No
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end
}}>Yes
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes
</option>
</select>
</div>
@ -314,11 +385,9 @@
<div class="flex justify-end">
<select autocomplete="off" name="share_languages"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected
{{ end }}>No
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{
end }}>Yes
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes
</option>
</select>
</div>
@ -330,11 +399,9 @@
<div class="flex justify-end">
<select autocomplete="off" name="share_editors"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{
end }}>No
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end
}}>Yes
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes
</option>
</select>
</div>
@ -346,8 +413,7 @@
<div class="flex justify-end">
<select autocomplete="off" name="share_oss"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end
}}>No
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>
Yes
@ -362,11 +428,23 @@
<div class="flex justify-end">
<select autocomplete="off" name="share_machines"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected
{{ end }}>No
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end
}}>Yes
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes
</option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share project labels: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_labels"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareLabels }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareLabels }} selected {{ end }}>Yes
</option>
</select>
</div>
@ -383,7 +461,7 @@
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700">
<details class="mb-8 pb-8 border-b border-gray-700" id="details-integrations">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="integrations">
@ -506,7 +584,7 @@
<p>You have the ability to create badges from your coding statistics using <a
href="https://shields.io" target="_blank" class="border-b border-green-800"
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
access to the respective endpoint. See <a href="settings#public_data" class="underline">Public
access to the respective endpoint. See <a href="settings#details-public-data" class="underline">Public
Data</a> setting.</p>
{{ end }}
</div>
@ -528,7 +606,7 @@
</div>
<div class="flex flex-col mb-4 mt-2">
<img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact"
class="with-url-src-no-scheme">
class="with-url-src-no-scheme" alt="Readme Stats Card">
<p class="mt-2"><strong>Source URL:</strong>
<span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme">
https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact
@ -540,7 +618,7 @@
</div>
</details>
<details class="mb-8 pb-8">
<details class="mb-8 pb-8" id="details-danger-zone">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
<span class="iconify inline" data-icon="emojione-v1:warning"></span>&nbsp; Danger Zone
@ -677,6 +755,12 @@
tzs.sort()
.map(createTzOption)
.forEach(o => selectTimezone.appendChild(o))
const hash = location.hash.replace('#', '')
if (hash) {
const elem = document.getElementById(hash)
if (elem) elem.open = true
}
</script>
{{ template "footer.tpl.html" . }}

View File

@ -170,6 +170,26 @@
</div>
</div>
</div>
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="label-container" style="height: 300px">
<div class="flex justify-between">
<div class="w-1/4 flex-1">
<a href="settings#details-labels" class="h-8 inline">
<span class="iconify inline" data-icon="twemoji:gear"></span>
</a>
</div>
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Labels</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="label-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="label-top-picker" data-entity="5" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-label"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
</div>
{{ else }}
@ -192,7 +212,7 @@
# <strong>Step 2:</strong> Adapt your config<br>
$ vi ~/.wakatime.cfg<br>
# Set <em>api_url = <span class="with-url-inner">%s/api/heartbeat</span></em><br>
# Set <em>api_url = <span class="with-url-inner">%s/api</span></em><br>
# Set <em>api_key = <span id="api-key-instruction"></span></em><br><br>
# <strong>Step 3:</strong> Start coding and then check back here!
@ -228,6 +248,7 @@
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
wakapiData.machines = {{ .Machines | json }}
wakapiData.labels = {{ .Labels | json }}
document.getElementById("to-date-picker").onchange = function () {
var input = document.getElementById("from-date-picker");