From cecb5e113cd6b3ca0546cd9f5b06ce5e20eeaafd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sun, 30 Aug 2020 01:45:01 +0200 Subject: [PATCH 01/13] chore: remove debug comments --- models/shared.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/shared.go b/models/shared.go index 8cb5712..92a715e 100644 --- a/models/shared.go +++ b/models/shared.go @@ -27,7 +27,7 @@ type KeyStringValue struct { type CustomTime time.Time func (j *CustomTime) UnmarshalJSON(b []byte) error { - s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1) // TODO: not always three decimal points! + s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1) i, err := strconv.ParseInt(s, 10, 64) if err != nil { return err From 97cb29ee4d9dd42d4dcc01e0dfb6a1034e2cac12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sun, 30 Aug 2020 16:51:37 +0200 Subject: [PATCH 02/13] feat: show placeholders when no data is available (resolve #42) feat: add jsx as custom language by default (resolve #50) --- config.ini | 3 +- static/assets/app.js | 264 +++++++++++++++++-------------- static/assets/images/no_data.svg | 1 + version.txt | 2 +- views/summary.tpl.html | 50 +++++- 5 files changed, 198 insertions(+), 122 deletions(-) create mode 100644 static/assets/images/no_data.svg diff --git a/config.ini b/config.ini index e8a7bd4..187169c 100644 --- a/config.ini +++ b/config.ini @@ -11,4 +11,5 @@ cleanup = false max_connections = 2 [languages] -vue = Vue \ No newline at end of file +vue = Vue +jsx = JSX \ No newline at end of file diff --git a/static/assets/app.js b/static/assets/app.js index 453d3ec..2d06ef2 100644 --- a/static/assets/app.js +++ b/static/assets/app.js @@ -1,5 +1,5 @@ const SHOW_TOP_N = 10 -const CHART_TARGET_SIZE = 170 +const CHART_TARGET_SIZE = 200 const projectsCanvas = document.getElementById('chart-projects') const osCanvas = document.getElementById('chart-os') @@ -7,6 +7,16 @@ const editorsCanvas = document.getElementById('chart-editor') const languagesCanvas = document.getElementById('chart-language') const machinesCanvas = document.getElementById('chart-machine') +const projectContainer = document.getElementById('project-container') +const osContainer = document.getElementById('os-container') +const editorContainer = document.getElementById('editor-container') +const languageContainer = document.getElementById('language-container') +const machineContainer = document.getElementById('machine-container') + +const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer] +const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas] +const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines] + let charts = [] let resizeCount = 0 @@ -29,11 +39,6 @@ String.prototype.toHHMMSS = function () { } function draw() { - let titleOptions = { - display: true, - fontSize: 16 - } - function getTooltipOptions(key, type) { return { mode: 'single', @@ -49,131 +54,158 @@ function draw() { charts.forEach(c => c.destroy()) - let projectChart = new Chart(projectsCanvas.getContext('2d'), { - type: 'horizontalBar', - data: { - datasets: wakapiData.projects - .slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length)) - .map(p => { - return { - label: p.key, - data: [parseInt(p.total) / 60], - backgroundColor: getRandomColor(p.key) - } - }) - }, - options: { - title: Object.assign(titleOptions, {text: `Projects (top ${SHOW_TOP_N})`}), - tooltips: getTooltipOptions('projects', 'bar'), - legend: { - display: false + let projectChart = !projectsCanvas.classList.contains('hidden') + ? new Chart(projectsCanvas.getContext('2d'), { + type: 'horizontalBar', + data: { + datasets: wakapiData.projects + .slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length)) + .map(p => { + return { + label: p.key, + data: [parseInt(p.total) / 60], + backgroundColor: getRandomColor(p.key) + } + }) }, - scales: { - xAxes: [{ - scaleLabel: { - display: true, - labelString: 'Minutes' - } - }] - }, - maintainAspectRatio: false, - onResize: onChartResize - } - }) + options: { + tooltips: getTooltipOptions('projects', 'bar'), + legend: { + display: false + }, + scales: { + xAxes: [{ + scaleLabel: { + display: true, + labelString: 'Minutes' + } + }] + }, + maintainAspectRatio: false, + onResize: onChartResize + } + }) + : null - let osChart = new Chart(osCanvas.getContext('2d'), { - type: 'pie', - data: { - datasets: [{ - data: wakapiData.operatingSystems + let osChart = !osCanvas.classList.contains('hidden') + ? new Chart(osCanvas.getContext('2d'), { + type: 'pie', + data: { + datasets: [{ + data: wakapiData.operatingSystems + .slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length)) + .map(p => parseInt(p.total)), + backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key)) + }], + labels: wakapiData.operatingSystems .slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length)) - .map(p => parseInt(p.total)), - backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key)) - }], - labels: wakapiData.operatingSystems - .slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length)) - .map(p => p.key) - }, - options: { - title: Object.assign(titleOptions, {text: `Operating Systems (top ${SHOW_TOP_N})`}), - tooltips: getTooltipOptions('operatingSystems', 'pie'), - maintainAspectRatio: false, - onResize: onChartResize - } - }) + .map(p => p.key) + }, + options: { + tooltips: getTooltipOptions('operatingSystems', 'pie'), + maintainAspectRatio: false, + onResize: onChartResize + } + }) + : null - let editorChart = new Chart(editorsCanvas.getContext('2d'), { - type: 'pie', - data: { - datasets: [{ - data: wakapiData.editors + let editorChart = !editorsCanvas.classList.contains('hidden') + ? new Chart(editorsCanvas.getContext('2d'), { + type: 'pie', + data: { + datasets: [{ + data: wakapiData.editors + .slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length)) + .map(p => parseInt(p.total)), + backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key)) + }], + labels: wakapiData.editors .slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length)) - .map(p => parseInt(p.total)), - backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key)) - }], - labels: wakapiData.editors - .slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length)) - .map(p => p.key) - }, - options: { - title: Object.assign(titleOptions, {text: `Editors (top ${SHOW_TOP_N})`}), - tooltips: getTooltipOptions('editors', 'pie'), - maintainAspectRatio: false, - onResize: onChartResize - } - }) + .map(p => p.key) + }, + options: { + tooltips: getTooltipOptions('editors', 'pie'), + maintainAspectRatio: false, + onResize: onChartResize + } + }) + : null - let languageChart = new Chart(languagesCanvas.getContext('2d'), { - type: 'pie', - data: { - datasets: [{ - data: wakapiData.languages + let languageChart = !languagesCanvas.classList.contains('hidden') + ? new Chart(languagesCanvas.getContext('2d'), { + type: 'pie', + data: { + datasets: [{ + data: wakapiData.languages + .slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length)) + .map(p => parseInt(p.total)), + backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key)) + }], + labels: wakapiData.languages .slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length)) - .map(p => parseInt(p.total)), - backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key)) - }], - labels: wakapiData.languages - .slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length)) - .map(p => p.key) - }, - options: { - title: Object.assign(titleOptions, {text: `Languages (top ${SHOW_TOP_N})`}), - tooltips: getTooltipOptions('languages', 'pie'), - maintainAspectRatio: false, - onResize: onChartResize - } - }) + .map(p => p.key) + }, + options: { + tooltips: getTooltipOptions('languages', 'pie'), + maintainAspectRatio: false, + onResize: onChartResize + } + }) + : null - let machineChart = new Chart(machinesCanvas.getContext('2d'), { - type: 'pie', - data: { - datasets: [{ - data: wakapiData.machines + let machineChart = !machinesCanvas.classList.contains('hidden') + ? new Chart(machinesCanvas.getContext('2d'), { + type: 'pie', + data: { + datasets: [{ + data: wakapiData.machines + .slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length)) + .map(p => parseInt(p.total)), + backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key)) + }], + labels: wakapiData.machines .slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length)) - .map(p => parseInt(p.total)), - backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key)) - }], - labels: wakapiData.machines - .slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length)) - .map(p => p.key) - }, - options: { - title: Object.assign(titleOptions, {text: `Machines (top ${SHOW_TOP_N})`}), - tooltips: getTooltipOptions('machines', 'pie'), - maintainAspectRatio: false, - onResize: onChartResize - } - }) + .map(p => p.key) + }, + options: { + tooltips: getTooltipOptions('machines', 'pie'), + maintainAspectRatio: false, + onResize: onChartResize + } + }) + : null getTotal(wakapiData.operatingSystems) - document.getElementById('grid-container').style.visibility = 'visible' - charts = [projectChart, osChart, editorChart, languageChart, machineChart] + charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c) charts.forEach(c => c.options.onResize(c.chart)) equalizeHeights() } +function setTopLabels() { + [...document.getElementsByClassName('top-label')] + .forEach(e => e.innerText = `(top ${SHOW_TOP_N})`) +} + +function togglePlaceholders(mask) { + const placeholderElements = containers.map(c => c.querySelector('.placeholder-container')) + + for (let i = 0; i < mask.length; i++) { + if (!mask[i]) { + canvases[i].classList.add('hidden') + placeholderElements[i].classList.remove('hidden') + } else { + canvases[i].classList.remove('hidden') + placeholderElements[i].classList.add('hidden') + } + } +} + +function getPresentDataMask() { + return data.map(list => list.reduce((acc, e) => acc + e.total, 0) > 0) +} + function getContainer(chart) { return chart.canvas.parentNode } @@ -257,7 +289,7 @@ if (favicon) { } // Click outside -window.addEventListener('click', function(event) { +window.addEventListener('click', function (event) { if (event.target.classList.contains('popup')) { return } @@ -268,5 +300,7 @@ window.addEventListener('click', function(event) { }) window.addEventListener('load', function () { + setTopLabels() + togglePlaceholders(getPresentDataMask()) draw() }) \ No newline at end of file diff --git a/static/assets/images/no_data.svg b/static/assets/images/no_data.svg new file mode 100644 index 0000000..ff49c9b --- /dev/null +++ b/static/assets/images/no_data.svg @@ -0,0 +1 @@ +no data \ No newline at end of file diff --git a/version.txt b/version.txt index fe4e75f..7b378be 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.8.3 \ No newline at end of file +1.8.4 \ No newline at end of file diff --git a/views/summary.tpl.html b/views/summary.tpl.html index 3ef2340..de2782d 100644 --- a/views/summary.tpl.html +++ b/views/summary.tpl.html @@ -59,28 +59,68 @@
-
+
+
+ Projects + +
+
-
+
+
+ Operating Systems + +
+
-
+
+
+ Languages + +
+
-
+
+
+ Editors + +
+
-
+
+
+ Machines + +
+
From 587ac6a3302d9cf9fb045a8c66479d7a5c54c8e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sun, 6 Sep 2020 12:15:46 +0200 Subject: [PATCH 03/13] feat: add wakatime-compatible alltime endpoint --- main.go | 8 ++++ models/compat/v1/all_time.go | 9 +++++ models/summary.go | 71 ++++++++++++++++++++++++++++-------- routes/compat/v1/all_time.go | 71 ++++++++++++++++++++++++++++++++++++ routes/summary.go | 52 ++++---------------------- services/summary.go | 2 +- utils/date.go | 13 ++++++- utils/summary.go | 52 ++++++++++++++++++++++++++ version.txt | 2 +- 9 files changed, 216 insertions(+), 64 deletions(-) create mode 100644 models/compat/v1/all_time.go create mode 100644 routes/compat/v1/all_time.go create mode 100644 utils/summary.go diff --git a/main.go b/main.go index 5a40bcf..3b99cef 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/models" "github.com/muety/wakapi/routes" + v1Routes "github.com/muety/wakapi/routes/compat/v1" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" ) @@ -86,12 +87,15 @@ func main() { go heartbeatService.ScheduleCleanUp() } + // TODO: move endpoint registration to the respective routes files + // Handlers heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService) summaryHandler := routes.NewSummaryHandler(summaryService) healthHandler := routes.NewHealthHandler(db) settingsHandler := routes.NewSettingsHandler(userService) publicHandler := routes.NewIndexHandler(userService, keyValueService) + compatV1AllHandler := v1Routes.NewCompatV1AllHandler(summaryService) // Setup Routers router := mux.NewRouter() @@ -99,6 +103,7 @@ func main() { settingsRouter := publicRouter.PathPrefix("/settings").Subrouter() summaryRouter := publicRouter.PathPrefix("/summary").Subrouter() apiRouter := router.PathPrefix("/api").Subrouter() + compatV1Router := apiRouter.PathPrefix("/compat/v1").Subrouter() // Middlewares recoveryMiddleware := handlers.RecoveryHandler() @@ -136,6 +141,9 @@ func main() { apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet) apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet) + // Compat V1 API Routes + compatV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(compatV1AllHandler.ApiGet) + // Static Routes router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static"))) diff --git a/models/compat/v1/all_time.go b/models/compat/v1/all_time.go new file mode 100644 index 0000000..7afcd23 --- /dev/null +++ b/models/compat/v1/all_time.go @@ -0,0 +1,9 @@ +package v1 + +// https://wakatime.com/developers#all_time_since_today + +type AllTimeVieModel struct { + Seconds float32 `json:"seconds"` // total number of seconds logged since account created + Text string `json:"text"` // total time logged since account created as human readable string> + IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon> +} diff --git a/models/summary.go b/models/summary.go index 8bfb6f0..4f09906 100644 --- a/models/summary.go +++ b/models/summary.go @@ -13,6 +13,15 @@ const ( SummaryMachine uint8 = 4 ) +const ( + IntervalToday string = "today" + IntervalLastDay string = "day" + IntervalLastWeek string = "week" + IntervalLastMonth string = "month" + IntervalLastYear string = "year" + IntervalAny string = "any" +) + const UnknownSummaryKey = "unknown" type Summary struct { @@ -48,6 +57,31 @@ type SummaryViewModel struct { ApiKey string } +type SummaryParams struct { + From time.Time + To time.Time + User *User + Recompute bool +} + +func SummaryTypes() []uint8 { + return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine} +} + +func (s *Summary) Types() []uint8 { + return SummaryTypes() +} + +func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem { + return map[uint8]*[]*SummaryItem{ + SummaryProject: &s.Projects, + SummaryLanguage: &s.Languages, + SummaryEditor: &s.Editors, + SummaryOS: &s.OperatingSystems, + SummaryMachine: &s.Machines, + } +} + /* Augments the summary in a way that at least one item is present for every type. If a summary has zero items for a given type, but one or more for any of the other types, the total summary duration can be derived from those and inserted as a dummy-item with key "unknown" @@ -60,22 +94,13 @@ To avoid having to modify persisted data retrospectively, i.e. inserting a dummy such is generated dynamically here, considering the "machine" for all old heartbeats "unknown". */ func (s *Summary) FillUnknown() { - types := []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine} + types := s.Types() + typeItems := s.MappedItems() missingTypes := make([]uint8, 0) - typeItems := map[uint8]*[]*SummaryItem{ - SummaryProject: &s.Projects, - SummaryLanguage: &s.Languages, - SummaryEditor: &s.Editors, - SummaryOS: &s.OperatingSystems, - SummaryMachine: &s.Machines, - } - var somePresentType uint8 for _, t := range types { if len(*typeItems[t]) == 0 { missingTypes = append(missingTypes, t) - } else { - somePresentType = t } } @@ -84,11 +109,7 @@ func (s *Summary) FillUnknown() { return } - // calculate total duration from any of the present sets of items - var timeSum time.Duration - for _, item := range *typeItems[somePresentType] { - timeSum += item.Total - } + timeSum := s.TotalTime() // construct dummy item for all missing types for _, t := range missingTypes { @@ -99,3 +120,21 @@ func (s *Summary) FillUnknown() { }) } } + +func (s *Summary) TotalTime() time.Duration { + var timeSum time.Duration + + mappedItems := s.MappedItems() + + // calculate total duration from any of the present sets of items + for _, t := range s.Types() { + if items := mappedItems[t]; len(*items) > 0 { + for _, item := range *items { + timeSum += item.Total + } + break + } + } + + return timeSum +} diff --git a/routes/compat/v1/all_time.go b/routes/compat/v1/all_time.go new file mode 100644 index 0000000..1cb4180 --- /dev/null +++ b/routes/compat/v1/all_time.go @@ -0,0 +1,71 @@ +package v1 + +import ( + "github.com/gorilla/mux" + "github.com/muety/wakapi/models" + v1 "github.com/muety/wakapi/models/compat/v1" + "github.com/muety/wakapi/services" + "github.com/muety/wakapi/utils" + "net/http" + "net/url" + "time" +) + +type CompatV1AllHandler struct { + summarySrvc *services.SummaryService + config *models.Config +} + +func NewCompatV1AllHandler(summaryService *services.SummaryService) *CompatV1AllHandler { + return &CompatV1AllHandler{ + summarySrvc: summaryService, + config: models.GetConfig(), + } +} + +func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + requestedUser := vars["user"] + authorizedUser := r.Context().Value(models.UserKey).(*models.User) + + if requestedUser != authorizedUser.ID && requestedUser != "current" { + w.WriteHeader(http.StatusForbidden) + return + } + + values, _ := url.ParseQuery(r.URL.RawQuery) + values.Set("interval", models.IntervalAny) + r.URL.RawQuery = values.Encode() + + summary, err, status := h.loadUserSummary(authorizedUser) + if err != nil { + w.WriteHeader(status) + w.Write([]byte(err.Error())) + return + } + + total := summary.TotalTime() + vm := &v1.AllTimeVieModel{ + Seconds: float32(total), + Text: utils.FmtWakatimeDuration(total * time.Second), + IsUpToDate: true, + } + + utils.RespondJSON(w, http.StatusOK, vm) +} + +func (h *CompatV1AllHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) { + summaryParams := &models.SummaryParams{ + From: time.Time{}, + To: time.Now(), + User: user, + Recompute: false, + } + + summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant + if err != nil { + return nil, err, http.StatusInternalServerError + } + + return summary, nil, http.StatusOK +} diff --git a/routes/summary.go b/routes/summary.go index 4b630ab..0a3137f 100644 --- a/routes/summary.go +++ b/routes/summary.go @@ -1,22 +1,10 @@ package routes import ( - "errors" - "net/http" - "time" - "github.com/muety/wakapi/models" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" -) - -const ( - IntervalToday string = "today" - IntervalLastDay string = "day" - IntervalLastWeek string = "week" - IntervalLastMonth string = "month" - IntervalLastYear string = "year" - IntervalAny string = "any" + "net/http" ) type SummaryHandler struct { @@ -32,7 +20,7 @@ func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler } func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) { - summary, err, status := loadUserSummary(r, h.summarySrvc) + summary, err, status := h.loadUserSummary(r) if err != nil { w.WriteHeader(status) w.Write([]byte(err.Error())) @@ -53,7 +41,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) { r.URL.RawQuery = q.Encode() } - summary, err, status := loadUserSummary(r, h.summarySrvc) + summary, err, status := h.loadUserSummary(r) if err != nil { respondAlert(w, err.Error(), "", "summary.tpl.html", status) return @@ -74,39 +62,13 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) { templates["summary.tpl.html"].Execute(w, vm) } -func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) { - user := r.Context().Value(models.UserKey).(*models.User) - params := r.URL.Query() - interval := params.Get("interval") - from, err := utils.ParseDate(params.Get("from")) +func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) { + summaryParams, err := utils.ParseSummaryParams(r) if err != nil { - switch interval { - case IntervalToday: - from = utils.StartOfDay() - case IntervalLastDay: - from = utils.StartOfDay().Add(-24 * time.Hour) - case IntervalLastWeek: - from = utils.StartOfWeek() - case IntervalLastMonth: - from = utils.StartOfMonth() - case IntervalLastYear: - from = utils.StartOfYear() - case IntervalAny: - from = time.Time{} - default: - return nil, errors.New("missing 'from' parameter"), http.StatusBadRequest - } + return nil, err, http.StatusBadRequest } - live := (params.Get("live") != "" && params.Get("live") != "false") || interval == IntervalToday - recompute := params.Get("recompute") != "" && params.Get("recompute") != "false" - to := utils.StartOfDay() - if live { - to = time.Now() - } - - var summary *models.Summary - summary, err = summaryService.Construct(from, to, user, recompute) // 'to' is always constant + summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant if err != nil { return nil, err, http.StatusInternalServerError } diff --git a/services/summary.go b/services/summary.go index 574498f..3415858 100644 --- a/services/summary.go +++ b/services/summary.go @@ -65,7 +65,7 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco heartbeats = append(heartbeats, hb...) } - types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS, models.SummaryMachine} + types := models.SummaryTypes() var projectItems []*models.SummaryItem var languageItems []*models.SummaryItem diff --git a/utils/date.go b/utils/date.go index ad93b55..2b81b59 100644 --- a/utils/date.go +++ b/utils/date.go @@ -1,6 +1,9 @@ package utils -import "time" +import ( + "fmt" + "time" +) func StartOfDay() time.Time { ref := time.Now() @@ -23,6 +26,14 @@ func StartOfYear() time.Time { return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location()) } +func FmtWakatimeDuration(d time.Duration) string { + d = d.Round(time.Minute) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + return fmt.Sprintf("%d hrs %d mins", h, m) +} + // https://stackoverflow.com/a/18632496 func firstDayOfISOWeek(year int, week int, timezone *time.Location) time.Time { date := time.Date(year, 0, 0, 0, 0, 0, 0, timezone) diff --git a/utils/summary.go b/utils/summary.go new file mode 100644 index 0000000..d233115 --- /dev/null +++ b/utils/summary.go @@ -0,0 +1,52 @@ +package utils + +import ( + "errors" + "github.com/muety/wakapi/models" + "net/http" + "time" +) + +func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) { + user := r.Context().Value(models.UserKey).(*models.User) + + params := r.URL.Query() + + interval := params.Get("interval") + + from, err := ParseDate(params.Get("from")) + if err != nil { + switch interval { + case models.IntervalToday: + from = StartOfDay() + case models.IntervalLastDay: + from = StartOfDay().Add(-24 * time.Hour) + case models.IntervalLastWeek: + from = StartOfWeek() + case models.IntervalLastMonth: + from = StartOfMonth() + case models.IntervalLastYear: + from = StartOfYear() + case models.IntervalAny: + from = time.Time{} + default: + return nil, errors.New("missing 'from' parameter") + } + } + + live := (params.Get("live") != "" && params.Get("live") != "false") || interval == models.IntervalToday + + recompute := params.Get("recompute") != "" && params.Get("recompute") != "false" + + to := StartOfDay() + if live { + to = time.Now() + } + + return &models.SummaryParams{ + From: from, + To: to, + User: user, + Recompute: recompute, + }, nil +} diff --git a/version.txt b/version.txt index 7b378be..abb1658 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.8.4 \ No newline at end of file +1.9.0 \ No newline at end of file From 7c8ea86d4ef8561bcf4e80f27449dc5d9c3d0799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sun, 6 Sep 2020 12:25:12 +0200 Subject: [PATCH 04/13] docs: document api endpoints in readme --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 2c88daa..ced6157 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,18 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam **NOTE:** In order for the aliases to take effect for non-live statistics, you would either have to wait 24 hours for the cache to be invalidated or restart Wakapi. +## API Endpoints +The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)). + +* `POST /api/heartbeat` +* `GET /api/summary` + * `string` parameter `interval`: One of `today`, `day`, `week`, `month`, `year`, `any` + * `bool` parameter `live`: Whether to compute the summary to present time +* `GET /api/compat/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today)) +* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries)) (⏳ [coming soon](https://github.com/muety/wakapi/issues/44)) +* `GET /api/health` + + ## Best Practices 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 `listen = 0.0.0.0` in `config.ini` From 84e9559860eeb737cb1da9d3c83c67d7eaa0c96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sun, 6 Sep 2020 17:20:37 +0200 Subject: [PATCH 05/13] fix: all time data model --- models/compat/v1/all_time.go | 4 ++++ routes/compat/v1/all_time.go | 8 +++++--- version.txt | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/models/compat/v1/all_time.go b/models/compat/v1/all_time.go index 7afcd23..b903a32 100644 --- a/models/compat/v1/all_time.go +++ b/models/compat/v1/all_time.go @@ -3,6 +3,10 @@ package v1 // https://wakatime.com/developers#all_time_since_today type AllTimeVieModel struct { + Data *AllTimeVieModelData `json:"data"` +} + +type AllTimeVieModelData struct { Seconds float32 `json:"seconds"` // total number of seconds logged since account created Text string `json:"text"` // total time logged since account created as human readable string> IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon> diff --git a/routes/compat/v1/all_time.go b/routes/compat/v1/all_time.go index 1cb4180..b4be03a 100644 --- a/routes/compat/v1/all_time.go +++ b/routes/compat/v1/all_time.go @@ -46,9 +46,11 @@ func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { total := summary.TotalTime() vm := &v1.AllTimeVieModel{ - Seconds: float32(total), - Text: utils.FmtWakatimeDuration(total * time.Second), - IsUpToDate: true, + Data: &v1.AllTimeVieModelData{ + Seconds: float32(total), + Text: utils.FmtWakatimeDuration(total * time.Second), + IsUpToDate: true, + }, } utils.RespondJSON(w, http.StatusOK, vm) diff --git a/version.txt b/version.txt index abb1658..ee672d8 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.0 \ No newline at end of file +1.9.1 \ No newline at end of file From a8009e107dd23f08541d5491fed99474b01415c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Fri, 11 Sep 2020 20:22:33 +0200 Subject: [PATCH 06/13] fix: support project query param for alltime endpoint --- models/compat/v1/all_time.go | 6 +++--- models/summary.go | 18 ++++++++++++++++++ routes/compat/v1/all_time.go | 12 +++++++++--- version.txt | 2 +- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/models/compat/v1/all_time.go b/models/compat/v1/all_time.go index b903a32..32b2320 100644 --- a/models/compat/v1/all_time.go +++ b/models/compat/v1/all_time.go @@ -2,11 +2,11 @@ package v1 // https://wakatime.com/developers#all_time_since_today -type AllTimeVieModel struct { - Data *AllTimeVieModelData `json:"data"` +type AllTimeViewModel struct { + Data *AllTimeViewModelData `json:"data"` } -type AllTimeVieModelData struct { +type AllTimeViewModelData struct { Seconds float32 `json:"seconds"` // total number of seconds logged since account created Text string `json:"text"` // total time logged since account created as human readable string> IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon> diff --git a/models/summary.go b/models/summary.go index 4f09906..9f40d41 100644 --- a/models/summary.go +++ b/models/summary.go @@ -138,3 +138,21 @@ func (s *Summary) TotalTime() time.Duration { return timeSum } + +func (s *Summary) TotalTimeBy(entityType uint8, key string) time.Duration { + var timeSum time.Duration + + mappedItems := s.MappedItems() + + // calculate total duration from any of the present sets of items + if items := mappedItems[entityType]; len(*items) > 0 { + for _, item := range *items { + if item.Key != key { + continue + } + timeSum += item.Total + } + } + + return timeSum +} diff --git a/routes/compat/v1/all_time.go b/routes/compat/v1/all_time.go index b4be03a..ad0c95b 100644 --- a/routes/compat/v1/all_time.go +++ b/routes/compat/v1/all_time.go @@ -44,9 +44,15 @@ func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { return } - total := summary.TotalTime() - vm := &v1.AllTimeVieModel{ - Data: &v1.AllTimeVieModelData{ + var total time.Duration + if key := values.Get("project"); key != "" { + total = summary.TotalTimeBy(models.SummaryProject, key) + } else { + total = summary.TotalTime() + } + + vm := &v1.AllTimeViewModel{ + Data: &v1.AllTimeViewModelData{ Seconds: float32(total), Text: utils.FmtWakatimeDuration(total * time.Second), IsUpToDate: true, diff --git a/version.txt b/version.txt index ee672d8..6f2d365 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.1 \ No newline at end of file +1.9.2 \ No newline at end of file From 21567e76019ad393add0832e301445ba7af592da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Fri, 11 Sep 2020 23:24:51 +0200 Subject: [PATCH 07/13] feat: implement summaries compat endpoint (resolve #44) fix: fix all time view model --- main.go | 2 + models/compat/v1/all_time.go | 35 ++++++-- models/compat/v1/common.go | 5 ++ models/compat/v1/summaries.go | 147 ++++++++++++++++++++++++++++++++++ models/summary.go | 20 +++-- routes/compat/v1/all_time.go | 22 +---- routes/compat/v1/summaries.go | 96 ++++++++++++++++++++++ services/heartbeat.go | 2 +- utils/common.go | 7 +- utils/date.go | 24 +++++- utils/summary.go | 8 +- version.txt | 2 +- 12 files changed, 326 insertions(+), 44 deletions(-) create mode 100644 models/compat/v1/common.go create mode 100644 models/compat/v1/summaries.go create mode 100644 routes/compat/v1/summaries.go diff --git a/main.go b/main.go index 3b99cef..3859f14 100644 --- a/main.go +++ b/main.go @@ -96,6 +96,7 @@ func main() { settingsHandler := routes.NewSettingsHandler(userService) publicHandler := routes.NewIndexHandler(userService, keyValueService) compatV1AllHandler := v1Routes.NewCompatV1AllHandler(summaryService) + compatV1SummariesHandler := v1Routes.NewCompatV1SummariesHandler(summaryService) // Setup Routers router := mux.NewRouter() @@ -143,6 +144,7 @@ func main() { // Compat V1 API Routes compatV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(compatV1AllHandler.ApiGet) + compatV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(compatV1SummariesHandler.ApiGet) // Static Routes router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static"))) diff --git a/models/compat/v1/all_time.go b/models/compat/v1/all_time.go index 32b2320..3ff00a2 100644 --- a/models/compat/v1/all_time.go +++ b/models/compat/v1/all_time.go @@ -1,13 +1,36 @@ package v1 +import ( + "github.com/muety/wakapi/models" + "github.com/muety/wakapi/utils" + "time" +) + // https://wakatime.com/developers#all_time_since_today -type AllTimeViewModel struct { - Data *AllTimeViewModelData `json:"data"` +type WakatimeAllTime struct { + Data *wakatimeAllTimeData `json:"data"` } -type AllTimeViewModelData struct { - Seconds float32 `json:"seconds"` // total number of seconds logged since account created - Text string `json:"text"` // total time logged since account created as human readable string> - IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon> +type wakatimeAllTimeData struct { + TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created + Text string `json:"text"` // total time logged since account created as human readable string> + IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon> +} + +func NewAllTimeFrom(summary *models.Summary, filters *Filters) *WakatimeAllTime { + var total time.Duration + if key := filters.Project; key != "" { + total = summary.TotalTimeByKey(models.SummaryProject, key) + } else { + total = summary.TotalTime() + } + + return &WakatimeAllTime{ + Data: &wakatimeAllTimeData{ + TotalSeconds: float32(total.Seconds()), + Text: utils.FmtWakatimeDuration(total), + IsUpToDate: true, + }, + } } diff --git a/models/compat/v1/common.go b/models/compat/v1/common.go new file mode 100644 index 0000000..7ddeae6 --- /dev/null +++ b/models/compat/v1/common.go @@ -0,0 +1,5 @@ +package v1 + +type Filters struct { + Project string +} diff --git a/models/compat/v1/summaries.go b/models/compat/v1/summaries.go new file mode 100644 index 0000000..4a2a280 --- /dev/null +++ b/models/compat/v1/summaries.go @@ -0,0 +1,147 @@ +package v1 + +import ( + "fmt" + "github.com/muety/wakapi/models" + "github.com/muety/wakapi/utils" + "math" + "time" +) + +// https://wakatime.com/developers#summaries +// https://pastr.de/v/736450 + +type WakatimeSummaries struct { + Data []*wakatimeSummariesData `json:"data"` + End time.Time `json:"end"` + Start time.Time `json:"start"` +} + +type wakatimeSummariesData struct { + Categories []*wakatimeSummariesEntry `json:"categories"` + Dependencies []*wakatimeSummariesEntry `json:"dependencies"` + Editors []*wakatimeSummariesEntry `json:"editors"` + Languages []*wakatimeSummariesEntry `json:"languages"` + Machines []*wakatimeSummariesEntry `json:"machines"` + OperatingSystems []*wakatimeSummariesEntry `json:"operating_systems"` + Projects []*wakatimeSummariesEntry `json:"projects"` + GrandTotal *wakatimeSummariesGrandTotal `json:"grand_total"` + Range *wakatimeSummariesRange `json:"range"` +} + +type wakatimeSummariesEntry struct { + Digital string `json:"digital"` + Hours int `json:"hours"` + Minutes int `json:"minutes"` + Name string `json:"name"` + Percent float64 `json:"percent"` + Seconds int `json:"seconds"` + Text string `json:"text"` + TotalSeconds float64 `json:"total_seconds"` +} + +type wakatimeSummariesGrandTotal struct { + Digital string `json:"digital"` + Hours int `json:"hours"` + Minutes int `json:"minutes"` + Text string `json:"text"` + TotalSeconds float64 `json:"total_seconds"` +} + +type wakatimeSummariesRange struct { + Date string `json:"date"` + End time.Time `json:"end"` + Start time.Time `json:"start"` + Text string `json:"text"` + Timezone string `json:"timezone"` +} + +func NewSummariesFrom(summaries []*models.Summary, filters *Filters) *WakatimeSummaries { + data := make([]*wakatimeSummariesData, len(summaries)) + minDate, maxDate := time.Now().Add(1*time.Second), time.Time{} + + for i, s := range summaries { + data[i] = newDataFrom(s) + + if s.FromTime.Before(minDate) { + minDate = s.FromTime + } + if s.ToTime.After(maxDate) { + maxDate = s.ToTime + } + } + + return &WakatimeSummaries{ + Data: data, + End: maxDate, + Start: minDate, + } +} + +func newDataFrom(s *models.Summary) *wakatimeSummariesData { + zone, _ := time.Now().Zone() + total := s.TotalTime() + totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes()) + + data := &wakatimeSummariesData{ + Categories: make([]*wakatimeSummariesEntry, 0), + Dependencies: make([]*wakatimeSummariesEntry, 0), + Editors: make([]*wakatimeSummariesEntry, len(s.Editors)), + Languages: make([]*wakatimeSummariesEntry, len(s.Languages)), + Machines: make([]*wakatimeSummariesEntry, len(s.Machines)), + OperatingSystems: make([]*wakatimeSummariesEntry, len(s.OperatingSystems)), + Projects: make([]*wakatimeSummariesEntry, len(s.Projects)), + GrandTotal: &wakatimeSummariesGrandTotal{ + Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins), + Hours: totalHrs, + Minutes: totalMins, + Text: utils.FmtWakatimeDuration(total), + TotalSeconds: total.Seconds(), + }, + Range: &wakatimeSummariesRange{ + Date: time.Now().Format(time.RFC3339), + End: s.ToTime, + Start: s.FromTime, + Text: "", + Timezone: zone, + }, + } + + for i, e := range s.Projects { + data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject)) + } + for i, e := range s.Editors { + data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor)) + } + for i, e := range s.Languages { + data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage)) + } + for i, e := range s.OperatingSystems { + data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS)) + } + for i, e := range s.Machines { + data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine)) + } + + return data +} + +func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *wakatimeSummariesEntry { + // this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds + // TODO: fix some day, while migrating persisted summary items + total := e.Total * time.Second + hrs := int(total.Hours()) + mins := int((total - time.Duration(hrs)*time.Hour).Minutes()) + secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds()) + + return &wakatimeSummariesEntry{ + Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs), + Hours: hrs, + Minutes: mins, + Name: e.Key, + Percent: math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100, + Seconds: secs, + Text: utils.FmtWakatimeDuration(total), + TotalSeconds: total.Seconds(), + } +} diff --git a/models/summary.go b/models/summary.go index 9f40d41..2c4d333 100644 --- a/models/summary.go +++ b/models/summary.go @@ -125,7 +125,6 @@ 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 { @@ -136,15 +135,26 @@ func (s *Summary) TotalTime() time.Duration { } } - return timeSum + return timeSum * time.Second } -func (s *Summary) TotalTimeBy(entityType uint8, key string) time.Duration { +func (s *Summary) TotalTimeBy(entityType uint8) time.Duration { var timeSum time.Duration mappedItems := s.MappedItems() + if items := mappedItems[entityType]; len(*items) > 0 { + for _, item := range *items { + timeSum += item.Total + } + } - // calculate total duration from any of the present sets of items + return timeSum * time.Second +} + +func (s *Summary) TotalTimeByKey(entityType uint8, key string) time.Duration { + var timeSum time.Duration + + mappedItems := s.MappedItems() if items := mappedItems[entityType]; len(*items) > 0 { for _, item := range *items { if item.Key != key { @@ -154,5 +164,5 @@ func (s *Summary) TotalTimeBy(entityType uint8, key string) time.Duration { } } - return timeSum + return timeSum * time.Second } diff --git a/routes/compat/v1/all_time.go b/routes/compat/v1/all_time.go index ad0c95b..0f65c00 100644 --- a/routes/compat/v1/all_time.go +++ b/routes/compat/v1/all_time.go @@ -25,6 +25,8 @@ func NewCompatV1AllHandler(summaryService *services.SummaryService) *CompatV1All func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) + values, _ := url.ParseQuery(r.URL.RawQuery) + requestedUser := vars["user"] authorizedUser := r.Context().Value(models.UserKey).(*models.User) @@ -33,10 +35,6 @@ func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { return } - values, _ := url.ParseQuery(r.URL.RawQuery) - values.Set("interval", models.IntervalAny) - r.URL.RawQuery = values.Encode() - summary, err, status := h.loadUserSummary(authorizedUser) if err != nil { w.WriteHeader(status) @@ -44,21 +42,7 @@ func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { return } - var total time.Duration - if key := values.Get("project"); key != "" { - total = summary.TotalTimeBy(models.SummaryProject, key) - } else { - total = summary.TotalTime() - } - - vm := &v1.AllTimeViewModel{ - Data: &v1.AllTimeViewModelData{ - Seconds: float32(total), - Text: utils.FmtWakatimeDuration(total * time.Second), - IsUpToDate: true, - }, - } - + vm := v1.NewAllTimeFrom(summary, &v1.Filters{Project: values.Get("project")}) utils.RespondJSON(w, http.StatusOK, vm) } diff --git a/routes/compat/v1/summaries.go b/routes/compat/v1/summaries.go new file mode 100644 index 0000000..7a69fef --- /dev/null +++ b/routes/compat/v1/summaries.go @@ -0,0 +1,96 @@ +package v1 + +import ( + "errors" + "github.com/gorilla/mux" + "github.com/muety/wakapi/models" + v1 "github.com/muety/wakapi/models/compat/v1" + "github.com/muety/wakapi/services" + "github.com/muety/wakapi/utils" + "net/http" + "strings" + "time" +) + +type CompatV1SummariesHandler struct { + summarySrvc *services.SummaryService + config *models.Config +} + +func NewCompatV1SummariesHandler(summaryService *services.SummaryService) *CompatV1SummariesHandler { + return &CompatV1SummariesHandler{ + summarySrvc: summaryService, + config: models.GetConfig(), + } +} + +/* +TODO: support parameters: branches, timeout, writes_only, timezone +https://wakatime.com/developers#summaries +timezone can be specified via an offset suffix (e.g. +02:00) in date strings +*/ + +func (h *CompatV1SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + requestedUser := vars["user"] + authorizedUser := r.Context().Value(models.UserKey).(*models.User) + + if requestedUser != authorizedUser.ID && requestedUser != "current" { + w.WriteHeader(http.StatusForbidden) + return + } + + summaries, err, status := h.loadUserSummaries(r) + if err != nil { + w.WriteHeader(status) + w.Write([]byte(err.Error())) + return + } + + vm := v1.NewSummariesFrom(summaries, &v1.Filters{}) + utils.RespondJSON(w, http.StatusOK, vm) +} + +func (h *CompatV1SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) { + user := r.Context().Value(models.UserKey).(*models.User) + params := r.URL.Query() + + var start, end time.Time + // TODO: find out what other special dates are supported by wakatime (e.g. tomorrow, yesterday, ...?) + if startKey, endKey := params.Get("start"), params.Get("end"); startKey == "today" && startKey == endKey { + start = utils.StartOfToday() + end = time.Now() + } else { + var err error + + start, err = time.Parse(time.RFC3339, strings.Replace(startKey, " ", "+", 1)) + if err != nil { + return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest + } + + end, err = time.Parse(time.RFC3339, strings.Replace(endKey, " ", "+", 1)) + if err != nil { + return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest + } + } + + overallParams := &models.SummaryParams{ + From: start, + To: end, + User: user, + Recompute: false, + } + + intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To) + summaries := make([]*models.Summary, len(intervals)) + + for i, interval := range intervals { + summary, err := h.summarySrvc.Construct(interval[0], interval[1], user, false) // 'to' is always constant + if err != nil { + return nil, err, http.StatusInternalServerError + } + summaries[i] = summary + } + + return summaries, nil, http.StatusOK +} diff --git a/services/heartbeat.go b/services/heartbeat.go index d4f5ee6..d73cd6b 100644 --- a/services/heartbeat.go +++ b/services/heartbeat.go @@ -77,7 +77,7 @@ func (srv *HeartbeatService) DeleteBefore(t time.Time) error { } func (srv *HeartbeatService) CleanUp() error { - refTime := utils.StartOfDay().Add(-cleanUpInterval) + refTime := utils.StartOfToday().Add(-cleanUpInterval) if err := srv.DeleteBefore(refTime); err != nil { log.Printf("Failed to clean up heartbeats older than %v – %v\n", refTime, err) return err diff --git a/utils/common.go b/utils/common.go index 86f2e94..738fbd2 100644 --- a/utils/common.go +++ b/utils/common.go @@ -43,15 +43,14 @@ func MakeConnectionString(config *models.Config) string { } func mySqlConnectionString(config *models.Config) string { - location, _ := time.LoadLocation("Local") - return fmt.Sprintf( - "%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s", + //location, _ := time.LoadLocation("Local") + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s", config.DbUser, config.DbPassword, config.DbHost, config.DbPort, config.DbName, - location.String(), + "Local", ) } diff --git a/utils/date.go b/utils/date.go index 2b81b59..faacd60 100644 --- a/utils/date.go +++ b/utils/date.go @@ -5,9 +5,12 @@ import ( "time" ) -func StartOfDay() time.Time { - ref := time.Now() - return time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, ref.Location()) +func StartOfToday() time.Time { + return StartOfDay(time.Now()) +} + +func StartOfDay(date time.Time) time.Time { + return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) } func StartOfWeek() time.Time { @@ -26,6 +29,21 @@ func StartOfYear() time.Time { return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location()) } +func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time { + intervals := make([][]time.Time, 0) + + for t1 := from; t1.Before(to); { + t2 := StartOfDay(t1).Add(24 * time.Hour) + if t2.After(to) { + t2 = to + } + intervals = append(intervals, []time.Time{t1, t2}) + t1 = t2 + } + + return intervals +} + func FmtWakatimeDuration(d time.Duration) string { d = d.Round(time.Minute) h := d / time.Hour diff --git a/utils/summary.go b/utils/summary.go index d233115..c85ee10 100644 --- a/utils/summary.go +++ b/utils/summary.go @@ -9,18 +9,16 @@ import ( func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) { user := r.Context().Value(models.UserKey).(*models.User) - params := r.URL.Query() - interval := params.Get("interval") from, err := ParseDate(params.Get("from")) if err != nil { switch interval { case models.IntervalToday: - from = StartOfDay() + from = StartOfToday() case models.IntervalLastDay: - from = StartOfDay().Add(-24 * time.Hour) + from = StartOfToday().Add(-24 * time.Hour) case models.IntervalLastWeek: from = StartOfWeek() case models.IntervalLastMonth: @@ -38,7 +36,7 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) { recompute := params.Get("recompute") != "" && params.Get("recompute") != "false" - to := StartOfDay() + to := StartOfToday() if live { to = time.Now() } diff --git a/version.txt b/version.txt index 6f2d365..e33692a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.2 \ No newline at end of file +1.10.1 \ No newline at end of file From 570aeebe01687490025a6d9f61869ea40f619bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Fri, 11 Sep 2020 23:51:46 +0200 Subject: [PATCH 08/13] docs: document prometheus export --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index ced6157..22e7b6b 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,11 @@ The following API endpoints are available. A more detailed Swagger documentation * `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries)) (⏳ [coming soon](https://github.com/muety/wakapi/issues/44)) * `GET /api/health` +## Prometheus Export +If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**. +It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible. + +Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/v1"` and set your API key accordingly. ## Best Practices It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS). From 8dca9f5cc0573367800bd70ace6e89e923566beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sat, 12 Sep 2020 00:20:16 +0200 Subject: [PATCH 09/13] chore: parallel summary conversion --- README.md | 2 -- models/compat/v1/summaries.go | 55 +++++++++++++++++++++++++---------- routes/compat/v1/summaries.go | 2 +- version.txt | 2 +- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 22e7b6b..cde1304 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,6 @@ ![](https://img.shields.io/github/license/muety/wakapi?style=flat-square) [![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi) -[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoff.ee/n1try) - --- **A minimalist, self-hosted WakaTime-compatible backend for coding statistics** diff --git a/models/compat/v1/summaries.go b/models/compat/v1/summaries.go index 4a2a280..2650080 100644 --- a/models/compat/v1/summaries.go +++ b/models/compat/v1/summaries.go @@ -5,6 +5,7 @@ import ( "github.com/muety/wakapi/models" "github.com/muety/wakapi/utils" "math" + "sync" "time" ) @@ -107,22 +108,46 @@ func newDataFrom(s *models.Summary) *wakatimeSummariesData { }, } - for i, e := range s.Projects { - data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject)) - } - for i, e := range s.Editors { - data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor)) - } - for i, e := range s.Languages { - data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage)) - } - for i, e := range s.OperatingSystems { - data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS)) - } - for i, e := range s.Machines { - data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine)) - } + var wg sync.WaitGroup + wg.Add(5) + go func(data *wakatimeSummariesData) { + defer wg.Done() + for i, e := range s.Projects { + data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject)) + } + }(data) + + go func(data *wakatimeSummariesData) { + defer wg.Done() + for i, e := range s.Editors { + data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor)) + } + }(data) + + go func(data *wakatimeSummariesData) { + defer wg.Done() + for i, e := range s.Languages { + data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage)) + + } + }(data) + + go func(data *wakatimeSummariesData) { + defer wg.Done() + for i, e := range s.OperatingSystems { + data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS)) + } + }(data) + + go func(data *wakatimeSummariesData) { + defer wg.Done() + for i, e := range s.Machines { + data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine)) + } + }(data) + + wg.Wait() return data } diff --git a/routes/compat/v1/summaries.go b/routes/compat/v1/summaries.go index 7a69fef..90f2134 100644 --- a/routes/compat/v1/summaries.go +++ b/routes/compat/v1/summaries.go @@ -25,7 +25,7 @@ func NewCompatV1SummariesHandler(summaryService *services.SummaryService) *Compa } /* -TODO: support parameters: branches, timeout, writes_only, timezone +TODO: support parameters: project, branches, timeout, writes_only, timezone https://wakatime.com/developers#summaries timezone can be specified via an offset suffix (e.g. +02:00) in date strings */ diff --git a/version.txt b/version.txt index e33692a..70ad429 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.10.1 \ No newline at end of file +1.10.2 \ No newline at end of file From fde8c35362ffa00e36c51dfd731ecc3e8a2cefbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sat, 12 Sep 2020 09:21:04 +0200 Subject: [PATCH 10/13] docs: include github project card for exporter repo --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index cde1304..7aa2b9c 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,9 @@ The following API endpoints are available. A more detailed Swagger documentation ## Prometheus Export If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**. + +[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true)](https://github.com/MacroPower/wakatime_exporter) + It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible. Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/v1"` and set your API key accordingly. From 19a8c61f77b2d4d6967dce825bac3d6d2af6a477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sat, 12 Sep 2020 12:40:38 +0200 Subject: [PATCH 11/13] feat: add more pre-configured intervals (resolve #51) --- README.md | 5 +---- models/summary.go | 15 +++++++++------ utils/summary.go | 42 ++++++++++++++++++++++++++++-------------- version.txt | 2 +- views/summary.tpl.html | 17 ++++++++++------- 5 files changed, 49 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7aa2b9c..aa9cdc2 100644 --- a/README.md +++ b/README.md @@ -80,17 +80,14 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam * OS ~ type **3** * Machine ~ type **4** -**NOTE:** In order for the aliases to take effect for non-live statistics, you would either have to wait 24 hours for the cache to be invalidated or restart Wakapi. - ## API Endpoints The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)). * `POST /api/heartbeat` * `GET /api/summary` * `string` parameter `interval`: One of `today`, `day`, `week`, `month`, `year`, `any` - * `bool` parameter `live`: Whether to compute the summary to present time * `GET /api/compat/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today)) -* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries)) (⏳ [coming soon](https://github.com/muety/wakapi/issues/44)) +* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries)) * `GET /api/health` ## Prometheus Export diff --git a/models/summary.go b/models/summary.go index 2c4d333..46ab044 100644 --- a/models/summary.go +++ b/models/summary.go @@ -14,12 +14,15 @@ const ( ) const ( - IntervalToday string = "today" - IntervalLastDay string = "day" - IntervalLastWeek string = "week" - IntervalLastMonth string = "month" - IntervalLastYear string = "year" - IntervalAny string = "any" + IntervalToday string = "today" + IntervalYesterday string = "day" + IntervalThisWeek string = "week" + IntervalThisMonth string = "month" + IntervalThisYear string = "year" + IntervalPast7Days string = "7_days" + IntervalPast30Days string = "30_days" + IntervalPast12Months string = "12_months" + IntervalAny string = "any" ) const UnknownSummaryKey = "unknown" diff --git a/utils/summary.go b/utils/summary.go index c85ee10..9f45ca1 100644 --- a/utils/summary.go +++ b/utils/summary.go @@ -10,37 +10,51 @@ import ( func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) { user := r.Context().Value(models.UserKey).(*models.User) params := r.URL.Query() - interval := params.Get("interval") - from, err := ParseDate(params.Get("from")) - if err != nil { + var from, to time.Time + + if interval := params.Get("interval"); interval != "" { + to = time.Now() + switch interval { case models.IntervalToday: from = StartOfToday() - case models.IntervalLastDay: + case models.IntervalYesterday: from = StartOfToday().Add(-24 * time.Hour) - case models.IntervalLastWeek: + to = StartOfToday() + case models.IntervalThisWeek: from = StartOfWeek() - case models.IntervalLastMonth: + case models.IntervalThisMonth: from = StartOfMonth() - case models.IntervalLastYear: + case models.IntervalThisYear: from = StartOfYear() + case models.IntervalPast7Days: + from = StartOfToday().AddDate(0, 0, -7) + case models.IntervalPast30Days: + from = StartOfToday().AddDate(0, 0, -30) + case models.IntervalPast12Months: + from = StartOfToday().AddDate(0, -12, 0) case models.IntervalAny: from = time.Time{} default: + return nil, errors.New("invalid interval") + } + } else { + var err error + + from, err = ParseDate(params.Get("from")) + if err != nil { return nil, errors.New("missing 'from' parameter") } + + to, err = ParseDate(params.Get("to")) + if err != nil { + return nil, errors.New("missing 'to' parameter") + } } - live := (params.Get("live") != "" && params.Get("live") != "false") || interval == models.IntervalToday - recompute := params.Get("recompute") != "" && params.Get("recompute") != "false" - to := StartOfToday() - if live { - to = time.Now() - } - return &models.SummaryParams{ From: from, To: to, diff --git a/version.txt b/version.txt index 70ad429..62321af 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.10.2 \ No newline at end of file +1.10.3 \ No newline at end of file diff --git a/views/summary.tpl.html b/views/summary.tpl.html index de2782d..24ba2a5 100644 --- a/views/summary.tpl.html +++ b/views/summary.tpl.html @@ -36,13 +36,16 @@

Your Coding Statistics 🤓

-
- Today (live) - Yesterday - This Week - This Month - This Year - All Time + {{ template "alerts.tpl.html" . }} From d60dddb55018a889298348841ffdfd17df67d998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sat, 12 Sep 2020 16:09:23 +0200 Subject: [PATCH 12/13] feat: implement badges endpoint and sharing functionality --- README.md | 7 +- main.go | 24 +++-- migrations/sqlite3/5_badges_column.sql | 11 +++ models/compat/shields/v1/badge.go | 37 ++++++++ models/compat/v1/common.go | 5 - models/compat/{ => wakatime}/v1/all_time.go | 12 +-- models/compat/{ => wakatime}/v1/summaries.go | 76 ++++++++-------- models/shared.go | 39 ++++++++ models/summary.go | 6 ++ models/user.go | 1 + routes/compat/shields/v1/badge.go | 96 ++++++++++++++++++++ routes/compat/{ => wakatime}/v1/all_time.go | 14 +-- routes/compat/{ => wakatime}/v1/summaries.go | 14 +-- routes/settings.go | 23 ++++- services/user.go | 13 +++ utils/strings.go | 9 ++ utils/summary.go | 59 ++++++------ version.txt | 2 +- views/settings.tpl.html | 63 +++++++++++++ 19 files changed, 409 insertions(+), 102 deletions(-) create mode 100644 migrations/sqlite3/5_badges_column.sql create mode 100644 models/compat/shields/v1/badge.go delete mode 100644 models/compat/v1/common.go rename models/compat/{ => wakatime}/v1/all_time.go (77%) rename models/compat/{ => wakatime}/v1/summaries.go (61%) create mode 100644 routes/compat/shields/v1/badge.go rename routes/compat/{ => wakatime}/v1/all_time.go (72%) rename routes/compat/{ => wakatime}/v1/summaries.go (83%) diff --git a/README.md b/README.md index aa9cdc2..5d4128f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try) ![](https://img.shields.io/github/license/muety/wakapi?style=flat-square) [![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi) - +![Coding Activity](https://img.shields.io/endpoint?url=https://apps.muetsch.io/wakapi/api/compat/shields/v1/n1try/interval:any/project:wakapi&style=flat-square&color=blue) --- **A minimalist, self-hosted WakaTime-compatible backend for coding statistics** @@ -97,7 +97,10 @@ If you want to export your Wakapi statistics to Prometheus to view them in a Gra It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible. -Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/v1"` and set your API key accordingly. +Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly. + +## Badges + ## Best Practices It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS). diff --git a/main.go b/main.go index 3859f14..8c7c4de 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,8 @@ import ( "github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/models" "github.com/muety/wakapi/routes" - v1Routes "github.com/muety/wakapi/routes/compat/v1" + shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1" + wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" ) @@ -95,8 +96,9 @@ func main() { healthHandler := routes.NewHealthHandler(db) settingsHandler := routes.NewSettingsHandler(userService) publicHandler := routes.NewIndexHandler(userService, keyValueService) - compatV1AllHandler := v1Routes.NewCompatV1AllHandler(summaryService) - compatV1SummariesHandler := v1Routes.NewCompatV1SummariesHandler(summaryService) + wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService) + wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService) + shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService) // Setup Routers router := mux.NewRouter() @@ -104,7 +106,9 @@ func main() { settingsRouter := publicRouter.PathPrefix("/settings").Subrouter() summaryRouter := publicRouter.PathPrefix("/summary").Subrouter() apiRouter := router.PathPrefix("/api").Subrouter() - compatV1Router := apiRouter.PathPrefix("/compat/v1").Subrouter() + compatRouter := apiRouter.PathPrefix("/compat").Subrouter() + wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1").Subrouter() + shieldsV1Router := compatRouter.PathPrefix("/shields/v1").Subrouter() // Middlewares recoveryMiddleware := handlers.RecoveryHandler() @@ -112,7 +116,7 @@ func main() { corsMiddleware := handlers.CORS() authenticateMiddleware := middlewares.NewAuthenticateMiddleware( userService, - []string{"/api/health"}, + []string{"/api/health", "/api/compat/shields/v1"}, ).Handler // Router configs @@ -136,15 +140,19 @@ func main() { settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex) settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials) settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey) + settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges) // API Routes apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost) apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet) apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet) - // Compat V1 API Routes - compatV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(compatV1AllHandler.ApiGet) - compatV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(compatV1SummariesHandler.ApiGet) + // Wakatime compat V1 API Routes + wakatimeV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(wakatimeV1AllHandler.ApiGet) + wakatimeV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(wakatimeV1SummariesHandler.ApiGet) + + // Shields.io compat API Routes + shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet) // Static Routes router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static"))) diff --git a/migrations/sqlite3/5_badges_column.sql b/migrations/sqlite3/5_badges_column.sql new file mode 100644 index 0000000..5fa2f32 --- /dev/null +++ b/migrations/sqlite3/5_badges_column.sql @@ -0,0 +1,11 @@ +-- +migrate Up +-- SQL in section 'Up' is executed when this migration is applied + +alter table users + add column `badges_enabled` tinyint(1) default 0 not null; + +-- +migrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +alter table users + drop column `badges_enabled`; \ No newline at end of file diff --git a/models/compat/shields/v1/badge.go b/models/compat/shields/v1/badge.go new file mode 100644 index 0000000..a6cd7ed --- /dev/null +++ b/models/compat/shields/v1/badge.go @@ -0,0 +1,37 @@ +package v1 + +import ( + "github.com/muety/wakapi/models" + "github.com/muety/wakapi/utils" + "time" +) + +// https://shields.io/endpoint + +const ( + defaultLabel = "coding time" + defaultColor = "#2D3748" // not working +) + +type BadgeData struct { + SchemaVersion int `json:"schemaVersion"` + Label string `json:"label"` + Message string `json:"message"` + Color string `json:"color"` +} + +func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData { + var total time.Duration + if hasFilter, filterType, filterKey := filters.First(); hasFilter { + total = summary.TotalTimeByKey(filterType, filterKey) + } else { + total = summary.TotalTime() + } + + return &BadgeData{ + SchemaVersion: 1, + Label: defaultLabel, + Message: utils.FmtWakatimeDuration(total), + Color: defaultColor, + } +} diff --git a/models/compat/v1/common.go b/models/compat/v1/common.go deleted file mode 100644 index 7ddeae6..0000000 --- a/models/compat/v1/common.go +++ /dev/null @@ -1,5 +0,0 @@ -package v1 - -type Filters struct { - Project string -} diff --git a/models/compat/v1/all_time.go b/models/compat/wakatime/v1/all_time.go similarity index 77% rename from models/compat/v1/all_time.go rename to models/compat/wakatime/v1/all_time.go index 3ff00a2..44aa537 100644 --- a/models/compat/v1/all_time.go +++ b/models/compat/wakatime/v1/all_time.go @@ -8,17 +8,17 @@ import ( // https://wakatime.com/developers#all_time_since_today -type WakatimeAllTime struct { - Data *wakatimeAllTimeData `json:"data"` +type AllTimeViewModel struct { + Data *allTimeData `json:"data"` } -type wakatimeAllTimeData struct { +type allTimeData struct { TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created Text string `json:"text"` // total time logged since account created as human readable string> IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon> } -func NewAllTimeFrom(summary *models.Summary, filters *Filters) *WakatimeAllTime { +func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel { var total time.Duration if key := filters.Project; key != "" { total = summary.TotalTimeByKey(models.SummaryProject, key) @@ -26,8 +26,8 @@ func NewAllTimeFrom(summary *models.Summary, filters *Filters) *WakatimeAllTime total = summary.TotalTime() } - return &WakatimeAllTime{ - Data: &wakatimeAllTimeData{ + return &AllTimeViewModel{ + Data: &allTimeData{ TotalSeconds: float32(total.Seconds()), Text: utils.FmtWakatimeDuration(total), IsUpToDate: true, diff --git a/models/compat/v1/summaries.go b/models/compat/wakatime/v1/summaries.go similarity index 61% rename from models/compat/v1/summaries.go rename to models/compat/wakatime/v1/summaries.go index 2650080..e064ec7 100644 --- a/models/compat/v1/summaries.go +++ b/models/compat/wakatime/v1/summaries.go @@ -12,25 +12,25 @@ import ( // https://wakatime.com/developers#summaries // https://pastr.de/v/736450 -type WakatimeSummaries struct { - Data []*wakatimeSummariesData `json:"data"` - End time.Time `json:"end"` - Start time.Time `json:"start"` +type SummariesViewModel struct { + Data []*summariesData `json:"data"` + End time.Time `json:"end"` + Start time.Time `json:"start"` } -type wakatimeSummariesData struct { - Categories []*wakatimeSummariesEntry `json:"categories"` - Dependencies []*wakatimeSummariesEntry `json:"dependencies"` - Editors []*wakatimeSummariesEntry `json:"editors"` - Languages []*wakatimeSummariesEntry `json:"languages"` - Machines []*wakatimeSummariesEntry `json:"machines"` - OperatingSystems []*wakatimeSummariesEntry `json:"operating_systems"` - Projects []*wakatimeSummariesEntry `json:"projects"` - GrandTotal *wakatimeSummariesGrandTotal `json:"grand_total"` - Range *wakatimeSummariesRange `json:"range"` +type summariesData struct { + Categories []*summariesEntry `json:"categories"` + Dependencies []*summariesEntry `json:"dependencies"` + Editors []*summariesEntry `json:"editors"` + Languages []*summariesEntry `json:"languages"` + Machines []*summariesEntry `json:"machines"` + OperatingSystems []*summariesEntry `json:"operating_systems"` + Projects []*summariesEntry `json:"projects"` + GrandTotal *summariesGrandTotal `json:"grand_total"` + Range *summariesRange `json:"range"` } -type wakatimeSummariesEntry struct { +type summariesEntry struct { Digital string `json:"digital"` Hours int `json:"hours"` Minutes int `json:"minutes"` @@ -41,7 +41,7 @@ type wakatimeSummariesEntry struct { TotalSeconds float64 `json:"total_seconds"` } -type wakatimeSummariesGrandTotal struct { +type summariesGrandTotal struct { Digital string `json:"digital"` Hours int `json:"hours"` Minutes int `json:"minutes"` @@ -49,7 +49,7 @@ type wakatimeSummariesGrandTotal struct { TotalSeconds float64 `json:"total_seconds"` } -type wakatimeSummariesRange struct { +type summariesRange struct { Date string `json:"date"` End time.Time `json:"end"` Start time.Time `json:"start"` @@ -57,8 +57,8 @@ type wakatimeSummariesRange struct { Timezone string `json:"timezone"` } -func NewSummariesFrom(summaries []*models.Summary, filters *Filters) *WakatimeSummaries { - data := make([]*wakatimeSummariesData, len(summaries)) +func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel { + data := make([]*summariesData, len(summaries)) minDate, maxDate := time.Now().Add(1*time.Second), time.Time{} for i, s := range summaries { @@ -72,34 +72,34 @@ func NewSummariesFrom(summaries []*models.Summary, filters *Filters) *WakatimeSu } } - return &WakatimeSummaries{ + return &SummariesViewModel{ Data: data, End: maxDate, Start: minDate, } } -func newDataFrom(s *models.Summary) *wakatimeSummariesData { +func newDataFrom(s *models.Summary) *summariesData { zone, _ := time.Now().Zone() total := s.TotalTime() totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes()) - data := &wakatimeSummariesData{ - Categories: make([]*wakatimeSummariesEntry, 0), - Dependencies: make([]*wakatimeSummariesEntry, 0), - Editors: make([]*wakatimeSummariesEntry, len(s.Editors)), - Languages: make([]*wakatimeSummariesEntry, len(s.Languages)), - Machines: make([]*wakatimeSummariesEntry, len(s.Machines)), - OperatingSystems: make([]*wakatimeSummariesEntry, len(s.OperatingSystems)), - Projects: make([]*wakatimeSummariesEntry, len(s.Projects)), - GrandTotal: &wakatimeSummariesGrandTotal{ + data := &summariesData{ + Categories: make([]*summariesEntry, 0), + Dependencies: make([]*summariesEntry, 0), + Editors: make([]*summariesEntry, len(s.Editors)), + Languages: make([]*summariesEntry, len(s.Languages)), + Machines: make([]*summariesEntry, len(s.Machines)), + OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)), + Projects: make([]*summariesEntry, len(s.Projects)), + GrandTotal: &summariesGrandTotal{ Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins), Hours: totalHrs, Minutes: totalMins, Text: utils.FmtWakatimeDuration(total), TotalSeconds: total.Seconds(), }, - Range: &wakatimeSummariesRange{ + Range: &summariesRange{ Date: time.Now().Format(time.RFC3339), End: s.ToTime, Start: s.FromTime, @@ -111,21 +111,21 @@ func newDataFrom(s *models.Summary) *wakatimeSummariesData { var wg sync.WaitGroup wg.Add(5) - go func(data *wakatimeSummariesData) { + go func(data *summariesData) { defer wg.Done() for i, e := range s.Projects { data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject)) } }(data) - go func(data *wakatimeSummariesData) { + go func(data *summariesData) { defer wg.Done() for i, e := range s.Editors { data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor)) } }(data) - go func(data *wakatimeSummariesData) { + go func(data *summariesData) { defer wg.Done() for i, e := range s.Languages { data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage)) @@ -133,14 +133,14 @@ func newDataFrom(s *models.Summary) *wakatimeSummariesData { } }(data) - go func(data *wakatimeSummariesData) { + go func(data *summariesData) { defer wg.Done() for i, e := range s.OperatingSystems { data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS)) } }(data) - go func(data *wakatimeSummariesData) { + go func(data *summariesData) { defer wg.Done() for i, e := range s.Machines { data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine)) @@ -151,7 +151,7 @@ func newDataFrom(s *models.Summary) *wakatimeSummariesData { return data } -func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *wakatimeSummariesEntry { +func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry { // this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds // TODO: fix some day, while migrating persisted summary items total := e.Total * time.Second @@ -159,7 +159,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *wakatimeSum mins := int((total - time.Duration(hrs)*time.Hour).Minutes()) secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds()) - return &wakatimeSummariesEntry{ + return &summariesEntry{ Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs), Hours: hrs, Minutes: mins, diff --git a/models/shared.go b/models/shared.go index 92a715e..87145a3 100644 --- a/models/shared.go +++ b/models/shared.go @@ -24,6 +24,45 @@ type KeyStringValue struct { Value string `gorm:"type:text"` } +type Filters struct { + Project string + OS string + Language string + Editor string + Machine string +} + +func NewFiltersWith(entity uint8, key string) *Filters { + switch entity { + case SummaryProject: + return &Filters{Project: key} + case SummaryOS: + return &Filters{Project: key} + case SummaryLanguage: + return &Filters{Project: key} + case SummaryEditor: + return &Filters{Project: key} + case SummaryMachine: + return &Filters{Project: key} + } + return &Filters{} +} + +func (f *Filters) First() (bool, uint8, string) { + if f.Project != "" { + return true, SummaryProject, f.Project + } else if f.OS != "" { + return true, SummaryOS, f.OS + } else if f.Language != "" { + return true, SummaryLanguage, f.Language + } else if f.Editor != "" { + return true, SummaryEditor, f.Editor + } else if f.Machine != "" { + return true, SummaryMachine, f.Machine + } + return false, 0, "" +} + type CustomTime time.Time func (j *CustomTime) UnmarshalJSON(b []byte) error { diff --git a/models/summary.go b/models/summary.go index 46ab044..ca33ff6 100644 --- a/models/summary.go +++ b/models/summary.go @@ -25,6 +25,12 @@ const ( IntervalAny string = "any" ) +func Intervals() []string { + return []string{ + IntervalToday, IntervalYesterday, IntervalThisWeek, IntervalThisMonth, IntervalThisYear, IntervalPast7Days, IntervalPast30Days, IntervalPast12Months, IntervalAny, + } +} + const UnknownSummaryKey = "unknown" type Summary struct { diff --git a/models/user.go b/models/user.go index 9a3101d..6016085 100644 --- a/models/user.go +++ b/models/user.go @@ -6,6 +6,7 @@ type User struct { Password string `json:"-"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` + BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"` } type Login struct { diff --git a/routes/compat/shields/v1/badge.go b/routes/compat/shields/v1/badge.go new file mode 100644 index 0000000..99499f7 --- /dev/null +++ b/routes/compat/shields/v1/badge.go @@ -0,0 +1,96 @@ +package v1 + +import ( + "github.com/gorilla/mux" + "github.com/muety/wakapi/models" + v1 "github.com/muety/wakapi/models/compat/shields/v1" + "github.com/muety/wakapi/services" + "github.com/muety/wakapi/utils" + "net/http" + "regexp" +) + +const ( + intervalPattern = `interval:([a-z0-9_]+)` + entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)` +) + +type BadgeHandler struct { + userSrvc *services.UserService + summarySrvc *services.SummaryService + config *models.Config +} + +func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler { + return &BadgeHandler{ + summarySrvc: summaryService, + userSrvc: userService, + config: models.GetConfig(), + } +} + +func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) { + intervalReg := regexp.MustCompile(intervalPattern) + entityFilterReg := regexp.MustCompile(entityFilterPattern) + + requestedUserId := mux.Vars(r)["user"] + user, err := h.userSrvc.GetUserById(requestedUserId) + if err != nil || !user.BadgesEnabled { + w.WriteHeader(http.StatusUnauthorized) + return + } + + var filterEntity, filterKey string + if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 { + filterEntity, filterKey = groups[1], groups[2] + } + + var interval = models.IntervalPast30Days + if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 { + interval = groups[1] + } + + filters := &models.Filters{} + switch filterEntity { + case "project": + filters.Project = filterKey + case "os": + filters.OS = filterKey + case "editor": + filters.Editor = filterKey + case "language": + filters.Language = filterKey + case "machine": + filters.Machine = filterKey + } + + summary, err, status := h.loadUserSummary(user, interval) + if err != nil { + w.WriteHeader(status) + w.Write([]byte(err.Error())) + return + } + + vm := v1.NewBadgeDataFrom(summary, filters) + utils.RespondJSON(w, http.StatusOK, vm) +} + +func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) { + err, from, to := utils.ResolveInterval(interval) + if err != nil { + return nil, err, http.StatusBadRequest + } + + summaryParams := &models.SummaryParams{ + From: from, + To: to, + User: user, + } + + summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) + if err != nil { + return nil, err, http.StatusInternalServerError + } + + return summary, nil, http.StatusOK +} diff --git a/routes/compat/v1/all_time.go b/routes/compat/wakatime/v1/all_time.go similarity index 72% rename from routes/compat/v1/all_time.go rename to routes/compat/wakatime/v1/all_time.go index 0f65c00..faac3dd 100644 --- a/routes/compat/v1/all_time.go +++ b/routes/compat/wakatime/v1/all_time.go @@ -3,7 +3,7 @@ package v1 import ( "github.com/gorilla/mux" "github.com/muety/wakapi/models" - v1 "github.com/muety/wakapi/models/compat/v1" + v1 "github.com/muety/wakapi/models/compat/wakatime/v1" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" "net/http" @@ -11,19 +11,19 @@ import ( "time" ) -type CompatV1AllHandler struct { +type AllTimeHandler struct { summarySrvc *services.SummaryService config *models.Config } -func NewCompatV1AllHandler(summaryService *services.SummaryService) *CompatV1AllHandler { - return &CompatV1AllHandler{ +func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler { + return &AllTimeHandler{ summarySrvc: summaryService, config: models.GetConfig(), } } -func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { +func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) values, _ := url.ParseQuery(r.URL.RawQuery) @@ -42,11 +42,11 @@ func (h *CompatV1AllHandler) ApiGet(w http.ResponseWriter, r *http.Request) { return } - vm := v1.NewAllTimeFrom(summary, &v1.Filters{Project: values.Get("project")}) + vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")}) utils.RespondJSON(w, http.StatusOK, vm) } -func (h *CompatV1AllHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) { +func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) { summaryParams := &models.SummaryParams{ From: time.Time{}, To: time.Now(), diff --git a/routes/compat/v1/summaries.go b/routes/compat/wakatime/v1/summaries.go similarity index 83% rename from routes/compat/v1/summaries.go rename to routes/compat/wakatime/v1/summaries.go index 90f2134..2223d95 100644 --- a/routes/compat/v1/summaries.go +++ b/routes/compat/wakatime/v1/summaries.go @@ -4,7 +4,7 @@ import ( "errors" "github.com/gorilla/mux" "github.com/muety/wakapi/models" - v1 "github.com/muety/wakapi/models/compat/v1" + v1 "github.com/muety/wakapi/models/compat/wakatime/v1" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" "net/http" @@ -12,13 +12,13 @@ import ( "time" ) -type CompatV1SummariesHandler struct { +type SummariesHandler struct { summarySrvc *services.SummaryService config *models.Config } -func NewCompatV1SummariesHandler(summaryService *services.SummaryService) *CompatV1SummariesHandler { - return &CompatV1SummariesHandler{ +func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler { + return &SummariesHandler{ summarySrvc: summaryService, config: models.GetConfig(), } @@ -30,7 +30,7 @@ https://wakatime.com/developers#summaries timezone can be specified via an offset suffix (e.g. +02:00) in date strings */ -func (h *CompatV1SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) { +func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) requestedUser := vars["user"] authorizedUser := r.Context().Value(models.UserKey).(*models.User) @@ -47,11 +47,11 @@ func (h *CompatV1SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request return } - vm := v1.NewSummariesFrom(summaries, &v1.Filters{}) + vm := v1.NewSummariesFrom(summaries, &models.Filters{}) utils.RespondJSON(w, http.StatusOK, vm) } -func (h *CompatV1SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) { +func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) { user := r.Context().Value(models.UserKey).(*models.User) params := r.URL.Query() diff --git a/routes/settings.go b/routes/settings.go index b4084ed..8dd0ffc 100644 --- a/routes/settings.go +++ b/routes/settings.go @@ -29,10 +29,16 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) { loadTemplates() } + user := r.Context().Value(models.UserKey).(*models.User) + data := map[string]interface{}{ + "User": user, + } + + // TODO: when alerts are present, other data will not be passed to the template if handleAlerts(w, r, "settings.tpl.html") { return } - templates["settings.tpl.html"].Execute(w, nil) + templates["settings.tpl.html"].Execute(w, data) } func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) { @@ -110,3 +116,18 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey)) http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound) } + +func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + user := r.Context().Value(models.UserKey).(*models.User) + + if _, err := h.userSrvc.ToggleBadges(user); err != nil { + respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.BasePath), http.StatusFound) +} diff --git a/services/user.go b/services/user.go index c6a672c..ee66066 100644 --- a/services/user.go +++ b/services/user.go @@ -87,6 +87,19 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) { return srv.Update(user) } +func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) { + result := srv.Db.Model(user).Update("badges_enabled", !user.BadgesEnabled) + if err := result.Error; err != nil { + return nil, err + } + + if result.RowsAffected != 1 { + return nil, errors.New("nothing updated") + } + + return user, nil +} + func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) { user.Password = login.Password if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil { diff --git a/utils/strings.go b/utils/strings.go index 3175d79..3f985a6 100644 --- a/utils/strings.go +++ b/utils/strings.go @@ -8,3 +8,12 @@ import ( func Capitalize(s string) string { return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:]) } + +func FindString(needle string, haystack []string, defaultVal string) string { + for _, s := range haystack { + if s == needle { + return s + } + } + return defaultVal +} diff --git a/utils/summary.go b/utils/summary.go index 9f45ca1..eaa1d13 100644 --- a/utils/summary.go +++ b/utils/summary.go @@ -7,41 +7,46 @@ import ( "time" ) +func ResolveInterval(interval string) (err error, from, to time.Time) { + to = time.Now() + + switch interval { + case models.IntervalToday: + from = StartOfToday() + case models.IntervalYesterday: + from = StartOfToday().Add(-24 * time.Hour) + to = StartOfToday() + case models.IntervalThisWeek: + from = StartOfWeek() + case models.IntervalThisMonth: + from = StartOfMonth() + case models.IntervalThisYear: + from = StartOfYear() + case models.IntervalPast7Days: + from = StartOfToday().AddDate(0, 0, -7) + case models.IntervalPast30Days: + from = StartOfToday().AddDate(0, 0, -30) + case models.IntervalPast12Months: + from = StartOfToday().AddDate(0, -12, 0) + case models.IntervalAny: + from = time.Time{} + default: + err = errors.New("invalid interval") + } + + return err, from, to +} + func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) { user := r.Context().Value(models.UserKey).(*models.User) params := r.URL.Query() + var err error var from, to time.Time if interval := params.Get("interval"); interval != "" { - to = time.Now() - - switch interval { - case models.IntervalToday: - from = StartOfToday() - case models.IntervalYesterday: - from = StartOfToday().Add(-24 * time.Hour) - to = StartOfToday() - case models.IntervalThisWeek: - from = StartOfWeek() - case models.IntervalThisMonth: - from = StartOfMonth() - case models.IntervalThisYear: - from = StartOfYear() - case models.IntervalPast7Days: - from = StartOfToday().AddDate(0, 0, -7) - case models.IntervalPast30Days: - from = StartOfToday().AddDate(0, 0, -30) - case models.IntervalPast12Months: - from = StartOfToday().AddDate(0, -12, 0) - case models.IntervalAny: - from = time.Time{} - default: - return nil, errors.New("invalid interval") - } + err, from, to = ResolveInterval(interval) } else { - var err error - from, err = ParseDate(params.Get("from")) if err != nil { return nil, errors.New("missing 'from' parameter") diff --git a/version.txt b/version.txt index 62321af..169f19b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.10.3 \ No newline at end of file +1.11.0 \ No newline at end of file diff --git a/views/settings.tpl.html b/views/settings.tpl.html index fea0b31..901d412 100644 --- a/views/settings.tpl.html +++ b/views/settings.tpl.html @@ -64,9 +64,72 @@
+ +
+
+ Badges +
+ +
+
+ {{ if .User.BadgesEnabled }} +

Badges are currently enabled. You can disable the feature by deactivating the respective API endpoint.

+ +
+ GET /api/compat/shields/v1 + +
+ +

Examples

+
+
+
+ +
+ + https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today + +
+
+
+ +
+ + https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d + +
+
+ +

You can also add /project:your-cool-project to the URL to filter by project.

+ {{ else }} +

You have the ability to create badges from your coding statistics using Shields.io. To do so, you need to grant public, unauthorized access to the respective endpoint.

+
+ GET /api/compat/shields/v1 + +
+ {{ end }} +
+
+
+ + {{ template "footer.tpl.html" . }} {{ template "foot.tpl.html" . }} From 6e2f3e6731ea56a2165dc87d34254d0924fa0ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sat, 12 Sep 2020 16:32:43 +0200 Subject: [PATCH 13/13] fix: attempt to fix invalid fixture query for postgres (fix #52) --- migrations/common/fixtures/1_imprint_content.sql | 5 +++-- utils/common.go | 2 +- version.txt | 2 +- views/imprint.tpl.html | 2 +- views/signup.tpl.html | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/migrations/common/fixtures/1_imprint_content.sql b/migrations/common/fixtures/1_imprint_content.sql index d300c3e..906e056 100644 --- a/migrations/common/fixtures/1_imprint_content.sql +++ b/migrations/common/fixtures/1_imprint_content.sql @@ -1,7 +1,8 @@ -- +migrate Up -- SQL in section 'Up' is executed when this migration is applied -insert into key_string_values (`key`, `value`) values ('imprint', 'no content here'); +insert into key_string_values ("key", "value") values ('imprint', 'no content here'); -- +migrate Down -- SQL section 'Down' is executed when this migration is rolled back -delete from key_string_values where `key` = 'imprint'; \ No newline at end of file +SET SQL_MODE=ANSI_QUOTES; +delete from key_string_values where key = 'imprint'; \ No newline at end of file diff --git a/utils/common.go b/utils/common.go index 738fbd2..39c658e 100644 --- a/utils/common.go +++ b/utils/common.go @@ -44,7 +44,7 @@ func MakeConnectionString(config *models.Config) string { func mySqlConnectionString(config *models.Config) string { //location, _ := time.LoadLocation("Local") - return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s", + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES", config.DbUser, config.DbPassword, config.DbHost, diff --git a/version.txt b/version.txt index 169f19b..b0f61c5 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.11.0 \ No newline at end of file +1.11.1 \ No newline at end of file diff --git a/views/imprint.tpl.html b/views/imprint.tpl.html index 226e5f0..81e116e 100644 --- a/views/imprint.tpl.html +++ b/views/imprint.tpl.html @@ -5,7 +5,7 @@
- +

Imprint & Data Privacy

diff --git a/views/signup.tpl.html b/views/signup.tpl.html index 3acd057..03104c0 100644 --- a/views/signup.tpl.html +++ b/views/signup.tpl.html @@ -5,7 +5,7 @@