mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
feat: wakatime data import (resolve #87)
This commit is contained in:
parent
d9e163bf73
commit
fd9e2acdf1
@ -29,22 +29,28 @@ const (
|
|||||||
|
|
||||||
KeyLatestTotalTime = "latest_total_time"
|
KeyLatestTotalTime = "latest_total_time"
|
||||||
KeyLatestTotalUsers = "latest_total_users"
|
KeyLatestTotalUsers = "latest_total_users"
|
||||||
|
KeyLastImportImport = "last_import"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
WakatimeApiUrl = "https://wakatime.com/api/v1"
|
WakatimeApiUrl = "https://wakatime.com/api/v1"
|
||||||
WakatimeApiHeartbeatsEndpoint = "/users/current/heartbeats.bulk"
|
WakatimeApiUserUrl = "/users/current"
|
||||||
WakatimeApiUserEndpoint = "/users/current"
|
WakatimeApiAllTimeUrl = "/users/current/all_time_since_today"
|
||||||
|
WakatimeApiHeartbeatsUrl = "/users/current/heartbeats"
|
||||||
|
WakatimeApiHeartbeatsBulkUrl = "/users/current/heartbeats.bulk"
|
||||||
|
WakatimeApiUserAgentsUrl = "/users/current/user_agents"
|
||||||
)
|
)
|
||||||
|
|
||||||
var cfg *Config
|
var cfg *Config
|
||||||
var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
||||||
|
|
||||||
type appConfig struct {
|
type appConfig struct {
|
||||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||||
CountingTime string `yaml:"counting_time" default:"05:15" env:"WAKAPI_COUNTING_TIME"`
|
CountingTime string `yaml:"counting_time" default:"05:15" env:"WAKAPI_COUNTING_TIME"`
|
||||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
|
||||||
Colors map[string]map[string]string `yaml:"-"`
|
ImportBatchSize int `yaml:"import_batch_size" default:"25" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
||||||
|
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||||
|
Colors map[string]map[string]string `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type securityConfig struct {
|
type securityConfig struct {
|
||||||
|
1
go.mod
1
go.mod
@ -21,6 +21,7 @@ require (
|
|||||||
github.com/stretchr/testify v1.6.1
|
github.com/stretchr/testify v1.6.1
|
||||||
go.uber.org/atomic v1.6.0
|
go.uber.org/atomic v1.6.0
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||||
gopkg.in/yaml.v2 v2.2.8 // indirect
|
gopkg.in/yaml.v2 v2.2.8 // indirect
|
||||||
gorm.io/driver/mysql v1.0.3
|
gorm.io/driver/mysql v1.0.3
|
||||||
|
2
main.go
2
main.go
@ -136,7 +136,7 @@ func main() {
|
|||||||
|
|
||||||
// MVC Handlers
|
// MVC Handlers
|
||||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||||
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aliasService, aggregationService, languageMappingService)
|
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService)
|
||||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||||
loginHandler := routes.NewLoginHandler(userService)
|
loginHandler := routes.NewLoginHandler(userService)
|
||||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||||
|
@ -63,7 +63,7 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
go m.send(
|
go m.send(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsEndpoint,
|
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsBulkUrl,
|
||||||
bytes.NewReader(body),
|
bytes.NewReader(body),
|
||||||
headers,
|
headers,
|
||||||
)
|
)
|
||||||
|
@ -9,10 +9,10 @@ import (
|
|||||||
// https://wakatime.com/developers#all_time_since_today
|
// https://wakatime.com/developers#all_time_since_today
|
||||||
|
|
||||||
type AllTimeViewModel struct {
|
type AllTimeViewModel struct {
|
||||||
Data *allTimeData `json:"data"`
|
Data *AllTimeData `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type allTimeData struct {
|
type AllTimeData struct {
|
||||||
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
|
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
|
||||||
Text string `json:"text"` // total time logged since account created as human readable string>
|
Text string `json:"text"` // total time logged since account created as human readable string>
|
||||||
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
|
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
|
||||||
@ -27,7 +27,7 @@ func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeVi
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &AllTimeViewModel{
|
return &AllTimeViewModel{
|
||||||
Data: &allTimeData{
|
Data: &AllTimeData{
|
||||||
TotalSeconds: float32(total.Seconds()),
|
TotalSeconds: float32(total.Seconds()),
|
||||||
Text: utils.FmtWakatimeDuration(total),
|
Text: utils.FmtWakatimeDuration(total),
|
||||||
IsUpToDate: true,
|
IsUpToDate: true,
|
||||||
|
25
models/compat/wakatime/v1/heartbeat.go
Normal file
25
models/compat/wakatime/v1/heartbeat.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import "github.com/muety/wakapi/models"
|
||||||
|
|
||||||
|
type HeartbeatsViewModel struct {
|
||||||
|
Data []*HeartbeatEntry `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incomplete, for now, only the subset of fields is implemented
|
||||||
|
// that is actually required for the import
|
||||||
|
|
||||||
|
type HeartbeatEntry struct {
|
||||||
|
Id string
|
||||||
|
Branch string
|
||||||
|
Category string
|
||||||
|
Entity string
|
||||||
|
IsWrite bool `json:"is_write"`
|
||||||
|
Language string
|
||||||
|
Project string
|
||||||
|
Time models.CustomTime
|
||||||
|
Type string
|
||||||
|
UserId string `json:"user_id"`
|
||||||
|
MachineNameId string `json:"machine_name_id"`
|
||||||
|
UserAgentId string `json:"user_agent_id"`
|
||||||
|
}
|
@ -13,24 +13,24 @@ import (
|
|||||||
// https://pastr.de/v/736450
|
// https://pastr.de/v/736450
|
||||||
|
|
||||||
type SummariesViewModel struct {
|
type SummariesViewModel struct {
|
||||||
Data []*summariesData `json:"data"`
|
Data []*SummariesData `json:"data"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type summariesData struct {
|
type SummariesData struct {
|
||||||
Categories []*summariesEntry `json:"categories"`
|
Categories []*SummariesEntry `json:"categories"`
|
||||||
Dependencies []*summariesEntry `json:"dependencies"`
|
Dependencies []*SummariesEntry `json:"dependencies"`
|
||||||
Editors []*summariesEntry `json:"editors"`
|
Editors []*SummariesEntry `json:"editors"`
|
||||||
Languages []*summariesEntry `json:"languages"`
|
Languages []*SummariesEntry `json:"languages"`
|
||||||
Machines []*summariesEntry `json:"machines"`
|
Machines []*SummariesEntry `json:"machines"`
|
||||||
OperatingSystems []*summariesEntry `json:"operating_systems"`
|
OperatingSystems []*SummariesEntry `json:"operating_systems"`
|
||||||
Projects []*summariesEntry `json:"projects"`
|
Projects []*SummariesEntry `json:"projects"`
|
||||||
GrandTotal *summariesGrandTotal `json:"grand_total"`
|
GrandTotal *SummariesGrandTotal `json:"grand_total"`
|
||||||
Range *summariesRange `json:"range"`
|
Range *SummariesRange `json:"range"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type summariesEntry struct {
|
type SummariesEntry struct {
|
||||||
Digital string `json:"digital"`
|
Digital string `json:"digital"`
|
||||||
Hours int `json:"hours"`
|
Hours int `json:"hours"`
|
||||||
Minutes int `json:"minutes"`
|
Minutes int `json:"minutes"`
|
||||||
@ -41,7 +41,7 @@ type summariesEntry struct {
|
|||||||
TotalSeconds float64 `json:"total_seconds"`
|
TotalSeconds float64 `json:"total_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type summariesGrandTotal struct {
|
type SummariesGrandTotal struct {
|
||||||
Digital string `json:"digital"`
|
Digital string `json:"digital"`
|
||||||
Hours int `json:"hours"`
|
Hours int `json:"hours"`
|
||||||
Minutes int `json:"minutes"`
|
Minutes int `json:"minutes"`
|
||||||
@ -49,7 +49,7 @@ type summariesGrandTotal struct {
|
|||||||
TotalSeconds float64 `json:"total_seconds"`
|
TotalSeconds float64 `json:"total_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type summariesRange struct {
|
type SummariesRange struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
@ -58,7 +58,7 @@ type summariesRange struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
|
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
|
||||||
data := make([]*summariesData, len(summaries))
|
data := make([]*SummariesData, len(summaries))
|
||||||
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
|
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
|
||||||
|
|
||||||
for i, s := range summaries {
|
for i, s := range summaries {
|
||||||
@ -79,27 +79,27 @@ func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *Sum
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDataFrom(s *models.Summary) *summariesData {
|
func newDataFrom(s *models.Summary) *SummariesData {
|
||||||
zone, _ := time.Now().Zone()
|
zone, _ := time.Now().Zone()
|
||||||
total := s.TotalTime()
|
total := s.TotalTime()
|
||||||
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
|
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
|
||||||
|
|
||||||
data := &summariesData{
|
data := &SummariesData{
|
||||||
Categories: make([]*summariesEntry, 0),
|
Categories: make([]*SummariesEntry, 0),
|
||||||
Dependencies: make([]*summariesEntry, 0),
|
Dependencies: make([]*SummariesEntry, 0),
|
||||||
Editors: make([]*summariesEntry, len(s.Editors)),
|
Editors: make([]*SummariesEntry, len(s.Editors)),
|
||||||
Languages: make([]*summariesEntry, len(s.Languages)),
|
Languages: make([]*SummariesEntry, len(s.Languages)),
|
||||||
Machines: make([]*summariesEntry, len(s.Machines)),
|
Machines: make([]*SummariesEntry, len(s.Machines)),
|
||||||
OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)),
|
OperatingSystems: make([]*SummariesEntry, len(s.OperatingSystems)),
|
||||||
Projects: make([]*summariesEntry, len(s.Projects)),
|
Projects: make([]*SummariesEntry, len(s.Projects)),
|
||||||
GrandTotal: &summariesGrandTotal{
|
GrandTotal: &SummariesGrandTotal{
|
||||||
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
||||||
Hours: totalHrs,
|
Hours: totalHrs,
|
||||||
Minutes: totalMins,
|
Minutes: totalMins,
|
||||||
Text: utils.FmtWakatimeDuration(total),
|
Text: utils.FmtWakatimeDuration(total),
|
||||||
TotalSeconds: total.Seconds(),
|
TotalSeconds: total.Seconds(),
|
||||||
},
|
},
|
||||||
Range: &summariesRange{
|
Range: &SummariesRange{
|
||||||
Date: time.Now().Format(time.RFC3339),
|
Date: time.Now().Format(time.RFC3339),
|
||||||
End: s.ToTime.T(),
|
End: s.ToTime.T(),
|
||||||
Start: s.FromTime.T(),
|
Start: s.FromTime.T(),
|
||||||
@ -111,21 +111,21 @@ func newDataFrom(s *models.Summary) *summariesData {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(5)
|
wg.Add(5)
|
||||||
|
|
||||||
go func(data *summariesData) {
|
go func(data *SummariesData) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i, e := range s.Projects {
|
for i, e := range s.Projects {
|
||||||
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
|
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
|
||||||
}
|
}
|
||||||
}(data)
|
}(data)
|
||||||
|
|
||||||
go func(data *summariesData) {
|
go func(data *SummariesData) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i, e := range s.Editors {
|
for i, e := range s.Editors {
|
||||||
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
|
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
|
||||||
}
|
}
|
||||||
}(data)
|
}(data)
|
||||||
|
|
||||||
go func(data *summariesData) {
|
go func(data *SummariesData) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i, e := range s.Languages {
|
for i, e := range s.Languages {
|
||||||
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
||||||
@ -133,14 +133,14 @@ func newDataFrom(s *models.Summary) *summariesData {
|
|||||||
}
|
}
|
||||||
}(data)
|
}(data)
|
||||||
|
|
||||||
go func(data *summariesData) {
|
go func(data *SummariesData) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i, e := range s.OperatingSystems {
|
for i, e := range s.OperatingSystems {
|
||||||
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
|
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
|
||||||
}
|
}
|
||||||
}(data)
|
}(data)
|
||||||
|
|
||||||
go func(data *summariesData) {
|
go func(data *SummariesData) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i, e := range s.Machines {
|
for i, e := range s.Machines {
|
||||||
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
|
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
|
||||||
@ -151,7 +151,7 @@ func newDataFrom(s *models.Summary) *summariesData {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry {
|
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEntry {
|
||||||
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
// 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
|
// TODO: fix some day, while migrating persisted summary items
|
||||||
total := e.Total * time.Second
|
total := e.Total * time.Second
|
||||||
@ -163,7 +163,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEn
|
|||||||
percentage = 0
|
percentage = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return &summariesEntry{
|
return &SummariesEntry{
|
||||||
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
|
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
|
||||||
Hours: hrs,
|
Hours: hrs,
|
||||||
Minutes: mins,
|
Minutes: mins,
|
||||||
|
12
models/compat/wakatime/v1/user_agent.go
Normal file
12
models/compat/wakatime/v1/user_agent.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
type UserAgentsViewModel struct {
|
||||||
|
Data []*UserAgentEntry `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserAgentEntry struct {
|
||||||
|
Id string
|
||||||
|
Editor string
|
||||||
|
Os string
|
||||||
|
Value string
|
||||||
|
}
|
@ -19,11 +19,12 @@ type Heartbeat struct {
|
|||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Language string `json:"language" gorm:"index:idx_language"`
|
Language string `json:"language" gorm:"index:idx_language"`
|
||||||
IsWrite bool `json:"is_write"`
|
IsWrite bool `json:"is_write"`
|
||||||
Editor string `json:"editor"`
|
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
|
||||||
OperatingSystem string `json:"operating_system"`
|
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
|
||||||
Machine string `json:"machine"`
|
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
|
||||||
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
|
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
|
||||||
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
|
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
|
||||||
|
Origin string `json:"-" hash:"ignore"`
|
||||||
languageRegex *regexp.Regexp `hash:"ignore"`
|
languageRegex *regexp.Regexp `hash:"ignore"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +26,17 @@ func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
if err := r.db.
|
||||||
|
Model(&models.Heartbeat{}).
|
||||||
|
Where(&models.Heartbeat{UserID: user.ID}).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||||
var heartbeats []*models.Heartbeat
|
var heartbeats []*models.Heartbeat
|
||||||
if err := r.db.
|
if err := r.db.
|
||||||
|
@ -17,6 +17,7 @@ type IAliasRepository interface {
|
|||||||
|
|
||||||
type IHeartbeatRepository interface {
|
type IHeartbeatRepository interface {
|
||||||
InsertBatch([]*models.Heartbeat) error
|
InsertBatch([]*models.Heartbeat) error
|
||||||
|
CountByUser(*models.User) (int64, error)
|
||||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||||
DeleteBefore(time.Time) error
|
DeleteBefore(time.Time) error
|
||||||
|
@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/models/view"
|
"github.com/muety/wakapi/models/view"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
|
"github.com/muety/wakapi/services/imports"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -21,15 +22,25 @@ type SettingsHandler struct {
|
|||||||
config *conf.Config
|
config *conf.Config
|
||||||
userSrvc services.IUserService
|
userSrvc services.IUserService
|
||||||
summarySrvc services.ISummaryService
|
summarySrvc services.ISummaryService
|
||||||
|
heartbeatSrvc services.IHeartbeatService
|
||||||
aliasSrvc services.IAliasService
|
aliasSrvc services.IAliasService
|
||||||
aggregationSrvc services.IAggregationService
|
aggregationSrvc services.IAggregationService
|
||||||
languageMappingSrvc services.ILanguageMappingService
|
languageMappingSrvc services.ILanguageMappingService
|
||||||
|
keyValueSrvc services.IKeyValueService
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialsDecoder = schema.NewDecoder()
|
var credentialsDecoder = schema.NewDecoder()
|
||||||
|
|
||||||
func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aliasService services.IAliasService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler {
|
func NewSettingsHandler(
|
||||||
|
userService services.IUserService,
|
||||||
|
heartbeatService services.IHeartbeatService,
|
||||||
|
summaryService services.ISummaryService,
|
||||||
|
aliasService services.IAliasService,
|
||||||
|
aggregationService services.IAggregationService,
|
||||||
|
languageMappingService services.ILanguageMappingService,
|
||||||
|
keyValueService services.IKeyValueService,
|
||||||
|
) *SettingsHandler {
|
||||||
return &SettingsHandler{
|
return &SettingsHandler{
|
||||||
config: conf.Get(),
|
config: conf.Get(),
|
||||||
summarySrvc: summaryService,
|
summarySrvc: summaryService,
|
||||||
@ -37,6 +48,8 @@ func NewSettingsHandler(userService services.IUserService, summaryService servic
|
|||||||
aggregationSrvc: aggregationService,
|
aggregationSrvc: aggregationService,
|
||||||
languageMappingSrvc: languageMappingService,
|
languageMappingSrvc: languageMappingService,
|
||||||
userSrvc: userService,
|
userSrvc: userService,
|
||||||
|
heartbeatSrvc: heartbeatService,
|
||||||
|
keyValueSrvc: keyValueService,
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,10 +101,12 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errorMsg != "" {
|
if errorMsg != "" {
|
||||||
|
w.WriteHeader(status)
|
||||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError(errorMsg))
|
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError(errorMsg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if successMsg != "" {
|
if successMsg != "" {
|
||||||
|
w.WriteHeader(status)
|
||||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(successMsg))
|
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(successMsg))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -116,6 +131,8 @@ func (h *SettingsHandler) dispatchAction(action string) action {
|
|||||||
return h.actionToggleBadges
|
return h.actionToggleBadges
|
||||||
case "toggle_wakatime":
|
case "toggle_wakatime":
|
||||||
return h.actionSetWakatimeApiKey
|
return h.actionSetWakatimeApiKey
|
||||||
|
case "import_wakatime":
|
||||||
|
return h.actionImportWaktime
|
||||||
case "regenerate_summaries":
|
case "regenerate_summaries":
|
||||||
return h.actionRegenerateSummaries
|
return h.actionRegenerateSummaries
|
||||||
case "delete_account":
|
case "delete_account":
|
||||||
@ -315,6 +332,66 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
|
|||||||
return http.StatusOK, "Wakatime API Key updated successfully", ""
|
return http.StatusOK, "Wakatime API Key updated successfully", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
|
if h.config.IsDev() {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
|
if user.WakatimeApiKey == "" {
|
||||||
|
return http.StatusForbidden, "", "not connected to wakatime"
|
||||||
|
}
|
||||||
|
|
||||||
|
kvKey := fmt.Sprintf("%s_%s", conf.KeyLastImportImport, user.ID)
|
||||||
|
|
||||||
|
if !h.config.IsDev() {
|
||||||
|
lastImportKv := h.keyValueSrvc.MustGetString(kvKey)
|
||||||
|
lastImport, _ := time.Parse(time.RFC822, lastImportKv.Value)
|
||||||
|
if time.Now().Sub(lastImport) < time.Duration(h.config.App.ImportBackoffMin)*time.Minute {
|
||||||
|
return http.StatusTooManyRequests,
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("Too many data imports. You are only allowed to request an import every %d minutes.", h.config.App.ImportBackoffMin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(user *models.User) {
|
||||||
|
importer := imports.NewWakatimeHeartbeatImporter(user.WakatimeApiKey)
|
||||||
|
|
||||||
|
countBefore, err := h.heartbeatSrvc.CountByUser(user)
|
||||||
|
if err != nil {
|
||||||
|
println(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
batch := make([]*models.Heartbeat, 0)
|
||||||
|
|
||||||
|
for hb := range importer.Import(user) {
|
||||||
|
count++
|
||||||
|
batch = append(batch, hb)
|
||||||
|
|
||||||
|
if len(batch) == h.config.App.ImportBatchSize {
|
||||||
|
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
|
||||||
|
logbuch.Warn("failed to insert imported heartbeat, already existing? – %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
batch = make([]*models.Heartbeat, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countAfter, _ := h.heartbeatSrvc.CountByUser(user)
|
||||||
|
logbuch.Info("downloaded %d heartbeats for user '%s' (%d actually imported)", count, user.ID, countAfter-countBefore)
|
||||||
|
|
||||||
|
h.regenerateSummaries(user)
|
||||||
|
}(user)
|
||||||
|
|
||||||
|
h.keyValueSrvc.PutString(&models.KeyStringValue{
|
||||||
|
Key: kvKey,
|
||||||
|
Value: time.Now().Format(time.RFC822),
|
||||||
|
})
|
||||||
|
|
||||||
|
return http.StatusAccepted, "Import started. This may take a few minutes.", ""
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
@ -322,16 +399,8 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
|
|||||||
|
|
||||||
user := r.Context().Value(models.UserKey).(*models.User)
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
|
|
||||||
logbuch.Info("clearing summaries for user '%s'", user.ID)
|
if err := h.regenerateSummaries(user); err != nil {
|
||||||
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
|
return http.StatusInternalServerError, "", "failed to regenerate summaries"
|
||||||
logbuch.Error("failed to clear summaries: %v", err)
|
|
||||||
return http.StatusInternalServerError, "", "failed to delete old summaries"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
|
|
||||||
logbuch.Error("failed to regenerate summaries: %v", err)
|
|
||||||
return http.StatusInternalServerError, "", "failed to generate aggregations"
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.StatusOK, "summaries are being regenerated – this may take a few seconds", ""
|
return http.StatusOK, "summaries are being regenerated – this may take a few seconds", ""
|
||||||
@ -368,7 +437,7 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
|
|||||||
|
|
||||||
request, err := http.NewRequest(
|
request, err := http.NewRequest(
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
conf.WakatimeApiUrl+conf.WakatimeApiUserEndpoint,
|
conf.WakatimeApiUrl+conf.WakatimeApiUserUrl,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -385,6 +454,21 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
|
||||||
|
logbuch.Info("clearing summaries for user '%s'", user.ID)
|
||||||
|
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
|
||||||
|
logbuch.Error("failed to clear summaries: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
|
||||||
|
logbuch.Error("failed to regenerate summaries: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
||||||
user := r.Context().Value(models.UserKey).(*models.User)
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
||||||
|
@ -22,10 +22,18 @@ func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, langua
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
|
||||||
|
return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat})
|
||||||
|
}
|
||||||
|
|
||||||
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||||
return srv.repository.InsertBatch(heartbeats)
|
return srv.repository.InsertBatch(heartbeats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *HeartbeatService) CountByUser(user *models.User) (int64, error) {
|
||||||
|
return srv.repository.CountByUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||||
heartbeats, err := srv.repository.GetAllWithin(from, to, user)
|
heartbeats, err := srv.repository.GetAllWithin(from, to, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
7
services/imports/importers.go
Normal file
7
services/imports/importers.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package imports
|
||||||
|
|
||||||
|
import "github.com/muety/wakapi/models"
|
||||||
|
|
||||||
|
type HeartbeatImporter interface {
|
||||||
|
Import(*models.User) <-chan *models.Heartbeat
|
||||||
|
}
|
229
services/imports/wakatime.go
Normal file
229
services/imports/wakatime.go
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
package imports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
|
"go.uber.org/atomic"
|
||||||
|
"golang.org/x/sync/semaphore"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxWorkers = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
type WakatimeHeartbeatImporter struct {
|
||||||
|
ApiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter {
|
||||||
|
return &WakatimeHeartbeatImporter{
|
||||||
|
ApiKey: apiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WakatimeHeartbeatImporter) Import(user *models.User) <-chan *models.Heartbeat {
|
||||||
|
out := make(chan *models.Heartbeat)
|
||||||
|
|
||||||
|
go func(user *models.User, out chan *models.Heartbeat) {
|
||||||
|
startDate, endDate, err := w.fetchRange()
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Error("failed to fetch date range while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userAgents, err := w.fetchUserAgents()
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
days := generateDays(startDate, endDate)
|
||||||
|
|
||||||
|
c := atomic.NewUint32(uint32(len(days)))
|
||||||
|
ctx := context.TODO()
|
||||||
|
sem := semaphore.NewWeighted(maxWorkers)
|
||||||
|
|
||||||
|
for _, d := range days {
|
||||||
|
if err := sem.Acquire(ctx, 1); err != nil {
|
||||||
|
logbuch.Error("failed to acquire semaphore – %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(day time.Time) {
|
||||||
|
defer sem.Release(1)
|
||||||
|
|
||||||
|
d := day.Format("2006-01-02")
|
||||||
|
heartbeats, err := w.fetchHeartbeats(d)
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Error("failed to fetch heartbeats for day '%s' and user '%s' – &v", day, user.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, h := range heartbeats {
|
||||||
|
out <- mapHeartbeat(h, userAgents, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Dec() == 0 {
|
||||||
|
close(out)
|
||||||
|
}
|
||||||
|
}(d)
|
||||||
|
}
|
||||||
|
}(user, out)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
|
||||||
|
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.HeartbeatEntry, error) {
|
||||||
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiHeartbeatsUrl, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Add("date", day)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
res, err := httpClient.Do(w.withHeaders(req))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var heartbeatsData wakatime.HeartbeatsViewModel
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return heartbeatsData.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://wakatime.com/api/v1/users/current/all_time_since_today
|
||||||
|
func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
|
||||||
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
notime := time.Time{}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiAllTimeUrl, nil)
|
||||||
|
if err != nil {
|
||||||
|
return notime, notime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := httpClient.Do(w.withHeaders(req))
|
||||||
|
if err != nil {
|
||||||
|
return notime, notime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var allTimeData map[string]interface{}
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&allTimeData); err != nil {
|
||||||
|
return notime, notime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := allTimeData["data"].(map[string]interface{})
|
||||||
|
if data == nil {
|
||||||
|
return notime, notime, errors.New("invalid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
dataRange := data["range"].(map[string]interface{})
|
||||||
|
if dataRange == nil {
|
||||||
|
return notime, notime, errors.New("invalid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate, err := time.Parse("2006-01-02", dataRange["start_date"].(string))
|
||||||
|
if err != nil {
|
||||||
|
return notime, notime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
endDate, err := time.Parse("2006-01-02", dataRange["end_date"].(string))
|
||||||
|
if err != nil {
|
||||||
|
return notime, notime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return startDate, endDate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://wakatime.com/api/v1/users/current/user_agents
|
||||||
|
func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.UserAgentEntry, error) {
|
||||||
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiUserAgentsUrl, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := httpClient.Do(w.withHeaders(req))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var userAgentsData wakatime.UserAgentsViewModel
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&userAgentsData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userAgents := make(map[string]*wakatime.UserAgentEntry)
|
||||||
|
for _, ua := range userAgentsData.Data {
|
||||||
|
userAgents[ua.Id] = ua
|
||||||
|
}
|
||||||
|
|
||||||
|
return userAgents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WakatimeHeartbeatImporter) withHeaders(req *http.Request) *http.Request {
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(w.ApiKey))))
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapHeartbeat(
|
||||||
|
entry *wakatime.HeartbeatEntry,
|
||||||
|
userAgents map[string]*wakatime.UserAgentEntry,
|
||||||
|
user *models.User,
|
||||||
|
) *models.Heartbeat {
|
||||||
|
ua := userAgents[entry.UserAgentId]
|
||||||
|
if ua == nil {
|
||||||
|
ua = &wakatime.UserAgentEntry{
|
||||||
|
Editor: "unknown",
|
||||||
|
Os: "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (&models.Heartbeat{
|
||||||
|
User: user,
|
||||||
|
UserID: user.ID,
|
||||||
|
Entity: entry.Entity,
|
||||||
|
Type: entry.Type,
|
||||||
|
Category: entry.Category,
|
||||||
|
Project: entry.Project,
|
||||||
|
Branch: entry.Branch,
|
||||||
|
Language: entry.Language,
|
||||||
|
IsWrite: entry.IsWrite,
|
||||||
|
Editor: ua.Editor,
|
||||||
|
OperatingSystem: ua.Os,
|
||||||
|
Machine: entry.MachineNameId, // TODO
|
||||||
|
Time: entry.Time,
|
||||||
|
Origin: fmt.Sprintf("wt@%s", entry.Id),
|
||||||
|
}).Hashed()
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateDays(from, to time.Time) []time.Time {
|
||||||
|
days := make([]time.Time, 0)
|
||||||
|
|
||||||
|
from = utils.StartOfDay(from)
|
||||||
|
to = utils.StartOfDay(to.Add(24 * time.Hour))
|
||||||
|
|
||||||
|
for d := from; d.Before(to); d = d.Add(24 * time.Hour) {
|
||||||
|
days = append(days, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
@ -22,6 +22,17 @@ func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error
|
|||||||
return srv.repository.GetString(key)
|
return srv.repository.GetString(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *KeyValueService) MustGetString(key string) *models.KeyStringValue {
|
||||||
|
kv, err := srv.repository.GetString(key)
|
||||||
|
if err != nil {
|
||||||
|
return &models.KeyStringValue{
|
||||||
|
Key: key,
|
||||||
|
Value: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kv
|
||||||
|
}
|
||||||
|
|
||||||
func (srv *KeyValueService) PutString(kv *models.KeyStringValue) error {
|
func (srv *KeyValueService) PutString(kv *models.KeyStringValue) error {
|
||||||
return srv.repository.PutString(kv)
|
return srv.repository.PutString(kv)
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,9 @@ type IAliasService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type IHeartbeatService interface {
|
type IHeartbeatService interface {
|
||||||
|
Insert(*models.Heartbeat) error
|
||||||
InsertBatch([]*models.Heartbeat) error
|
InsertBatch([]*models.Heartbeat) error
|
||||||
|
CountByUser(*models.User) (int64, error)
|
||||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||||
DeleteBefore(time.Time) error
|
DeleteBefore(time.Time) error
|
||||||
@ -34,6 +36,7 @@ type IHeartbeatService interface {
|
|||||||
|
|
||||||
type IKeyValueService interface {
|
type IKeyValueService interface {
|
||||||
GetString(string) (*models.KeyStringValue, error)
|
GetString(string) (*models.KeyStringValue, error)
|
||||||
|
MustGetString(string) *models.KeyStringValue
|
||||||
PutString(*models.KeyStringValue) error
|
PutString(*models.KeyStringValue) error
|
||||||
DeleteString(string) error
|
DeleteString(string) error
|
||||||
}
|
}
|
||||||
|
@ -1 +1 @@
|
|||||||
1.22.6
|
1.23.0
|
@ -356,17 +356,32 @@
|
|||||||
</button>
|
</button>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">Disconnect
|
class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
|
||||||
|
style="width: 130px">Disconnect
|
||||||
</button>
|
</button>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ if .User.WakatimeApiKey }}
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button id="btn-import-wakatime" type="button" style="width: 130px"
|
||||||
|
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||||
|
⤵ Import Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action="" method="post" id="form-import-wakatime" class="mt-6">
|
||||||
|
<input type="hidden" name="action" value="import_wakatime">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-6">
|
<p class="mt-6">
|
||||||
<span class="font-semibold">👉 Please note:</span>
|
<span class="font-semibold">👉 Please note:</span>
|
||||||
<span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
|
<span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
|
||||||
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94" rel="noopener noreferrer">#94</a>) to be implemented.</span>
|
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94"
|
||||||
|
rel="noopener noreferrer">#94</a>) to be implemented.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -455,6 +470,12 @@
|
|||||||
formDelete.submit()
|
formDelete.submit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const btnImportWakatime = document.querySelector('#btn-import-wakatime')
|
||||||
|
const formImportWakatime = document.querySelector('#form-import-wakatime')
|
||||||
|
btnImportWakatime.addEventListener('click', () => {
|
||||||
|
formImportWakatime.submit()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{ template "footer.tpl.html" . }}
|
{{ template "footer.tpl.html" . }}
|
||||||
|
Loading…
Reference in New Issue
Block a user