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

Merge pull request #112 from muety/87-wakatime-data-import

feat: wakatime data import (resolve #87)
This commit is contained in:
Ferdinand Mütsch
2021-02-06 23:47:44 +01:00
committed by GitHub
42 changed files with 1855 additions and 878 deletions

View File

@@ -39,10 +39,12 @@
## Table of Contents ## Table of Contents
* [User Survey](#-user-survey) * [User Survey](#-user-survey)
* [Features](#-features) * [Features](#-features)
* [Roadmap](#-roadmap)
* [How to use](#-how-to-use) * [How to use](#-how-to-use)
* [Configuration Options](#-configuration-options) * [Configuration Options](#-configuration-options)
* [API Endpoints](#-api-endpoints) * [API Endpoints](#-api-endpoints)
* [Prometheus Export](#-prometheus-export) * [Integrations](#-integrations)
* [WakaTime Integration](#%EF%B8%8F-wakatime-integration)
* [Best Practices](#-best-practices) * [Best Practices](#-best-practices)
* [Developer Notes](#-developer-notes) * [Developer Notes](#-developer-notes)
* [Support](#-support) * [Support](#-support)
@@ -58,12 +60,15 @@ I'd love to get some community feedback from active Wakapi users. If you want, p
* ✅ Badges * ✅ Badges
* ✅ REST API * ✅ REST API
* ✅ Partially compatible with WakaTime * ✅ Partially compatible with WakaTime
* ✅ WakaTime relay to use both * ✅ WakaTime integration
* ✅ Support for [Prometheus](https://github.com/muety/wakapi#%EF%B8%8F-prometheus-export) exports * ✅ Support for Prometheus exports
* ✅ Self-hosted * ✅ Self-hosted
## 🚧 Roadmap
Plans for the near future mainly include, besides usual improvements and bug fixes, a UI redesign as well as additional types of charts and statistics (see [#101](https://github.com/muety/wakapi/issues/101), [#80](https://github.com/muety/wakapi/issues/80), [#76](https://github.com/muety/wakapi/issues/76), [#12](https://github.com/muety/wakapi/issues/12)). If you have feature requests or any kind of improvement proposals feel free to open an issue or share them in our [user survey](https://github.com/muety/wakapi/issues/82).
## ⌨️ How to use? ## ⌨️ How to use?
There are different options for how to use Wakapi, ranging from out hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition. There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
### ☁️ Option 1: Use [wakapi.dev](https://wakapi.dev) ### ☁️ Option 1: Use [wakapi.dev](https://wakapi.dev)
If you want to you out free, hosted cloud service, all you need to do is create an account and the set up your client-side tooling (see below). If you want to you out free, hosted cloud service, all you need to do is create an account and the set up your client-side tooling (see below).
@@ -190,15 +195,24 @@ The following API endpoints are available. A more detailed Swagger documentation
* `GET /api/compat/wakatime/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries)) * `GET /api/compat/wakatime/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
* `GET /api/health` * `GET /api/health`
## ⤴️ Prometheus Export ## 🤝 Integrations
### 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)**. 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) [![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff)](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. 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/wakatime/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.
### WakaTime Integration
Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page.
### GitHub Readme Stats Integrations
Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats) to generate fancy cards for you. Here is an example.
![](https://github-readme-stats.vercel.app/api/wakatime?username=n1try&api_domain=wakapi.dev&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact)
## 👍 Best Practices ## 👍 Best Practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS). It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml` However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`

View File

@@ -29,22 +29,28 @@ const (
KeyLatestTotalTime = "latest_total_time" KeyLatestTotalTime = "latest_total_time"
KeyLatestTotalUsers = "latest_total_users" KeyLatestTotalUsers = "latest_total_users"
KeyLastImportImport = "last_import"
) )
const ( const (
WakatimeApiUrl = "https://wakatime.com/api/v1" WakatimeApiUrl = "https://wakatime.com/api/v1"
WakatimeApiHeartbeatsEndpoint = "/users/current/heartbeats.bulk" WakatimeApiUserUrl = "/users/current"
WakatimeApiUserEndpoint = "/users/current" WakatimeApiAllTimeUrl = "/users/current/all_time_since_today"
WakatimeApiHeartbeatsUrl = "/users/current/heartbeats"
WakatimeApiHeartbeatsBulkUrl = "/users/current/heartbeats.bulk"
WakatimeApiUserAgentsUrl = "/users/current/user_agents"
) )
var cfg *Config var cfg *Config
var cFlag = flag.String("config", defaultConfigPath, "config file location") var cFlag = flag.String("config", defaultConfigPath, "config file location")
type appConfig struct { type appConfig struct {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"` AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
CountingTime string `yaml:"counting_time" default:"05:15" env:"WAKAPI_COUNTING_TIME"` CountingTime string `yaml:"counting_time" default:"05:15" env:"WAKAPI_COUNTING_TIME"`
CustomLanguages map[string]string `yaml:"custom_languages"` ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
Colors map[string]map[string]string `yaml:"-"` ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
} }
type securityConfig struct { type securityConfig struct {

View File

@@ -1,9 +1,34 @@
mode: set mode: set
github.com/muety/wakapi/models/alias.go:12.32,14.2 1 0 github.com/muety/wakapi/models/heartbeats.go:7.31,9.2 1 0
github.com/muety/wakapi/models/alias.go:16.37,17.35 1 0 github.com/muety/wakapi/models/heartbeats.go:11.41,13.2 1 0
github.com/muety/wakapi/models/alias.go:22.2,22.14 1 0 github.com/muety/wakapi/models/heartbeats.go:15.36,17.2 1 0
github.com/muety/wakapi/models/alias.go:17.35,18.18 1 0 github.com/muety/wakapi/models/heartbeats.go:19.43,22.2 2 0
github.com/muety/wakapi/models/alias.go:18.18,20.4 1 0 github.com/muety/wakapi/models/heartbeats.go:24.41,26.18 1 0
github.com/muety/wakapi/models/heartbeats.go:29.2,29.16 1 0
github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0
github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0
github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0
github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0
github.com/muety/wakapi/models/interval.go:5.47,6.23 1 0
github.com/muety/wakapi/models/interval.go:11.2,11.14 1 0
github.com/muety/wakapi/models/interval.go:6.23,7.13 1 0
github.com/muety/wakapi/models/interval.go:7.13,9.4 1 0
github.com/muety/wakapi/models/models.go:3.14,5.2 0 1
github.com/muety/wakapi/models/shared.go:34.52,37.16 3 0
github.com/muety/wakapi/models/shared.go:40.2,42.12 3 0
github.com/muety/wakapi/models/shared.go:37.16,39.3 1 0
github.com/muety/wakapi/models/shared.go:46.52,52.22 2 0
github.com/muety/wakapi/models/shared.go:68.2,71.12 3 0
github.com/muety/wakapi/models/shared.go:53.14,55.17 2 0
github.com/muety/wakapi/models/shared.go:58.13,60.8 2 0
github.com/muety/wakapi/models/shared.go:61.17,63.8 2 0
github.com/muety/wakapi/models/shared.go:64.10,65.64 1 0
github.com/muety/wakapi/models/shared.go:55.17,57.4 1 0
github.com/muety/wakapi/models/shared.go:74.45,76.2 1 0
github.com/muety/wakapi/models/shared.go:78.51,81.2 2 0
github.com/muety/wakapi/models/shared.go:83.37,86.2 2 0
github.com/muety/wakapi/models/shared.go:88.35,90.2 1 0
github.com/muety/wakapi/models/shared.go:92.34,94.2 1 0
github.com/muety/wakapi/models/filters.go:16.56,17.16 1 0 github.com/muety/wakapi/models/filters.go:16.56,17.16 1 0
github.com/muety/wakapi/models/filters.go:29.2,29.19 1 0 github.com/muety/wakapi/models/filters.go:29.2,29.19 1 0
github.com/muety/wakapi/models/filters.go:18.22,19.32 1 0 github.com/muety/wakapi/models/filters.go:18.22,19.32 1 0
@@ -22,134 +47,207 @@ github.com/muety/wakapi/models/filters.go:39.8,39.27 1 1
github.com/muety/wakapi/models/filters.go:39.27,41.3 1 0 github.com/muety/wakapi/models/filters.go:39.27,41.3 1 0
github.com/muety/wakapi/models/filters.go:41.8,41.28 1 1 github.com/muety/wakapi/models/filters.go:41.8,41.28 1 1
github.com/muety/wakapi/models/filters.go:41.28,43.3 1 0 github.com/muety/wakapi/models/filters.go:41.28,43.3 1 0
github.com/muety/wakapi/models/heartbeat.go:30.34,32.2 1 1 github.com/muety/wakapi/models/heartbeat.go:32.34,34.2 1 1
github.com/muety/wakapi/models/heartbeat.go:34.65,35.28 1 1 github.com/muety/wakapi/models/heartbeat.go:36.65,37.28 1 1
github.com/muety/wakapi/models/heartbeat.go:38.2,39.45 2 1 github.com/muety/wakapi/models/heartbeat.go:40.2,41.45 2 1
github.com/muety/wakapi/models/heartbeat.go:42.2,43.44 2 1 github.com/muety/wakapi/models/heartbeat.go:44.2,45.44 2 1
github.com/muety/wakapi/models/heartbeat.go:46.2,46.42 1 1 github.com/muety/wakapi/models/heartbeat.go:48.2,48.42 1 1
github.com/muety/wakapi/models/heartbeat.go:35.28,37.3 1 1 github.com/muety/wakapi/models/heartbeat.go:37.28,39.3 1 1
github.com/muety/wakapi/models/heartbeat.go:39.45,41.3 1 0 github.com/muety/wakapi/models/heartbeat.go:41.45,43.3 1 0
github.com/muety/wakapi/models/heartbeat.go:43.44,45.3 1 0 github.com/muety/wakapi/models/heartbeat.go:45.44,47.3 1 0
github.com/muety/wakapi/models/heartbeat.go:49.50,50.11 1 1 github.com/muety/wakapi/models/heartbeat.go:51.50,52.11 1 1
github.com/muety/wakapi/models/heartbeat.go:63.2,63.15 1 1 github.com/muety/wakapi/models/heartbeat.go:65.2,65.15 1 1
github.com/muety/wakapi/models/heartbeat.go:67.2,67.12 1 1 github.com/muety/wakapi/models/heartbeat.go:69.2,69.12 1 1
github.com/muety/wakapi/models/heartbeat.go:51.22,52.18 1 1 github.com/muety/wakapi/models/heartbeat.go:53.22,54.18 1 1
github.com/muety/wakapi/models/heartbeat.go:53.21,54.17 1 1 github.com/muety/wakapi/models/heartbeat.go:55.21,56.17 1 1
github.com/muety/wakapi/models/heartbeat.go:55.23,56.19 1 1 github.com/muety/wakapi/models/heartbeat.go:57.23,58.19 1 1
github.com/muety/wakapi/models/heartbeat.go:57.17,58.26 1 1 github.com/muety/wakapi/models/heartbeat.go:59.17,60.26 1 1
github.com/muety/wakapi/models/heartbeat.go:59.22,60.18 1 1 github.com/muety/wakapi/models/heartbeat.go:61.22,62.18 1 1
github.com/muety/wakapi/models/heartbeat.go:63.15,65.3 1 1 github.com/muety/wakapi/models/heartbeat.go:65.15,67.3 1 1
github.com/muety/wakapi/models/heartbeat.go:70.37,86.2 1 0 github.com/muety/wakapi/models/heartbeat.go:72.37,88.2 1 0
github.com/muety/wakapi/models/heartbeat.go:94.41,96.16 2 0 github.com/muety/wakapi/models/heartbeat.go:96.41,98.16 2 0
github.com/muety/wakapi/models/heartbeat.go:99.2,100.10 2 0 github.com/muety/wakapi/models/heartbeat.go:101.2,102.10 2 0
github.com/muety/wakapi/models/heartbeat.go:96.16,98.3 1 0 github.com/muety/wakapi/models/heartbeat.go:98.16,100.3 1 0
github.com/muety/wakapi/models/user.go:35.43,38.2 1 0
github.com/muety/wakapi/models/user.go:40.33,44.2 1 0
github.com/muety/wakapi/models/user.go:46.45,48.2 1 0
github.com/muety/wakapi/models/user.go:50.45,52.2 1 0
github.com/muety/wakapi/models/heartbeats.go:7.31,9.2 1 0
github.com/muety/wakapi/models/heartbeats.go:11.41,13.2 1 0
github.com/muety/wakapi/models/heartbeats.go:15.36,17.2 1 0
github.com/muety/wakapi/models/heartbeats.go:19.43,22.2 2 0
github.com/muety/wakapi/models/heartbeats.go:24.41,26.18 1 0
github.com/muety/wakapi/models/heartbeats.go:29.2,29.16 1 0
github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0
github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0
github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0
github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0
github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0 github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0
github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0 github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0
github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0 github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0
github.com/muety/wakapi/models/models.go:3.14,5.2 0 1 github.com/muety/wakapi/models/summary.go:101.29,103.2 1 1
github.com/muety/wakapi/models/shared.go:34.52,37.16 3 0 github.com/muety/wakapi/models/summary.go:105.37,112.2 6 1
github.com/muety/wakapi/models/shared.go:40.2,42.12 3 0 github.com/muety/wakapi/models/summary.go:114.35,116.2 1 1
github.com/muety/wakapi/models/shared.go:37.16,39.3 1 0 github.com/muety/wakapi/models/summary.go:118.57,126.2 1 1
github.com/muety/wakapi/models/shared.go:46.52,52.22 2 0 github.com/muety/wakapi/models/summary.go:139.33,144.26 4 1
github.com/muety/wakapi/models/shared.go:68.2,71.12 3 0 github.com/muety/wakapi/models/summary.go:151.2,151.37 1 1
github.com/muety/wakapi/models/shared.go:53.14,55.17 2 0 github.com/muety/wakapi/models/summary.go:155.2,158.33 2 1
github.com/muety/wakapi/models/shared.go:58.13,60.8 2 0 github.com/muety/wakapi/models/summary.go:144.26,145.30 1 1
github.com/muety/wakapi/models/shared.go:61.17,63.8 2 0 github.com/muety/wakapi/models/summary.go:145.30,147.4 1 1
github.com/muety/wakapi/models/shared.go:64.10,65.64 1 0 github.com/muety/wakapi/models/summary.go:151.37,153.3 1 0
github.com/muety/wakapi/models/shared.go:55.17,57.4 1 0 github.com/muety/wakapi/models/summary.go:158.33,164.3 1 1
github.com/muety/wakapi/models/shared.go:74.45,76.2 1 0 github.com/muety/wakapi/models/summary.go:167.45,172.30 3 1
github.com/muety/wakapi/models/shared.go:78.51,81.2 2 0 github.com/muety/wakapi/models/summary.go:181.2,181.30 1 1
github.com/muety/wakapi/models/shared.go:83.37,86.2 2 0 github.com/muety/wakapi/models/summary.go:172.30,173.47 1 1
github.com/muety/wakapi/models/shared.go:88.35,90.2 1 0 github.com/muety/wakapi/models/summary.go:173.47,174.32 1 1
github.com/muety/wakapi/models/shared.go:92.34,94.2 1 0 github.com/muety/wakapi/models/summary.go:177.4,177.9 1 1
github.com/muety/wakapi/models/summary.go:41.27,45.2 1 0 github.com/muety/wakapi/models/summary.go:174.32,176.5 1 1
github.com/muety/wakapi/models/summary.go:97.29,99.2 1 1 github.com/muety/wakapi/models/summary.go:184.73,186.55 2 1
github.com/muety/wakapi/models/summary.go:101.37,108.2 6 1 github.com/muety/wakapi/models/summary.go:191.2,191.16 1 1
github.com/muety/wakapi/models/summary.go:110.35,112.2 1 1 github.com/muety/wakapi/models/summary.go:186.55,187.31 1 1
github.com/muety/wakapi/models/summary.go:114.57,122.2 1 1 github.com/muety/wakapi/models/summary.go:187.31,189.4 1 1
github.com/muety/wakapi/models/summary.go:135.33,140.26 4 1 github.com/muety/wakapi/models/summary.go:194.88,196.55 2 1
github.com/muety/wakapi/models/summary.go:147.2,147.37 1 1 github.com/muety/wakapi/models/summary.go:204.2,204.16 1 1
github.com/muety/wakapi/models/summary.go:151.2,154.33 2 1 github.com/muety/wakapi/models/summary.go:196.55,197.31 1 1
github.com/muety/wakapi/models/summary.go:140.26,141.30 1 1 github.com/muety/wakapi/models/summary.go:197.31,198.23 1 1
github.com/muety/wakapi/models/summary.go:141.30,143.4 1 1 github.com/muety/wakapi/models/summary.go:201.4,201.46 1 1
github.com/muety/wakapi/models/summary.go:147.37,149.3 1 0 github.com/muety/wakapi/models/summary.go:198.23,199.13 1 1
github.com/muety/wakapi/models/summary.go:154.33,160.3 1 1 github.com/muety/wakapi/models/summary.go:207.70,209.8 2 1
github.com/muety/wakapi/models/summary.go:163.45,168.30 3 1 github.com/muety/wakapi/models/summary.go:212.2,212.10 1 1
github.com/muety/wakapi/models/summary.go:177.2,177.30 1 1 github.com/muety/wakapi/models/summary.go:209.8,211.3 1 1
github.com/muety/wakapi/models/summary.go:168.30,169.47 1 1 github.com/muety/wakapi/models/summary.go:215.71,216.63 1 1
github.com/muety/wakapi/models/summary.go:169.47,170.32 1 1 github.com/muety/wakapi/models/summary.go:256.2,262.10 6 1
github.com/muety/wakapi/models/summary.go:173.4,173.9 1 1 github.com/muety/wakapi/models/summary.go:216.63,219.45 2 1
github.com/muety/wakapi/models/summary.go:170.32,172.5 1 1 github.com/muety/wakapi/models/summary.go:228.3,228.31 1 1
github.com/muety/wakapi/models/summary.go:180.73,182.55 2 1 github.com/muety/wakapi/models/summary.go:235.3,235.31 1 1
github.com/muety/wakapi/models/summary.go:187.2,187.16 1 1 github.com/muety/wakapi/models/summary.go:252.3,252.16 1 1
github.com/muety/wakapi/models/summary.go:182.55,183.31 1 1 github.com/muety/wakapi/models/summary.go:219.45,220.32 1 1
github.com/muety/wakapi/models/summary.go:183.31,185.4 1 1 github.com/muety/wakapi/models/summary.go:225.4,225.14 1 1
github.com/muety/wakapi/models/summary.go:190.88,192.55 2 1 github.com/muety/wakapi/models/summary.go:220.32,221.24 1 1
github.com/muety/wakapi/models/summary.go:200.2,200.16 1 1 github.com/muety/wakapi/models/summary.go:221.24,223.6 1 1
github.com/muety/wakapi/models/summary.go:192.55,193.31 1 1 github.com/muety/wakapi/models/summary.go:228.31,230.60 1 1
github.com/muety/wakapi/models/summary.go:193.31,194.23 1 1 github.com/muety/wakapi/models/summary.go:230.60,232.5 1 1
github.com/muety/wakapi/models/summary.go:197.4,197.46 1 1 github.com/muety/wakapi/models/summary.go:235.31,237.60 1 1
github.com/muety/wakapi/models/summary.go:194.23,195.13 1 1 github.com/muety/wakapi/models/summary.go:237.60,238.55 1 1
github.com/muety/wakapi/models/summary.go:203.70,205.8 2 1 github.com/muety/wakapi/models/summary.go:238.55,240.6 1 1
github.com/muety/wakapi/models/summary.go:208.2,208.10 1 1 github.com/muety/wakapi/models/summary.go:240.11,248.6 1 1
github.com/muety/wakapi/models/summary.go:205.8,207.3 1 1 github.com/muety/wakapi/models/summary.go:265.33,267.2 1 1
github.com/muety/wakapi/models/summary.go:211.71,212.63 1 1 github.com/muety/wakapi/models/summary.go:269.43,271.2 1 1
github.com/muety/wakapi/models/summary.go:252.2,258.10 6 1 github.com/muety/wakapi/models/summary.go:273.38,275.2 1 1
github.com/muety/wakapi/models/summary.go:212.63,215.45 2 1 github.com/muety/wakapi/models/user.go:40.43,43.2 1 0
github.com/muety/wakapi/models/summary.go:224.3,224.31 1 1 github.com/muety/wakapi/models/user.go:45.33,49.2 1 0
github.com/muety/wakapi/models/summary.go:231.3,231.31 1 1 github.com/muety/wakapi/models/user.go:51.45,53.2 1 0
github.com/muety/wakapi/models/summary.go:248.3,248.16 1 1 github.com/muety/wakapi/models/user.go:55.45,57.2 1 0
github.com/muety/wakapi/models/summary.go:215.45,216.32 1 1 github.com/muety/wakapi/models/alias.go:12.32,14.2 1 0
github.com/muety/wakapi/models/summary.go:221.4,221.14 1 1 github.com/muety/wakapi/models/alias.go:16.37,17.35 1 0
github.com/muety/wakapi/models/summary.go:216.32,217.24 1 1 github.com/muety/wakapi/models/alias.go:22.2,22.14 1 0
github.com/muety/wakapi/models/summary.go:217.24,219.6 1 1 github.com/muety/wakapi/models/alias.go:17.35,18.18 1 0
github.com/muety/wakapi/models/summary.go:224.31,226.60 1 1 github.com/muety/wakapi/models/alias.go:18.18,20.4 1 0
github.com/muety/wakapi/models/summary.go:226.60,228.5 1 1 github.com/muety/wakapi/utils/template.go:8.41,10.16 2 0
github.com/muety/wakapi/models/summary.go:231.31,233.60 1 1 github.com/muety/wakapi/utils/template.go:13.2,13.23 1 0
github.com/muety/wakapi/models/summary.go:233.60,234.55 1 1 github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0
github.com/muety/wakapi/models/summary.go:234.55,236.6 1 1 github.com/muety/wakapi/utils/template.go:16.37,17.30 1 0
github.com/muety/wakapi/models/summary.go:236.11,244.6 1 1 github.com/muety/wakapi/utils/template.go:20.2,20.10 1 0
github.com/muety/wakapi/models/summary.go:261.33,263.2 1 1 github.com/muety/wakapi/utils/template.go:17.30,19.3 1 0
github.com/muety/wakapi/models/summary.go:265.43,267.2 1 1 github.com/muety/wakapi/utils/auth.go:16.79,18.54 2 0
github.com/muety/wakapi/models/summary.go:269.38,271.2 1 1 github.com/muety/wakapi/utils/auth.go:22.2,24.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:20.116,26.2 1 1 github.com/muety/wakapi/utils/auth.go:28.2,30.45 3 0
github.com/muety/wakapi/middlewares/authenticate.go:28.71,29.71 1 0 github.com/muety/wakapi/utils/auth.go:33.2,34.32 2 0
github.com/muety/wakapi/middlewares/authenticate.go:29.71,31.3 1 0 github.com/muety/wakapi/utils/auth.go:18.54,20.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:34.107,35.37 1 0 github.com/muety/wakapi/utils/auth.go:24.16,26.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:42.2,45.16 3 0 github.com/muety/wakapi/utils/auth.go:30.45,32.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:49.2,49.16 1 0 github.com/muety/wakapi/utils/auth.go:37.65,39.54 2 0
github.com/muety/wakapi/middlewares/authenticate.go:59.2,60.29 2 0 github.com/muety/wakapi/utils/auth.go:43.2,44.30 2 0
github.com/muety/wakapi/middlewares/authenticate.go:35.37,36.58 1 0 github.com/muety/wakapi/utils/auth.go:39.54,41.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:36.58,39.4 2 0 github.com/muety/wakapi/utils/auth.go:47.94,49.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:45.16,47.3 1 0 github.com/muety/wakapi/utils/auth.go:53.2,53.107 1 0
github.com/muety/wakapi/middlewares/authenticate.go:49.16,50.44 1 0 github.com/muety/wakapi/utils/auth.go:57.2,57.22 1 0
github.com/muety/wakapi/middlewares/authenticate.go:56.3,56.9 1 0 github.com/muety/wakapi/utils/auth.go:49.16,51.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:50.44,52.4 1 0 github.com/muety/wakapi/utils/auth.go:53.107,55.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:52.9,55.4 2 0 github.com/muety/wakapi/utils/auth.go:60.56,64.2 3 0
github.com/muety/wakapi/middlewares/authenticate.go:63.92,65.16 2 1 github.com/muety/wakapi/utils/auth.go:66.55,69.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:69.2,72.16 4 1 github.com/muety/wakapi/utils/auth.go:72.2,72.16 1 0
github.com/muety/wakapi/middlewares/authenticate.go:75.2,75.18 1 1 github.com/muety/wakapi/utils/auth.go:69.16,71.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:65.16,67.3 1 1 github.com/muety/wakapi/utils/color.go:8.90,10.32 2 0
github.com/muety/wakapi/middlewares/authenticate.go:72.16,74.3 1 0 github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0
github.com/muety/wakapi/middlewares/authenticate.go:78.92,80.16 2 0 github.com/muety/wakapi/utils/color.go:10.32,11.50 1 0
github.com/muety/wakapi/middlewares/authenticate.go:84.2,85.16 2 0 github.com/muety/wakapi/utils/color.go:11.50,13.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:92.2,92.18 1 0 github.com/muety/wakapi/utils/common.go:9.48,11.2 1 0
github.com/muety/wakapi/middlewares/authenticate.go:80.16,82.3 1 0 github.com/muety/wakapi/utils/common.go:13.40,15.2 1 0
github.com/muety/wakapi/middlewares/authenticate.go:85.16,87.3 1 0 github.com/muety/wakapi/utils/common.go:17.45,19.2 1 0
github.com/muety/wakapi/utils/common.go:21.24,23.2 1 0
github.com/muety/wakapi/utils/common.go:25.56,28.45 3 1
github.com/muety/wakapi/utils/common.go:31.2,31.40 1 1
github.com/muety/wakapi/utils/common.go:28.45,30.3 1 1
github.com/muety/wakapi/utils/date.go:8.31,10.2 1 0
github.com/muety/wakapi/utils/date.go:12.43,14.2 1 0
github.com/muety/wakapi/utils/date.go:16.30,20.2 3 0
github.com/muety/wakapi/utils/date.go:22.31,25.2 2 0
github.com/muety/wakapi/utils/date.go:27.30,30.2 2 0
github.com/muety/wakapi/utils/date.go:32.67,35.33 2 0
github.com/muety/wakapi/utils/date.go:44.2,44.18 1 0
github.com/muety/wakapi/utils/date.go:35.33,37.19 2 0
github.com/muety/wakapi/utils/date.go:40.3,41.10 2 0
github.com/muety/wakapi/utils/date.go:37.19,39.4 1 0
github.com/muety/wakapi/utils/date.go:47.50,53.2 5 0
github.com/muety/wakapi/utils/date.go:56.79,59.36 3 0
github.com/muety/wakapi/utils/date.go:63.2,63.21 1 0
github.com/muety/wakapi/utils/date.go:67.2,67.21 1 0
github.com/muety/wakapi/utils/date.go:71.2,71.13 1 0
github.com/muety/wakapi/utils/date.go:59.36,62.3 2 0
github.com/muety/wakapi/utils/date.go:63.21,66.3 2 0
github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0
github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0
github.com/muety/wakapi/utils/http.go:12.58,14.3 1 0
github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0
github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0
github.com/muety/wakapi/utils/strings.go:18.2,18.19 1 0
github.com/muety/wakapi/utils/strings.go:13.29,14.18 1 0
github.com/muety/wakapi/utils/strings.go:14.18,16.4 1 0
github.com/muety/wakapi/utils/summary.go:10.66,11.40 1 0
github.com/muety/wakapi/utils/summary.go:16.2,16.48 1 0
github.com/muety/wakapi/utils/summary.go:11.40,12.27 1 0
github.com/muety/wakapi/utils/summary.go:12.27,14.4 1 0
github.com/muety/wakapi/utils/summary.go:19.74,21.16 2 0
github.com/muety/wakapi/utils/summary.go:24.2,24.32 1 0
github.com/muety/wakapi/utils/summary.go:21.16,23.3 1 0
github.com/muety/wakapi/utils/summary.go:27.84,30.18 2 0
github.com/muety/wakapi/utils/summary.go:65.2,65.22 1 0
github.com/muety/wakapi/utils/summary.go:31.28,32.24 1 0
github.com/muety/wakapi/utils/summary.go:33.32,35.22 2 0
github.com/muety/wakapi/utils/summary.go:36.31,37.23 1 0
github.com/muety/wakapi/utils/summary.go:38.31,40.21 2 0
github.com/muety/wakapi/utils/summary.go:41.32,42.24 1 0
github.com/muety/wakapi/utils/summary.go:43.32,45.22 2 0
github.com/muety/wakapi/utils/summary.go:46.31,47.23 1 0
github.com/muety/wakapi/utils/summary.go:48.32,49.42 1 0
github.com/muety/wakapi/utils/summary.go:50.41,52.40 2 0
github.com/muety/wakapi/utils/summary.go:53.33,54.43 1 0
github.com/muety/wakapi/utils/summary.go:55.33,56.43 1 0
github.com/muety/wakapi/utils/summary.go:57.35,58.43 1 0
github.com/muety/wakapi/utils/summary.go:59.26,60.21 1 0
github.com/muety/wakapi/utils/summary.go:61.10,62.39 1 0
github.com/muety/wakapi/utils/summary.go:68.73,75.56 5 0
github.com/muety/wakapi/utils/summary.go:89.2,96.8 2 0
github.com/muety/wakapi/utils/summary.go:75.56,77.3 1 0
github.com/muety/wakapi/utils/summary.go:77.8,79.17 2 0
github.com/muety/wakapi/utils/summary.go:83.3,84.17 2 0
github.com/muety/wakapi/utils/summary.go:79.17,81.4 1 0
github.com/muety/wakapi/utils/summary.go:84.17,86.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:20.91,26.2 1 1
github.com/muety/wakapi/middlewares/authenticate.go:28.90,31.2 2 0
github.com/muety/wakapi/middlewares/authenticate.go:33.71,34.71 1 0
github.com/muety/wakapi/middlewares/authenticate.go:34.71,36.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:39.107,43.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:47.2,47.31 1 0
github.com/muety/wakapi/middlewares/authenticate.go:62.2,63.29 2 0
github.com/muety/wakapi/middlewares/authenticate.go:43.16,45.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:47.31,48.31 1 0
github.com/muety/wakapi/middlewares/authenticate.go:53.3,53.44 1 0
github.com/muety/wakapi/middlewares/authenticate.go:59.3,59.9 1 0
github.com/muety/wakapi/middlewares/authenticate.go:48.31,51.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:53.44,55.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:55.9,58.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:66.70,67.39 1 0
github.com/muety/wakapi/middlewares/authenticate.go:72.2,72.14 1 0
github.com/muety/wakapi/middlewares/authenticate.go:67.39,68.60 1 0
github.com/muety/wakapi/middlewares/authenticate.go:68.60,70.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:75.92,77.16 2 1
github.com/muety/wakapi/middlewares/authenticate.go:81.2,84.16 4 1
github.com/muety/wakapi/middlewares/authenticate.go:87.2,87.18 1 1
github.com/muety/wakapi/middlewares/authenticate.go:77.16,79.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:84.16,86.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:90.92,92.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:96.2,97.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:104.2,104.18 1 0
github.com/muety/wakapi/middlewares/authenticate.go:92.16,94.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:97.16,99.3 1 0
github.com/muety/wakapi/middlewares/logging.go:17.79,18.43 1 0 github.com/muety/wakapi/middlewares/logging.go:17.79,18.43 1 0
github.com/muety/wakapi/middlewares/logging.go:18.43,23.3 1 0 github.com/muety/wakapi/middlewares/logging.go:18.43,23.3 1 0
github.com/muety/wakapi/middlewares/logging.go:26.80,44.2 6 0 github.com/muety/wakapi/middlewares/logging.go:26.80,44.2 6 0
@@ -171,70 +269,70 @@ github.com/muety/wakapi/middlewares/logging.go:124.36,126.2 1 0
github.com/muety/wakapi/middlewares/logging.go:127.42,129.2 1 0 github.com/muety/wakapi/middlewares/logging.go:127.42,129.2 1 0
github.com/muety/wakapi/middlewares/logging.go:130.40,132.2 1 0 github.com/muety/wakapi/middlewares/logging.go:130.40,132.2 1 0
github.com/muety/wakapi/middlewares/logging.go:133.52,135.2 1 0 github.com/muety/wakapi/middlewares/logging.go:133.52,135.2 1 0
github.com/muety/wakapi/config/config.go:95.70,97.2 1 0
github.com/muety/wakapi/config/config.go:99.65,101.2 1 0
github.com/muety/wakapi/config/config.go:103.82,113.2 1 0
github.com/muety/wakapi/config/config.go:115.31,117.2 1 0
github.com/muety/wakapi/config/config.go:119.32,121.2 1 0
github.com/muety/wakapi/config/config.go:123.74,124.19 1 0
github.com/muety/wakapi/config/config.go:125.10,126.34 1 0
github.com/muety/wakapi/config/config.go:126.34,135.4 8 0
github.com/muety/wakapi/config/config.go:139.73,140.33 1 0
github.com/muety/wakapi/config/config.go:140.33,148.17 5 0
github.com/muety/wakapi/config/config.go:152.3,153.13 2 0
github.com/muety/wakapi/config/config.go:148.17,150.4 1 0
github.com/muety/wakapi/config/config.go:157.50,158.19 1 0
github.com/muety/wakapi/config/config.go:171.2,171.12 1 0
github.com/muety/wakapi/config/config.go:159.23,163.5 1 0
github.com/muety/wakapi/config/config.go:164.26,167.5 1 0
github.com/muety/wakapi/config/config.go:168.24,169.48 1 0
github.com/muety/wakapi/config/config.go:174.53,184.2 1 1
github.com/muety/wakapi/config/config.go:186.56,188.16 2 1
github.com/muety/wakapi/config/config.go:192.2,199.3 1 1
github.com/muety/wakapi/config/config.go:188.16,190.3 1 0
github.com/muety/wakapi/config/config.go:202.54,204.2 1 1
github.com/muety/wakapi/config/config.go:206.60,208.2 1 0
github.com/muety/wakapi/config/config.go:210.59,212.2 1 0
github.com/muety/wakapi/config/config.go:214.57,216.2 1 0
github.com/muety/wakapi/config/config.go:218.53,220.2 1 0
github.com/muety/wakapi/config/config.go:222.29,224.2 1 1
github.com/muety/wakapi/config/config.go:226.27,228.16 2 0
github.com/muety/wakapi/config/config.go:231.2,234.16 3 0
github.com/muety/wakapi/config/config.go:238.2,238.41 1 0
github.com/muety/wakapi/config/config.go:228.16,230.3 1 0
github.com/muety/wakapi/config/config.go:234.16,236.3 1 0
github.com/muety/wakapi/config/config.go:241.48,253.16 3 0
github.com/muety/wakapi/config/config.go:256.2,258.16 3 0
github.com/muety/wakapi/config/config.go:262.2,262.55 1 0
github.com/muety/wakapi/config/config.go:266.2,266.15 1 0
github.com/muety/wakapi/config/config.go:253.16,255.3 1 0
github.com/muety/wakapi/config/config.go:258.16,260.3 1 0
github.com/muety/wakapi/config/config.go:262.55,264.3 1 0
github.com/muety/wakapi/config/config.go:269.38,270.43 1 0
github.com/muety/wakapi/config/config.go:273.2,273.15 1 0
github.com/muety/wakapi/config/config.go:270.43,272.3 1 0
github.com/muety/wakapi/config/config.go:276.45,277.27 1 0
github.com/muety/wakapi/config/config.go:280.2,280.15 1 0
github.com/muety/wakapi/config/config.go:277.27,279.3 1 0
github.com/muety/wakapi/config/config.go:283.26,285.2 1 0
github.com/muety/wakapi/config/config.go:287.20,289.2 1 0
github.com/muety/wakapi/config/config.go:291.21,296.96 3 0
github.com/muety/wakapi/config/config.go:300.2,308.52 5 0
github.com/muety/wakapi/config/config.go:312.2,312.47 1 0
github.com/muety/wakapi/config/config.go:318.2,318.70 1 0
github.com/muety/wakapi/config/config.go:322.2,322.28 1 0
github.com/muety/wakapi/config/config.go:326.2,327.14 2 0
github.com/muety/wakapi/config/config.go:296.96,298.3 1 0
github.com/muety/wakapi/config/config.go:308.52,310.3 1 0
github.com/muety/wakapi/config/config.go:312.47,313.14 1 0
github.com/muety/wakapi/config/config.go:313.14,315.4 1 0
github.com/muety/wakapi/config/config.go:318.70,320.3 1 0
github.com/muety/wakapi/config/config.go:322.28,324.3 1 0
github.com/muety/wakapi/config/utils.go:5.78,7.22 2 0 github.com/muety/wakapi/config/utils.go:5.78,7.22 2 0
github.com/muety/wakapi/config/utils.go:13.2,13.11 1 0 github.com/muety/wakapi/config/utils.go:13.2,13.11 1 0
github.com/muety/wakapi/config/utils.go:7.22,8.18 1 0 github.com/muety/wakapi/config/utils.go:7.22,8.18 1 0
github.com/muety/wakapi/config/utils.go:11.3,11.12 1 0 github.com/muety/wakapi/config/utils.go:11.3,11.12 1 0
github.com/muety/wakapi/config/utils.go:8.18,10.4 1 0 github.com/muety/wakapi/config/utils.go:8.18,10.4 1 0
github.com/muety/wakapi/config/config.go:88.70,90.2 1 0
github.com/muety/wakapi/config/config.go:92.65,94.2 1 0
github.com/muety/wakapi/config/config.go:96.82,106.2 1 0
github.com/muety/wakapi/config/config.go:108.31,110.2 1 0
github.com/muety/wakapi/config/config.go:112.32,114.2 1 0
github.com/muety/wakapi/config/config.go:116.74,117.19 1 0
github.com/muety/wakapi/config/config.go:118.10,119.34 1 0
github.com/muety/wakapi/config/config.go:119.34,128.4 8 0
github.com/muety/wakapi/config/config.go:132.73,133.33 1 0
github.com/muety/wakapi/config/config.go:133.33,141.17 5 0
github.com/muety/wakapi/config/config.go:145.3,146.13 2 0
github.com/muety/wakapi/config/config.go:141.17,143.4 1 0
github.com/muety/wakapi/config/config.go:150.50,151.19 1 0
github.com/muety/wakapi/config/config.go:164.2,164.12 1 0
github.com/muety/wakapi/config/config.go:152.23,156.5 1 0
github.com/muety/wakapi/config/config.go:157.26,160.5 1 0
github.com/muety/wakapi/config/config.go:161.24,162.48 1 0
github.com/muety/wakapi/config/config.go:167.53,177.2 1 1
github.com/muety/wakapi/config/config.go:179.56,181.16 2 1
github.com/muety/wakapi/config/config.go:185.2,192.3 1 1
github.com/muety/wakapi/config/config.go:181.16,183.3 1 0
github.com/muety/wakapi/config/config.go:195.54,197.2 1 1
github.com/muety/wakapi/config/config.go:199.60,201.2 1 0
github.com/muety/wakapi/config/config.go:203.59,205.2 1 0
github.com/muety/wakapi/config/config.go:207.57,209.2 1 0
github.com/muety/wakapi/config/config.go:211.53,213.2 1 0
github.com/muety/wakapi/config/config.go:215.29,217.2 1 1
github.com/muety/wakapi/config/config.go:219.27,221.16 2 0
github.com/muety/wakapi/config/config.go:224.2,227.16 3 0
github.com/muety/wakapi/config/config.go:231.2,231.41 1 0
github.com/muety/wakapi/config/config.go:221.16,223.3 1 0
github.com/muety/wakapi/config/config.go:227.16,229.3 1 0
github.com/muety/wakapi/config/config.go:234.48,246.16 3 0
github.com/muety/wakapi/config/config.go:249.2,251.16 3 0
github.com/muety/wakapi/config/config.go:255.2,255.55 1 0
github.com/muety/wakapi/config/config.go:259.2,259.15 1 0
github.com/muety/wakapi/config/config.go:246.16,248.3 1 0
github.com/muety/wakapi/config/config.go:251.16,253.3 1 0
github.com/muety/wakapi/config/config.go:255.55,257.3 1 0
github.com/muety/wakapi/config/config.go:262.38,263.43 1 0
github.com/muety/wakapi/config/config.go:266.2,266.15 1 0
github.com/muety/wakapi/config/config.go:263.43,265.3 1 0
github.com/muety/wakapi/config/config.go:269.45,270.27 1 0
github.com/muety/wakapi/config/config.go:273.2,273.15 1 0
github.com/muety/wakapi/config/config.go:270.27,272.3 1 0
github.com/muety/wakapi/config/config.go:276.26,278.2 1 0
github.com/muety/wakapi/config/config.go:280.20,282.2 1 0
github.com/muety/wakapi/config/config.go:284.21,289.96 3 0
github.com/muety/wakapi/config/config.go:293.2,301.52 5 0
github.com/muety/wakapi/config/config.go:305.2,305.47 1 0
github.com/muety/wakapi/config/config.go:311.2,311.70 1 0
github.com/muety/wakapi/config/config.go:315.2,315.28 1 0
github.com/muety/wakapi/config/config.go:319.2,320.14 2 0
github.com/muety/wakapi/config/config.go:289.96,291.3 1 0
github.com/muety/wakapi/config/config.go:301.52,303.3 1 0
github.com/muety/wakapi/config/config.go:305.47,306.14 1 0
github.com/muety/wakapi/config/config.go:306.14,308.4 1 0
github.com/muety/wakapi/config/config.go:311.70,313.3 1 0
github.com/muety/wakapi/config/config.go:315.28,317.3 1 0
github.com/muety/wakapi/services/language_mapping.go:18.118,24.2 1 0 github.com/muety/wakapi/services/language_mapping.go:18.118,24.2 1 0
github.com/muety/wakapi/services/language_mapping.go:26.86,28.2 1 0 github.com/muety/wakapi/services/language_mapping.go:26.86,28.2 1 0
github.com/muety/wakapi/services/language_mapping.go:30.96,31.53 1 0 github.com/muety/wakapi/services/language_mapping.go:30.96,31.53 1 0
@@ -277,6 +375,61 @@ github.com/muety/wakapi/services/misc.go:113.2,116.17 1 0
github.com/muety/wakapi/services/misc.go:101.30,104.3 2 0 github.com/muety/wakapi/services/misc.go:101.30,104.3 2 0
github.com/muety/wakapi/services/misc.go:109.17,111.3 1 0 github.com/muety/wakapi/services/misc.go:109.17,111.3 1 0
github.com/muety/wakapi/services/misc.go:116.17,118.3 1 0 github.com/muety/wakapi/services/misc.go:116.17,118.3 1 0
github.com/muety/wakapi/services/aggregation.go:24.142,31.2 1 0
github.com/muety/wakapi/services/aggregation.go:40.43,42.37 1 0
github.com/muety/wakapi/services/aggregation.go:46.2,48.19 3 0
github.com/muety/wakapi/services/aggregation.go:42.37,44.3 1 0
github.com/muety/wakapi/services/aggregation.go:51.67,55.40 3 0
github.com/muety/wakapi/services/aggregation.go:59.2,59.50 1 0
github.com/muety/wakapi/services/aggregation.go:64.2,64.60 1 0
github.com/muety/wakapi/services/aggregation.go:70.2,70.35 1 0
github.com/muety/wakapi/services/aggregation.go:55.40,57.3 1 0
github.com/muety/wakapi/services/aggregation.go:59.50,61.3 1 0
github.com/muety/wakapi/services/aggregation.go:64.60,68.3 3 0
github.com/muety/wakapi/services/aggregation.go:73.109,74.24 1 0
github.com/muety/wakapi/services/aggregation.go:74.24,75.111 1 0
github.com/muety/wakapi/services/aggregation.go:75.111,77.4 1 0
github.com/muety/wakapi/services/aggregation.go:77.9,80.4 2 0
github.com/muety/wakapi/services/aggregation.go:84.80,85.33 1 0
github.com/muety/wakapi/services/aggregation.go:85.33,86.60 1 0
github.com/muety/wakapi/services/aggregation.go:86.60,88.4 1 0
github.com/muety/wakapi/services/aggregation.go:92.100,96.59 3 0
github.com/muety/wakapi/services/aggregation.go:111.2,112.16 2 0
github.com/muety/wakapi/services/aggregation.go:118.2,119.16 2 0
github.com/muety/wakapi/services/aggregation.go:125.2,126.44 2 0
github.com/muety/wakapi/services/aggregation.go:131.2,131.41 1 0
github.com/muety/wakapi/services/aggregation.go:145.2,145.12 1 0
github.com/muety/wakapi/services/aggregation.go:96.59,99.3 2 0
github.com/muety/wakapi/services/aggregation.go:99.8,99.47 1 0
github.com/muety/wakapi/services/aggregation.go:99.47,101.30 2 0
github.com/muety/wakapi/services/aggregation.go:101.30,102.43 1 0
github.com/muety/wakapi/services/aggregation.go:102.43,104.5 1 0
github.com/muety/wakapi/services/aggregation.go:106.8,108.3 1 0
github.com/muety/wakapi/services/aggregation.go:112.16,115.3 2 0
github.com/muety/wakapi/services/aggregation.go:119.16,122.3 2 0
github.com/muety/wakapi/services/aggregation.go:126.44,128.3 1 0
github.com/muety/wakapi/services/aggregation.go:131.41,132.21 1 0
github.com/muety/wakapi/services/aggregation.go:132.21,136.4 1 0
github.com/muety/wakapi/services/aggregation.go:136.9,136.62 1 0
github.com/muety/wakapi/services/aggregation.go:136.62,140.4 1 0
github.com/muety/wakapi/services/aggregation.go:148.83,163.41 5 0
github.com/muety/wakapi/services/aggregation.go:163.41,173.3 3 0
github.com/muety/wakapi/services/aggregation.go:176.34,179.2 2 0
github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0
github.com/muety/wakapi/services/heartbeat.go:25.72,27.2 1 0
github.com/muety/wakapi/services/heartbeat.go:29.80,31.2 1 0
github.com/muety/wakapi/services/heartbeat.go:33.76,35.2 1 0
github.com/muety/wakapi/services/heartbeat.go:37.111,39.16 2 0
github.com/muety/wakapi/services/heartbeat.go:42.2,42.43 1 0
github.com/muety/wakapi/services/heartbeat.go:39.16,41.3 1 0
github.com/muety/wakapi/services/heartbeat.go:45.116,47.2 1 0
github.com/muety/wakapi/services/heartbeat.go:49.78,51.2 1 0
github.com/muety/wakapi/services/heartbeat.go:53.62,55.2 1 0
github.com/muety/wakapi/services/heartbeat.go:57.116,59.16 2 0
github.com/muety/wakapi/services/heartbeat.go:63.2,63.28 1 0
github.com/muety/wakapi/services/heartbeat.go:67.2,67.24 1 0
github.com/muety/wakapi/services/heartbeat.go:59.16,61.3 1 0
github.com/muety/wakapi/services/heartbeat.go:63.28,65.3 1 0
github.com/muety/wakapi/services/summary.go:27.149,35.2 1 1 github.com/muety/wakapi/services/summary.go:27.149,35.2 1 1
github.com/muety/wakapi/services/summary.go:39.120,42.52 2 1 github.com/muety/wakapi/services/summary.go:39.120,42.52 2 1
github.com/muety/wakapi/services/summary.go:47.2,47.44 1 1 github.com/muety/wakapi/services/summary.go:47.2,47.44 1 1
@@ -366,6 +519,31 @@ github.com/muety/wakapi/services/summary.go:324.54,326.3 1 1
github.com/muety/wakapi/services/summary.go:331.59,333.25 2 1 github.com/muety/wakapi/services/summary.go:331.59,333.25 2 1
github.com/muety/wakapi/services/summary.go:336.2,336.32 1 1 github.com/muety/wakapi/services/summary.go:336.2,336.32 1 1
github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1 github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1
github.com/muety/wakapi/services/user.go:19.73,25.2 1 0
github.com/muety/wakapi/services/user.go:27.74,28.40 1 0
github.com/muety/wakapi/services/user.go:32.2,33.16 2 0
github.com/muety/wakapi/services/user.go:37.2,38.15 2 0
github.com/muety/wakapi/services/user.go:28.40,30.3 1 0
github.com/muety/wakapi/services/user.go:33.16,35.3 1 0
github.com/muety/wakapi/services/user.go:41.72,42.37 1 0
github.com/muety/wakapi/services/user.go:46.2,47.16 2 0
github.com/muety/wakapi/services/user.go:51.2,52.15 2 0
github.com/muety/wakapi/services/user.go:42.37,44.3 1 0
github.com/muety/wakapi/services/user.go:47.16,49.3 1 0
github.com/muety/wakapi/services/user.go:55.58,57.2 1 0
github.com/muety/wakapi/services/user.go:59.88,66.93 2 0
github.com/muety/wakapi/services/user.go:72.2,72.38 1 0
github.com/muety/wakapi/services/user.go:66.93,68.3 1 0
github.com/muety/wakapi/services/user.go:68.8,70.3 1 0
github.com/muety/wakapi/services/user.go:75.73,78.2 2 0
github.com/muety/wakapi/services/user.go:80.78,84.2 3 0
github.com/muety/wakapi/services/user.go:86.99,89.2 2 0
github.com/muety/wakapi/services/user.go:91.106,94.96 3 0
github.com/muety/wakapi/services/user.go:99.2,99.68 1 0
github.com/muety/wakapi/services/user.go:94.96,96.3 1 0
github.com/muety/wakapi/services/user.go:96.8,98.3 1 0
github.com/muety/wakapi/services/user.go:102.57,105.2 2 0
github.com/muety/wakapi/services/user.go:107.38,109.2 1 0
github.com/muety/wakapi/services/alias.go:17.77,22.2 1 1 github.com/muety/wakapi/services/alias.go:17.77,22.2 1 1
github.com/muety/wakapi/services/alias.go:26.60,27.43 1 1 github.com/muety/wakapi/services/alias.go:26.60,27.43 1 1
github.com/muety/wakapi/services/alias.go:30.2,30.14 1 1 github.com/muety/wakapi/services/alias.go:30.2,30.14 1 1
@@ -401,168 +579,10 @@ github.com/muety/wakapi/services/alias.go:95.21,97.4 1 0
github.com/muety/wakapi/services/alias.go:104.31,106.3 1 0 github.com/muety/wakapi/services/alias.go:104.31,106.3 1 0
github.com/muety/wakapi/services/alias.go:111.52,112.51 1 0 github.com/muety/wakapi/services/alias.go:111.52,112.51 1 0
github.com/muety/wakapi/services/alias.go:112.51,114.3 1 0 github.com/muety/wakapi/services/alias.go:112.51,114.3 1 0
github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0
github.com/muety/wakapi/services/heartbeat.go:25.80,27.2 1 0
github.com/muety/wakapi/services/heartbeat.go:29.111,31.16 2 0
github.com/muety/wakapi/services/heartbeat.go:34.2,34.43 1 0
github.com/muety/wakapi/services/heartbeat.go:31.16,33.3 1 0
github.com/muety/wakapi/services/heartbeat.go:37.78,39.2 1 0
github.com/muety/wakapi/services/heartbeat.go:41.62,43.2 1 0
github.com/muety/wakapi/services/heartbeat.go:45.116,47.16 2 0
github.com/muety/wakapi/services/heartbeat.go:51.2,51.28 1 0
github.com/muety/wakapi/services/heartbeat.go:55.2,55.24 1 0
github.com/muety/wakapi/services/heartbeat.go:47.16,49.3 1 0
github.com/muety/wakapi/services/heartbeat.go:51.28,53.3 1 0
github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0 github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0
github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0 github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0
github.com/muety/wakapi/services/key_value.go:25.72,27.2 1 0 github.com/muety/wakapi/services/key_value.go:25.78,27.16 2 0
github.com/muety/wakapi/services/key_value.go:29.60,31.2 1 0 github.com/muety/wakapi/services/key_value.go:33.2,33.11 1 0
github.com/muety/wakapi/services/aggregation.go:24.142,31.2 1 0 github.com/muety/wakapi/services/key_value.go:27.16,32.3 1 0
github.com/muety/wakapi/services/aggregation.go:40.43,42.37 1 0 github.com/muety/wakapi/services/key_value.go:36.72,38.2 1 0
github.com/muety/wakapi/services/aggregation.go:46.2,48.19 3 0 github.com/muety/wakapi/services/key_value.go:40.60,42.2 1 0
github.com/muety/wakapi/services/aggregation.go:42.37,44.3 1 0
github.com/muety/wakapi/services/aggregation.go:51.67,55.40 3 0
github.com/muety/wakapi/services/aggregation.go:59.2,59.50 1 0
github.com/muety/wakapi/services/aggregation.go:64.2,64.60 1 0
github.com/muety/wakapi/services/aggregation.go:70.2,70.35 1 0
github.com/muety/wakapi/services/aggregation.go:55.40,57.3 1 0
github.com/muety/wakapi/services/aggregation.go:59.50,61.3 1 0
github.com/muety/wakapi/services/aggregation.go:64.60,68.3 3 0
github.com/muety/wakapi/services/aggregation.go:73.109,74.24 1 0
github.com/muety/wakapi/services/aggregation.go:74.24,75.111 1 0
github.com/muety/wakapi/services/aggregation.go:75.111,77.4 1 0
github.com/muety/wakapi/services/aggregation.go:77.9,80.4 2 0
github.com/muety/wakapi/services/aggregation.go:84.80,85.33 1 0
github.com/muety/wakapi/services/aggregation.go:85.33,86.60 1 0
github.com/muety/wakapi/services/aggregation.go:86.60,88.4 1 0
github.com/muety/wakapi/services/aggregation.go:92.100,96.59 3 0
github.com/muety/wakapi/services/aggregation.go:111.2,112.16 2 0
github.com/muety/wakapi/services/aggregation.go:118.2,119.16 2 0
github.com/muety/wakapi/services/aggregation.go:125.2,126.44 2 0
github.com/muety/wakapi/services/aggregation.go:131.2,131.41 1 0
github.com/muety/wakapi/services/aggregation.go:145.2,145.12 1 0
github.com/muety/wakapi/services/aggregation.go:96.59,99.3 2 0
github.com/muety/wakapi/services/aggregation.go:99.8,99.47 1 0
github.com/muety/wakapi/services/aggregation.go:99.47,101.30 2 0
github.com/muety/wakapi/services/aggregation.go:101.30,102.43 1 0
github.com/muety/wakapi/services/aggregation.go:102.43,104.5 1 0
github.com/muety/wakapi/services/aggregation.go:106.8,108.3 1 0
github.com/muety/wakapi/services/aggregation.go:112.16,115.3 2 0
github.com/muety/wakapi/services/aggregation.go:119.16,122.3 2 0
github.com/muety/wakapi/services/aggregation.go:126.44,128.3 1 0
github.com/muety/wakapi/services/aggregation.go:131.41,132.21 1 0
github.com/muety/wakapi/services/aggregation.go:132.21,136.4 1 0
github.com/muety/wakapi/services/aggregation.go:136.9,136.62 1 0
github.com/muety/wakapi/services/aggregation.go:136.62,140.4 1 0
github.com/muety/wakapi/services/aggregation.go:148.83,163.41 5 0
github.com/muety/wakapi/services/aggregation.go:163.41,173.3 3 0
github.com/muety/wakapi/services/aggregation.go:176.34,179.2 2 0
github.com/muety/wakapi/services/user.go:19.73,25.2 1 0
github.com/muety/wakapi/services/user.go:27.74,28.40 1 0
github.com/muety/wakapi/services/user.go:32.2,33.16 2 0
github.com/muety/wakapi/services/user.go:37.2,38.15 2 0
github.com/muety/wakapi/services/user.go:28.40,30.3 1 0
github.com/muety/wakapi/services/user.go:33.16,35.3 1 0
github.com/muety/wakapi/services/user.go:41.72,42.37 1 0
github.com/muety/wakapi/services/user.go:46.2,47.16 2 0
github.com/muety/wakapi/services/user.go:51.2,52.15 2 0
github.com/muety/wakapi/services/user.go:42.37,44.3 1 0
github.com/muety/wakapi/services/user.go:47.16,49.3 1 0
github.com/muety/wakapi/services/user.go:55.58,57.2 1 0
github.com/muety/wakapi/services/user.go:59.88,66.93 2 0
github.com/muety/wakapi/services/user.go:72.2,72.38 1 0
github.com/muety/wakapi/services/user.go:66.93,68.3 1 0
github.com/muety/wakapi/services/user.go:68.8,70.3 1 0
github.com/muety/wakapi/services/user.go:75.73,78.2 2 0
github.com/muety/wakapi/services/user.go:80.78,84.2 3 0
github.com/muety/wakapi/services/user.go:86.79,89.2 2 0
github.com/muety/wakapi/services/user.go:91.99,94.2 2 0
github.com/muety/wakapi/services/user.go:96.106,99.96 3 0
github.com/muety/wakapi/services/user.go:104.2,104.68 1 0
github.com/muety/wakapi/services/user.go:99.96,101.3 1 0
github.com/muety/wakapi/services/user.go:101.8,103.3 1 0
github.com/muety/wakapi/services/user.go:107.57,110.2 2 0
github.com/muety/wakapi/utils/auth.go:16.79,18.54 2 0
github.com/muety/wakapi/utils/auth.go:22.2,24.16 3 0
github.com/muety/wakapi/utils/auth.go:28.2,30.45 3 0
github.com/muety/wakapi/utils/auth.go:33.2,34.32 2 0
github.com/muety/wakapi/utils/auth.go:18.54,20.3 1 0
github.com/muety/wakapi/utils/auth.go:24.16,26.3 1 0
github.com/muety/wakapi/utils/auth.go:30.45,32.3 1 0
github.com/muety/wakapi/utils/auth.go:37.65,39.54 2 0
github.com/muety/wakapi/utils/auth.go:43.2,44.30 2 0
github.com/muety/wakapi/utils/auth.go:39.54,41.3 1 0
github.com/muety/wakapi/utils/auth.go:47.94,49.16 2 0
github.com/muety/wakapi/utils/auth.go:53.2,53.107 1 0
github.com/muety/wakapi/utils/auth.go:57.2,57.22 1 0
github.com/muety/wakapi/utils/auth.go:49.16,51.3 1 0
github.com/muety/wakapi/utils/auth.go:53.107,55.3 1 0
github.com/muety/wakapi/utils/auth.go:60.56,64.2 3 0
github.com/muety/wakapi/utils/auth.go:66.55,69.16 3 0
github.com/muety/wakapi/utils/auth.go:72.2,72.16 1 0
github.com/muety/wakapi/utils/auth.go:69.16,71.3 1 0
github.com/muety/wakapi/utils/color.go:8.90,10.32 2 0
github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0
github.com/muety/wakapi/utils/color.go:10.32,11.50 1 0
github.com/muety/wakapi/utils/color.go:11.50,13.4 1 0
github.com/muety/wakapi/utils/common.go:9.48,11.2 1 0
github.com/muety/wakapi/utils/common.go:13.40,15.2 1 0
github.com/muety/wakapi/utils/common.go:17.45,19.2 1 0
github.com/muety/wakapi/utils/common.go:21.24,23.2 1 0
github.com/muety/wakapi/utils/common.go:25.56,28.45 3 1
github.com/muety/wakapi/utils/common.go:31.2,31.40 1 1
github.com/muety/wakapi/utils/common.go:28.45,30.3 1 1
github.com/muety/wakapi/utils/date.go:8.31,10.2 1 0
github.com/muety/wakapi/utils/date.go:12.43,14.2 1 0
github.com/muety/wakapi/utils/date.go:16.30,20.2 3 0
github.com/muety/wakapi/utils/date.go:22.31,25.2 2 0
github.com/muety/wakapi/utils/date.go:27.30,30.2 2 0
github.com/muety/wakapi/utils/date.go:32.67,35.33 2 0
github.com/muety/wakapi/utils/date.go:44.2,44.18 1 0
github.com/muety/wakapi/utils/date.go:35.33,37.19 2 0
github.com/muety/wakapi/utils/date.go:40.3,41.10 2 0
github.com/muety/wakapi/utils/date.go:37.19,39.4 1 0
github.com/muety/wakapi/utils/date.go:47.50,53.2 5 0
github.com/muety/wakapi/utils/date.go:56.79,59.36 3 0
github.com/muety/wakapi/utils/date.go:63.2,63.21 1 0
github.com/muety/wakapi/utils/date.go:67.2,67.21 1 0
github.com/muety/wakapi/utils/date.go:71.2,71.13 1 0
github.com/muety/wakapi/utils/date.go:59.36,62.3 2 0
github.com/muety/wakapi/utils/date.go:63.21,66.3 2 0
github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0
github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0
github.com/muety/wakapi/utils/http.go:12.58,14.3 1 0
github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0
github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0
github.com/muety/wakapi/utils/strings.go:18.2,18.19 1 0
github.com/muety/wakapi/utils/strings.go:13.29,14.18 1 0
github.com/muety/wakapi/utils/strings.go:14.18,16.4 1 0
github.com/muety/wakapi/utils/summary.go:10.71,13.18 2 0
github.com/muety/wakapi/utils/summary.go:49.2,49.22 1 0
github.com/muety/wakapi/utils/summary.go:14.58,15.24 1 0
github.com/muety/wakapi/utils/summary.go:16.66,18.22 2 0
github.com/muety/wakapi/utils/summary.go:19.64,20.23 1 0
github.com/muety/wakapi/utils/summary.go:21.39,23.21 2 0
github.com/muety/wakapi/utils/summary.go:24.66,25.24 1 0
github.com/muety/wakapi/utils/summary.go:26.40,28.22 2 0
github.com/muety/wakapi/utils/summary.go:29.31,30.23 1 0
github.com/muety/wakapi/utils/summary.go:31.66,32.42 1 0
github.com/muety/wakapi/utils/summary.go:33.49,35.40 2 0
github.com/muety/wakapi/utils/summary.go:36.41,37.43 1 0
github.com/muety/wakapi/utils/summary.go:38.68,40.42 2 0
github.com/muety/wakapi/utils/summary.go:41.35,42.43 1 0
github.com/muety/wakapi/utils/summary.go:43.26,44.21 1 0
github.com/muety/wakapi/utils/summary.go:45.10,46.39 1 0
github.com/muety/wakapi/utils/summary.go:52.73,59.56 5 0
github.com/muety/wakapi/utils/summary.go:73.2,80.8 2 0
github.com/muety/wakapi/utils/summary.go:59.56,61.3 1 0
github.com/muety/wakapi/utils/summary.go:61.8,63.17 2 0
github.com/muety/wakapi/utils/summary.go:67.3,68.17 2 0
github.com/muety/wakapi/utils/summary.go:63.17,65.4 1 0
github.com/muety/wakapi/utils/summary.go:68.17,70.4 1 0
github.com/muety/wakapi/utils/template.go:8.41,10.16 2 0
github.com/muety/wakapi/utils/template.go:13.2,13.23 1 0
github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0
github.com/muety/wakapi/utils/template.go:16.37,17.30 1 0
github.com/muety/wakapi/utils/template.go:20.2,20.10 1 0
github.com/muety/wakapi/utils/template.go:17.30,19.3 1 0

1
go.mod
View File

@@ -21,6 +21,7 @@ require (
github.com/stretchr/testify v1.6.1 github.com/stretchr/testify v1.6.1
go.uber.org/atomic v1.6.0 go.uber.org/atomic v1.6.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect gopkg.in/yaml.v2 v2.2.8 // indirect
gorm.io/driver/mysql v1.0.3 gorm.io/driver/mysql v1.0.3

24
main.go
View File

@@ -126,17 +126,18 @@ func main() {
// API Handlers // API Handlers
healthApiHandler := api.NewHealthApiHandler(db) healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(heartbeatService, languageMappingService) heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(summaryService) summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
// Compat Handlers // Compat Handlers
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService) wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService) wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService)
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService) shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers // MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService) summaryHandler := routes.NewSummaryHandler(summaryService, userService)
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aliasService, aggregationService, languageMappingService) settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService)
homeHandler := routes.NewHomeHandler(keyValueService) homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService) loginHandler := routes.NewLoginHandler(userService)
imprintHandler := routes.NewImprintHandler(keyValueService) imprintHandler := routes.NewImprintHandler(keyValueService)
@@ -145,17 +146,15 @@ func main() {
router := mux.NewRouter() router := mux.NewRouter()
rootRouter := router.PathPrefix("/").Subrouter() rootRouter := router.PathPrefix("/").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter() apiRouter := router.PathPrefix("/api").Subrouter()
compatApiRouter := apiRouter.PathPrefix("/compat").Subrouter()
// Globally used middlewares // Globally used middlewares
recoveryMiddleware := handlers.RecoveryHandler() recoveryMiddleware := handlers.RecoveryHandler()
loggingMiddleware := middlewares.NewLoggingMiddleware(log.New(os.Stdout, "", log.LstdFlags)) loggingMiddleware := middlewares.NewLoggingMiddleware(log.New(os.Stdout, "", log.LstdFlags))
corsMiddleware := handlers.CORS() corsMiddleware := handlers.CORS()
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(userService, []string{"/api/health", "/api/compat/shields/v1"}).Handler
// Router configs // Router configs
router.Use(loggingMiddleware, recoveryMiddleware) router.Use(loggingMiddleware, recoveryMiddleware)
apiRouter.Use(corsMiddleware, authenticateMiddleware) apiRouter.Use(corsMiddleware)
// Route registrations // Route registrations
homeHandler.RegisterRoutes(rootRouter) homeHandler.RegisterRoutes(rootRouter)
@@ -168,11 +167,10 @@ func main() {
summaryApiHandler.RegisterRoutes(apiRouter) summaryApiHandler.RegisterRoutes(apiRouter)
healthApiHandler.RegisterRoutes(apiRouter) healthApiHandler.RegisterRoutes(apiRouter)
heartbeatApiHandler.RegisterRoutes(apiRouter) heartbeatApiHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
// Compat route registrations wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.RegisterRoutes(compatApiRouter) wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(compatApiRouter) shieldV1BadgeHandler.RegisterRoutes(apiRouter)
shieldV1BadgeHandler.RegisterRoutes(compatApiRouter)
// Static Routes // Static Routes
router.PathPrefix("/assets").Handler(http.FileServer(pkger.Dir("/static"))) router.PathPrefix("/assets").Handler(http.FileServer(pkger.Dir("/static")))

View File

@@ -12,19 +12,24 @@ import (
) )
type AuthenticateMiddleware struct { type AuthenticateMiddleware struct {
config *conf.Config config *conf.Config
userSrvc services.IUserService userSrvc services.IUserService
whitelistPaths []string optionalForPaths []string
} }
func NewAuthenticateMiddleware(userService services.IUserService, whitelistPaths []string) *AuthenticateMiddleware { func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
return &AuthenticateMiddleware{ return &AuthenticateMiddleware{
config: conf.Get(), config: conf.Get(),
userSrvc: userService, userSrvc: userService,
whitelistPaths: whitelistPaths, optionalForPaths: []string{},
} }
} }
func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) *AuthenticateMiddleware {
m.optionalForPaths = paths
return m
}
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler { func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.ServeHTTP(w, r, h.ServeHTTP) m.ServeHTTP(w, r, h.ServeHTTP)
@@ -32,13 +37,6 @@ func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
} }
func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
for _, p := range m.whitelistPaths {
if strings.HasPrefix(r.URL.Path, p) || r.URL.Path == p {
next(w, r)
return
}
}
var user *models.User var user *models.User
user, err := m.tryGetUserByCookie(r) user, err := m.tryGetUserByCookie(r)
@@ -46,7 +44,12 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
user, err = m.tryGetUserByApiKey(r) user, err = m.tryGetUserByApiKey(r)
} }
if err != nil { if err != nil || user == nil {
if m.isOptional(r.URL.Path) {
next(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/api") { if strings.HasPrefix(r.URL.Path, "/api") {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
} else { } else {
@@ -60,6 +63,15 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
next(w, r.WithContext(ctx)) next(w, r.WithContext(ctx))
} }
func (m *AuthenticateMiddleware) isOptional(requestPath string) bool {
for _, p := range m.optionalForPaths {
if strings.HasPrefix(requestPath, p) || requestPath == p {
return true
}
}
return false
}
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) { func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) {
key, err := utils.ExtractBearerAuth(r) key, err := utils.ExtractBearerAuth(r)
if err != nil { if err != nil {

View File

@@ -24,7 +24,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
userServiceMock := new(mocks.UserServiceMock) userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil) userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock, []string{}) sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKey(mockRequest) result, err := sut.tryGetUserByApiKey(mockRequest)
@@ -45,7 +45,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
userServiceMock := new(mocks.UserServiceMock) userServiceMock := new(mocks.UserServiceMock)
sut := NewAuthenticateMiddleware(userServiceMock, []string{}) sut := NewAuthenticateMiddleware(userServiceMock)
result, err := sut.tryGetUserByApiKey(mockRequest) result, err := sut.tryGetUserByApiKey(mockRequest)

View File

@@ -63,7 +63,7 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
go m.send( go m.send(
http.MethodPost, http.MethodPost,
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsEndpoint, config.WakatimeApiUrl+config.WakatimeApiHeartbeatsBulkUrl,
bytes.NewReader(body), bytes.NewReader(body),
headers, headers,
) )

View File

@@ -0,0 +1,51 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
f := migrationFunc{
name: "20210206_drop_badges_column_add_sharing_flags",
f: func(db *gorm.DB, cfg *config.Config) error {
migrator := db.Migrator()
if !migrator.HasColumn(&models.User{}, "badges_enabled") {
// empty database, nothing to migrate
return nil
}
if err := db.Exec("UPDATE users SET share_data_max_days = 30 WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_editors = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_languages = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_projects = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_oss = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := db.Exec("UPDATE users SET share_machines = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
return err
}
if err := migrator.DropColumn(&models.User{}, "badges_enabled"); err != nil {
return err
} else {
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
}
return nil
},
}
registerPostMigration(f)
}

View File

@@ -10,11 +10,21 @@ type HeartbeatServiceMock struct {
mock.Mock mock.Mock
} }
func (m *HeartbeatServiceMock) Insert(heartbeat *models.Heartbeat) error {
args := m.Called(heartbeat)
return args.Error(0)
}
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error { func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
args := m.Called(heartbeats) args := m.Called(heartbeats)
return args.Error(0) return args.Error(0)
} }
func (m *HeartbeatServiceMock) CountByUser(user *models.User) (int64, error) {
args := m.Called(user)
return args.Get(0).(int64), args.Error(0)
}
func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) { func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
args := m.Called(time, time2, user) args := m.Called(time, time2, user)
return args.Get(0).([]*models.Heartbeat), args.Error(1) return args.Get(0).([]*models.Heartbeat), args.Error(1)
@@ -25,6 +35,11 @@ func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
return args.Get(0).([]*models.TimeByUser), args.Error(1) return args.Get(0).([]*models.TimeByUser), args.Error(1)
} }
func (m *HeartbeatServiceMock) GetLatestByOriginAndUser(s string, user *models.User) (*models.Heartbeat, error) {
args := m.Called(s, user)
return args.Get(0).(*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error { func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
args := m.Called(time) args := m.Called(time)
return args.Error(0) return args.Error(0)

View File

@@ -58,3 +58,7 @@ func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Lo
args := m.Called(user, login) args := m.Called(user, login)
return args.Get(0).(*models.User), args.Error(1) return args.Get(0).(*models.User), args.Error(1)
} }
func (m *UserServiceMock) FlushCache() {
m.Called()
}

View File

@@ -9,10 +9,10 @@ import (
// https://wakatime.com/developers#all_time_since_today // https://wakatime.com/developers#all_time_since_today
type AllTimeViewModel struct { type AllTimeViewModel struct {
Data *allTimeData `json:"data"` Data *AllTimeData `json:"data"`
} }
type allTimeData struct { type AllTimeData struct {
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
Text string `json:"text"` // total time logged since account created as human readable string> Text string `json:"text"` // total time logged since account created as human readable string>
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon> IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
@@ -27,7 +27,7 @@ func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeVi
} }
return &AllTimeViewModel{ return &AllTimeViewModel{
Data: &allTimeData{ Data: &AllTimeData{
TotalSeconds: float32(total.Seconds()), TotalSeconds: float32(total.Seconds()),
Text: utils.FmtWakatimeDuration(total), Text: utils.FmtWakatimeDuration(total),
IsUpToDate: true, IsUpToDate: true,

View File

@@ -0,0 +1,25 @@
package v1
import "github.com/muety/wakapi/models"
type HeartbeatsViewModel struct {
Data []*HeartbeatEntry `json:"data"`
}
// Incomplete, for now, only the subset of fields is implemented
// that is actually required for the import
type HeartbeatEntry struct {
Id string `json:"id"`
Branch string `json:"branch"`
Category string `json:"category"`
Entity string `json:"entity"`
IsWrite bool `json:"is_write"`
Language string `json:"language"`
Project string `json:"project"`
Time models.CustomTime `json:"time"`
Type string `json:"type"`
UserId string `json:"user_id"`
MachineNameId string `json:"machine_name_id"`
UserAgentId string `json:"user_agent_id"`
}

View File

@@ -0,0 +1,78 @@
package v1
import (
"github.com/muety/wakapi/models"
"time"
)
// https://wakatime.com/api/v1/users/current/stats/last_7_days
// https://pastr.de/p/f2fxg6ragj7z5e7fhsow9rb6
type StatsViewModel struct {
Data *StatsData `json:"data"`
}
type StatsData struct {
Username string `json:"username"`
UserId string `json:"user_id"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
TotalSeconds float64 `json:"total_seconds"`
DailyAverage float64 `json:"daily_average"`
DaysIncludingHolidays int `json:"days_including_holidays"`
Editors []*SummariesEntry `json:"editors"`
Languages []*SummariesEntry `json:"languages"`
Machines []*SummariesEntry `json:"machines"`
Projects []*SummariesEntry `json:"projects"`
OperatingSystems []*SummariesEntry `json:"operating_systems"`
}
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
totalTime := summary.TotalTime()
numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24)
data := &StatsData{
Username: summary.UserID,
UserId: summary.UserID,
Start: summary.FromTime.T(),
End: summary.ToTime.T(),
TotalSeconds: totalTime.Seconds(),
DailyAverage: totalTime.Seconds() / float64(numDays),
DaysIncludingHolidays: numDays,
}
editors := make([]*SummariesEntry, len(summary.Editors))
for i, e := range summary.Editors {
editors[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryEditor))
}
languages := make([]*SummariesEntry, len(summary.Languages))
for i, e := range summary.Languages {
languages[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryLanguage))
}
machines := make([]*SummariesEntry, len(summary.Machines))
for i, e := range summary.Machines {
machines[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryMachine))
}
projects := make([]*SummariesEntry, len(summary.Projects))
for i, e := range summary.Projects {
projects[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryProject))
}
oss := make([]*SummariesEntry, len(summary.OperatingSystems))
for i, e := range summary.OperatingSystems {
oss[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryOS))
}
data.Editors = editors
data.Languages = languages
data.Machines = machines
data.Projects = projects
data.OperatingSystems = oss
return &StatsViewModel{
Data: data,
}
}

View File

@@ -13,24 +13,24 @@ import (
// https://pastr.de/v/736450 // https://pastr.de/v/736450
type SummariesViewModel struct { type SummariesViewModel struct {
Data []*summariesData `json:"data"` Data []*SummariesData `json:"data"`
End time.Time `json:"end"` End time.Time `json:"end"`
Start time.Time `json:"start"` Start time.Time `json:"start"`
} }
type summariesData struct { type SummariesData struct {
Categories []*summariesEntry `json:"categories"` Categories []*SummariesEntry `json:"categories"`
Dependencies []*summariesEntry `json:"dependencies"` Dependencies []*SummariesEntry `json:"dependencies"`
Editors []*summariesEntry `json:"editors"` Editors []*SummariesEntry `json:"editors"`
Languages []*summariesEntry `json:"languages"` Languages []*SummariesEntry `json:"languages"`
Machines []*summariesEntry `json:"machines"` Machines []*SummariesEntry `json:"machines"`
OperatingSystems []*summariesEntry `json:"operating_systems"` OperatingSystems []*SummariesEntry `json:"operating_systems"`
Projects []*summariesEntry `json:"projects"` Projects []*SummariesEntry `json:"projects"`
GrandTotal *summariesGrandTotal `json:"grand_total"` GrandTotal *SummariesGrandTotal `json:"grand_total"`
Range *summariesRange `json:"range"` Range *SummariesRange `json:"range"`
} }
type summariesEntry struct { type SummariesEntry struct {
Digital string `json:"digital"` Digital string `json:"digital"`
Hours int `json:"hours"` Hours int `json:"hours"`
Minutes int `json:"minutes"` Minutes int `json:"minutes"`
@@ -41,7 +41,7 @@ type summariesEntry struct {
TotalSeconds float64 `json:"total_seconds"` TotalSeconds float64 `json:"total_seconds"`
} }
type summariesGrandTotal struct { type SummariesGrandTotal struct {
Digital string `json:"digital"` Digital string `json:"digital"`
Hours int `json:"hours"` Hours int `json:"hours"`
Minutes int `json:"minutes"` Minutes int `json:"minutes"`
@@ -49,7 +49,7 @@ type summariesGrandTotal struct {
TotalSeconds float64 `json:"total_seconds"` TotalSeconds float64 `json:"total_seconds"`
} }
type summariesRange struct { type SummariesRange struct {
Date string `json:"date"` Date string `json:"date"`
End time.Time `json:"end"` End time.Time `json:"end"`
Start time.Time `json:"start"` Start time.Time `json:"start"`
@@ -58,7 +58,7 @@ type summariesRange struct {
} }
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel { func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
data := make([]*summariesData, len(summaries)) data := make([]*SummariesData, len(summaries))
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{} minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
for i, s := range summaries { for i, s := range summaries {
@@ -79,27 +79,27 @@ func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *Sum
} }
} }
func newDataFrom(s *models.Summary) *summariesData { func newDataFrom(s *models.Summary) *SummariesData {
zone, _ := time.Now().Zone() zone, _ := time.Now().Zone()
total := s.TotalTime() total := s.TotalTime()
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes()) totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
data := &summariesData{ data := &SummariesData{
Categories: make([]*summariesEntry, 0), Categories: make([]*SummariesEntry, 0),
Dependencies: make([]*summariesEntry, 0), Dependencies: make([]*SummariesEntry, 0),
Editors: make([]*summariesEntry, len(s.Editors)), Editors: make([]*SummariesEntry, len(s.Editors)),
Languages: make([]*summariesEntry, len(s.Languages)), Languages: make([]*SummariesEntry, len(s.Languages)),
Machines: make([]*summariesEntry, len(s.Machines)), Machines: make([]*SummariesEntry, len(s.Machines)),
OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)), OperatingSystems: make([]*SummariesEntry, len(s.OperatingSystems)),
Projects: make([]*summariesEntry, len(s.Projects)), Projects: make([]*SummariesEntry, len(s.Projects)),
GrandTotal: &summariesGrandTotal{ GrandTotal: &SummariesGrandTotal{
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins), Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
Hours: totalHrs, Hours: totalHrs,
Minutes: totalMins, Minutes: totalMins,
Text: utils.FmtWakatimeDuration(total), Text: utils.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(), TotalSeconds: total.Seconds(),
}, },
Range: &summariesRange{ Range: &SummariesRange{
Date: time.Now().Format(time.RFC3339), Date: time.Now().Format(time.RFC3339),
End: s.ToTime.T(), End: s.ToTime.T(),
Start: s.FromTime.T(), Start: s.FromTime.T(),
@@ -111,21 +111,21 @@ func newDataFrom(s *models.Summary) *summariesData {
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(5) wg.Add(5)
go func(data *summariesData) { go func(data *SummariesData) {
defer wg.Done() defer wg.Done()
for i, e := range s.Projects { for i, e := range s.Projects {
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject)) data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
} }
}(data) }(data)
go func(data *summariesData) { go func(data *SummariesData) {
defer wg.Done() defer wg.Done()
for i, e := range s.Editors { for i, e := range s.Editors {
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor)) data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
} }
}(data) }(data)
go func(data *summariesData) { go func(data *SummariesData) {
defer wg.Done() defer wg.Done()
for i, e := range s.Languages { for i, e := range s.Languages {
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage)) data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
@@ -133,14 +133,14 @@ func newDataFrom(s *models.Summary) *summariesData {
} }
}(data) }(data)
go func(data *summariesData) { go func(data *SummariesData) {
defer wg.Done() defer wg.Done()
for i, e := range s.OperatingSystems { for i, e := range s.OperatingSystems {
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS)) data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
} }
}(data) }(data)
go func(data *summariesData) { go func(data *SummariesData) {
defer wg.Done() defer wg.Done()
for i, e := range s.Machines { for i, e := range s.Machines {
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine)) data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
@@ -151,7 +151,7 @@ func newDataFrom(s *models.Summary) *summariesData {
return data return data
} }
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry { func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *SummariesEntry {
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds // this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
// TODO: fix some day, while migrating persisted summary items // TODO: fix some day, while migrating persisted summary items
total := e.Total * time.Second total := e.Total * time.Second
@@ -163,7 +163,7 @@ func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEn
percentage = 0 percentage = 0
} }
return &summariesEntry{ return &SummariesEntry{
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs), Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
Hours: hrs, Hours: hrs,
Minutes: mins, Minutes: mins,

View File

@@ -0,0 +1,12 @@
package v1
type UserAgentsViewModel struct {
Data []*UserAgentEntry `json:"data"`
}
type UserAgentEntry struct {
Id string `json:"id"`
Editor string `json:"editor"`
Os string `json:"os"`
Value string `json:"value"`
}

View File

@@ -19,11 +19,13 @@ type Heartbeat struct {
Branch string `json:"branch"` Branch string `json:"branch"`
Language string `json:"language" gorm:"index:idx_language"` Language string `json:"language" gorm:"index:idx_language"`
IsWrite bool `json:"is_write"` IsWrite bool `json:"is_write"`
Editor string `json:"editor"` Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
OperatingSystem string `json:"operating_system"` OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine"` Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"` Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"` Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore"`
OriginId string `json:"-" hash:"ignore"`
languageRegex *regexp.Regexp `hash:"ignore"` languageRegex *regexp.Regexp `hash:"ignore"`
} }

12
models/interval.go Normal file
View File

@@ -0,0 +1,12 @@
package models
type IntervalKey []string
func (k *IntervalKey) HasAlias(s string) bool {
for _, e := range *k {
if e == s {
return true
}
}
return false
}

View File

@@ -14,34 +14,38 @@ const (
SummaryMachine uint8 = 4 SummaryMachine uint8 = 4
) )
const ( // Support Wakapi and WakaTime range / interval identifiers
IntervalToday string = "today" // See https://wakatime.com/developers/#summaries
IntervalYesterday string = "day" var (
IntervalThisWeek string = "week" IntervalToday = &IntervalKey{"today", "Today"}
IntervalThisMonth string = "month" IntervalYesterday = &IntervalKey{"day", "yesterday", "Yesterday"}
IntervalThisYear string = "year" IntervalThisWeek = &IntervalKey{"week", "This Week"}
IntervalPast7Days string = "7_days" IntervalLastWeek = &IntervalKey{"Last Week"}
IntervalPast30Days string = "30_days" IntervalThisMonth = &IntervalKey{"month", "This Month"}
IntervalPast12Months string = "12_months" IntervalLastMonth = &IntervalKey{"Last Month"}
IntervalAny string = "any" IntervalThisYear = &IntervalKey{"year"}
IntervalPast7Days = &IntervalKey{"7_days", "last_7_days", "Last 7 Days"}
// https://wakatime.com/developers/#summaries IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
IntervalWakatimeToday string = "Today" IntervalPast14Days = &IntervalKey{"Last 14 Days"}
IntervalWakatimeYesterday string = "Yesterday" IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
IntervalWakatimeLast7Days string = "Last 7 Days" IntervalPast12Months = &IntervalKey{"12_months", "last_12_months"}
IntervalWakatimeLast7DaysYesterday string = "Last 7 Days from Yesterday" IntervalAny = &IntervalKey{"any"}
IntervalWakatimeLast14Days string = "Last 14 Days"
IntervalWakatimeLast30Days string = "Last 30 Days"
IntervalWakatimeThisWeek string = "This Week"
IntervalWakatimeLastWeek string = "Last Week"
IntervalWakatimeThisMonth string = "This Month"
IntervalWakatimeLastMonth string = "Last Month"
) )
func Intervals() []string { var AllIntervals = []*IntervalKey{
return []string{ IntervalToday,
IntervalToday, IntervalYesterday, IntervalThisWeek, IntervalThisMonth, IntervalThisYear, IntervalPast7Days, IntervalPast30Days, IntervalPast12Months, IntervalAny, IntervalYesterday,
} IntervalThisWeek,
IntervalLastWeek,
IntervalThisMonth,
IntervalLastMonth,
IntervalThisYear,
IntervalPast7Days,
IntervalPast7DaysYesterday,
IntervalPast14Days,
IntervalPast30Days,
IntervalPast12Months,
IntervalAny,
} }
const UnknownSummaryKey = "unknown" const UnknownSummaryKey = "unknown"

View File

@@ -1,13 +1,18 @@
package models package models
type User struct { type User struct {
ID string `json:"id" gorm:"primary_key"` ID string `json:"id" gorm:"primary_key"`
ApiKey string `json:"api_key" gorm:"unique"` ApiKey string `json:"api_key" gorm:"unique"`
Password string `json:"-"` Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
BadgesEnabled bool `json:"-" gorm:"default:false; type:bool"` ShareDataMaxDays int `json:"-" gorm:"default:0"`
WakatimeApiKey string `json:"-"` ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"`
} }
type Login struct { type Login struct {

View File

@@ -26,6 +26,32 @@ func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error
return nil return nil
} }
func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Where(&models.Heartbeat{UserID: user.ID}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
var heartbeat models.Heartbeat
if err := r.db.
Model(&models.Heartbeat{}).
Where(&models.Heartbeat{
UserID: user.ID,
Origin: origin,
}).
Order("time desc").
First(&heartbeat).Error; err != nil {
return nil, err
}
return &heartbeat, nil
}
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) { func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat var heartbeats []*models.Heartbeat
if err := r.db. if err := r.db.

View File

@@ -17,8 +17,10 @@ type IAliasRepository interface {
type IHeartbeatRepository interface { type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error InsertBatch([]*models.Heartbeat) error
CountByUser(*models.User) (int64, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error) GetFirstByUsers() ([]*models.TimeByUser, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
DeleteBefore(time.Time) error DeleteBefore(time.Time) error
} }

View File

@@ -54,7 +54,20 @@ func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, err
} }
func (r *UserRepository) Update(user *models.User) (*models.User, error) { func (r *UserRepository) Update(user *models.User) (*models.User, error) {
result := r.db.Model(user).Updates(user) updateMap := map[string]interface{}{
"api_key": user.ApiKey,
"password": user.Password,
"last_logged_in_at": user.LastLoggedInAt,
"share_data_max_days": user.ShareDataMaxDays,
"share_editors": user.ShareEditors,
"share_languages": user.ShareLanguages,
"share_oss": user.ShareOSs,
"share_projects": user.ShareProjects,
"share_machines": user.ShareMachines,
"wakatime_api_key": user.WakatimeApiKey,
}
result := r.db.Model(user).Updates(updateMap)
if err := result.Error; err != nil { if err := result.Error; err != nil {
return nil, err return nil, err
} }

View File

@@ -5,6 +5,7 @@ import (
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/gorilla/mux" "github.com/gorilla/mux"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
customMiddleware "github.com/muety/wakapi/middlewares/custom" customMiddleware "github.com/muety/wakapi/middlewares/custom"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
@@ -15,13 +16,15 @@ import (
type HeartbeatApiHandler struct { type HeartbeatApiHandler struct {
config *conf.Config config *conf.Config
userSrvc services.IUserService
heartbeatSrvc services.IHeartbeatService heartbeatSrvc services.IHeartbeatService
languageMappingSrvc services.ILanguageMappingService languageMappingSrvc services.ILanguageMappingService
} }
func NewHeartbeatApiHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatApiHandler { func NewHeartbeatApiHandler(userService services.IUserService, heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatApiHandler {
return &HeartbeatApiHandler{ return &HeartbeatApiHandler{
config: conf.Get(), config: conf.Get(),
userSrvc: userService,
heartbeatSrvc: heartbeatService, heartbeatSrvc: heartbeatService,
languageMappingSrvc: languageMappingService, languageMappingSrvc: languageMappingService,
} }
@@ -34,6 +37,7 @@ type heartbeatResponseVm struct {
func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) { func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/heartbeat").Subrouter() r := router.PathPrefix("/heartbeat").Subrouter()
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
customMiddleware.NewWakatimeRelayMiddleware().Handler, customMiddleware.NewWakatimeRelayMiddleware().Handler,
) )
r.Methods(http.MethodPost).HandlerFunc(h.Post) r.Methods(http.MethodPost).HandlerFunc(h.Post)

View File

@@ -3,6 +3,7 @@ package api
import ( import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
su "github.com/muety/wakapi/routes/utils" su "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
@@ -11,18 +12,23 @@ import (
type SummaryApiHandler struct { type SummaryApiHandler struct {
config *conf.Config config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService summarySrvc services.ISummaryService
} }
func NewSummaryApiHandler(summaryService services.ISummaryService) *SummaryApiHandler { func NewSummaryApiHandler(userService services.IUserService, summaryService services.ISummaryService) *SummaryApiHandler {
return &SummaryApiHandler{ return &SummaryApiHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
userSrvc: userService,
config: conf.Get(), config: conf.Get(),
} }
} }
func (h *SummaryApiHandler) RegisterRoutes(router *mux.Router) { func (h *SummaryApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/summary").Subrouter() r := router.PathPrefix("/summary").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.Get) r.Methods(http.MethodGet).HandlerFunc(h.Get)
} }

View File

@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
"time"
) )
const ( const (
@@ -32,7 +33,8 @@ func NewBadgeHandler(summaryService services.ISummaryService, userService servic
} }
func (h *BadgeHandler) RegisterRoutes(router *mux.Router) { func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/shields/v1/{user}").Subrouter() // no auth middleware here, handler itself resolves the user
r := router.PathPrefix("/compat/shields/v1/{user}").Subrouter()
r.Methods(http.MethodGet).HandlerFunc(h.Get) r.Methods(http.MethodGet).HandlerFunc(h.Get)
} }
@@ -45,13 +47,6 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
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 var filterEntity, filterKey string
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 { if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
filterEntity, filterKey = groups[1], groups[2] filterEntity, filterKey = groups[1], groups[2]
@@ -59,7 +54,25 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
var interval = models.IntervalPast30Days var interval = models.IntervalPast30Days
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 { if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
interval = groups[1] if i, err := utils.ParseInterval(groups[1]); err == nil {
interval = i
}
}
requestedUserId := mux.Vars(r)["user"]
user, err := h.userSrvc.GetUserById(requestedUserId)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
_, rangeFrom, rangeTo := utils.ResolveInterval(interval)
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
// negative value means no limit
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("requested time range too broad"))
return
} }
var filters *models.Filters var filters *models.Filters
@@ -89,7 +102,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
utils.RespondJSON(w, http.StatusOK, vm) utils.RespondJSON(w, http.StatusOK, vm)
} }
func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) { func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
err, from, to := utils.ResolveInterval(interval) err, from, to := utils.ResolveInterval(interval)
if err != nil { if err != nil {
return nil, err, http.StatusBadRequest return nil, err, http.StatusBadRequest

View File

@@ -3,6 +3,7 @@ package v1
import ( import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
@@ -14,18 +15,24 @@ import (
type AllTimeHandler struct { type AllTimeHandler struct {
config *conf.Config config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService summarySrvc services.ISummaryService
} }
func NewAllTimeHandler(summaryService services.ISummaryService) *AllTimeHandler { func NewAllTimeHandler(userService services.IUserService, summaryService services.ISummaryService) *AllTimeHandler {
return &AllTimeHandler{ return &AllTimeHandler{
userSrvc: userService,
summarySrvc: summaryService, summarySrvc: summaryService,
config: conf.Get(), config: conf.Get(),
} }
} }
func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) { func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
router.Path("/wakatime/v1/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(h.Get) r := router.PathPrefix("/compat/wakatime/v1/users/{user}/all_time_since_today").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.Get)
} }
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,117 @@
package v1
import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
type StatsHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewStatsHandler(userService services.IUserService, summaryService services.ISummaryService) *StatsHandler {
return &StatsHandler{
userSrvc: userService,
summarySrvc: summaryService,
config: conf.Get(),
}
}
func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithOptionalFor([]string{"/"}).Handler,
)
r.Path("/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get)
r.Path("/compat/wakatime/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get)
}
// TODO: support filtering (requires https://github.com/muety/wakapi/issues/108)
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
var vars = mux.Vars(r)
var authorizedUser, requestedUser *models.User
if u := r.Context().Value(models.UserKey); u != nil {
authorizedUser = u.(*models.User)
}
if authorizedUser != nil && vars["user"] == "current" {
vars["user"] = authorizedUser.ID
}
requestedUser, err := h.userSrvc.GetUserById(vars["user"])
if err != nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("user not found"))
return
}
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(vars["range"])
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range"))
return
}
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)))
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("requested time range too broad"))
return
}
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
stats := v1.NewStatsFrom(summary, &models.Filters{})
// post filter stats according to user's given sharing permissions
if !requestedUser.ShareEditors {
stats.Data.Editors = nil
}
if !requestedUser.ShareLanguages {
stats.Data.Languages = nil
}
if !requestedUser.ShareProjects {
stats.Data.Projects = nil
}
if !requestedUser.ShareOSs {
stats.Data.OperatingSystems = nil
}
if !requestedUser.ShareMachines {
stats.Data.Machines = nil
}
utils.RespondJSON(w, http.StatusOK, stats)
}
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {
overallParams := &models.SummaryParams{
From: start,
To: end,
User: user,
Recompute: false,
}
summary, err := h.summarySrvc.Aliased(overallParams.From, overallParams.To, user, h.summarySrvc.Retrieve)
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"github.com/gorilla/mux" "github.com/gorilla/mux"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
@@ -15,18 +16,24 @@ import (
type SummariesHandler struct { type SummariesHandler struct {
config *conf.Config config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService summarySrvc services.ISummaryService
} }
func NewSummariesHandler(summaryService services.ISummaryService) *SummariesHandler { func NewSummariesHandler(userService services.IUserService, summaryService services.ISummaryService) *SummariesHandler {
return &SummariesHandler{ return &SummariesHandler{
userSrvc: userService,
summarySrvc: summaryService, summarySrvc: summaryService,
config: conf.Get(), config: conf.Get(),
} }
} }
func (h *SummariesHandler) RegisterRoutes(router *mux.Router) { func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
router.Path("/wakatime/v1/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(h.Get) r := router.PathPrefix("/compat/wakatime/v1/users/{user}/summaries").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.Get)
} }
// TODO: Support parameters: project, branches, timeout, writes_only, timezone // TODO: Support parameters: project, branches, timeout, writes_only, timezone
@@ -68,12 +75,12 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
var start, end time.Time var start, end time.Time
if rangeParam != "" { if rangeParam != "" {
// range param takes precedence // range param takes precedence
if err, parsedFrom, parsedTo := utils.ResolveInterval(rangeParam); err == nil { if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(rangeParam); err == nil {
start, end = parsedFrom, parsedTo start, end = parsedFrom, parsedTo
} else { } else {
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
} }
} else if err, parsedFrom, parsedTo := utils.ResolveInterval(startParam); err == nil && startParam == endParam { } else if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(startParam); err == nil && startParam == endParam {
// also accept start param to be a range param // also accept start param to be a range param
start, end = parsedFrom, parsedTo start, end = parsedFrom, parsedTo
} else { } else {

View File

@@ -11,6 +11,7 @@ import (
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view" "github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/services/imports"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
"strconv" "strconv"
@@ -21,15 +22,25 @@ type SettingsHandler struct {
config *conf.Config config *conf.Config
userSrvc services.IUserService userSrvc services.IUserService
summarySrvc services.ISummaryService summarySrvc services.ISummaryService
heartbeatSrvc services.IHeartbeatService
aliasSrvc services.IAliasService aliasSrvc services.IAliasService
aggregationSrvc services.IAggregationService aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService languageMappingSrvc services.ILanguageMappingService
keyValueSrvc services.IKeyValueService
httpClient *http.Client httpClient *http.Client
} }
var credentialsDecoder = schema.NewDecoder() var credentialsDecoder = schema.NewDecoder()
func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aliasService services.IAliasService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler { func NewSettingsHandler(
userService services.IUserService,
heartbeatService services.IHeartbeatService,
summaryService services.ISummaryService,
aliasService services.IAliasService,
aggregationService services.IAggregationService,
languageMappingService services.ILanguageMappingService,
keyValueService services.IKeyValueService,
) *SettingsHandler {
return &SettingsHandler{ return &SettingsHandler{
config: conf.Get(), config: conf.Get(),
summarySrvc: summaryService, summarySrvc: summaryService,
@@ -37,6 +48,8 @@ func NewSettingsHandler(userService services.IUserService, summaryService servic
aggregationSrvc: aggregationService, aggregationSrvc: aggregationService,
languageMappingSrvc: languageMappingService, languageMappingSrvc: languageMappingService,
userSrvc: userService, userSrvc: userService,
heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService,
httpClient: &http.Client{Timeout: 10 * time.Second}, httpClient: &http.Client{Timeout: 10 * time.Second},
} }
} }
@@ -44,7 +57,7 @@ func NewSettingsHandler(userService services.IUserService, summaryService servic
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) { func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/settings").Subrouter() r := router.PathPrefix("/settings").Subrouter()
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc, []string{}).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
) )
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex) r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
r.Methods(http.MethodPost).HandlerFunc(h.PostIndex) r.Methods(http.MethodPost).HandlerFunc(h.PostIndex)
@@ -88,10 +101,12 @@ func (h *SettingsHandler) PostIndex(w http.ResponseWriter, r *http.Request) {
} }
if errorMsg != "" { if errorMsg != "" {
w.WriteHeader(status)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError(errorMsg)) templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError(errorMsg))
return return
} }
if successMsg != "" { if successMsg != "" {
w.WriteHeader(status)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(successMsg)) templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(successMsg))
return return
} }
@@ -112,10 +127,12 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return h.actionDeleteLanguageMapping return h.actionDeleteLanguageMapping
case "add_mapping": case "add_mapping":
return h.actionAddLanguageMapping return h.actionAddLanguageMapping
case "toggle_badges": case "update_sharing":
return h.actionToggleBadges return h.actionUpdateSharing
case "toggle_wakatime": case "toggle_wakatime":
return h.actionSetWakatimeApiKey return h.actionSetWakatimeApiKey
case "import_wakatime":
return h.actionImportWaktime
case "regenerate_summaries": case "regenerate_summaries":
return h.actionRegenerateSummaries return h.actionRegenerateSummaries
case "delete_account": case "delete_account":
@@ -185,6 +202,34 @@ func (h *SettingsHandler) actionResetApiKey(w http.ResponseWriter, r *http.Reque
return http.StatusOK, msg, "" return http.StatusOK, msg, ""
} }
func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
var err error
user := r.Context().Value(models.UserKey).(*models.User)
defer h.userSrvc.FlushCache()
user.ShareProjects, err = strconv.ParseBool(r.PostFormValue("share_projects"))
user.ShareLanguages, err = strconv.ParseBool(r.PostFormValue("share_languages"))
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days"))
if err != nil {
return http.StatusBadRequest, "", "invalid input"
}
if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", "internal sever error"
}
return http.StatusOK, "settings updated", ""
}
func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Request) (int, string, string) { func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
@@ -282,19 +327,6 @@ func (h *SettingsHandler) actionAddLanguageMapping(w http.ResponseWriter, r *htt
return http.StatusOK, "mapping added successfully", "" return http.StatusOK, "mapping added successfully", ""
} }
func (h *SettingsHandler) actionToggleBadges(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
return http.StatusInternalServerError, "", "internal server error"
}
return http.StatusOK, "", ""
}
func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) { func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
@@ -315,6 +347,74 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
return http.StatusOK, "Wakatime API Key updated successfully", "" return http.StatusOK, "Wakatime API Key updated successfully", ""
} }
func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if user.WakatimeApiKey == "" {
return http.StatusForbidden, "", "not connected to wakatime"
}
kvKey := fmt.Sprintf("%s_%s", conf.KeyLastImportImport, user.ID)
if !h.config.IsDev() {
lastImportKv := h.keyValueSrvc.MustGetString(kvKey)
lastImport, _ := time.Parse(time.RFC822, lastImportKv.Value)
if time.Now().Sub(lastImport) < time.Duration(h.config.App.ImportBackoffMin)*time.Minute {
return http.StatusTooManyRequests,
"",
fmt.Sprintf("Too many data imports. You are only allowed to request an import every %d minutes.", h.config.App.ImportBackoffMin)
}
}
go func(user *models.User) {
importer := imports.NewWakatimeHeartbeatImporter(user.WakatimeApiKey)
countBefore, err := h.heartbeatSrvc.CountByUser(user)
if err != nil {
println(err)
}
var stream <-chan *models.Heartbeat
if latest, err := h.heartbeatSrvc.GetLatestByOriginAndUser(imports.OriginWakatime, user); latest == nil || err != nil {
stream = importer.ImportAll(user)
} else {
// if an import has happened before, only import heartbeats newer than the latest of the last import
stream = importer.Import(user, latest.Time.T(), time.Now())
}
count := 0
batch := make([]*models.Heartbeat, 0)
for hb := range stream {
count++
batch = append(batch, hb)
if len(batch) == h.config.App.ImportBatchSize {
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
logbuch.Warn("failed to insert imported heartbeat, already existing? %v", err)
}
batch = make([]*models.Heartbeat, 0)
}
}
countAfter, _ := h.heartbeatSrvc.CountByUser(user)
logbuch.Info("downloaded %d heartbeats for user '%s' (%d actually imported)", count, user.ID, countAfter-countBefore)
h.regenerateSummaries(user)
}(user)
h.keyValueSrvc.PutString(&models.KeyStringValue{
Key: kvKey,
Value: time.Now().Format(time.RFC822),
})
return http.StatusAccepted, "ImportAll started. This may take a few minutes.", ""
}
func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *http.Request) (int, string, string) { func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
@@ -322,16 +422,8 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
logbuch.Info("clearing summaries for user '%s'", user.ID) if err := h.regenerateSummaries(user); err != nil {
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil { return http.StatusInternalServerError, "", "failed to regenerate summaries"
logbuch.Error("failed to clear summaries: %v", err)
return http.StatusInternalServerError, "", "failed to delete old summaries"
}
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
logbuch.Error("failed to regenerate summaries: %v", err)
return http.StatusInternalServerError, "", "failed to generate aggregations"
} }
return http.StatusOK, "summaries are being regenerated this may take a few seconds", "" return http.StatusOK, "summaries are being regenerated this may take a few seconds", ""
@@ -368,7 +460,7 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
request, err := http.NewRequest( request, err := http.NewRequest(
http.MethodGet, http.MethodGet,
conf.WakatimeApiUrl+conf.WakatimeApiUserEndpoint, conf.WakatimeApiUrl+conf.WakatimeApiUserUrl,
nil, nil,
) )
if err != nil { if err != nil {
@@ -385,6 +477,21 @@ func (h *SettingsHandler) validateWakatimeKey(apiKey string) bool {
return true return true
} }
func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
logbuch.Info("clearing summaries for user '%s'", user.ID)
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
logbuch.Error("failed to clear summaries: %v", err)
return err
}
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
logbuch.Error("failed to regenerate summaries: %v", err)
return err
}
return nil
}
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel { func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID) mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)

View File

@@ -29,7 +29,7 @@ func NewSummaryHandler(summaryService services.ISummaryService, userService serv
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) { func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/summary").Subrouter() r := router.PathPrefix("/summary").Subrouter()
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc, []string{}).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
) )
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex) r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
} }

View File

@@ -22,10 +22,18 @@ func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, langua
} }
} }
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat})
}
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error { func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
return srv.repository.InsertBatch(heartbeats) return srv.repository.InsertBatch(heartbeats)
} }
func (srv *HeartbeatService) CountByUser(user *models.User) (int64, error) {
return srv.repository.CountByUser(user)
}
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) { func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
heartbeats, err := srv.repository.GetAllWithin(from, to, user) heartbeats, err := srv.repository.GetAllWithin(from, to, user)
if err != nil { if err != nil {
@@ -34,6 +42,10 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
return srv.augmented(heartbeats, user.ID) return srv.augmented(heartbeats, user.ID)
} }
func (srv *HeartbeatService) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
return srv.repository.GetLatestByOriginAndUser(origin, user)
}
func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) { func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
return srv.repository.GetFirstByUsers() return srv.repository.GetFirstByUsers()
} }

View File

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

View File

@@ -0,0 +1,240 @@
package imports
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/utils"
"go.uber.org/atomic"
"golang.org/x/sync/semaphore"
"net/http"
"time"
)
const OriginWakatime = "wakatime"
const maxWorkers = 6
type WakatimeHeartbeatImporter struct {
ApiKey string
}
func NewWakatimeHeartbeatImporter(apiKey string) *WakatimeHeartbeatImporter {
return &WakatimeHeartbeatImporter{
ApiKey: apiKey,
}
}
func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time, maxTo time.Time) <-chan *models.Heartbeat {
out := make(chan *models.Heartbeat)
go func(user *models.User, out chan *models.Heartbeat) {
startDate, endDate, err := w.fetchRange()
if err != nil {
logbuch.Error("failed to fetch date range while importing wakatime heartbeats for user '%s' %v", user.ID, err)
return
}
if startDate.Before(minFrom) {
startDate = minFrom
}
if endDate.After(maxTo) {
endDate = maxTo
}
userAgents, err := w.fetchUserAgents()
if err != nil {
logbuch.Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' %v", user.ID, err)
return
}
days := generateDays(startDate, endDate)
c := atomic.NewUint32(uint32(len(days)))
ctx := context.TODO()
sem := semaphore.NewWeighted(maxWorkers)
for _, d := range days {
if err := sem.Acquire(ctx, 1); err != nil {
logbuch.Error("failed to acquire semaphore %v", err)
break
}
go func(day time.Time) {
defer sem.Release(1)
d := day.Format("2006-01-02")
heartbeats, err := w.fetchHeartbeats(d)
if err != nil {
logbuch.Error("failed to fetch heartbeats for day '%s' and user '%s' &v", day, user.ID, err)
}
for _, h := range heartbeats {
out <- mapHeartbeat(h, userAgents, user)
}
if c.Dec() == 0 {
close(out)
}
}(d)
}
}(user, out)
return out
}
func (w *WakatimeHeartbeatImporter) ImportAll(user *models.User) <-chan *models.Heartbeat {
return w.Import(user, time.Time{}, time.Now())
}
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.HeartbeatEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiHeartbeatsUrl, nil)
if err != nil {
return nil, err
}
q := req.URL.Query()
q.Add("date", day)
req.URL.RawQuery = q.Encode()
res, err := httpClient.Do(w.withHeaders(req))
if err != nil {
return nil, err
}
var heartbeatsData wakatime.HeartbeatsViewModel
if err := json.NewDecoder(res.Body).Decode(&heartbeatsData); err != nil {
return nil, err
}
return heartbeatsData.Data, nil
}
// https://wakatime.com/api/v1/users/current/all_time_since_today
func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
notime := time.Time{}
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiAllTimeUrl, nil)
if err != nil {
return notime, notime, err
}
res, err := httpClient.Do(w.withHeaders(req))
if err != nil {
return notime, notime, err
}
var allTimeData map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&allTimeData); err != nil {
return notime, notime, err
}
data := allTimeData["data"].(map[string]interface{})
if data == nil {
return notime, notime, errors.New("invalid response")
}
dataRange := data["range"].(map[string]interface{})
if dataRange == nil {
return notime, notime, errors.New("invalid response")
}
startDate, err := time.Parse("2006-01-02", dataRange["start_date"].(string))
if err != nil {
return notime, notime, err
}
endDate, err := time.Parse("2006-01-02", dataRange["end_date"].(string))
if err != nil {
return notime, notime, err
}
return startDate, endDate, nil
}
// https://wakatime.com/api/v1/users/current/user_agents
func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.UserAgentEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiUserAgentsUrl, nil)
if err != nil {
return nil, err
}
res, err := httpClient.Do(w.withHeaders(req))
if err != nil {
return nil, err
}
var userAgentsData wakatime.UserAgentsViewModel
if err := json.NewDecoder(res.Body).Decode(&userAgentsData); err != nil {
return nil, err
}
userAgents := make(map[string]*wakatime.UserAgentEntry)
for _, ua := range userAgentsData.Data {
userAgents[ua.Id] = ua
}
return userAgents, nil
}
func (w *WakatimeHeartbeatImporter) withHeaders(req *http.Request) *http.Request {
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(w.ApiKey))))
return req
}
func mapHeartbeat(
entry *wakatime.HeartbeatEntry,
userAgents map[string]*wakatime.UserAgentEntry,
user *models.User,
) *models.Heartbeat {
ua := userAgents[entry.UserAgentId]
if ua == nil {
ua = &wakatime.UserAgentEntry{
Editor: "unknown",
Os: "unknown",
}
}
return (&models.Heartbeat{
User: user,
UserID: user.ID,
Entity: entry.Entity,
Type: entry.Type,
Category: entry.Category,
Project: entry.Project,
Branch: entry.Branch,
Language: entry.Language,
IsWrite: entry.IsWrite,
Editor: ua.Editor,
OperatingSystem: ua.Os,
Machine: entry.MachineNameId, // TODO
Time: entry.Time,
Origin: OriginWakatime,
OriginId: entry.Id,
}).Hashed()
}
func generateDays(from, to time.Time) []time.Time {
days := make([]time.Time, 0)
from = utils.StartOfDay(from)
to = utils.StartOfDay(to.Add(24 * time.Hour))
for d := from; d.Before(to); d = d.Add(24 * time.Hour) {
days = append(days, d)
}
return days
}

View File

@@ -22,6 +22,17 @@ func (srv *KeyValueService) GetString(key string) (*models.KeyStringValue, error
return srv.repository.GetString(key) return srv.repository.GetString(key)
} }
func (srv *KeyValueService) MustGetString(key string) *models.KeyStringValue {
kv, err := srv.repository.GetString(key)
if err != nil {
return &models.KeyStringValue{
Key: key,
Value: "",
}
}
return kv
}
func (srv *KeyValueService) PutString(kv *models.KeyStringValue) error { func (srv *KeyValueService) PutString(kv *models.KeyStringValue) error {
return srv.repository.PutString(kv) return srv.repository.PutString(kv)
} }

View File

@@ -26,14 +26,18 @@ type IAliasService interface {
} }
type IHeartbeatService interface { type IHeartbeatService interface {
Insert(*models.Heartbeat) error
InsertBatch([]*models.Heartbeat) error InsertBatch([]*models.Heartbeat) error
CountByUser(*models.User) (int64, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error) GetFirstByUsers() ([]*models.TimeByUser, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
DeleteBefore(time.Time) error DeleteBefore(time.Time) error
} }
type IKeyValueService interface { type IKeyValueService interface {
GetString(string) (*models.KeyStringValue, error) GetString(string) (*models.KeyStringValue, error)
MustGetString(string) *models.KeyStringValue
PutString(*models.KeyStringValue) error PutString(*models.KeyStringValue) error
DeleteString(string) error DeleteString(string) error
} }
@@ -63,7 +67,7 @@ type IUserService interface {
Update(*models.User) (*models.User, error) Update(*models.User) (*models.User, error)
Delete(*models.User) error Delete(*models.User) error
ResetApiKey(*models.User) (*models.User, error) ResetApiKey(*models.User) (*models.User, error)
ToggleBadges(*models.User) (*models.User, error)
SetWakatimeApiKey(*models.User, string) (*models.User, error) SetWakatimeApiKey(*models.User, string) (*models.User, error)
MigrateMd5Password(*models.User, *models.Login) (*models.User, error) MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
FlushCache()
} }

View File

@@ -83,11 +83,6 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
return srv.Update(user) return srv.Update(user)
} }
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
srv.cache.Flush()
return srv.repository.UpdateField(user, "badges_enabled", !user.BadgesEnabled)
}
func (srv *UserService) SetWakatimeApiKey(user *models.User, apiKey string) (*models.User, error) { func (srv *UserService) SetWakatimeApiKey(user *models.User, apiKey string) (*models.User, error) {
srv.cache.Flush() srv.cache.Flush()
return srv.repository.UpdateField(user, "wakatime_api_key", apiKey) return srv.repository.UpdateField(user, "wakatime_api_key", apiKey)
@@ -108,3 +103,7 @@ func (srv *UserService) Delete(user *models.User) error {
srv.cache.Flush() srv.cache.Flush()
return srv.repository.Delete(user) return srv.repository.Delete(user)
} }
func (srv *UserService) FlushCache() {
srv.cache.Flush()
}

View File

@@ -7,35 +7,52 @@ import (
"time" "time"
) )
func ResolveInterval(interval string) (err error, from, to time.Time) { func ParseInterval(interval string) (*models.IntervalKey, error) {
for _, i := range models.AllIntervals {
if i.HasAlias(interval) {
return i, nil
}
}
return nil, errors.New("not a valid interval")
}
func ResolveIntervalRaw(interval string) (err error, from, to time.Time) {
parsed, err := ParseInterval(interval)
if err != nil {
return err, time.Time{}, time.Time{}
}
return ResolveInterval(parsed)
}
func ResolveInterval(interval *models.IntervalKey) (err error, from, to time.Time) {
to = time.Now() to = time.Now()
switch interval { switch interval {
case models.IntervalToday, models.IntervalWakatimeToday: case models.IntervalToday:
from = StartOfToday() from = StartOfToday()
case models.IntervalYesterday, models.IntervalWakatimeYesterday: case models.IntervalYesterday:
from = StartOfToday().Add(-24 * time.Hour) from = StartOfToday().Add(-24 * time.Hour)
to = StartOfToday() to = StartOfToday()
case models.IntervalThisWeek, models.IntervalWakatimeThisWeek: case models.IntervalThisWeek:
from = StartOfWeek() from = StartOfWeek()
case models.IntervalWakatimeLastWeek: case models.IntervalLastWeek:
from = StartOfWeek().AddDate(0, 0, -7) from = StartOfWeek().AddDate(0, 0, -7)
to = StartOfWeek() to = StartOfWeek()
case models.IntervalThisMonth, models.IntervalWakatimeThisMonth: case models.IntervalThisMonth:
from = StartOfMonth() from = StartOfMonth()
case models.IntervalWakatimeLastMonth: case models.IntervalLastMonth:
from = StartOfMonth().AddDate(0, -1, 0) from = StartOfMonth().AddDate(0, -1, 0)
to = StartOfMonth() to = StartOfMonth()
case models.IntervalThisYear: case models.IntervalThisYear:
from = StartOfYear() from = StartOfYear()
case models.IntervalPast7Days, models.IntervalWakatimeLast7Days: case models.IntervalPast7Days:
from = StartOfToday().AddDate(0, 0, -7) from = StartOfToday().AddDate(0, 0, -7)
case models.IntervalWakatimeLast7DaysYesterday: case models.IntervalPast7DaysYesterday:
from = StartOfToday().AddDate(0, 0, -1).AddDate(0, 0, -7) from = StartOfToday().AddDate(0, 0, -1).AddDate(0, 0, -7)
to = StartOfToday().AddDate(0, 0, -1) to = StartOfToday().AddDate(0, 0, -1)
case models.IntervalWakatimeLast14Days: case models.IntervalPast14Days:
from = StartOfToday().AddDate(0, 0, -14) from = StartOfToday().AddDate(0, 0, -14)
case models.IntervalPast30Days, models.IntervalWakatimeLast30Days: case models.IntervalPast30Days:
from = StartOfToday().AddDate(0, 0, -30) from = StartOfToday().AddDate(0, 0, -30)
case models.IntervalPast12Months: case models.IntervalPast12Months:
from = StartOfToday().AddDate(0, -12, 0) from = StartOfToday().AddDate(0, -12, 0)
@@ -56,7 +73,7 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
var from, to time.Time var from, to time.Time
if interval := params.Get("interval"); interval != "" { if interval := params.Get("interval"); interval != "" {
err, from, to = ResolveInterval(interval) err, from, to = ResolveIntervalRaw(interval)
} else { } else {
from, err = ParseDate(params.Get("from")) from, err = ParseDate(params.Get("from"))
if err != nil { if err != nil {

View File

@@ -1 +1 @@
1.22.6 1.23.0

View File

@@ -22,7 +22,7 @@
class="text-green-700">Your</span> Coding Time 🕓</h1> class="text-green-700">Your</span> Coding Time 🕓</h1>
<p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the <p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the
time you have spent coding on different projects in different programming languages and more. Ideal for time you have spent coding on different projects in different programming languages and more. Ideal for
statistics freaks any anyone else.</p> statistics freaks and anyone else.</p>
<p class="text-center text-gray-500 text-xl my-4"> <p class="text-center text-gray-500 text-xl my-4">
<span class="mr-1">💡 The system has tracked a total of </span> <span class="mr-1">💡 The system has tracked a total of </span>

View File

@@ -33,238 +33,359 @@
<main class="mt-4 flex-grow flex justify-center w-full"> <main class="mt-4 flex-grow flex justify-center w-full">
<div class="flex flex-col flex-grow max-w-xl mt-8"> <div class="flex flex-col flex-grow max-w-xl mt-8">
<div class="text-gray-500 text-xs mb-8">
<ul class="flex justify-center flex-wrap space-x-1 inline-bullet-list px-12">
<li class="hover:text-gray-400 mb-1">
<a href="settings#password">Change Password</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#apikey">Reset API Key</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#aliases">Aliases</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#languages">Languages & File Extensions</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#badges">Badges</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#integrations">Integrations</a>
</li>
<li class="hover:text-gray-400 mb-1">
<a href="settings#danger">Danger Zone</a>
</li>
</ul>
</div>
<div class="w-full my-8 pb-8 border-b border-gray-700"> <details class="my-8 pb-8 border-b border-gray-700">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password"> <summary class="cursor-pointer">
Change Password <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
</h2> Change Password
</h2>
<form class="mt-10" action="" method="post"> </summary>
<input type="hidden" name="action" value="change_password"> <div class="w-full">
<div class="mb-8"> <form class="mt-10" action="" method="post">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current Password</label> <input type="hidden" name="action" value="change_password">
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" <div class="mb-8">
type="password" id="password_old" <label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current Password</label>
name="password_old" placeholder="Enter your old password" minlength="6" required> <input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
</div> type="password" id="password_old"
<div class="mb-8"> name="password_old" placeholder="Enter your old password" minlength="6" required>
<label class="inline-block text-sm mb-1 text-gray-500" for="password_new">New Password</label> </div>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" <div class="mb-8">
type="password" id="password_new" <label class="inline-block text-sm mb-1 text-gray-500" for="password_new">New Password</label>
name="password_new" placeholder="Choose a password" minlength="6" required> <input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
</div> type="password" id="password_new"
<div class="mb-8"> name="password_new" placeholder="Choose a password" minlength="6" required>
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label> </div>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" <div class="mb-8">
type="password" id="password_repeat" <label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
name="password_repeat" placeholder="Repeat your password" minlength="6" required> <input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
</div> type="password" id="password_repeat"
<div class="flex justify-between float-right"> name="password_repeat" placeholder="Repeat your password" minlength="6" required>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm"> </div>
Save <div class="flex justify-between float-right">
</button> <button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
</div> Save
</form> </button>
</div> </div>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="apikey">
Reset API Key
</h2>
<form class="mt-6" action="" method="post">
<input type="hidden" name="action" value="reset_apikey">
<div class="text-gray-300 text-sm mb-4">
<strong>⚠️ Caution:</strong> Resetting your API key requires you to update your <span
class="font-mono">.wakatime.cfg</span> files on all of your computers to make the WakaTime
client send heartbeats again.
</div>
<div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
Reset
</button>
</div>
</form>
</div>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700" id="aliases">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Aliases
</h2>
<div class="text-gray-300 text-sm mb-4 mt-6">
You can specify aliases for any type of entity. For instance, you can define a rule, that both <span
class="inline-block mb-1 text-gray-500 italic">myapp-frontend</span> and <span
class="inline-block mb-1 text-gray-500 italic">myapp-backend</span> are combined under a
project called <span class="inline-block mb-1 text-gray-500 italic">myapp</span>.
</div>
{{ if .Aliases }}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Rules</h3>
{{ range $i, $alias := .Aliases }}
<div class="flex items-center">
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
style="line-height: 1.8">
&#9656;&nbsp; All <span class="underline">{{ $alias.Type | typeName }}s</span> named
{{ range $j, $value := $alias.Values }}
<span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">{{- $value -}}</span>
{{ if lt $j (add (len $alias.Values) -2) }}
<span class="-ml-1">{{- ", " | capitalize -}}</span>
{{ else if lt $j (add (len $alias.Values) -1) }}
<span>{{- "or" -}}</span>
{{ end }}
{{ end }}
are mapped to <span class="underline">{{ $alias.Type | typeName }}</span> <span
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">{{ $alias.Key }}</span>.
</div>
<form class="float-right" action="" method="post">
<input type="hidden" name="action" value="delete_alias">
<input type="hidden" id="delete_alias_key" name="key" required value="{{ $alias.Key }}">
<input type="hidden" id="delete_alias_type" name="type" required value="{{ $alias.Type }}">
<button type="submit"
class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
</button>
</form> </form>
</div> </div>
{{end}} </details>
<div class="mb-8"></div>
{{end}}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Rule</h3> <details class="mb-8 pb-8 border-b border-gray-700">
<form action="" method="post"> <summary class="cursor-pointer">
<input type="hidden" name="action" value="add_alias"> <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
<div class="flex items-center mt-2 w-full text-gray-500 text-sm"> Aliases
<span class="mr-2">Map</span> </h2>
<select name="type" id="select-type" </summary>
class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3 cursor-pointer"> <div class="w-full" id="aliases">
{{ range $i, $t := entityTypes }} <div class="text-gray-300 text-sm mb-4 mt-6">
<option value="{{ $t }}">{{ $t | typeName | capitalize }}</option> You can specify aliases for any type of entity. For instance, you can define a rule, that both <span
class="inline-block mb-1 text-gray-500 italic">myapp-frontend</span> and <span
class="inline-block mb-1 text-gray-500 italic">myapp-backend</span> are combined under a
project called <span class="inline-block mb-1 text-gray-500 italic">myapp</span>.
</div>
{{ if .Aliases }}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Rules</h3>
{{ range $i, $alias := .Aliases }}
<div class="flex items-center">
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
style="line-height: 1.8">
&#9656;&nbsp; All <span class="underline">{{ $alias.Type | typeName }}s</span> named
{{ range $j, $value := $alias.Values }}
<span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">{{- $value -}}</span>
{{ if lt $j (add (len $alias.Values) -2) }}
<span class="-ml-1">{{- ", " | capitalize -}}</span>
{{ else if lt $j (add (len $alias.Values) -1) }}
<span>{{- "or" -}}</span>
{{ end }} {{ end }}
</select> {{ end }}
<span class="mx-2">named</span> are mapped to <span class="underline">{{ $alias.Type | typeName }}</span> <span
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3" class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">{{ $alias.Key }}</span>.
type="text" id="alias-value" style="width: 130px;"
name="value" placeholder="myapp-frontend" minlength="1" required>
<span class="mx-2">to</span>
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
type="text" id="alias-key" style="width: 100px"
name="key" placeholder="myapp" minlength="1" required>
<div class="flex-grow flex justify-end">
<button type="submit"
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Add
</button>
</div> </div>
<form class="float-right" action="" method="post">
<input type="hidden" name="action" value="delete_alias">
<input type="hidden" id="delete_alias_key" name="key" required value="{{ $alias.Key }}">
<input type="hidden" id="delete_alias_type" name="type" required value="{{ $alias.Type }}">
<button type="submit"
class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
</button>
</form>
</div> </div>
</form> {{end}}
</div> <div class="mb-8"></div>
{{end}}
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700"> <h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Rule</h3>
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="languages"> <form action="" method="post">
Languages & File Extensions <input type="hidden" name="action" value="add_alias">
</div> <div class="flex items-center mt-2 w-full text-gray-500 text-sm">
<span class="mr-2">Map</span>
<div class="text-gray-300 text-sm mb-4 mt-6"> <select name="type" id="select-type"
You can specify custom mapping from file extensions to programming languages, for instance a <span class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3 cursor-pointer">
class="inline-block mb-1 text-gray-500 italic">.jsx</span> file could be mapped to the <span {{ range $i, $t := entityTypes }}
class="inline-block mb-1 text-gray-500 italic">React</span> language. <option value="{{ $t }}">{{ $t | typeName | capitalize }}</option>
</div> {{ end }}
</select>
{{ if .LanguageMappings }} <span class="mx-2">named</span>
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Rules</h3> <input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
{{ range $i, $mapping := .LanguageMappings }} type="text" id="alias-value" style="width: 130px;"
<div class="flex items-center"> name="value" placeholder="myapp-frontend" minlength="1" required>
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"> <span class="mx-2">to</span>
&#9656;&nbsp; When filename ends in <span <input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono mr-1">{{ $mapping.Extension }}</span> type="text" id="alias-key" style="width: 100px"
then change the <span class="underline">language</span> to <span name="key" placeholder="myapp" minlength="1" required>
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono mr-1">{{ $mapping.Language }}</span> <div class="flex-grow flex justify-end">
</div> <button type="submit"
<form class="float-right" action="" method="post"> class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
<input type="hidden" name="action" value="delete_mapping"> Add
<input type="hidden" id="mapping_id" name="mapping_id" required value="{{ $mapping.ID }}"> </button>
<button type="submit" </div>
class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm"> </div>
</button>
</form> </form>
</div> </div>
{{end}} </details>
<div class="mb-8"></div>
{{end}}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Add Rule</h3> <details class="mb-8 pb-8 border-b border-gray-700">
<form action="" method="post"> <summary class="cursor-pointer">
<input type="hidden" name="action" value="add_mapping"> <h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
<div class="flex items-center w-full text-gray-500 text-sm"> id="languages">
<span class="mr-2">When filename ends in</span> Custom Mappings
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3" </h2>
type="text" id="extension" style="width: 70px" </summary>
name="extension" placeholder=".py" minlength="1" required> <div class="w-full">
<span class="mx-2">change language to</span> <div class="text-gray-300 text-sm mb-4 mt-6">
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3" You can specify custom mapping from file extensions to programming languages, for instance a <span
type="text" id="language" style="width: 100px" class="inline-block mb-1 text-gray-500 italic">.jsx</span> file could be mapped to the <span
name="language" placeholder="Python" minlength="1" required> class="inline-block mb-1 text-gray-500 italic">React</span> language.
<div class="flex-grow flex justify-end">
<button type="submit"
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Add
</button>
</div>
</div> </div>
</form>
</div>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700"> {{ if .LanguageMappings }}
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="badges"> <h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Rules</h3>
Badges {{ range $i, $mapping := .LanguageMappings }}
</div> <div class="flex items-center">
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm">
<form class="mt-6" action="" method="post"> &#9656;&nbsp; When filename ends in <span
<input type="hidden" name="action" value="toggle_badges"> class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono mr-1">{{ $mapping.Extension }}</span>
<div class="text-gray-300 text-sm mb-4"> then change the <span class="underline">language</span> to <span
{{ if .User.BadgesEnabled }} class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono mr-1">{{ $mapping.Language }}</span>
<p>Badges are currently enabled. You can disable the feature by deactivating the respective API </div>
endpoint.</p> <form class="float-right" action="" method="post">
<input type="hidden" name="action" value="delete_mapping">
<div class="flex justify-around mt-4"> <input type="hidden" id="mapping_id" name="mapping_id" required value="{{ $mapping.ID }}">
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
<button type="submit" <button type="submit"
class="py-1 px-2 rounded bg-orange-700 hover:bg-orange-800 text-white text-xs" class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
title="Disable support for badges to secure endpoint">
Status: public
</button> </button>
</form>
</div>
{{end}}
<div class="mb-8"></div>
{{end}}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Add Rule</h3>
<form action="" method="post">
<input type="hidden" name="action" value="add_mapping">
<div class="flex items-center w-full text-gray-500 text-sm">
<span class="mr-2">When filename ends in</span>
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
type="text" id="extension" style="width: 70px"
name="extension" placeholder=".py" minlength="1" required>
<span class="mx-2">change language to</span>
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
type="text" id="language" style="width: 100px"
name="language" placeholder="Python" minlength="1" required>
<div class="flex-grow flex justify-end">
<button type="submit"
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Add
</button>
</div>
</div>
</form>
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700" id="public_data">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Public Data
</h2>
</summary>
<div>
<p class="text-gray-300 text-sm mb-4 mt-6">Some features require public access to your data without authentication. This mainly includes <strong>Badges</strong> and the integration with <strong>GitHub Readme Stats</strong>, corresponding to these API endpoints:</p>
<ul class="list-disc list-inside text-gray-300">
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/compat/shields/v1/{user}</span></li>
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/v1/users/{user}/stats/{range}</span></li>
</ul>
<form action="" method="post" class="mt-8">
<input type="hidden" name="action" value="update_sharing">
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<span class="mr-2">Publicly accessible data range:<br><span class="text-xs text-gray-500">(in days; 0 = not public, -1 = unlimited)</span></span>
<div>
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
style="width: 70px;" type="number" id="max_days" name="max_days" min="-1" required value="{{ .User.ShareDataMaxDays }}">
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share projects: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_projects" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes</option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share languages: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_languages" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes</option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share editors: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_editors" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes</option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share operating systems: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_oss" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>Yes</option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share machines: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_machines" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes</option>
</select>
</div>
</div> </div>
<h3 class="font-semibold mb-2 mt-8">Examples</h3> <div class="flex justify-between float-right mt-4">
<div class="flex flex-col mb-4"> <button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm" style="width: 100px;">
Save
</button>
</div>
</form>
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="integrations">
Integrations
</h2>
</summary>
<div class="w-full">
<div class="mt-8 text-gray-300 text-sm">
<h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700">
WakaTime
</h3>
<div class="flex space-x-4">
<img alt="WakaTime Logo"
width="55px"
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzQwIiBoZWlnaHQ9IjM0MCIgdmlld0JveD0iMCAwIDM0MCAzNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTcwIDIwQzg3LjE1NiAyMCAyMCA4Ny4xNTYgMjAgMTcwQzIwIDI1Mi44NDQgODcuMTU2IDMyMCAxNzAgMzIwQzI1Mi44NDQgMzIwIDMyMCAyNTIuODQ0IDMyMCAxNzBDMzIwIDg3LjE1NiAyNTIuODQ0IDIwIDE3MCAyMFYyMFYyMFoiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iNDAiLz4KPHBhdGggZD0iTTE5MC4xODMgMjEzLjU0MUMxODguNzQgMjE1LjQ0MyAxODYuNTc2IDIxNi42NjcgMTg0LjE1MSAyMTYuNjY3QzE4My45MTMgMjE2LjY2NyAxODMuNjc3IDIxNi42NTEgMTgzLjQ0MyAyMTYuNjI3QzE4My4wNDIgMjE2LjU3OSAxODIuODIzIDIxNi41NDUgMTgyLjYwNiAyMTYuNDk3QzE4Mi4zMzcgMjE2LjQzNCAxODIuMTM3IDIxNi4zNzUgMTgxLjk0IDIxNi4zMDhDMTgxLjU2MSAyMTYuMTc2IDE4MS4zOTIgMjE2LjEwOSAxODEuMjI4IDIxNi4wMzVDMTgwLjg0MyAyMTUuODQ5IDE4MC43MDcgMjE1Ljc3OCAxODAuNTcyIDIxNS43MDFDMTgwLjIwNSAyMTUuNDc4IDE4MC4xMDkgMjE1LjQxMiAxODAuMDE0IDIxNS4zNDVDMTc5Ljg1NiAyMTUuMjMzIDE3OS42OTggMjE1LjExNyAxNzkuNTQ3IDIxNC45OTJDMTc5LjI1MSAyMTQuNzQ2IDE3OS4xNDcgMjE0LjY1IDE3OS4wNDQgMjE0LjU1MkMxNzguNzMxIDIxNC4yNDEgMTc4LjUzMSAyMTQuMDE4IDE3OC4zNDEgMjEzLjc4NUMxNzcuOTgyIDIxMy4zMzEgMTc3LjY5IDIxMi44ODggMTc3LjQzOCAyMTIuNDE1TDE2OC42IDE5OC4yMTRMMTU5Ljc2NiAyMTIuNDE1QzE1OC4zOCAyMTQuOTM5IDE1NS44NzQgMjE2LjY2NyAxNTIuOTk1IDIxNi42NjdDMTUwLjEwNiAyMTYuNjY3IDE0Ny41ODggMjE0LjkyNiAxNDYuMjQzIDIxMi4zNDZMMTA3LjYwNyAxNTYuMDYxQzEwNi4zMzcgMTU0LjUyOSAxMDUuNTU2IDE1Mi40OTkgMTA1LjU1NiAxNTAuMjU4QzEwNS41NTYgMTQ1LjUxNCAxMDkuMDQzIDE0MS42NjUgMTEzLjM0NCAxNDEuNjY1QzExNi4xMjcgMTQxLjY2NSAxMTguNTY0IDE0My4yODIgMTE5Ljk0MiAxNDUuNzA4TDE1Mi41NTUgMTkzLjlMMTYxLjczNSAxNzguOTUyQzE2My4wNTggMTc2LjI4OCAxNjUuNjI2IDE3NC40NzggMTY4LjU3NSAxNzQuNDc4QzE3MS4yNzMgMTc0LjQ3OCAxNzMuNjUyIDE3NS45OTYgMTc1LjA0OSAxNzguMjk4TDE4NC41MTcgMTkzLjgzOUwyMzUuNjg0IDEyMC41ODNDMjM3LjA3NSAxMTguMjI2IDIzOS40NzUgMTE2LjY2NyAyNDIuMjEzIDExNi42NjdDMjQ2LjUxNCAxMTYuNjY3IDI1MCAxMjAuNTE0IDI1MCAxMjUuMjU4QzI1MCAxMjcuMzMyIDI0OS4zMzcgMTI5LjIzMiAyNDguMjMgMTMwLjcxNUwxOTAuMTgzIDIxMy41NDFWMjEzLjU0MVoiIGZpbGw9IndoaXRlIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjEwIi8+Cjwvc3ZnPgo=">
<p class="text-sm">You can connect Wakapi with the official <a class="underline"
href="https://wakatime.com"
rel="noopener noreferrer"
target="_blank">WakaTime</a> in a
way
that all heartbeats sent to Wakapi are relayed. This way, you can use both services
at
the same time. To get started, <a class="underline"
href="https://wakatime.com/developers#authentication"
rel="noopener noreferrer"
target="_blank">get your API key</a> and paste it here.
</p>
</div>
<form action="" method="post">
<input type="hidden" name="action" value="toggle_wakatime">
{{ $placeholderText := "Paste your WakaTime API key here ..." }}
{{ if .User.WakatimeApiKey }}
{{ $placeholderText = "********" }}
{{ end }}
<div class="flex items-center mt-8 space-x-2">
<label class="text-gray-500 font-semibold">API Key:</label>
<input type="password" name="api_key" id="wakatime_api_key"
class="flex-grow shadow appearance-nonshadow appearance-none bg-gray-800 text-gray-300 border-green-700 border rounded py-1 px-3 {{ if not .User.WakatimeApiKey }}focus:bg-gray-700 focus:border-gray-500{{ end }}"
placeholder="{{ $placeholderText }}" {{ if .User.WakatimeApiKey }}readonly{{ end }}>
<div class="flex-grow flex justify-end">
{{ if not .User.WakatimeApiKey }}
<button type="submit"
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Connect
</button>
{{ else }}
<button type="submit"
class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
style="width: 130px">Disconnect
</button>
{{ end }}
</div>
</div>
{{ if .User.WakatimeApiKey }}
<div class="flex justify-end">
<button id="btn-import-wakatime" type="button" style="width: 130px"
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
⤵ Import Data
</button>
</div>
{{ end }}
</form>
<form action="" method="post" id="form-import-wakatime" class="mt-6">
<input type="hidden" name="action" value="import_wakatime">
</form>
<p class="mt-6">
<span class="font-semibold">👉 Please note:</span>
<span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94"
rel="noopener noreferrer">#94</a>) to be implemented.</span>
</p>
</div>
<div class="mt-10 text-gray-300 text-sm">
<h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700">
Badges (Shields.io)
</h3>
{{ if ne .User.ShareDataMaxDays 0 }}
<div class="flex space-x-1">
<h3 class="font-semibold">Examples:</h3>
<span class="text-xs text-gray-500">(Only available on public instances, not on localhost)</span>
</div>
<div class="flex flex-col mb-4 mt-2">
<div class="flex justify-between my-2"> <div class="flex justify-between my-2">
<div> <div>
<img class="with-url-src" <img class="with-url-src"
@@ -295,137 +416,120 @@
<p>You have the ability to create badges from your coding statistics using <a <p>You have the ability to create badges from your coding statistics using <a
href="https://shields.io" target="_blank" class="border-b border-green-800" href="https://shields.io" target="_blank" class="border-b border-green-800"
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
access to the respective endpoint.</p> access to the respective endpoint. See <a href="settings#public_data" class="underline">Public Data</a> setting.</p>
<div class="flex justify-around mt-4"> {{ end }}
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span> </div>
<button type="submit"
class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs" <div class="mt-10 text-gray-300 text-sm">
title="Make endpoint public to enable badges"> <h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700">
Status: protected GitHub Readme Stats
</h3>
<p class="mb-4">Wakapi intregrates with <a href="https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats" class="underline" target="_blank" rel="noopener noreferrer">GitHub Readme Stats</a> to generate fancy cards for you.</p>
{{ if ne .User.ShareDataMaxDays 0 }}
<div class="flex space-x-1">
<h3 class="font-semibold">Example:</h3>
<span class="text-xs text-gray-500">(Only available on public instances, not on localhost)</span>
</div>
<div class="flex flex-col mb-4 mt-2">
<img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact" class="with-url-src-no-scheme">
<p class="mt-2"><strong>Source URL:</strong>
<span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme">
https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact
</span>
</p>
</div>
{{ end }}
</div>
</div>
</details>
<details class="mb-8 pb-8">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
⚠️ Danger Zone
</h2>
</summary>
<div class="w-full">
<div class="mt-10 text-gray-300 text-sm">
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
Regenerate summaries
</h3>
<p>
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to
summaries on a per-day basis.
That is, historic summaries, i.e. such from past days, are generated once and only fetched from
the
database in a static fashion afterwards, unless you pass <span
class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span>
with your request.
</p>
<p class="mt-2">
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g.
you
modified language mappings retrospectively), you may want to re-generate them from raw
heartbeats.
</p>
<p class="mt-2">
<strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost
is
case heartbeats were deleted after the respective summaries had been generated.
</p>
</div>
<div class="mt-6 flex justify-center">
<form action="" method="post" id="form-regenerate-summaries">
<input type="hidden" name="action" value="regenerate_summaries">
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
id="btn-regenerate-summaries">
Clear & Regenerate
</button> </button>
</div> </form>
{{ end }}
</div>
</form>
</div>
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="integrations">
Integrations
</h2>
<div class="mt-10 text-gray-300 text-sm">
<h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700">
WakaTime
</h3>
<div class="flex space-x-4">
<img alt="WakaTime Logo"
width="55px"
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzQwIiBoZWlnaHQ9IjM0MCIgdmlld0JveD0iMCAwIDM0MCAzNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTcwIDIwQzg3LjE1NiAyMCAyMCA4Ny4xNTYgMjAgMTcwQzIwIDI1Mi44NDQgODcuMTU2IDMyMCAxNzAgMzIwQzI1Mi44NDQgMzIwIDMyMCAyNTIuODQ0IDMyMCAxNzBDMzIwIDg3LjE1NiAyNTIuODQ0IDIwIDE3MCAyMFYyMFYyMFoiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iNDAiLz4KPHBhdGggZD0iTTE5MC4xODMgMjEzLjU0MUMxODguNzQgMjE1LjQ0MyAxODYuNTc2IDIxNi42NjcgMTg0LjE1MSAyMTYuNjY3QzE4My45MTMgMjE2LjY2NyAxODMuNjc3IDIxNi42NTEgMTgzLjQ0MyAyMTYuNjI3QzE4My4wNDIgMjE2LjU3OSAxODIuODIzIDIxNi41NDUgMTgyLjYwNiAyMTYuNDk3QzE4Mi4zMzcgMjE2LjQzNCAxODIuMTM3IDIxNi4zNzUgMTgxLjk0IDIxNi4zMDhDMTgxLjU2MSAyMTYuMTc2IDE4MS4zOTIgMjE2LjEwOSAxODEuMjI4IDIxNi4wMzVDMTgwLjg0MyAyMTUuODQ5IDE4MC43MDcgMjE1Ljc3OCAxODAuNTcyIDIxNS43MDFDMTgwLjIwNSAyMTUuNDc4IDE4MC4xMDkgMjE1LjQxMiAxODAuMDE0IDIxNS4zNDVDMTc5Ljg1NiAyMTUuMjMzIDE3OS42OTggMjE1LjExNyAxNzkuNTQ3IDIxNC45OTJDMTc5LjI1MSAyMTQuNzQ2IDE3OS4xNDcgMjE0LjY1IDE3OS4wNDQgMjE0LjU1MkMxNzguNzMxIDIxNC4yNDEgMTc4LjUzMSAyMTQuMDE4IDE3OC4zNDEgMjEzLjc4NUMxNzcuOTgyIDIxMy4zMzEgMTc3LjY5IDIxMi44ODggMTc3LjQzOCAyMTIuNDE1TDE2OC42IDE5OC4yMTRMMTU5Ljc2NiAyMTIuNDE1QzE1OC4zOCAyMTQuOTM5IDE1NS44NzQgMjE2LjY2NyAxNTIuOTk1IDIxNi42NjdDMTUwLjEwNiAyMTYuNjY3IDE0Ny41ODggMjE0LjkyNiAxNDYuMjQzIDIxMi4zNDZMMTA3LjYwNyAxNTYuMDYxQzEwNi4zMzcgMTU0LjUyOSAxMDUuNTU2IDE1Mi40OTkgMTA1LjU1NiAxNTAuMjU4QzEwNS41NTYgMTQ1LjUxNCAxMDkuMDQzIDE0MS42NjUgMTEzLjM0NCAxNDEuNjY1QzExNi4xMjcgMTQxLjY2NSAxMTguNTY0IDE0My4yODIgMTE5Ljk0MiAxNDUuNzA4TDE1Mi41NTUgMTkzLjlMMTYxLjczNSAxNzguOTUyQzE2My4wNTggMTc2LjI4OCAxNjUuNjI2IDE3NC40NzggMTY4LjU3NSAxNzQuNDc4QzE3MS4yNzMgMTc0LjQ3OCAxNzMuNjUyIDE3NS45OTYgMTc1LjA0OSAxNzguMjk4TDE4NC41MTcgMTkzLjgzOUwyMzUuNjg0IDEyMC41ODNDMjM3LjA3NSAxMTguMjI2IDIzOS40NzUgMTE2LjY2NyAyNDIuMjEzIDExNi42NjdDMjQ2LjUxNCAxMTYuNjY3IDI1MCAxMjAuNTE0IDI1MCAxMjUuMjU4QzI1MCAxMjcuMzMyIDI0OS4zMzcgMTI5LjIzMiAyNDguMjMgMTMwLjcxNUwxOTAuMTgzIDIxMy41NDFWMjEzLjU0MVoiIGZpbGw9IndoaXRlIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjEwIi8+Cjwvc3ZnPgo=">
<p class="text-sm">You can connect Wakapi with the official <a class="underline"
href="https://wakatime.com"
rel="noopener noreferrer"
target="_blank">WakaTime</a> in a way
that all heartbeats sent to Wakapi are relayed. This way, you can use both services
at
the same time. To get started, <a class="underline"
href="https://wakatime.com/developers#authentication"
rel="noopener noreferrer"
target="_blank">get your API key</a> and paste it here.</p>
</div> </div>
<form action="" method="post"> <div class="mt-10 text-gray-300 text-sm">
<input type="hidden" name="action" value="toggle_wakatime"> <h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block" id="apikey">
Reset API Key
</h3>
{{ $placeholderText := "Paste your WakaTime API key here ..." }} <form class="mt-2" action="" method="post">
{{ if .User.WakatimeApiKey }} <input type="hidden" name="action" value="reset_apikey">
{{ $placeholderText = "********" }} <div class="text-gray-300 text-sm mb-4">
{{ end }} <strong>⚠️ Caution:</strong> Resetting your API key requires you to update your <span
class="font-mono">.wakatime.cfg</span> files on all of your computers to make the
<div class="flex items-center mt-8 space-x-2"> WakaTime
<label class="text-gray-500 font-semibold">API Key:</label> client send heartbeats again.
<input type="password" name="api_key" id="wakatime_api_key"
class="flex-grow shadow appearance-nonshadow appearance-none bg-gray-800 text-gray-300 border-green-700 border rounded py-1 px-3 {{ if not .User.WakatimeApiKey }}focus:bg-gray-700 focus:border-gray-500{{ end }}"
placeholder="{{ $placeholderText }}" {{ if .User.WakatimeApiKey }}readonly{{ end }}>
<div class="flex-grow flex justify-end">
{{ if not .User.WakatimeApiKey }}
<button type="submit"
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Connect
</button>
{{ else }}
<button type="submit"
class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">Disconnect
</button>
{{ end }}
</div> </div>
</div>
</form>
<p class="mt-6"> <div class="flex justify-center">
<span class="font-semibold">👉 Please note:</span> <button type="submit"
<span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a class="mt-2 py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94" rel="noopener noreferrer">#94</a>) to be implemented.</span> Reset API Key
</p> </button>
</div> </div>
</div> </form>
</div>
<div class="mt-10 text-gray-300 text-sm">
<div class="w-full mt-4 mb-8 pb-8"> <h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger"> Delete Account
⚠️ Danger Zone </h3>
<p>
Deleting your account will cause all data, including all your heartbeats, to be erased from the
server immediately. This action is irreversible. <strong>Be careful!</strong>
</p>
</div>
<div class="mt-6 flex justify-center">
<form action="" method="post" id="form-delete-user">
<input type="hidden" name="action" value="delete_account">
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
id="btn-confirm-delete-user">
Delete my Account
</button>
</form>
</div>
</div> </div>
<div class="mt-10 text-gray-300 text-sm"> </details>
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
Regenerate summaries
</h3>
<p>
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to
summaries on a per-day basis.
That is, historic summaries, i.e. such from past days, are generated once and only fetched from the
database in a static fashion afterwards, unless you pass <span
class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span>
with your request.
</p>
<p class="mt-2">
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g. you
modified language mappings retrospectively), you may want to re-generate them from raw heartbeats.
</p>
<p class="mt-2">
<strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost is
case heartbeats were deleted after the respective summaries had been generated.
</p>
</div>
<div class="mt-6 flex justify-center">
<form action="" method="post" id="form-regenerate-summaries">
<input type="hidden" name="action" value="regenerate_summaries">
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
id="btn-regenerate-summaries">
Clear & Regenerate
</button>
</form>
</div>
<div class="mt-10 text-gray-300 text-sm">
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
Delete Account
</h3>
<p>
Deleting your account will cause all data, including all your heartbeats, to be erased from the
server immediately. This action is irreversible. <strong>Be careful!</strong>
</p>
</div>
<div class="mt-6 flex justify-center">
<form action="" method="post" id="form-delete-user">
<input type="hidden" name="action" value="delete_account">
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
id="btn-confirm-delete-user">
Delete my Account
</button>
</form>
</div>
</div>
</div> </div>
</main> </main>
@@ -435,10 +539,20 @@
e.setAttribute('src', e.getAttribute('src').replace('%s', baseUrl)) e.setAttribute('src', e.getAttribute('src').replace('%s', baseUrl))
e.classList.remove('hidden') e.classList.remove('hidden')
}) })
document.querySelectorAll('.with-url-src-no-scheme').forEach(e => {
const strippedUrl = baseUrl.replace(/https?:\/\//, '')
e.setAttribute('src', e.getAttribute('src').replace('%s', strippedUrl))
e.classList.remove('hidden')
})
document.querySelectorAll('.with-url-inner').forEach(e => { document.querySelectorAll('.with-url-inner').forEach(e => {
e.innerHTML = e.innerHTML.replace('%s', baseUrl) e.innerHTML = e.innerHTML.replace('%s', baseUrl)
e.classList.remove('hidden') e.classList.remove('hidden')
}) })
document.querySelectorAll('.with-url-inner-no-scheme').forEach(e => {
const strippedUrl = baseUrl.replace(/https?:\/\//, '')
e.innerHTML = e.innerHTML.replace('%s', strippedUrl)
e.classList.remove('hidden')
})
const btnRegenerate = document.querySelector('#btn-regenerate-summaries') const btnRegenerate = document.querySelector('#btn-regenerate-summaries')
const formRegenerate = document.querySelector('#form-regenerate-summaries') const formRegenerate = document.querySelector('#form-regenerate-summaries')
@@ -455,6 +569,14 @@
formDelete.submit() formDelete.submit()
} }
}) })
const btnImportWakatime = document.querySelector('#btn-import-wakatime')
const formImportWakatime = document.querySelector('#form-import-wakatime')
btnImportWakatime.addEventListener('click', () => {
if (confirm('Are you sure? The import can not be undone.')) {
formImportWakatime.submit()
}
})
</script> </script>
{{ template "footer.tpl.html" . }} {{ template "footer.tpl.html" . }}

View File

@@ -41,13 +41,13 @@
<div class="text-white text-sm flex items-center justify-center mt-4 self-center max-w-lg flex-wrap"> <div class="text-white text-sm flex items-center justify-center mt-4 self-center max-w-lg flex-wrap">
<a href="summary?interval=today" class="mx-2 my-1 border-b border-green-700">Today</a> <a href="summary?interval=today" class="mx-2 my-1 border-b border-green-700">Today</a>
<a href="summary?interval=day" class="mx-2 my-1 border-b border-green-700">Yesterday</a> <a href="summary?interval=yesterday" class="mx-2 my-1 border-b border-green-700">Yesterday</a>
<a href="summary?interval=week" class="mx-2 my-1 border-b border-green-700">This Week</a> <a href="summary?interval=week" class="mx-2 my-1 border-b border-green-700">This Week</a>
<a href="summary?interval=month" class="mx-2 my-1 border-b border-green-700">This Month</a> <a href="summary?interval=month" class="mx-2 my-1 border-b border-green-700">This Month</a>
<a href="summary?interval=year" class="mx-2 my-1 border-b border-green-700">This Year</a> <a href="summary?interval=year" class="mx-2 my-1 border-b border-green-700">This Year</a>
<a href="summary?interval=7_days" class="mx-2 my-1 border-b border-green-700">Past 7 Days</a> <a href="summary?interval=last_7_days" class="mx-2 my-1 border-b border-green-700">Past 7 Days</a>
<a href="summary?interval=30_days" class="mx-2 my-1 border-b border-green-700">Past 30 Days</a> <a href="summary?interval=last_30_days" class="mx-2 my-1 border-b border-green-700">Past 30 Days</a>
<a href="summary?interval=12_months" class="mx-2 my-1 border-b border-green-700">Past 12 Months</a> <a href="summary?interval=last_12_months" class="mx-2 my-1 border-b border-green-700">Past 12 Months</a>
<a href="summary?interval=any" class="mx-2 my-1 border-b border-green-700">All Time</a> <a href="summary?interval=any" class="mx-2 my-1 border-b border-green-700">All Time</a>
</div> </div>