mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
82b8951437 | |||
25464e9519 | |||
650fffa344 | |||
69627fbe11 | |||
561198b203 | |||
7c4a2024b6 | |||
7bcd6890d1 | |||
1e4e530c21 | |||
490cca05eb | |||
3780ae4255 | |||
628ea0b9dd | |||
0d64858721 | |||
c1c78d8d5b | |||
538b9d2463 | |||
f4612fd542 | |||
fb643571d2 | |||
101fdfb957 | |||
a4d47fb566 | |||
1a808f9197 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@ build
|
||||
*.db
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
!testing/config.testing.yml
|
||||
pkged.go
|
||||
package.json
|
||||
yarn.lock
|
||||
|
41
README.md
41
README.md
@ -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
|
||||
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
9
main.go
9
main.go
@ -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)
|
||||
@ -173,7 +178,7 @@ func main() {
|
||||
|
||||
// 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)
|
||||
|
35
mocks/project_label_service.go
Normal file
35
mocks/project_label_service.go
Normal 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)
|
||||
}
|
@ -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
13
models/project_label.go
Normal 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 != ""
|
||||
}
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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:"-"`
|
||||
|
@ -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
|
||||
|
60
repositories/project_label.go
Normal file
60
repositories/project_label.go
Normal 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
|
||||
}
|
@ -46,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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
|
@ -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)
|
||||
|
@ -112,6 +112,9 @@ func typeName(t uint8) string {
|
||||
if t == models.SummaryMachine {
|
||||
return "machine"
|
||||
}
|
||||
if t == models.SummaryLabel {
|
||||
return "label"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
|
@ -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"),
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
@ -90,8 +91,16 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srv.cache.Set(cacheKey, utils.StringsToSet(results), cache.DefaultExpiration)
|
||||
return results, nil
|
||||
|
||||
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 {
|
||||
|
78
services/project_label.go
Normal file
78
services/project_label.go
Normal 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
|
||||
}
|
@ -54,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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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))
|
||||
|
26
static/assets/vendor/tailwind.dist.css
vendored
26
static/assets/vendor/tailwind.dist.css
vendored
@ -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%;
|
||||
}
|
||||
|
2516
testing/Wakapi API Tests.postman_collection.json
Normal file
2516
testing/Wakapi API Tests.postman_collection.json
Normal file
File diff suppressed because it is too large
Load Diff
61
testing/config.testing.yml
Normal file
61
testing/config.testing.yml
Normal 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
14
testing/data.sql
Normal 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
40
testing/run_api_tests.sh
Executable 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
147
testing/schema.sql
Normal 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
9
utils/collection.go
Normal 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
|
||||
}
|
@ -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:
|
||||
|
@ -1 +1 @@
|
||||
1.27.2
|
||||
1.28.1
|
||||
|
@ -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">
|
||||
▸ <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> 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" . }}
|
||||
|
@ -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: </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 }}
|
||||
@ -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");
|
||||
|
Reference in New Issue
Block a user