1
0
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:
Ferdinand Mütsch 2021-02-05 18:47:28 +01:00
parent d9e163bf73
commit fd9e2acdf1
19 changed files with 483 additions and 63 deletions

View File

@ -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
View File

@ -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

View File

@ -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)

View File

@ -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,
) )

View File

@ -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,

View 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"`
}

View File

@ -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,

View 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
}

View File

@ -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"`
} }

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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 {

View File

@ -0,0 +1,7 @@
package imports
import "github.com/muety/wakapi/models"
type HeartbeatImporter interface {
Import(*models.User) <-chan *models.Heartbeat
}

View 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
}

View File

@ -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)
} }

View File

@ -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
} }

View File

@ -1 +1 @@
1.22.6 1.23.0

View File

@ -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" . }}