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

Compare commits

...

55 Commits

Author SHA1 Message Date
a3136ebb13 chore: fix upload relese 2022-05-20 12:01:34 +08:00
4a4d0dad4b chore: merge mapi, test, build to ci.yml & keep release in release.yml 2022-05-20 11:46:29 +08:00
3b87511f48 chore: try use include 2022-05-19 14:12:00 +08:00
f5fba04097 chore: try keep same name 2022-05-19 13:50:43 +08:00
ad566993ad chore: fix release.yml 2022-05-19 13:10:23 +08:00
5f1e498454 chore: fix release.yml 2022-05-19 13:09:50 +08:00
2e0f79df3b chore: try merge release action 2022-05-19 13:06:11 +08:00
4a4e19fcbd fix: error when querying with label filter (resolve #374) 2022-05-16 23:23:18 +02:00
45d4ba89f5 fix: update swaggo dependency 2022-05-13 16:19:35 +02:00
29b3e619ca chore: update mapi org 2022-05-13 16:13:55 +02:00
1a85ebc0f7 Merge branch 'mapi' of https://github.com/mayhemheroes/wakapi into mayhemheroes-mapi 2022-05-13 16:12:45 +02:00
4bd58789f4 fix: server error when passing empty heartbeats slice
fix: do not allow to set id for diagnostics inputs
chore: remove authentication for diagnostics endpoint from swagger docs
2022-05-13 16:12:18 +02:00
09d1124794 fix: work around invalid all_time_since_today data schema to fix failing import (resolve #370) 2022-05-12 00:59:42 +02:00
41584bdd82 add Mayhem for API as a github workflow 2022-05-11 10:27:37 -07:00
1b7baf6fc9 fix: explicitly set default value for unique columns (fix #367) 2022-05-07 23:17:15 +02:00
a76db3e95f fix: clear summary cache on new project label (resolve #369) 2022-05-07 09:37:10 +02:00
74a5226e73 Merge pull request #364 from bdeshi/fork
chore: remove hard-coded volume from Dockerfile
2022-04-24 20:28:22 +02:00
d245b1e5d0 chore: remove hard-coded volume from Dockerfile 2022-04-24 17:33:42 +06:00
d5eff46651 fix: summary page layout 2022-04-24 09:33:04 +02:00
30a65b4de9 chore: minor changes to vibrant colors toggling 2022-04-24 09:26:04 +02:00
9048a8eb7a Merge branch 'master' into fork 2022-04-24 03:56:18 +06:00
1f19c5e93c feat: make vibrantColors a localStorage setting 2022-04-24 03:39:08 +06:00
4b0a3cf0d6 fix: index error during summary generation (resolve #361)
chore(sentry): include stacktrace with panics
2022-04-20 21:36:39 +02:00
d778612242 fix: remove authentication requirement from diagnostics endpoint 2022-04-18 21:32:30 +02:00
ff7d595a86 chore: do not run expensive jobs initially but only scheduled 2022-04-18 21:16:27 +02:00
9d7688957f chore: explicit width and height for front page images [ci skip] 2022-04-18 19:28:30 +02:00
179042f81b refactor: use cross join instead of subquery for populating summary items (see #350) 2022-04-18 17:15:09 +02:00
e6441f124c chore: adapt tests 2022-04-18 16:14:58 +02:00
15c391d1d4 chore: downgrade postgres driver 2022-04-18 16:07:52 +02:00
91c765202c fix: prevent large difference between aggregated and recomputed summaries (resolve #354) 2022-04-18 16:06:32 +02:00
5276f68918 fix: double counting when using precise missing intervals 2022-04-18 15:18:01 +02:00
e774039831 chore: fall back to today badge on project page 2022-04-18 11:49:06 +02:00
40067d252e fix: non-ascii project badges (resolve #357)
chore: locally generated badges (resolve #348)
2022-04-18 11:39:26 +02:00
1a47243f70 chore: make summary items subquery unique by summary id 2022-04-13 00:05:18 +02:00
a1f6c2884b Merge remote-tracking branch 'origin/master' 2022-04-03 18:03:25 +02:00
977420c68d fix: failing heartbeats index auto-migration on sqlite (resolve #346) 2022-04-03 18:03:09 +02:00
3ae66a3898 docs: add public url parameter to readme (resolve #349) [ci skip] 2022-04-02 09:26:14 +02:00
5c5c462035 docs: update readme
- adds `app.vibrant_color` config description
- adds missing `server.public_url` config description
- removes trailing spaces
- makes letter cases consistent in headings
- misc. typo fix and adjustments
2022-03-28 03:12:54 +06:00
f6cc489425 feat: allow toggling vibrant color for all charts
- supports new config key `app.vibrant_color` or env `WAKAPI_VIBRANT_COLOR`
- updates and extends `data/colors.json` with editor and os colors
- fixes #343
2022-03-28 01:56:13 +06:00
5aae18e241 refactor: replace most custom date util functions by lancet ones
refactor: add precision mode to missing intervals function
2022-03-25 12:51:40 +01:00
8a731a252a chore: ditch lo module again 2022-03-25 11:34:16 +01:00
8fc0d78f64 refactor: include generics based utility lib and refactor some parts accordingly [ci-skip] 2022-03-20 16:40:14 +01:00
a675417ab9 chore: upgrade dependencies
chore: update dockerfile to build with go 1.18
2022-03-20 16:40:14 +01:00
408d9086e7 fix(ci): use go 1.18 in ci build 2022-03-20 16:39:38 +01:00
8f933d8648 chore: upgrade dependencies
chore: update dockerfile to build with go 1.18
2022-03-20 16:29:13 +01:00
bbc85de34b chore: metrics performance improvements 2022-03-19 10:30:32 +01:00
ec70d024fa fix: remove user property of diagnostics as sent without auth 2022-03-19 09:27:13 +01:00
eae45baf38 chore: allow heartbeats from one hour into the future to compensate for clock inaccuracies (see #342) 2022-03-19 09:02:15 +01:00
4cea50b5c8 chore: add user project index on heartbeats table 2022-03-19 08:57:33 +01:00
e4814431e0 feat: add database size metric 2022-03-18 18:20:13 +01:00
91b4cb2c13 fix: explicit milliseconds precision of timestamp columns 2022-03-18 13:48:28 +01:00
a3acdc7041 fix: duration aggregation for heartbeats with identical timestamps (resolve #340) 2022-03-18 12:29:43 +01:00
e7e5254673 feat: ability to clear all user data (resolve #339) 2022-03-17 11:55:13 +01:00
8e558d8dee chore: introduce heartbeat max age 2022-03-17 11:35:20 +01:00
b763c4acc6 fix(perf): speed up summary retrieval of all time interval (resolve #336) 2022-03-17 11:08:40 +01:00
89 changed files with 2849 additions and 2223 deletions

97
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,97 @@
name: ci
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.18
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Unit Tests
run: go test ./... -run ./...
- name: API Tests
run: |
npm -g install newman
./testing/run_api_tests.sh
mapi:
name: 'Mayhem for API'
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.18
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Build
run: GO111MODULE=on go build -v .
- name: start wakapi
run: ./wakapi --config config.default.yml &
- name: create a trivial testing user
run: sqlite3 wakapi_db.db "insert into users (id, api_key) values ('mapi', 'test-api-key')"
- name: Run Mayhem for API
uses: ForAllSecure/mapi-action@v1
continue-on-error: true
with:
mapi-token: ${{ secrets.MAPI_TOKEN }}
api-url: http://localhost:3000/api/
api-spec: static/docs/swagger.yaml
target: muety/wakapi
duration: 1min
sarif-report: mapi.sarif
run-args: |
--header-auth
Authorization: Basic dGVzdC1hcGkta2V5
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v1
with:
sarif_file: mapi.sarif
build:
strategy:
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.18
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Build
run: go build -v .

View File

@ -1,55 +0,0 @@
name: Linux
on:
push:
branches:
pull_request:
release:
types:
- published
jobs:
build-and-release:
name: Linux - Build, Test & Release
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.16
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Unit Tests
run: go test ./... -run ./...
- name: API Tests
run: |
npm -g install newman
./testing/run_api_tests.sh
- name: Build
run: GO111MODULE=on go build -v .
- name: Zip executable and sample config
if: github.event_name == 'release'
run: |
cp config.default.yml config.yml
zip -9 release.zip wakapi config.yml
- name: Upload built executable to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: release.zip
asset_name: wakapi_linux_amd64.zip
asset_content_type: application/gzip

55
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,55 @@
name: Release
on:
release:
types:
- published
jobs:
release:
name: Release
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
include:
- platform: ubuntu-latest
alias: linux
- platform: macos-latest
alias: mac
- platform: windows-latest
alias: win
runs-on: ${{ matrix.platform }}
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.18
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- name: Build
run: go build -v .
- name: Compress working folder on Windows
if: runner.os == 'Windows'
run: |
cp .\config.default.yml .\config.yml
Compress-Archive -Path .\wakapi.exe, .\config.yml -DestinationPath wakapi_${{ matrix.alias }}_amd64.zip
- name: Compress working folder on Unix
if: runner.os != 'Windows'
run: |
cp config.default.yml config.yml
zip -9 wakapi_${{ matrix.alias }}_amd64.zip wakapi config.yml
- name: Upload built executable to Release
uses: softprops/action-gh-release@v1
with:
files: wakapi_${{ matrix.alias }}_amd64.zip

View File

@ -1,51 +0,0 @@
name: Windows
on:
push:
branches:
pull_request:
release:
types:
- published
jobs:
build-and-release:
name: Windows - Build & Release
runs-on: windows-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.16
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: |
go get
- name: Enable Go 1.11 modules
run: cmd /c "set GO111MODULE=on"
- name: Build
run: go build -v .
- name: Compress working folder
if: github.event_name == 'release'
run: |
cp .\config.default.yml .\config.yml
Compress-Archive -Path .\wakapi.exe, .\config.yml -DestinationPath release.zip
- name: Upload built executable to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: release.zip
asset_name: wakapi_win_amd64.zip
asset_content_type: application/gzip

View File

@ -1,7 +1,7 @@
# To build locally: docker buildx build . -t wakapi --load
# Preparation to save some time
FROM --platform=$BUILDPLATFORM golang:1.17-alpine AS prep-env
FROM --platform=$BUILDPLATFORM golang:1.18-alpine AS prep-env
WORKDIR /src
ADD ./go.mod .
@ -12,7 +12,7 @@ RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-fo
chmod +x wait-for-it.sh
# Build Stage
FROM golang:1.17-alpine AS build-env
FROM golang:1.18-alpine AS build-env
# Required for go-sqlite3
RUN apk add --no-cache gcc musl-dev
@ -54,8 +54,6 @@ ENV WAKAPI_ALLOW_SIGNUP 'true'
COPY --from=build-env /app .
VOLUME /data
EXPOSE 3000
ENTRYPOINT /app/entrypoint.sh

146
README.md
View File

@ -6,7 +6,7 @@
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
<a href="#-treeware"><img src="https://badges.fw-web.space:/treeware/trees/muety/wakapi?color=%234EC820&label=%F0%9F%8C%B3%20trees"></a>
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
<img src="https://wakapi.dev/api/badge/n1try/interval:any/project:wakapi?label=wakapi">
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
@ -35,11 +35,12 @@
Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki).
## 🚀 Features
* ✅ 100 % free and open-source
* ✅ Built by developers for developers
* ✅ Statistics for projects, languages, editors, hosts and operating systems
* ✅ Badges
* ✅ Weekly E-Mail Reports
* ✅ Weekly E-Mail reports
* ✅ REST API
* ✅ Partially compatible with WakaTime
* ✅ WakaTime integration
@ -48,20 +49,25 @@ Installation instructions can be found below and in the [Wiki](https://github.co
* ✅ 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), [#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).
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), [#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?
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.
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)
If you want to try out a free, hosted cloud service, all you need to do is create an account and then set up your client-side tooling (see below).
### 📦 Option 2: Quick-run a Release
### 📦 Option 2: Quick-run a release
```bash
$ curl -L https://wakapi.dev/get | bash
```
### 🐳 Option 3: Use Docker
```bash
# Create a persistent volume
$ docker volume create wakapi-data
@ -82,14 +88,17 @@ $ docker run -d \
If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) for quick and easy deployment.
### 🧑‍💻 Option 4: Compile and run from source
#### Prerequisites
* Go >= 1.16 (with `$GOPATH` properly set)
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
* Fedora / RHEL: `dnf install @development-tools`
* Ubuntu / Debian: `apt install build-essential`
* Windows: See [here](https://github.com/mattn/go-sqlite3/issues/214#issuecomment-253216476)
#### Compile & Run
#### Prerequisites
* Go >= 1.18
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
* Fedora / RHEL: `dnf install @development-tools`
* Ubuntu / Debian: `apt install build-essential`
* Windows: See [here](https://github.com/mattn/go-sqlite3/issues/214#issuecomment-253216476)
#### Compile & run
```bash
# Build the executable
$ go build -o wakapi
@ -102,13 +111,14 @@ $ vi config.yml
$ ./wakapi
```
**Note:** Check the comments `config.yml` for best practices regarding security configuration and more.
**Note:** Check the comments in `config.yml` for best practices regarding security configuration and more.
### 💻 Client setup
### 💻 Client Setup
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics for Wakapi, you need to set them up.
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
2. **Editing your local `~/.wakatime.cfg`** file as follows
2. **Edit your local `~/.wakatime.cfg`** file as follows.
```ini
[settings]
@ -122,12 +132,18 @@ api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
Optionally, you can set up a [client-side proxy](https://github.com/muety/wakapi/wiki/Advanced-Setup:-Client-side-proxy) in addition.
## 🔧 Configuration Options
## 🔧 Configuration options
You can specify configuration options either via a config file (default: `config.yml`, customizable through the `-c` argument) or via environment variables. Here is an overview of all options.
| YAML Key / Env. Variable | Default | Description |
| YAML key / Env. variable | Default | Description |
|------------------------------------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `02:15` | Time of day at which to periodically run summary generation for all users |
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `fri,18:00` | Week day and time at which to send e-mail reports |
| `app.import_batch_size` /<br>`WAKAPI_IMPORT_BATCH_SIZE` | `50` | Size of batches of heartbeats to insert to the database during importing from external services |
| `app.inactive_days` /<br>`WAKAPI_INACTIVE_DAYS` | `7` | Number of days after which to consider a user inactive (only for metrics) |
| `app.heartbeat_max_age /`<br>`WAKAPI_HEARTBEAT_MAX_AGE` | `4320h` | Maximum acceptable age of a heartbeat (see [`ParseDuration`](https://pkg.go.dev/time#ParseDuration)) |
| `app.custom_languages` | - | Map from file endings to language names |
| `app.avatar_url_template` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
| `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
@ -138,6 +154,7 @@ You can specify configuration options either via a config file (default: `config
| `server.tls_cert_path` /<br> `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` /<br> `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `server.base_path` /<br> `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `server.public_url` /<br> `WAKAPI_PUBLIC_URL` | `http://localhost:3000` | URL at which your Wakapi instance can be found publicly |
| `security.password_salt` /<br> `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `security.insecure_cookies` /<br> `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `security.cookie_max_age` /<br> `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
@ -161,34 +178,40 @@ You can specify configuration options either via a config file (default: `config
| `mail.smtp.username` /<br> `WAKAPI_MAIL_SMTP_USER` | - | SMTP server authentication username |
| `mail.smtp.password` /<br> `WAKAPI_MAIL_SMTP_PASS` | - | SMTP server authentication password |
| `mail.smtp.tls` /<br> `WAKAPI_MAIL_SMTP_TLS` | `false` | Whether the SMTP server requires TLS encryption (`false` for STARTTLS or no encryption) |
| `mail.mailwhale.url` /<br> `WAKAPI_MAIL_MAILWHALE_URL` | - | URL of [MailWhale](https://mailwhale.dev) instance (e.g. `https://mailwhale.dev`) (if using `mailwhale` mail provider`) |
| `mail.mailwhale.url` /<br> `WAKAPI_MAIL_MAILWHALE_URL` | - | URL of [MailWhale](https://mailwhale.dev) instance (e.g. `https://mailwhale.dev`) (if using `mailwhale` mail provider) |
| `mail.mailwhale.client_id` /<br> `WAKAPI_MAIL_MAILWHALE_CLIENT_ID` | - | MailWhale API client ID |
| `mail.mailwhale.client_secret` /<br> `WAKAPI_MAIL_MAILWHALE_CLIENT_SECRET` | - | MailWhale API client secret |
| `sentry.dsn` /<br> `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
| `sentry.enable_tracing` /<br> `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
| `sentry.sample_rate` /<br> `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
| `sentry.sample_rate_heartbeats` /<br> `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
| `sentry.sample_rate_heartbeats` /<br> `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeat request in Sentry |
| `quick_start` /<br> `WAKAPI_QUICK_START` | `false` | Whether to skip initial boot tasks. Use only for development purposes! |
### Supported databases
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
* [SQLite](https://sqlite.org/) (_default, easy setup_)
* [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_)
* [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_)
* [Postgres](https://hub.docker.com/_/postgres) (_open-source as well_)
* [CockroachDB](https://www.cockroachlabs.com/docs/stable/install-cockroachdb-linux.html) (_cloud-native, distributed, Postgres-compatible API_)
## 🔧 API Endpoints
## 🔧 API endpoints
See our [Swagger API Documentation](https://wakapi.dev/swagger-ui).
### Generating Swagger docs
```bash
$ go get -u github.com/swaggo/swag/cmd/swag
$ go install github.com/swaggo/swag/cmd/swag@latest
$ swag init -o static/docs
```
## 🤝 Integrations
### Prometheus Export
### Prometheus export
You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how.
```bash
@ -203,6 +226,7 @@ $ echo "<YOUR_API_KEY>" | base64
```
#### Scrape config example
```yml
# prometheus.yml
# (assuming your Wakapi instance listens at localhost, port 3000)
@ -216,15 +240,18 @@ scrape_configs:
- targets: ['localhost:3000']
```
#### Grafana
#### Grafana
There is also a [nice Grafana dashboard](https://grafana.com/grafana/dashboards/12790), provided by the author of [wakatime_exporter](https://github.com/MacroPower/wakatime_exporter).
![](https://grafana.com/api/dashboards/12790/images/8741/image)
### 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.
### 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
### 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)
@ -232,20 +259,20 @@ Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra
<details>
<summary>Click to view code</summary>
```md
```markdown
![](https://github-readme-stats.vercel.app/api/wakatime?username={yourusername}&api_domain=wakapi.dev&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact)
```
</details>
<br>
### Github Readme Metrics integration
### Github Readme Metrics Integration
There is a [WakaTime plugin](https://github.com/lowlighter/metrics/tree/master/source/plugins/wakatime) for GitHub [metrics](https://github.com/lowlighter/metrics/) that is also compatible with Wakapi.
There is a [WakaTime plugin](https://github.com/lowlighter/metrics/tree/master/source/plugins/wakatime) for GitHub [Metrics](https://github.com/lowlighter/metrics/) that is also compatible with Wakapi.
Preview:
![](https://raw.githubusercontent.com/lowlighter/lowlighter/master/metrics.plugin.wakatime.svg)
![](https://raw.githubusercontent.com/lowlighter/metrics/examples/metrics.plugin.wakatime.svg)
<details>
<summary>Click to view code</summary>
@ -259,7 +286,7 @@ Preview:
plugin_wakatime_days: 7 # Display last week stats
plugin_wakatime_sections: time, projects, projects-graphs # Display time and projects sections, along with projects graphs
plugin_wakatime_limit: 4 # Show 4 entries per graph
plugin_wakatime_url: http://wakapi.dev # Wakatime url endpoint
plugin_wakatime_url: http://wakapi.dev # Wakatime url endpoint
plugin_wakatime_user: .user.login # User
```
@ -267,20 +294,26 @@ Preview:
</details>
<br>
## 👍 Best Practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
## 👍 Best practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or [nginx](https://www.nginx.com/), 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`.
## 🧪 Tests
### Unit Tests
### Unit tests
Unit tests are supposed to test business logic on a fine-grained level. They are implemented as part of the application, using Go's [testing](https://pkg.go.dev/testing?utm_source=godoc) package alongside [stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify).
#### How to run
```bash
$ CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
```
### API Tests
### API tests
API tests are implemented as black box tests, which interact with a fully-fledged, standalone Wakapi through HTTP requests. They are supposed to check Wakapi's web stack and endpoints, including response codes, headers and data on a syntactical level, rather than checking the actual content that is returned.
Our API (or end-to-end, in some way) tests are implemented as a [Postman](https://www.postman.com/) collection and can be run either from inside Postman, or using [newman](https://www.npmjs.com/package/newman) as a command-line runner.
@ -288,6 +321,7 @@ Our API (or end-to-end, in some way) tests are implemented as a [Postman](https:
To get a predictable environment, tests are run against a fresh and clean Wakapi instance with a SQLite database that is populated with nothing but some seed data (see [data.sql](testing/data.sql)). It is usually recommended for software tests to be [safe](https://www.restapitutorial.com/lessons/idempotency.html), stateless and without side effects. In contrary to that paradigm, our API tests strictly require a fixed execution order (which Postman assures) and their assertions may rely on specific previous tests having succeeded.
#### Prerequisites (Linux only)
```bash
# 1. sqlite (cli)
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
@ -296,26 +330,31 @@ $ sudo apt install sqlite # Fedora: sudo dnf install sqlite
$ npm install -g newman
```
#### How to run (Linux only)
#### How to run (Linux only)
```bash
$ ./testing/run_api_tests.sh
```
## 🤓 Developer Notes
## 🤓 Developer notes
### Building web assets
To keep things minimal, all JS and CSS assets are included as static files and checked in to Git. [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) and [Iconify](https://iconify.design/docs/icon-bundles/) require an additional build step. To only require this at the time of development, the compiled assets are checked in to Git as well.
To keep things minimal, all JS and CSS assets are included as static files and checked in to Git. [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) and [Iconify](https://iconify.design/docs/icon-bundles/) require an additional build step. To only require this at the time of development, the compiled assets are checked in to Git as well.
```bash
$ yarn
$ yarn build # or: yarn watch
$ yarn build # or: yarn watch
```
New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js).
#### Precompression
As explained in [#284](https://github.com/muety/wakapi/issues/284), precompressed (using Brotli) versions of some of the assets are delivered to save additional bandwidth. This was inspired by Caddy's [`precompressed`](https://caddyserver.com/docs/caddyfile/directives/file_server) directive. [`gzipped.FileServer`](https://github.com/muety/wakapi/blob/07a367ce0a97c7738ba8e255e9c72df273fd43a3/main.go#L249) checks for every static file's `.br` or `.gz` equivalents and, if present, delivers those instead of the actual file, alongside `Content-Encoding: br`. Currently, compressed assets are simply checked in to Git. Later we might want to have this be part of a new build step.
To pre-compress files, run this:
```bash
# Install brotli first
$ sudo apt install brotli # or: sudo dnf install brotli
@ -331,35 +370,36 @@ $ yarn compress
```
## ❔ FAQs
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) apply to Wakapi for large parts as well. You might find answers there.
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) largely apply to Wakapi as well. You might find answers there.
<details>
<summary><b>What data is sent to Wakapi?</b></summary>
<summary><b>What data are sent to Wakapi?</b></summary>
<ul>
<li>File names</li>
<li>Project names</li>
<li>Editor names</li>
<li>You computer's host name</li>
<li>Your computer's host name</li>
<li>Timestamps for every action you take in your editor</li>
<li>...</li>
</ul>
See the related [WakaTime FAQ section](https://wakatime.com/faq#data-collected) for details.
If you host Wakapi yourself, you have control over all your data. However, if you use our webservice and are concerned about privacy, you can also [exclude or obfuscate](https://wakatime.com/faq#exclude-paths) certain file- or project names.
If you host Wakapi yourself, you have control over all your data. However, if you use our webservice and are concerned about privacy, you can also [exclude or obfuscate](https://wakatime.com/faq#exclude-paths) certain file- or project names.
</details>
<details>
<summary><b>What happens if I'm offline?</b></summary>
All data is cached locally on your machine and sent in batches once you're online again.
All data are cached locally on your machine and sent in batches once you're online again.
</details>
<details>
<summary><b>How did Wakapi come about?</b></summary>
Wakapi was started when I was a student, who wanted to track detailed statistics about my coding time. Although I'm a big fan of WakaTime I didn't want to pay <a href="https://wakatime.com/pricing">$9 a month</a> back then. Luckily, most parts of WakaTime are open source!
Wakapi was started when I was a student, who wanted to track detailed statistics about my coding time. Although I'm a big fan of WakaTime I didn't want to pay <a href="https://wakatime.com/pricing">$9 a month</a> back then. Luckily, most parts of WakaTime are open source!
</details>
<details>
@ -372,11 +412,11 @@ Wakapi is a small subset of WakaTime and has a lot less features. Cool WakaTime
<li><a href="https://wakatime.com/share/embed">Embeddable Charts</a></li>
<li>Personal Goals</li>
<li>Team- / Organization Support</li>
<li>Integrations (with GitLab, etc.)</li>
<li>Additional Integrations (with GitLab, etc.)</li>
<li>Richer API</li>
</ul>
WakaTime is worth the price. However, if you only want basic statistics and keep sovereignty over your data, you might want to go with Wakapi.
WakaTime is worth the price. However, if you only need basic statistics and like to keep sovereignty over your data, you might want to go with Wakapi.
</details>
<details>
@ -386,13 +426,13 @@ Inferring a measure for your coding time from heartbeats works a bit differently
Here is an example (circles are heartbeats):
```
```text
|---o---o--------------o---o---|
| |10s| 3m |10s| |
```
It is unclear how to handle the three minutes in between. Did the developer do a 3-minute break or were just no heartbeats being sent, e.g. because the developer was starring at the screen trying to find a solution, but not actually typing code.
It is unclear how to handle the three minutes in between. Did the developer do a 3-minute break, or were just no heartbeats being sent, e.g. because the developer was staring at the screen trying to find a solution, but not actually typing code?
<ul>
<li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec
@ -404,17 +444,21 @@ Wakapi adds a "padding" of two minutes before the third heartbeat. This is why t
</details>
## 🌳 Treeware
This package is [Treeware](https://treeware.earth). If you use it in production, then we ask that you [**buy the world a tree**](https://plant.treeware.earth/muety/wakapi) to thank us for our work. By contributing to the Treeware forest youll be creating employment for local families and restoring wildlife habitats.
## 👏 Support
Coding in open source is my passion and I would love to do it on a full-time basis and make a living from it one day. So if you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts my motivation to keep improving Wakapi!
## 🙏 Thanks
I highly appreciate the efforts of **[@alanhamlett](https://github.com/alanhamlett)** and the WakaTime team and am thankful for their software being open source.
I highly appreciate the efforts of **[@alanhamlett](https://github.com/alanhamlett)** and the WakaTime team and am thankful for their software being open source.
Moreover, thanks to **[JetBrains](https://jb.gg/OpenSource)** for supporting this project as part of their open-source program.
![](static/assets/images/jetbrains-logo.png)
## 📓 License
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)

View File

@ -16,6 +16,7 @@ app:
report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>')
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
custom_languages:
vue: Vue
jsx: JSX
@ -71,4 +72,5 @@ mail:
client_id:
client_secret:
quick_start: false # whether to skip initial tasks on application startup, like summary generation
quick_start: false # whether to skip initial tasks on application startup, like summary generation
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes

View File

@ -68,6 +68,7 @@ type appConfig struct {
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg"`
CustomLanguages map[string]string `yaml:"custom_languages"`
@ -141,16 +142,17 @@ type SMTPMailConfig struct {
}
type Config struct {
Env string `default:"dev" env:"ENVIRONMENT"`
Version string `yaml:"-"`
QuickStart bool `yaml:"quick_start" env:"WAKAPI_QUICK_START"`
InstanceId string `yaml:"-"` // only temporary, changes between runs
App appConfig
Security securityConfig
Db dbConfig
Server serverConfig
Sentry sentryConfig
Mail mailConfig
Env string `default:"dev" env:"ENVIRONMENT"`
Version string `yaml:"-"`
QuickStart bool `yaml:"quick_start" env:"WAKAPI_QUICK_START"`
SkipMigrations bool `yaml:"skip_migrations" env:"WAKAPI_SKIP_MIGRATIONS"`
InstanceId string `yaml:"-"` // only temporary, changes between runs
App appConfig
Security securityConfig
Db dbConfig
Server serverConfig
Sentry sentryConfig
Mail mailConfig
}
func (c *Config) CreateCookie(name, value string) *http.Cookie {
@ -242,6 +244,11 @@ func (c *appConfig) GetWeeklyReportTime() string {
return strings.Split(c.ReportTimeWeekly, ",")[1]
}
func (c *appConfig) HeartbeatsMaxAge() time.Duration {
d, _ := time.ParseDuration(c.HeartbeatMaxAge)
return d
}
func (c *dbConfig) IsSQLite() bool {
return c.Dialect == "sqlite3"
}
@ -400,6 +407,9 @@ func Load(version string) *Config {
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
logbuch.Fatal("invalid interval set for aggregation_time")
}
if _, err := time.ParseDuration(config.App.HeartbeatMaxAge); err != nil {
logbuch.Fatal("invalid duration set for heartbeat_max_age")
}
Set(config)
return Get()

View File

@ -109,8 +109,9 @@ var excludedRoutes = []string{
func initSentry(config sentryConfig, debug bool) {
if err := sentry.Init(sentry.ClientOptions{
Dsn: config.Dsn,
Debug: debug,
Dsn: config.Dsn,
Debug: debug,
AttachStacktrace: true,
TracesSampler: sentry.TracesSamplerFunc(func(ctx sentry.SamplingContext) sentry.Sampled {
if !config.EnableTracing {
return sentry.SampledFalse

File diff suppressed because it is too large Load Diff

View File

@ -2,246 +2,417 @@
"languages": {
"1C Enterprise": "#814CCC",
"ABAP": "#E8274B",
"ActionScript": "#882B0F",
"Ada": "#02f88c",
"Agda": "#315665",
"AGS Script": "#B9D9FF",
"Alloy": "#64C800",
"AL": "#3AA2B5",
"AMPL": "#E6EFBB",
"AngelScript": "#C7D7DC",
"ANTLR": "#9DC3FF",
"API Blueprint": "#2ACCA8",
"APL": "#5A8164",
"AppleScript": "#101F1F",
"Arc": "#aa2afe",
"ASP": "#6a40fd",
"AspectJ": "#a957b0",
"Assembly": "#6E4C13",
"Asymptote": "#4a0c0c",
"APL": "#8a0707",
"ASP.NET": "#9400ff",
"ATS": "#1ac620",
"ActionScript": "#e3491a",
"Ada": "#02f88c",
"Agda": "#467C91",
"Alloy": "#cc5c24",
"AngelScript": "#C7D7DC",
"Apex": "#1797c0",
"Apollo Guidance Computer": "#0B3D91",
"AppleScript": "#101F1F",
"Arc": "#ca2afe",
"Arduino": "#bd79d1",
"AspectJ": "#1957b0",
"Assembly": "#6E4C13",
"Asymptote": "#ff0000",
"Augeas": "#62331f",
"AutoHotkey": "#6594b9",
"AutoIt": "#1C3552",
"AutoIt": "#36699B",
"Ballerina": "#FF5000",
"Batchfile": "#C1F12E",
"Beef": "#a52f4e",
"Bison": "#6A463F",
"Blade": "#f7523f",
"BlitzMax": "#cd6400",
"Boo": "#d4bec1",
"Boogie": "#c80fa0",
"Brainfuck": "#2F2530",
"Browserslist": "#ffd539",
"C": "#555555",
"C#": "#178600",
"C Sharp": "#178600",
"C#": "#5a25a2",
"C++": "#f34b7d",
"CSON": "#244776",
"CSS": "#563d7c",
"Ceylon": "#dfa535",
"Chapel": "#8dc63f",
"Cirru": "#ccccff",
"Cirru": "#aaaaff",
"Clarion": "#db901e",
"Clean": "#3F85AF",
"Classic ASP": "#6a40fd",
"Clean": "#3a81ad",
"Click": "#E4E6F3",
"Clojure": "#db5855",
"Closure Templates": "#0d948f",
"CoffeeScript": "#244776",
"ColdFusion": "#ed2cd6",
"ColdFusion CFC": "#ed2cd6",
"Common Lisp": "#3fb68b",
"Common Workflow Language": "#B5314C",
"Component Pascal": "#B0CE4E",
"Component Pascal": "#b0ce4e",
"Crystal": "#000100",
"CSS": "#563d7c",
"Cuda": "#3A4E3A",
"D": "#ba595e",
"Dart": "#00B4AB",
"D": "#fcd46d",
"DM": "#075ff1",
"Dafny": "#FFEC25",
"Dart": "#98BAD6",
"DataWeave": "#003a52",
"DM": "#447265",
"Denizen": "#faf094",
"Dhall": "#dfafff",
"Dockerfile": "#384d54",
"Docker": "#384d54",
"Dogescript": "#cca760",
"Dylan": "#6c616e",
"Dylan": "#3ebc27",
"E": "#ccce35",
"eC": "#913960",
"ECL": "#8a1267",
"EJS": "#a91e50",
"EQ": "#a78649",
"Eagle": "#3994bc",
"Eiffel": "#946d57",
"Elixir": "#6e4a7e",
"Elm": "#60B5CC",
"Emacs Lisp": "#c065db",
"EmberScript": "#FFF4F3",
"EQ": "#a78649",
"Erlang": "#B83998",
"EmberScript": "#f64e3e",
"Erlang": "#0faf8d",
"F#": "#b845fc",
"F*": "#572e30",
"FLUX": "#33CCFF",
"FORTRAN": "#4d41b1",
"Factor": "#636746",
"Fancy": "#7b9db4",
"Fantom": "#14253c",
"FLUX": "#88ccff",
"Fantom": "#dbded5",
"Faust": "#c37240",
"Forth": "#341708",
"Fortran": "#4d41b1",
"FreeMarker": "#0050b2",
"Frege": "#00cafe",
"Game Maker Language": "#71b417",
"Futhark": "#5f021f",
"G-code": "#D08CF2",
"GAML": "#FFC766",
"GDScript": "#355570",
"Game Maker Language": "#8ad353",
"Genie": "#fb855d",
"Gherkin": "#5B2063",
"Glyph": "#c1ac7f",
"Glyph": "#e4cc98",
"Gnuplot": "#f0a9f0",
"Go": "#00ADD8",
"Golo": "#88562A",
"Go": "#375eab",
"Golo": "#f6a51f",
"Gosu": "#82937f",
"Grammatical Framework": "#79aa7a",
"Grammatical Framework": "#ff0000",
"GraphQL": "#e10098",
"Groovy": "#e69f56",
"HTML": "#e44b23",
"Hack": "#878787",
"Haml": "#ece2a9",
"Handlebars": "#f7931e",
"Harbour": "#0e60e3",
"Haskell": "#5e5086",
"Haxe": "#df7900",
"Haskell": "#29b544",
"Haxe": "#f7941e",
"HiveQL": "#dce200",
"HTML": "#e34c26",
"Hy": "#7790B2",
"IDL": "#a3522f",
"HolyC": "#ffefaf",
"Hy": "#7891b1",
"IDL": "#e3592c",
"IGOR Pro": "#0000cc",
"Idris": "#b30000",
"ImageJ Macro": "#99AAFF",
"Io": "#a9188d",
"Ioke": "#078193",
"Isabelle": "#FEFE00",
"Isabelle": "#fdcd00",
"J": "#9EEDFF",
"JFlex": "#DBCA00",
"JSONiq": "#40d47e",
"Java": "#b07219",
"JavaScript": "#f1e05a",
"Jolie": "#843179",
"JSONiq": "#40d47e",
"Jsonnet": "#0064bd",
"Julia": "#a270ba",
"Jupyter Notebook": "#DA5B0B",
"KRL": "#f5c800",
"Kaitai Struct": "#773b37",
"Kotlin": "#F18E33",
"KRL": "#28430A",
"Lasso": "#999999",
"Lex": "#DBCA00",
"LFE": "#4C3023",
"LiveScript": "#499886",
"LFE": "#004200",
"LLVM": "#185619",
"LOLCODE": "#cc9900",
"LookML": "#652B81",
"LSL": "#3d9970",
"Lua": "#000080",
"Makefile": "#427819",
"Mask": "#f97732",
"Lark": "#0b130f",
"Lasso": "#2584c3",
"Latte": "#A8FF97",
"Less": "#1d365d",
"Lex": "#DBCA00",
"Liquid": "#67b8de",
"LiveScript": "#499886",
"LookML": "#652B81",
"Lua": "#fa1fa1",
"MATLAB": "#e16737",
"Max": "#c4a79c",
"MAXScript": "#00a6a6",
"mcfunction": "#E22837",
"Mercury": "#ff2b2b",
"MLIR": "#5EC8DB",
"MQL4": "#62A8D6",
"MQL5": "#4A76B8",
"MTML": "#0095d9",
"Macaulay2": "#d8ffff",
"Makefile": "#427819",
"Markdown": "#083fa1",
"Marko": "#42bff2",
"Mask": "#f97732",
"Matlab": "#bb92ac",
"Max": "#ce279c",
"Mercury": "#abcdef",
"Meson": "#007800",
"Metal": "#8f14e9",
"Mirah": "#c7a938",
"Modula-3": "#223388",
"MQL4": "#62A8D6",
"MQL5": "#4A76B8",
"MTML": "#b7e1f4",
"Mustache": "#724b3b",
"NCL": "#28431f",
"NWScript": "#111522",
"Nearley": "#990000",
"Nemerle": "#3d3c6e",
"nesC": "#94B0C7",
"Nemerle": "#0d3c6e",
"NetLinx": "#0aa0ff",
"NetLinx+ERB": "#747faa",
"NetLogo": "#ff6375",
"NewLisp": "#87AED7",
"NetLogo": "#ff2b2b",
"NewLisp": "#eedd66",
"Nextflow": "#3ac486",
"Nim": "#37775b",
"Nit": "#009917",
"Nix": "#7e7eff",
"Nim": "#ffc200",
"Nimrod": "#37775b",
"Nit": "#0d8921",
"Nix": "#7070ff",
"Nu": "#c9df40",
"Objective-C": "#438eff",
"Objective-C++": "#6866fb",
"Objective-J": "#ff0c5a",
"NumPy": "#9C8AF9",
"Nunjucks": "#3d8137",
"OCaml": "#3be133",
"ObjectScript": "#424893",
"Objective-C": "#438eff",
"Objective-C++": "#4886FC",
"Objective-J": "#ff0c5a",
"Odin": "#60AFFE",
"Omgrofl": "#cabbff",
"ooc": "#b0b77e",
"Opal": "#f7ede0",
"Oxygene": "#cdd0e3",
"Oz": "#fab738",
"OpenQASM": "#AA70FF",
"Org": "#77aa99",
"Oxygene": "#5a63a3",
"Oz": "#fcaf3e",
"P4": "#7055b5",
"PAWN": "#dbb284",
"PHP": "#4F5D95",
"PLSQL": "#dad8d8",
"Pan": "#cc0000",
"Papyrus": "#6600cc",
"Parrot": "#f3ca0a",
"Pascal": "#E3F171",
"Pascal": "#b0ce4e",
"Pawn": "#dbb284",
"Pep8": "#C76F5B",
"Perl": "#0298c3",
"Perl 6": "#0000fb",
"PHP": "#4F5D95",
"Perl6": "#0298c3",
"PigLatin": "#fcd7de",
"Pike": "#005390",
"PLSQL": "#dad8d8",
"Pike": "#066ab2",
"PogoScript": "#d80074",
"PostScript": "#da291c",
"PowerBuilder": "#8f0f8d",
"PowerShell": "#012456",
"Processing": "#0096D8",
"Prisma": "#0c344b",
"Processing": "#2779ab",
"Prolog": "#74283c",
"Propeller Spin": "#7fa2a7",
"Puppet": "#302B6D",
"Propeller Spin": "#2b446d",
"Pug": "#a86454",
"Puppet": "#cc5555",
"Pure Data": "#91de79",
"PureBasic": "#5a6986",
"PureScript": "#1D222D",
"Python": "#3572A5",
"q": "#0040cd",
"PureScript": "#bcdc53",
"Python": "#3581ba",
"Q#": "#fed659",
"QML": "#44a51c",
"Qt Script": "#00b841",
"Quake": "#882233",
"R": "#198CE7",
"Racket": "#3c5caa",
"Ragel": "#9d5200",
"R": "#198ce7",
"RAML": "#77d9fb",
"RUNOFF": "#665a4e",
"Racket": "#ae17ff",
"Ragel": "#9d5200",
"Ragel in Ruby Host": "#ff9c2e",
"Raku": "#0000fb",
"Rascal": "#fffaa0",
"ReScript": "#ed5051",
"Reason": "#ff5847",
"Rebol": "#358a5b",
"Red": "#f50000",
"Record Jar": "#0673ba",
"Red": "#ee0000",
"Ren'Py": "#ff7f7f",
"Ring": "#2D54CB",
"Riot": "#A71E49",
"Roff": "#ecdebe",
"Rouge": "#cc0088",
"Ruby": "#701516",
"RUNOFF": "#665a4e",
"Rust": "#dea584",
"SAS": "#1E90FF",
"SCSS": "#c6538c",
"SQF": "#FFCB1F",
"SRecode Template": "#348a34",
"SVG": "#ff9900",
"SaltStack": "#646464",
"SAS": "#B34936",
"Scala": "#c22d40",
"Sass": "#a53b70",
"Scala": "#7dd3b0",
"Scaml": "#bd181a",
"Scheme": "#1e4aec",
"sed": "#64b970",
"Self": "#0579aa",
"Shell": "#89e051",
"Shell": "#5861ce",
"Shen": "#120F14",
"Slash": "#007eff",
"Slice": "#003fa2",
"Slim": "#ff8877",
"SmPL": "#c94949",
"Smalltalk": "#596706",
"Solidity": "#AA6746",
"SourcePawn": "#5c7611",
"SQF": "#3F3F3F",
"SourcePawn": "#f69e1d",
"Squirrel": "#800000",
"SRecode Template": "#348a34",
"Stan": "#b2011d",
"Standard ML": "#dc566d",
"Starlark": "#76d275",
"Stylus": "#ff6347",
"SuperCollider": "#46390b",
"Svelte": "#ff3e00",
"Swift": "#ffac45",
"SystemVerilog": "#DAE1C2",
"Tcl": "#e4cc98",
"Terra": "#00004c",
"TeX": "#3D6117",
"SystemVerilog": "#343761",
"TI Program": "#A0AA87",
"Turing": "#cf142b",
"TypeScript": "#2b7489",
"Tcl": "#e4cc98",
"TeX": "#3D6117",
"Terra": "#00004c",
"Turing": "#45f715",
"Twig": "#c1d026",
"TypeScript": "#31859c",
"Unified Parallel C": "#755223",
"Uno": "#9933cc",
"UnrealScript": "#a54c4d",
"Vala": "#fbe5cd",
"VCL": "#148AA8",
"Verilog": "#b2b7f8",
"VHDL": "#adb2cb",
"V": "#4f87c4",
"VBA": "#867db1",
"VBScript": "#15dcdc",
"VCL": "#0298c3",
"VHDL": "#543978",
"Vala": "#ee7d06",
"Verilog": "#848bf3",
"Vim script": "#199f4b",
"VimL": "#199c4b",
"Visual Basic": "#945db7",
"Volt": "#1F1F1F",
"Visual Basic .NET": "#945db7",
"Volt": "#0098db",
"Vue": "#2c3e50",
"wdl": "#42f1f4",
"Web Ontology Language": "#3994bc",
"WebAssembly": "#04133b",
"wisp": "#7582D1",
"Wollok": "#a23738",
"X10": "#4B6BEF",
"xBase": "#403a40",
"XC": "#99DA07",
"XQuery": "#5232e7",
"XQuery": "#2700e2",
"XSLT": "#EB8CEB",
"Yacc": "#4B6C4B",
"YAML": "#cb171e",
"YARA": "#220000",
"YASnippet": "#32AB90",
"Yacc": "#4B6C4B",
"ZAP": "#0d665e",
"ZIL": "#dc75e5",
"ZenScript": "#00BCD1",
"Zephir": "#118f9e",
"Zig": "#ec915c",
"ZIL": "#dc75e5"
"cpp": "#f34b7d",
"eC": "#913960",
"edn": "#db5855",
"mIRC Script": "#3d57c3",
"mcfunction": "#E22837",
"nesC": "#ffce3b",
"ooc": "#b0b77e",
"q": "#0040cd",
"sed": "#64b970",
"wdl": "#42f1f4",
"wisp": "#7582D1",
"xBase": "#3a4040",
"Other": "#1f9aef"
},
"editors": {
"Adobe XD": "#fd27bc",
"Android Studio": "#99cd00",
"AppCode": "#04dbde",
"Aptana": "#ec8623",
"Atom": "#49b77e",
"Azure Data Studio": "#0271c6",
"Blender": "#fb8007",
"BlueJ": "#5d89af",
"Brackets": "#067dc3",
"Chrome": "#fdd308",
"CLion": "#14c9a5",
"Cloud9": "#25a6d9",
"Coda": "#3e8e1c",
"Code: :Blocks": "#d0ce71",
"Code::Blocks": "#d0ce71",
"CodeLite": "#1892e5",
"CodeTasty": "#7368a8",
"DataGrip": "#907cf2",
"DBeaver": "#897363",
"Eclipse": "#443582",
"Emacs": "#8c76c3",
"Embarcadero Delphi": "#d9242a",
"EmEditor": "#ed3103",
"Eric": "#423f13",
"Excel": "#0f753c",
"Figma": "#c7b9ff",
"Firefox": "#d96527",
"Flash Builder": "#aca3a4",
"Geany": "#fbec75",
"Gedit": "#872114",
"GoLand": "#bd4ffc",
"HBuilder X": "#1ba334",
"IntelliJ IDEA": "#2876e1",
"IntelliJ": "#2876e1",
"Kakoune": "#dd5f4a",
"Kate": "#3f4040",
"KDevelop": "#22a273",
"Komodo": "#fcb414",
"Light Table": "#007ac1",
"MacRabbit Espresso": "#e6593f",
"Micro": "#2c3494",
"MonoDevelop": "#6185b3",
"MySQL Workbench": "#245279",
"Neovim": "#068304",
"NetBeans": "#f1f6e2",
"Notepad++": "#9ecf54",
"Nova": "#ff054a",
"Onivim": "#ee848e",
"Photoshop": "#0a0054",
"PhpStorm": "#d93ac1",
"PowerPoint": "#c6421f",
"Processing": "#6a7152",
"PyCharm": "#d2ee5c",
"Pymakr": "#323d4f",
"QtCreator": "#7fc342",
"Rider": "#f7a415",
"RStudio": "#2369c7",
"RubyMine": "#ff6336",
"Sketch": "#fdad00",
"SlickEdit": "#57ca57",
"Spyder": "#ee181e",
"SQL Server Management Studio": "#ffb901",
"Sublime Text": "#ff9800",
"Terminal": "#133f1c",
"TeXstudio": "#652d96",
"TextMate": "#822b7a",
"Unity": "#222d36",
"Vim": "#068304",
"Visual Studio": "#9460cd",
"VS Code": "#027acd",
"VSCode": "#027acd",
"WebMatrix": "#aeaeae",
"WebStorm": "#00c6d7",
"Wing": "#b3b3b3",
"Word": "#0f4091",
"WPS Office": "#fc6143",
"Xamarin": "#3598db",
"Xcode": "#3fa7e4"
},
"operating_systems": {
"Linux": "#f0b912",
"Windows": "#00b7ee",
"Mac": "#4d66cb"
}
}
}

View File

@ -22,3 +22,8 @@ services:
POSTGRES_USER: "wakapi"
POSTGRES_PASSWORD: "choose-a-password"
POSTGRES_DB: "wakapi"
volumes:
- wakapi-db-data:/var/lib/postgresql/data
volumes:
wakapi-db-data: {}

68
go.mod
View File

@ -1,41 +1,75 @@
module github.com/muety/wakapi
go 1.16
go 1.18
require (
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69
github.com/BurntSushi/toml v0.4.1 // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/duke-git/lancet/v2 v2.0.4
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
github.com/emersion/go-smtp v0.15.0
github.com/emvi/logbuch v1.2.0
github.com/felixge/httpsnoop v1.0.2 // indirect
github.com/getsentry/sentry-go v0.11.0
github.com/go-co-op/gocron v1.11.0
github.com/go-openapi/spec v0.20.2 // indirect
github.com/getsentry/sentry-go v0.13.0
github.com/go-co-op/gocron v1.13.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
github.com/gorilla/securecookie v1.1.1
github.com/hashicorp/golang-lru v0.5.4
github.com/jackc/pgx/v4 v4.14.1 // indirect
github.com/jinzhu/configor v1.2.1
github.com/jinzhu/now v1.1.4 // indirect
github.com/leandro-lugaresi/hub v1.1.1
github.com/lpar/gzipped/v2 v2.0.2
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.7.0
github.com/swaggo/swag v1.7.0
github.com/swaggo/swag v1.8.1
go.uber.org/atomic v1.9.0
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/tools v0.1.0 // indirect
gorm.io/driver/mysql v1.2.1
gorm.io/driver/postgres v1.2.3
gorm.io/driver/sqlite v1.2.6
gorm.io/gorm v1.22.4
gorm.io/driver/mysql v1.3.3
gorm.io/driver/postgres v1.3.4
gorm.io/driver/sqlite v1.3.1
gorm.io/gorm v1.23.4
)
require (
github.com/BurntSushi/toml v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/httpsnoop v1.0.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.6 // indirect
github.com/go-openapi/swag v0.21.1 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.11.0 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.2.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.10.0 // indirect
github.com/jackc/pgx/v4 v4.15.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/stretchr/objx v0.2.0 // indirect
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 // indirect
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 // indirect
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.10 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

236
go.sum
View File

@ -1,12 +1,8 @@
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69 h1:/XvI42KX57UTpeIOIt7IfM+pmEFTL8FGtiIUGcGDOIU=
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
@ -15,32 +11,20 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/duke-git/lancet/v2 v2.0.4 h1:IvMurTpL0cGhQmGPtkCge2eCkuiu3USQtglZJnKXxEo=
github.com/duke-git/lancet/v2 v2.0.4/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
@ -48,57 +32,46 @@ github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDm
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.0.2 h1:+nS9g82KMXccJ/wp0zyRW9ZBHFETmMGtkk+2CTTrW4o=
github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/getsentry/sentry-go v0.11.0 h1:qro8uttJGvNAMr5CLcFI9CHR0aDzXl0Vs3Pmw/oTPg8=
github.com/getsentry/sentry-go v0.11.0/go.mod h1:KBQIxiZAetw62Cj8Ri964vAEWVdgfaUCn30Q3bCvANo=
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-co-op/gocron v1.11.0 h1:ujOMubCpGcTxnnR/9vJIPIEpgwuAjbueAYqJRNr+nHg=
github.com/go-co-op/gocron v1.11.0/go.mod h1:qtlsoMpHlSdIZ3E/xuZzrrAbeX3u5JtPvWf2TcdutU0=
github.com/go-co-op/gocron v1.13.0 h1:BjkuNImPy5NuIPEifhWItFG7pYyr27cyjS6BN9w/D4c=
github.com/go-co-op/gocron v1.13.0/go.mod h1:GD5EIEly1YNW+LovFVx5dzbYVcIc8544K99D8UVRpGo=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
github.com/go-openapi/spec v0.20.2 h1:pFPUZsiIbZ20kLUcuCGeuQWG735fPMxW7wHF9BWlnQU=
github.com/go-openapi/spec v0.20.2/go.mod h1:RW6Xcbs6LOyWLU/mXGdzn2Qc+3aj+ASfI7rvSZh1Vls=
github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=
github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
github.com/go-openapi/swag v0.19.13 h1:233UVgMy1DlmCYYfOiFpta6e2urloh+sEs5id6lyzog=
github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU=
github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
@ -107,20 +80,8 @@ github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk=
github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g=
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
@ -131,8 +92,8 @@ github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsU
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.10.1 h1:DzdIHIjG1AxGwoEEqS+mGsURyjt4enSmqzACXvVzOT8=
github.com/jackc/pgconn v1.10.1/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.11.0 h1:HiHArx4yFbwl91X3qqIHtUFoiIfLNJXCQRsnzkiwwaQ=
github.com/jackc/pgconn v1.11.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
@ -141,7 +102,6 @@ github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5W
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
@ -157,45 +117,31 @@ github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01C
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.9.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgtype v1.9.1 h1:MJc2s0MFS8C3ok1wQTdQxWuXQcB6+HwAm5x1CzW7mf0=
github.com/jackc/pgtype v1.9.1/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgtype v1.10.0 h1:ILnBWrRMSXGczYvmkYD6PsYyVFUNLTnIUJHHDLmqk38=
github.com/jackc/pgtype v1.10.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.14.0/go.mod h1:jT3ibf/A0ZVCp89rtCIN0zCJxcE74ypROmHEZYsG/j8=
github.com/jackc/pgx/v4 v4.14.1 h1:71oo1KAGI6mXhLiTMn6iDFcp3e7+zon/capWjl2OEFU=
github.com/jackc/pgx/v4 v4.14.1/go.mod h1:RgDuE4Z34o7XE92RpLsvFiOEfrAUT0Xt2KxvX73W06M=
github.com/jackc/pgx/v4 v4.15.0 h1:B7dTkXsdILD3MF987WGGCcg+tvLW6bZJdEcqVFeU//w=
github.com/jackc/pgx/v4 v4.15.0/go.mod h1:D/zyOyXiaM1TmVWnOM18p0xdDtdakRBa0RsVGI3U3bw=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.2.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.3/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4 h1:tHnRBy1i5F2Dh8BAFxqFzxKqqvezXrL2OW1TnX+Mlas=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8=
github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE=
github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE=
github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro=
github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
github.com/kevinpollet/nego v0.0.0-20200324111829-b3061ca9dd9d h1:BaIpmhcqpBnz4+NZjUjVGxKNA+/E7ovKsjmwqjXcGYc=
github.com/kevinpollet/nego v0.0.0-20200324111829-b3061ca9dd9d/go.mod h1:3FSWkzk9h42opyV0o357Fq6gsLF/A6MI/qOca9kKobY=
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -204,8 +150,6 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/leandro-lugaresi/hub v1.1.1 h1:zqp0HzFvj4HtqjMBXM2QF17o6PNmR8MJOChgeKl/aw8=
github.com/leandro-lugaresi/hub v1.1.1/go.mod h1:XEFWanhHv6Rt3XlteHMxuNDYi8dJcpJjodpqkU+BtIo=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -215,49 +159,28 @@ github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lpar/gzipped/v2 v2.0.2 h1:y7FjyTH07f8dX0YQ5o0sg2DTbRnmS3oT1pUvxViQ//o=
github.com/lpar/gzipped/v2 v2.0.2/go.mod h1:qb7pLOGFgqz5w9xGGiiRFPxuGZ7GqWEuXUKXSbgonkQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e h1:bR8DQ4ZfItytLJwRlrLOPUHd5z18V6tECwYQFy8W+8g=
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -268,27 +191,15 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
@ -302,24 +213,9 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E=
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/swaggo/swag v1.8.1 h1:JuARzFX1Z1njbCGz+ZytBR15TFJwF2Q7fu8puJHhQYI=
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@ -335,67 +231,61 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 h1:LRtI4W37N+KFebI/qV0OFiLUv4GLOWeEW5hn/KEJvxE=
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57 h1:LQmS1nU0twXLA96Kt7U9qtHJEbBk3z6Q0V4UXjZkpr4=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -407,10 +297,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@ -419,13 +306,14 @@ golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023 h1:0c3L82FDQ5rt1bjTBlchS8t6RQ6299/+5bWMnRLh+uI=
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -433,31 +321,23 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.2.1 h1:h+3f1l9Ng2C072Y2tIiLgPpWN78r1KXL7bHJ0nTjlhU=
gorm.io/driver/mysql v1.2.1/go.mod h1:qsiz+XcAyMrS6QY+X3M9R6b/lKM1imKmcuK9kac5LTo=
gorm.io/driver/postgres v1.2.3 h1:f4t0TmNMy9gh3TU2PX+EppoA6YsgFnyq8Ojtddb42To=
gorm.io/driver/postgres v1.2.3/go.mod h1:pJV6RgYQPG47aM1f0QeOzFH9HxQc8JcmAgjRCgS0wjs=
gorm.io/driver/sqlite v1.2.6 h1:SStaH/b+280M7C8vXeZLz/zo9cLQmIGwwj3cSj7p6l4=
gorm.io/driver/sqlite v1.2.6/go.mod h1:gyoX0vHiiwi0g49tv+x2E7l8ksauLK0U/gShcdUsjWY=
gorm.io/gorm v1.22.3/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.22.4 h1:8aPcyEJhY0MAt8aY6Dc524Pn+pO29K+ydu+e/cXSpQM=
gorm.io/gorm v1.22.4/go.mod h1:1aeVC+pe9ZmvKZban/gW4QPra7PRoTEssyc922qCAkk=
gorm.io/driver/mysql v1.3.3 h1:jXG9ANrwBc4+bMvBcSl8zCfPBaVoPyBEBshA8dA93X8=
gorm.io/driver/mysql v1.3.3/go.mod h1:ChK6AHbHgDCFZyJp0F+BmVGb06PSIoh9uVYKAlRbb2U=
gorm.io/driver/postgres v1.3.4 h1:evZ7plF+Bp+Lr1mO5NdPvd6M/N98XtwHixGB+y7fdEQ=
gorm.io/driver/postgres v1.3.4/go.mod h1:y0vEuInFKJtijuSGu9e5bs5hzzSzPK+LancpKpvbRBw=
gorm.io/driver/sqlite v1.3.1 h1:bwfE+zTEWklBYoEodIOIBwuWHpnx52Z9zJFW5F33WLk=
gorm.io/driver/sqlite v1.3.1/go.mod h1:wJx0hJspfycZ6myN38x1O/AqLtNS6c5o9TndewFbELg=
gorm.io/gorm v1.23.1/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.23.4 h1:1BKWM67O6CflSLcwGQR7ccfmC4ebOxQrTfOQGRE9wjg=
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=

33
main.go
View File

@ -2,6 +2,7 @@ package main
import (
"embed"
"github.com/muety/wakapi/migrations"
"io/fs"
"log"
"net"
@ -11,13 +12,11 @@ import (
"time"
"github.com/lpar/gzipped/v2"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes/relay"
"github.com/emvi/logbuch"
"github.com/gorilla/handlers"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/routes/api"
"github.com/muety/wakapi/services/mail"
@ -58,6 +57,7 @@ var (
summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository
diagnosticsRepository repositories.IDiagnosticsRepository
metricsRepository *repositories.MetricsRepository
)
var (
@ -138,7 +138,9 @@ func main() {
defer sqlDb.Close()
// Migrate database schema
migrations.Run(db, config)
if !config.SkipMigrations {
migrations.Run(db, config)
}
// Repositories
aliasRepository = repositories.NewAliasRepository(db)
@ -149,6 +151,7 @@ func main() {
summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db)
diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
metricsRepository = repositories.NewMetricsRepository(db)
// Services
mailService = mail.NewMailService()
@ -166,11 +169,9 @@ func main() {
miscService = services.NewMiscService(userService, summaryService, keyValueService)
// Schedule background tasks
if !config.QuickStart {
go aggregationService.Schedule()
go miscService.ScheduleCountTotalTime()
go reportService.Schedule()
}
go aggregationService.Schedule()
go miscService.ScheduleCountTotalTime()
go reportService.Schedule()
routes.Init()
@ -178,9 +179,10 @@ func main() {
healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService, metricsRepository)
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
avatarHandler := api.NewAvatarHandler()
badgeHandler := api.NewBadgeHandler(userService, summaryService)
// Compat Handlers
wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService)
@ -239,6 +241,7 @@ func main() {
metricsHandler.RegisterRoutes(apiRouter)
diagnosticsHandler.RegisterRoutes(apiRouter)
avatarHandler.RegisterRoutes(apiRouter)
badgeHandler.RegisterRoutes(apiRouter)
wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
@ -267,18 +270,6 @@ func main() {
middlewares.NewFileTypeFilterMiddleware([]string{".go"})(staticFileServer),
)
// Miscellaneous
// Pre-warm projects cache
if !config.IsDev() {
allUsers, err := userService.GetAll()
if err == nil {
logbuch.Info("pre-warming user project cache")
for _, u := range allUsers {
go heartbeatService.GetEntitySetByUser(models.SummaryProject, u)
}
}
}
// Listen HTTP
listen(router)
}

View File

@ -0,0 +1,56 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20220317-align_num_heartbeats"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
logbuch.Info("this may take a while!")
// find all summaries whose num_heartbeats is zero even though they have items
var faultyIds []uint
if err := db.Model(&models.Summary{}).
Distinct("summaries.id").
Joins("INNER JOIN summary_items ON summaries.num_heartbeats = 0 AND summaries.id = summary_items.summary_id").
Scan(&faultyIds).Error; err != nil {
return err
}
// update their heartbeats counter
result := db.
Table("summaries AS s1").
Where("s1.id IN ?", faultyIds).
Update(
"num_heartbeats",
db.
Model(&models.Heartbeat{}).
Select("COUNT(*)").
Where("user_id = ?", gorm.Expr("s1.user_id")).
Where("time BETWEEN ? AND ?", gorm.Expr("s1.from_time"), gorm.Expr("s1.to_time")),
)
if err := result.Error; err != nil {
return err
}
logbuch.Info("corrected heartbeats counter of %d summaries", result.RowsAffected)
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,41 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
func init() {
const name = "20220318-mysql_timestamp_precision"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
if cfg.Db.IsMySQL() {
logbuch.Info("altering heartbeats table, this may take a while (up to hours)")
db.Exec("SET foreign_key_checks=0;")
db.Exec("SET unique_checks=0;")
if err := db.Exec("ALTER TABLE heartbeats MODIFY COLUMN `time` TIMESTAMP(3) NOT NULL").Error; err != nil {
return err
}
if err := db.Exec("ALTER TABLE heartbeats MODIFY COLUMN `created_at` TIMESTAMP(3) NOT NULL").Error; err != nil {
return err
}
db.Exec("SET foreign_key_checks=1;")
db.Exec("SET unique_checks=1;")
logbuch.Info("migrated timestamp columns to millisecond precision")
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,39 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "202203191-drop_diagnostics_user"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
migrator := db.Migrator()
if migrator.HasColumn(&models.Diagnostics{}, "user_id") {
logbuch.Info("running migration '%s'", name)
if err := migrator.DropConstraint(&models.Diagnostics{}, "fk_diagnostics_user"); err != nil {
logbuch.Warn("failed to drop 'fk_diagnostics_user' constraint (%v)", err)
}
if err := migrator.DropColumn(&models.Diagnostics{}, "user_id"); err != nil {
logbuch.Warn("failed to drop user_id column of diagnostics (%v)", err)
}
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,40 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
// migration to fix https://github.com/muety/wakapi/issues/346
// caused by https://github.com/muety/wakapi/blob/2.3.2/migrations/20220319_add_user_project_idx.go in combination with
// the wrongly defined index at https://github.com/muety/wakapi/blob/5aae18e2415d9e620f383f98cd8cbdf39cd99f27/models/heartbeat.go#L18
// and https://github.com/go-gorm/sqlite/issues/87
// -> drop index and let it be auto-created again with properly formatted ddl
func init() {
const name = "20220403-drop_user_project_idx"
const idxName = "idx_user_project"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if !db.Migrator().HasTable(&models.KeyStringValue{}) || hasRun(name, db) {
return nil
}
if cfg.Db.IsSQLite() && db.Migrator().HasIndex(&models.Heartbeat{}, idxName) {
logbuch.Info("running migration '%s'", name)
if err := db.Migrator().DropIndex(&models.Heartbeat{}, idxName); err != nil {
logbuch.Warn("failed to drop %s", idxName)
}
}
setHasRun(name, db)
return nil
},
}
registerPreMigration(f)
}

View File

@ -20,8 +20,8 @@ func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error
return args.Error(0)
}
func (m *HeartbeatServiceMock) Count() (int64, error) {
args := m.Called()
func (m *HeartbeatServiceMock) Count(a bool) (int64, error) {
args := m.Called(a)
return int64(args.Int(0)), args.Error(1)
}
@ -69,3 +69,8 @@ func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
args := m.Called(time)
return args.Error(0)
}
func (m *HeartbeatServiceMock) DeleteByUser(u *models.User) error {
args := m.Called(u)
return args.Error(0)
}

View File

@ -8,8 +8,8 @@ import (
// https://shields.io/endpoint
const (
defaultLabel = "coding time"
defaultColor = "#2D3748" // not working
defaultLabel = "wakapi.dev"
defaultColor = "2F855A"
)
type BadgeData struct {

View File

@ -3,8 +3,8 @@ package v1
// https://wakatime.com/api/v1/users/current/machine_names
type MachineViewModel struct {
Data []*MachineEntry `json:"data"`
TotalPages int `json:"total_pages"`
Data []*MachineEntry `json:"data"`
TotalPages int `json:"total_pages"`
}
type MachineEntry struct {

View File

@ -1,8 +1,8 @@
package v1
type UserAgentsViewModel struct {
Data []*UserAgentEntry `json:"data"`
TotalPages int `json:"total_pages"`
Data []*UserAgentEntry `json:"data"`
TotalPages int `json:"total_pages"`
}
type UserAgentEntry struct {

View File

@ -2,8 +2,6 @@ package models
type Diagnostics struct {
ID uint `gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_diagnostics_user"`
Platform string `json:"platform"`
Architecture string `json:"architecture"`
Plugin string `json:"plugin"`

View File

@ -11,11 +11,11 @@ import (
type Heartbeat struct {
ID uint64 `gorm:"primary_key" hash:"ignore"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
UserID string `json:"-" gorm:"not null; index:idx_time_user; index:idx_user_project"` // idx_user_project is for quickly fetching a user's project list (settings page)
Entity string `json:"entity" gorm:"not null"`
Type string `json:"type"`
Category string `json:"category"`
Project string `json:"project" gorm:"index:idx_project"`
Project string `json:"project" gorm:"index:idx_project; index:idx_user_project"`
Branch string `json:"branch" gorm:"index:idx_branch"`
Language string `json:"language" gorm:"index:idx_language"`
IsWrite bool `json:"is_write"`
@ -23,17 +23,22 @@ type Heartbeat struct {
OperatingSystem string `json:"operating_system" gorm:"index:idx_operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" gorm:"index:idx_machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
UserAgent string `json:"user_agent" hash:"ignore" gorm:"type:varchar(255)"`
Time CustomTime `json:"time" gorm:"type:timestamp; index:idx_time,idx_time_user" swaggertype:"primitive,number"`
Time CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time,idx_time_user" swaggertype:"primitive,number"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp(3)" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
}
func (h *Heartbeat) Valid() bool {
return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
}
func (h *Heartbeat) Timely(maxAge time.Duration) bool {
now := time.Now()
return now.Sub(h.Time.T()) <= maxAge && h.Time.T().Sub(now) < 1*time.Hour
}
func (h *Heartbeat) Augment(languageMappings map[string]string) {
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
for ending, value := range languageMappings {

View File

@ -4,7 +4,7 @@ import "fmt"
type CounterMetric struct {
Name string
Value int
Value int64
Desc string
Labels Labels
}

View File

@ -29,6 +29,11 @@ type Interval struct {
End time.Time
}
type KeyedInterval struct {
Interval
Key *IntervalKey
}
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
type CustomTime time.Time

View File

@ -101,7 +101,23 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
}
func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
return s.MappedItems()[summaryType]
switch summaryType {
case SummaryProject:
return &s.Projects
case SummaryLanguage:
return &s.Languages
case SummaryEditor:
return &s.Editors
case SummaryOS:
return &s.OperatingSystems
case SummaryMachine:
return &s.Machines
case SummaryLabel:
return &s.Labels
case SummaryBranch:
return &s.Branches
}
return nil
}
func (s *Summary) KeepOnly(types map[uint8]bool) *Summary {

View File

@ -14,7 +14,7 @@ func init() {
type User struct {
ID string `json:"id" gorm:"primary_key"`
ApiKey string `json:"api_key" gorm:"unique"`
ApiKey string `json:"api_key" gorm:"unique; default:NULL"`
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
Location string `json:"location"`
Password string `json:"-"`

View File

@ -7,7 +7,9 @@ type SummaryViewModel struct {
*models.SummaryParams
User *models.User
AvatarURL string
EditorColors map[string]string
LanguageColors map[string]string
OSColors map[string]string
Error string
Success string
ApiKey string

View File

@ -1,6 +1,7 @@
package repositories
import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@ -8,11 +9,12 @@ import (
)
type HeartbeatRepository struct {
db *gorm.DB
db *gorm.DB
config *conf.Config
}
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
return &HeartbeatRepository{db: db}
return &HeartbeatRepository{config: conf.Get(), db: db}
}
// Use with caution!!
@ -116,12 +118,19 @@ func (r *HeartbeatRepository) GetLastByUsers() ([]*models.TimeByUser, error) {
return result, nil
}
func (r *HeartbeatRepository) Count() (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Count(&count).Error; err != nil {
return 0, err
func (r *HeartbeatRepository) Count(approximate bool) (count int64, err error) {
if r.config.Db.IsMySQL() && approximate {
err = r.db.Table("information_schema.tables").
Select("table_rows").
Where("table_schema = ?", r.config.Db.Name).
Where("table_name = 'heartbeats'").
Scan(&count).Error
}
if count == 0 {
err = r.db.
Model(&models.Heartbeat{}).
Count(&count).Error
}
return count, nil
}
@ -145,6 +154,10 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
userIds[i] = u.ID
}
if len(userIds) == 0 {
return counts, nil
}
if err := r.db.
Model(&models.Heartbeat{}).
Select("user_id as user, count(id) as count").
@ -153,6 +166,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
Find(&counts).Error; err != nil {
return counts, err
}
return counts, nil
}
@ -176,3 +190,12 @@ func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
}
return nil
}
func (r *HeartbeatRepository) DeleteByUser(user *models.User) error {
if err := r.db.
Where("user_id = ?", user.ID).
Delete(models.Heartbeat{}).Error; err != nil {
return err
}
return nil
}

43
repositories/metrics.go Normal file
View File

@ -0,0 +1,43 @@
package repositories
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
type MetricsRepository struct {
config *config.Config
db *gorm.DB
}
const sizeTplMysql = `
SELECT SUM(data_length + index_length)
FROM information_schema.tables
WHERE table_schema = ?
GROUP BY table_schema`
const sizeTplPostgres = `SELECT pg_database_size('%s');`
const sizeTplSqlite = `
SELECT page_count * page_size as size
FROM pragma_page_count(), pragma_page_size();`
func NewMetricsRepository(db *gorm.DB) *MetricsRepository {
return &MetricsRepository{config: config.Get(), db: db}
}
func (srv *MetricsRepository) GetDatabaseSize() (size int64, err error) {
cfg := srv.config.Db
query := srv.db.Raw("SELECT 0")
if cfg.IsMySQL() {
query = srv.db.Raw(sizeTplMysql, cfg.Name)
} else if cfg.IsPostgres() {
query = srv.db.Raw(sizeTplPostgres, cfg.Name)
} else if cfg.IsSQLite() {
query = srv.db.Raw(sizeTplSqlite)
}
err = query.Scan(&size).Error
return size, err
}

View File

@ -25,11 +25,12 @@ type IHeartbeatRepository interface {
GetLastByUsers() ([]*models.TimeByUser, error)
GetLatestByUser(*models.User) (*models.Heartbeat, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
Count() (int64, error)
Count(bool) (int64, error)
CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error
DeleteByUser(*models.User) error
}
type IDiagnosticsRepository interface {

View File

@ -1,8 +1,10 @@
package repositories
import (
"github.com/duke-git/lancet/v2/slice"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"time"
)
@ -18,15 +20,15 @@ func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
var summaries []*models.Summary
if err := r.db.
Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
// branch summaries are currently not persisted, as only relevant in combination with project filter
Find(&summaries).Error; err != nil {
return nil, err
}
if err := r.populateItems(summaries, []clause.Interface{}); err != nil {
return nil, err
}
return summaries, nil
}
@ -39,20 +41,29 @@ func (r *SummaryRepository) Insert(summary *models.Summary) error {
func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
var summaries []*models.Summary
if err := r.db.
Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from.Local()).
Where("to_time <= ?", to.Local()).
Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
// branch summaries are currently not persisted, as only relevant in combination with project filter
Find(&summaries).Error; err != nil {
queryConditions := []clause.Interface{
clause.Where{Exprs: r.db.Statement.BuildCondition("user_id = ?", user.ID)},
clause.Where{Exprs: r.db.Statement.BuildCondition("from_time >= ?", from.Local())},
clause.Where{Exprs: r.db.Statement.BuildCondition("to_time <= ?", to.Local())},
}
q := r.db.Model(&models.Summary{}).
Order("from_time asc")
for _, c := range queryConditions {
q.Statement.AddClause(c)
}
// branch summaries are currently not persisted, as only relevant in combination with project filter
if err := q.Find(&summaries).Error; err != nil {
return nil, err
}
if err := r.populateItems(summaries, queryConditions); err != nil {
return nil, err
}
return summaries, nil
}
@ -74,3 +85,36 @@ func (r *SummaryRepository) DeleteByUser(userId string) error {
}
return nil
}
// inplace
func (r *SummaryRepository) populateItems(summaries []*models.Summary, conditions []clause.Interface) error {
var items []*models.SummaryItem
summaryMap := slice.GroupWith[*models.Summary, uint](summaries, func(s *models.Summary) uint {
return s.ID
})
q := r.db.Model(&models.SummaryItem{}).
Select("summary_items.*").
Joins("cross join summaries").
Where("summary_items.summary_id = summaries.id").
Where("num_heartbeats > ?", 0)
for _, c := range conditions {
q.Statement.AddClause(c)
}
if err := q.Find(&items).Error; err != nil {
return err
}
for _, item := range items {
if _, ok := summaryMap[item.SummaryID]; ok {
continue
}
l := summaryMap[item.SummaryID][0].ItemsByType(item.Type)
*l = append(*l, item)
}
return nil
}

View File

@ -98,10 +98,9 @@ func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error)
// Returns a list of user ids, whose last heartbeat is not older than t
// NOTE: Only ID field will be populated
func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, error) {
subQuery1 := r.db.Model(&models.User{}).
Select("users.id as user, max(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user")
subQuery1 := r.db.Model(&models.Heartbeat{}).
Select("user_id as user, max(time) as time").
Group("user_id")
var userIds []string
if err := r.db.

View File

@ -5,7 +5,9 @@ import (
"github.com/gorilla/mux"
lru "github.com/hashicorp/golang-lru"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/utils"
"net/http"
"time"
)
type AvatarHandler struct {
@ -33,6 +35,10 @@ func (h *AvatarHandler) RegisterRoutes(router *mux.Router) {
func (h *AvatarHandler) Get(w http.ResponseWriter, r *http.Request) {
hash := mux.Vars(r)["hash"]
if utils.IsNoCache(r, 1*time.Hour) {
h.cache.Remove(hash)
}
if !h.cache.Contains(hash) {
h.cache.Add(hash, avatars.MakeMaleAvatar(hash))
}

98
routes/api/badge.go Normal file
View File

@ -0,0 +1,98 @@
package api
import (
"fmt"
"github.com/duke-git/lancet/v2/maputil"
"github.com/duke-git/lancet/v2/slice"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"github.com/narqo/go-badge"
"github.com/patrickmn/go-cache"
"net/http"
"time"
)
type BadgeHandler struct {
config *conf.Config
cache *cache.Cache
userSrvc services.IUserService
summarySrvc services.ISummaryService
}
func NewBadgeHandler(userService services.IUserService, summaryService services.ISummaryService) *BadgeHandler {
return &BadgeHandler{
config: conf.Get(),
cache: cache.New(time.Hour, time.Hour),
userSrvc: userService,
summarySrvc: summaryService,
}
}
func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/badge/{user}").Subrouter()
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
user, err := h.userSrvc.GetUserById(mux.Vars(r)["user"])
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
interval, filters, err := routeutils.GetBadgeParams(r, user)
if err != nil {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(err.Error()))
return
}
cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash())
noCache := utils.IsNoCache(r, 1*time.Hour)
if cacheResult, ok := h.cache.Get(cacheKey); ok && !noCache {
respondSvg(w, cacheResult.([]byte))
return
}
params := &models.SummaryParams{
From: interval.Start,
To: interval.End,
User: user,
Filters: filters,
}
summary, err, status := routeutils.LoadUserSummaryByParams(h.summarySrvc, params)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
badgeData := v1.NewBadgeDataFrom(summary)
if customLabel := r.URL.Query().Get("label"); customLabel != "" {
badgeData.Label = customLabel
}
if customColor := r.URL.Query().Get("color"); customColor != "" {
badgeData.Color = customColor
}
if badgeData.Color[0:1] != "#" && !slice.Contain(maputil.Keys(badge.ColorScheme), badgeData.Color) {
badgeData.Color = "#" + badgeData.Color
}
badgeSvg, err := badge.RenderBytes(badgeData.Label, badgeData.Message, badge.Color(badgeData.Color))
h.cache.SetDefault(cacheKey, badgeSvg)
respondSvg(w, badgeSvg)
}
func respondSvg(w http.ResponseWriter, data []byte) {
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "max-age=3600")
w.WriteHeader(http.StatusOK)
w.Write(data)
}

View File

@ -6,7 +6,6 @@ import (
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
@ -29,9 +28,6 @@ func NewDiagnosticsApiHandler(userService services.IUserService, diagnosticsServ
func (h *DiagnosticsApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/plugins/errors").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Path("").Methods(http.MethodPost).HandlerFunc(h.Post)
}
@ -40,26 +36,17 @@ func (h *DiagnosticsApiHandler) RegisterRoutes(router *mux.Router) {
// @Tags diagnostics
// @Accept json
// @Param diagnostics body models.Diagnostics true "A single diagnostics object sent by WakaTime CLI"
// @Security ApiKeyAuth
// @Success 201
// @Router /plugins/errors [post]
func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
var diagnostics models.Diagnostics
user := middlewares.GetPrincipal(r)
if user == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(conf.ErrUnauthorized))
return
}
if err := json.NewDecoder(r.Body).Decode(&diagnostics); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(conf.ErrBadRequest))
conf.Log().Request(r).Error("failed to parse diagnostics for user %s - %v", err)
return
}
diagnostics.UserID = user.ID
if _, err := h.diagnosticsSrvc.Create(&diagnostics); err != nil {
w.WriteHeader(http.StatusInternalServerError)

View File

@ -86,7 +86,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
hb.UserID = user.ID
hb.UserAgent = userAgent
if !hb.Valid() {
if !hb.Valid() || !hb.Timely(h.config.App.HeartbeatsMaxAge()) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid heartbeat object"))
return

View File

@ -9,6 +9,7 @@ import (
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
mm "github.com/muety/wakapi/models/metrics"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
@ -39,6 +40,7 @@ const (
DescMemAllocTotal = "Total number of bytes allocated for heap"
DescMemSysTotal = "Total number of bytes obtained from the OS"
DescGoroutines = "Total number of running goroutines"
DescDatabaseSize = "Total database size in bytes"
)
type MetricsHandler struct {
@ -47,14 +49,16 @@ type MetricsHandler struct {
summarySrvc services.ISummaryService
heartbeatSrvc services.IHeartbeatService
keyValueSrvc services.IKeyValueService
metricsRepo *repositories.MetricsRepository
}
func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService) *MetricsHandler {
func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService, metricsRepo *repositories.MetricsRepository) *MetricsHandler {
return &MetricsHandler{
userSrvc: userService,
summarySrvc: summaryService,
heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService,
metricsRepo: metricsRepo,
config: conf.Get(),
}
}
@ -141,21 +145,21 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_cumulative_seconds_total",
Desc: DescAllTime,
Value: int(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
Value: int64(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_seconds_total",
Desc: DescTotal,
Value: int(summaryToday.TotalTime().Seconds()),
Value: int64(summaryToday.TotalTime().Seconds()),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_heartbeats_total",
Desc: DescHeartbeats,
Value: int(heartbeatCount),
Value: int64(heartbeatCount),
Labels: []mm.Label{},
})
@ -163,7 +167,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_project_seconds_total",
Desc: DescProjects,
Value: int(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
Value: int64(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: p.Key}},
})
}
@ -172,7 +176,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_language_seconds_total",
Desc: DescLanguages,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
Value: int64(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: l.Key}},
})
}
@ -181,7 +185,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_editor_seconds_total",
Desc: DescEditors,
Value: int(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
Value: int64(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: e.Key}},
})
}
@ -190,7 +194,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_operating_system_seconds_total",
Desc: DescOperatingSystems,
Value: int(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
Value: int64(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: o.Key}},
})
}
@ -199,7 +203,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_machine_seconds_total",
Desc: DescMachines,
Value: int(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
Value: int64(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}},
})
}
@ -208,7 +212,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_label_seconds_total",
Desc: DescLabels,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
Value: int64(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}},
})
}
@ -220,21 +224,34 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_goroutines_total",
Desc: DescGoroutines,
Value: runtime.NumGoroutine(),
Value: int64(runtime.NumGoroutine()),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_mem_alloc_total",
Desc: DescMemAllocTotal,
Value: int(memStats.Alloc),
Value: int64(memStats.Alloc),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_mem_sys_total",
Desc: DescMemSysTotal,
Value: int(memStats.Sys),
Value: int64(memStats.Sys),
Labels: []mm.Label{},
})
// Database metrics
dbSize, err := h.metricsRepo.GetDatabaseSize()
if err != nil {
logbuch.Warn("failed to get database size (%v)", err)
}
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_db_total_bytes",
Desc: DescDatabaseSize,
Value: dbSize,
Labels: []mm.Label{},
})
@ -256,7 +273,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
}
totalUsers, _ := h.userSrvc.Count()
totalHeartbeats, _ := h.heartbeatSrvc.Count()
totalHeartbeats, _ := h.heartbeatSrvc.Count(true)
activeUsers, err := h.userSrvc.GetActive(false)
if err != nil {
@ -267,28 +284,28 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_seconds_total",
Desc: DescAdminTotalTime,
Value: totalSeconds,
Value: int64(totalSeconds),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_heartbeats_total",
Desc: DescAdminTotalHeartbeats,
Value: int(totalHeartbeats),
Value: totalHeartbeats,
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_users_total",
Desc: DescAdminTotalUsers,
Value: int(totalUsers),
Value: totalUsers,
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_users_active_total",
Desc: DescAdminActiveUsers,
Value: len(activeUsers),
Value: int64(len(activeUsers)),
Labels: []mm.Label{},
})
@ -304,7 +321,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_user_heartbeats_total",
Desc: DescAdminUserHeartbeats,
Value: int(uc.Count),
Value: uc.Count,
Labels: []mm.Label{{Key: "user", Value: uc.User}},
})
}

View File

@ -2,8 +2,8 @@ package v1
import (
"fmt"
routeutils "github.com/muety/wakapi/routes/utils"
"net/http"
"regexp"
"time"
"github.com/gorilla/mux"
@ -15,11 +15,6 @@ import (
"github.com/patrickmn/go-cache"
)
const (
intervalPattern = `interval:([a-z0-9_]+)`
entityFilterPattern = `(project|os|editor|language|machine|label):([_a-zA-Z0-9-\s\.]+)`
)
type BadgeHandler struct {
config *conf.Config
userSrvc services.IUserService
@ -53,77 +48,33 @@ func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {
// @Success 200 {object} v1.BadgeData
// @Router /compat/shields/v1/{user}/{interval}/{filter} [get]
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
intervalReg := regexp.MustCompile(intervalPattern)
entityFilterReg := regexp.MustCompile(entityFilterPattern)
var filterEntity, filterKey string
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
filterEntity, filterKey = groups[1], groups[2]
}
var interval = models.IntervalPast30Days
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
if i, err := utils.ParseInterval(groups[1]); err == nil {
interval = i
}
}
requestedUserId := mux.Vars(r)["user"]
user, err := h.userSrvc.GetUserById(requestedUserId)
user, err := h.userSrvc.GetUserById(mux.Vars(r)["user"])
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))
// negative value means no limit
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
interval, filters, err := routeutils.GetBadgeParams(r, user)
if err != nil {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("requested time range too broad"))
w.Write([]byte(err.Error()))
return
}
var permitEntity bool
var filters *models.Filters
switch filterEntity {
case "project":
permitEntity = user.ShareProjects
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
case "os":
permitEntity = user.ShareOSs
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
case "editor":
permitEntity = user.ShareEditors
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
case "language":
permitEntity = user.ShareLanguages
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
case "machine":
permitEntity = user.ShareMachines
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
case "label":
permitEntity = user.ShareLabels
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
// branches are intentionally omitted here, as only relevant in combination with a project filter
default:
permitEntity = true
filters = &models.Filters{}
}
if !permitEntity {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("user did not opt in to share entity-specific data"))
return
}
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash())
if cacheResult, ok := h.cache.Get(cacheKey); ok {
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
return
}
summary, err, status := h.loadUserSummary(user, interval, filters)
params := &models.SummaryParams{
From: interval.Start,
To: interval.End,
User: user,
Filters: filters,
}
summary, err, status := routeutils.LoadUserSummaryByParams(h.summarySrvc, params)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))

View File

@ -30,7 +30,7 @@ func TestBadgeHandler_EntityPattern(t *testing.T) {
{test: pathPrefix + "project:Anchr-Android_v2.0", key: "project", val: "Anchr-Android_v2.0"}, // all the way
}
sut := regexp.MustCompile(entityFilterPattern)
sut := regexp.MustCompile(`(project|os|editor|language|machine|label):([^:?&/]+)`) // see entityFilterPattern in badge_utils.go
for _, tc := range tests {
var key, val string

View File

@ -1,6 +1,7 @@
package v1
import (
"github.com/duke-git/lancet/v2/datetime"
"net/http"
"time"
@ -65,7 +66,7 @@ func (h *HeartbeatHandler) Get(w http.ResponseWriter, r *http.Request) {
}
timezone := user.TZ()
rangeFrom, rangeTo := utils.StartOfDay(date.In(timezone)), utils.EndOfDay(date.In(timezone))
rangeFrom, rangeTo := datetime.BeginOfDay(date.In(timezone)), datetime.EndOfDay(date.In(timezone))
heartbeats, err := h.heartbeatSrvc.GetAllWithin(rangeFrom, rangeTo, user)
if err != nil {

View File

@ -2,6 +2,7 @@ package v1
import (
"errors"
"github.com/duke-git/lancet/v2/datetime"
"net/http"
"strings"
"time"
@ -120,7 +121,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
// i.e. for wakatime, an interval 2021-04-29 - 2021-04-29 is actually 2021-04-29 - 2021-04-30,
// while for wakapi it would be empty
// see https://github.com/muety/wakapi/issues/192
end = utils.EndOfDay(end).Add(-1 * time.Second)
end = datetime.EndOfDay(end)
overallParams := &models.SummaryParams{
From: start,

View File

@ -91,6 +91,7 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login.Username)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
conf.Log().Request(r).Error("failed to encode secure cookie - %v", err)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
@ -163,6 +164,7 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
_, created, err := h.userSrvc.CreateOrGet(&signup, numUsers == 0)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
conf.Log().Request(r).Error("failed to create new user - %v", err)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
return
}
@ -237,6 +239,7 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
user.ResetToken = ""
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
w.WriteHeader(http.StatusInternalServerError)
conf.Log().Request(r).Error("failed to set new password - %v", err)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to set new password"))
return
} else {
@ -245,6 +248,7 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
conf.Log().Request(r).Error("failed to save new password - %v", err)
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to save new password"))
return
}
@ -278,6 +282,7 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
if user, err := h.userSrvc.GetUserByEmail(resetRequest.Email); user != nil && err == nil {
if u, err := h.userSrvc.GenerateResetToken(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
conf.Log().Request(r).Error("failed to generate password reset token - %v", err)
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate password reset token"))
return
} else {

View File

@ -2,6 +2,7 @@ package routes
import (
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"github.com/muety/wakapi/views"
"html/template"
"net/http"
@ -28,7 +29,7 @@ func DefaultTemplateFuncs() template.FuncMap {
"simpledate": utils.FormatDate,
"simpledatetime": utils.FormatDateTime,
"duration": utils.FmtWakatimeDuration,
"floordate": utils.FloorDate,
"floordate": datetime.BeginOfDay,
"ceildate": utils.CeilDate,
"title": strings.Title,
"join": strings.Join,

View File

@ -151,6 +151,8 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return h.actionImportWakatime
case "regenerate_summaries":
return h.actionRegenerateSummaries
case "clear_data":
return h.actionClearData
case "delete_account":
return h.actionDeleteUser
}
@ -553,6 +555,29 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
return http.StatusAccepted, "summaries are being regenerated - this may take a up to a couple of minutes, please come back later", ""
}
func (h *SettingsHandler) actionClearData(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
logbuch.Info("user '%s' requested to delete all data", user.ID)
go func(user *models.User) {
logbuch.Info("deleting summaries for user '%s'", user.ID)
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
logbuch.Error("failed to clear summaries: %v", err)
}
logbuch.Info("deleting heartbeats for user '%s'", user.ID)
if err := h.heartbeatSrvc.DeleteByUser(user); err != nil {
logbuch.Error("failed to clear heartbeats: %v", err)
}
}(user)
return http.StatusAccepted, "deletion in progress, this may take a couple of seconds", ""
}
func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()

View File

@ -51,6 +51,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
if err != nil {
w.WriteHeader(status)
conf.Log().Request(r).Error("failed to load summary - %v", err)
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
return
}
@ -66,7 +67,9 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
Summary: summary,
SummaryParams: summaryParams,
User: user,
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
ApiKey: user.ApiKey,
RawQuery: rawQuery,
}

View File

@ -0,0 +1,85 @@
package utils
import (
"errors"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"net/http"
"regexp"
"time"
)
const (
intervalPattern = `interval:([a-z0-9_]+)`
entityFilterPattern = `(project|os|editor|language|machine|label):([^:?&/]+)`
)
var (
intervalReg *regexp.Regexp
entityFilterReg *regexp.Regexp
)
func init() {
intervalReg = regexp.MustCompile(intervalPattern)
entityFilterReg = regexp.MustCompile(entityFilterPattern)
}
func GetBadgeParams(r *http.Request, requestedUser *models.User) (*models.KeyedInterval, *models.Filters, error) {
var filterEntity, filterKey string
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
filterEntity, filterKey = groups[1], groups[2]
}
var intervalKey = models.IntervalPast30Days
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
if i, err := utils.ParseInterval(groups[1]); err == nil {
intervalKey = i
}
}
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(intervalKey, requestedUser.TZ())
interval := &models.KeyedInterval{
Interval: models.Interval{Start: rangeFrom, End: rangeTo},
Key: intervalKey,
}
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
// negative value means no limit
if rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
return nil, nil, errors.New("requested time range too broad")
}
var permitEntity bool
var filters *models.Filters
switch filterEntity {
case "project":
permitEntity = requestedUser.ShareProjects
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
case "os":
permitEntity = requestedUser.ShareOSs
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
case "editor":
permitEntity = requestedUser.ShareEditors
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
case "language":
permitEntity = requestedUser.ShareLanguages
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
case "machine":
permitEntity = requestedUser.ShareMachines
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
case "label":
permitEntity = requestedUser.ShareLabels
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
// branches are intentionally omitted here, as only relevant in combination with a project filter
default:
// non-entity-specific request, just a general, in-total query
permitEntity = true
filters = &models.Filters{}
}
if !permitEntity {
return nil, nil, errors.New("user did not opt in to share entity-specific data")
}
return interval, filters, nil
}

View File

@ -1,6 +1,7 @@
package utils
import (
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"sort"
@ -12,42 +13,28 @@ import (
// -> Instead, the "virtual" project "AB" shall appear
// See https://github.com/muety/wakapi/issues/231
func GetEffectiveProjectsList(user *models.User, heartbeatSrvc services.IHeartbeatService, aliasSrvc services.IAliasService) ([]string, error) {
projectsMap := make(map[string]bool) // proper sets as part of stdlib would be nice...
// extract actual projects from heartbeats
realProjects, err := heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
if err != nil {
return []string{}, err
}
// create a "set" / lookup table
for _, p := range realProjects {
projectsMap[p] = true
}
// fetch aliases
projectAliases, err := aliasSrvc.GetByUserAndType(user.ID, models.SummaryProject)
if err != nil {
return []string{}, err
}
projects := datastructure.NewSet[string](realProjects...)
// remove alias values (source of a mapping)
// add alias key (target of a mapping) instead
for _, a := range projectAliases {
if projectsMap[a.Value] {
projectsMap[a.Value] = false
}
projectsMap[a.Key] = true
projects.Delete(a.Value)
projects.Add(a.Key)
}
projects := make([]string, 0, len(projectsMap))
for key, val := range projectsMap {
if !val {
continue
}
projects = append(projects, key)
}
sort.Strings(projects)
return projects, nil
sorted := projects.Values()
sort.Strings(sorted)
return sorted, nil
}

View File

@ -1,7 +1,6 @@
package utils
import (
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
@ -9,24 +8,33 @@ import (
)
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
user := middlewares.GetPrincipal(r)
summaryParams, err := utils.ParseSummaryParams(r)
if err != nil {
return nil, err, http.StatusBadRequest
}
return LoadUserSummaryByParams(ss, summaryParams)
}
func LoadUserSummaryByParams(ss services.ISummaryService, params *models.SummaryParams) (*models.Summary, error, int) {
var retrieveSummary services.SummaryRetriever = ss.Retrieve
if summaryParams.Recompute {
if params.Recompute {
retrieveSummary = ss.Summarize
}
summary, err := ss.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Filters, summaryParams.Recompute)
summary, err := ss.Aliased(
params.From,
params.To,
params.User,
retrieveSummary,
params.Filters,
params.Recompute,
)
if err != nil {
return nil, err, http.StatusInternalServerError
}
summary.FromTime = models.CustomTime(summary.FromTime.T().In(user.TZ()))
summary.ToTime = models.CustomTime(summary.ToTime.T().In(user.TZ()))
summary.FromTime = models.CustomTime(summary.FromTime.T().In(params.User.TZ()))
summary.ToTime = models.CustomTime(summary.ToTime.T().In(params.User.TZ()))
return summary, nil, http.StatusOK
}

View File

@ -0,0 +1,9 @@
SELECT project, language, editor, operating_system, machine, branch, SUM(GREATEST(1, diff)) as 'sum'
FROM (
SELECT project, language, editor, operating_system, machine, branch, TIME_TO_SEC(LEAST(TIMEDIFF(time, LAG(time) over w), '00:02:00')) as 'diff'
FROM heartbeats
WHERE user_id = 'n1try'
WINDOW w AS (ORDER BY time)
) s2
WHERE diff IS NOT NULL
GROUP BY project, language, editor, operating_system, machine, branch;

View File

@ -0,0 +1,12 @@
DELETE t1
FROM heartbeats t1
INNER JOIN heartbeats t2
WHERE t1.id < t2.id
AND t1.time = t2.time
AND t1.entity = t2.entity
AND t1.is_write = t2.is_write
AND t1.branch = t2.branch
AND t1.editor = t2.editor
AND t1.machine = t2.machine
AND t1.operating_system = t2.operating_system
AND t1.user_id = t2.user_id;

View File

@ -0,0 +1,10 @@
SELECT s2.user_id, sum(c) as count, total, (sum(c) / total) as ratio
FROM (
SELECT time, user_id, entity, is_write, branch, editor, machine, operating_system, COUNT(time) as c
FROM heartbeats
GROUP BY time, user_id, entity, is_write, branch, editor, machine, operating_system
HAVING COUNT(time) > 1
) s2
LEFT JOIN (SELECT user_id, count(id) AS total FROM heartbeats GROUP BY user_id) s3 ON s2.user_id = s3.user_id
GROUP BY user_id
ORDER BY count DESC;

View File

@ -2,6 +2,7 @@ package services
import (
"errors"
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"runtime"
@ -23,7 +24,7 @@ type AggregationService struct {
userService IUserService
summaryService ISummaryService
heartbeatService IHeartbeatService
inProgress map[string]bool
inProgress datastructure.Set[string]
}
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
@ -32,7 +33,7 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
userService: userService,
summaryService: summaryService,
heartbeatService: heartbeatService,
inProgress: map[string]bool{},
inProgress: datastructure.NewSet[string](),
}
}
@ -44,17 +45,12 @@ type AggregationJob struct {
// Schedule a job to (re-)generate summaries every day shortly after midnight
func (srv *AggregationService) Schedule() {
// Run once initially
if err := srv.Run(nil); err != nil {
logbuch.Fatal("failed to run AggregationJob: %v", err)
}
s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, map[string]bool{})
s.Every(1).Day().At(srv.config.App.AggregationTime).WaitForSchedule().Do(srv.Run, datastructure.NewSet[string]())
s.StartBlocking()
}
func (srv *AggregationService) Run(userIds map[string]bool) error {
func (srv *AggregationService) Run(userIds datastructure.Set[string]) error {
if err := srv.lockUsers(userIds); err != nil {
return err
}
@ -100,24 +96,9 @@ func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
}
}
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[string]bool) error {
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds datastructure.Set[string]) error {
logbuch.Info("generating summaries")
var users []*models.User
if allUsers, err := srv.userService.GetAll(); err != nil {
config.Log().Error(err.Error())
return err
} else if userIds != nil && len(userIds) > 0 {
users = make([]*models.User, 0)
for _, u := range allUsers {
if yes, ok := userIds[u.ID]; yes && ok {
users = append(users, u)
}
}
} else {
users = allUsers
}
// Get a map from user ids to the time of their latest summary or nil if none exists yet
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
if err != nil {
@ -140,6 +121,10 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
// Generate summary aggregation jobs
for _, e := range lastUserSummaryTimes {
if userIds != nil && !userIds.IsEmpty() && !userIds.Contain(e.User) {
continue
}
if e.Time.Valid() {
// Case 1: User has aggregated summaries already
// -> Spawn jobs to create summaries from their latest aggregation to now
@ -156,25 +141,23 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
return nil
}
func (srv *AggregationService) lockUsers(userIds map[string]bool) error {
func (srv *AggregationService) lockUsers(userIds datastructure.Set[string]) error {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
if _, ok := srv.inProgress[uid]; ok {
if srv.inProgress.Contain(uid) {
return errors.New("aggregation already in progress for at least of the request users")
}
}
for uid := range userIds {
srv.inProgress[uid] = true
}
srv.inProgress = srv.inProgress.Union(userIds)
return nil
}
func (srv *AggregationService) unlockUsers(userIds map[string]bool) {
func (srv *AggregationService) unlockUsers(userIds datastructure.Set[string]) {
aggregationLock.Lock()
defer aggregationLock.Unlock()
for uid := range userIds {
delete(srv.inProgress, uid)
srv.inProgress.Delete(uid)
}
}

View File

@ -3,6 +3,7 @@ package services
import (
"errors"
"fmt"
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@ -117,12 +118,13 @@ func (srv *AliasService) Delete(alias *models.Alias) error {
func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error {
ids := make([]uint, len(aliases))
affectedUsers := make(map[string]bool)
affectedUsers := datastructure.NewSet[string]()
for i, a := range aliases {
if a.UserID == "" {
return errors.New("no user id specified")
}
affectedUsers[a.UserID] = true
affectedUsers.Add(a.UserID)
ids[i] = a.ID
}

View File

@ -19,5 +19,6 @@ func NewDiagnosticsService(diagnosticsRepo repositories.IDiagnosticsRepository)
}
func (srv *DiagnosticsService) Create(diagnostics *models.Diagnostics) (*models.Diagnostics, error) {
diagnostics.ID = 0
return srv.repository.Insert(diagnostics)
}

View File

@ -1,6 +1,7 @@
package services
import (
"github.com/duke-git/lancet/v2/mathutil"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"time"
@ -36,6 +37,8 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
}
// Aggregation
// the below logic is approximately equivalent to the SQL query at scripts/aggregate_durations.sql,
// but unfortunately we cannot use it, as it features mysql-specific functions (lag(), timediff(), ...)
var count int
var latest *models.Duration
@ -57,13 +60,26 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
continue
}
dur := d1.Time.T().Sub(latest.Time.T().Add(latest.Duration))
if dur > HeartbeatDiffThreshold {
dur = HeartbeatDiffThreshold
sameDay := d1.Time.T().Day() == latest.Time.T().Day()
dur := time.Duration(mathutil.Min(
int64(d1.Time.T().Sub(latest.Time.T().Add(latest.Duration))),
int64(HeartbeatDiffThreshold),
))
// skip heartbeats that span across two adjacent summaries (assuming there are no more than 1 summary per day)
// this is relevant to prevent the time difference between generating summaries from raw heartbeats and aggregating pre-generated summaries
// for the latter case, the very last heartbeat of a day won't be counted, so we don't want to count it here either
// another option would be to adapt the Summarize() method to always append up to HeartbeatDiffThreshold seconds to a day's very last duration
if !sameDay {
dur = 0
}
latest.Duration += dur
if dur >= HeartbeatDiffThreshold || latest.GroupHash != d1.GroupHash {
// start new "group" if:
// (a) heartbeats were too far apart each other,
// (b) if they are of a different entity or,
// (c) if they span across two days
if dur >= HeartbeatDiffThreshold || latest.GroupHash != d1.GroupHash || !sameDay {
list := mapping[d1.GroupHash]
if d0 := list[len(list)-1]; d0 != d1 {
mapping[d1.GroupHash] = append(mapping[d1.GroupHash], d1)
@ -80,12 +96,20 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
for _, list := range mapping {
for _, d := range list {
// will only happen if two heartbeats with different hashes (e.g. different project) have the same timestamp
// that, in turn, will most likely only happen for mysql, where `time` column's precision was set to second for a while
// assume that two non-identical heartbeats with identical time are sub-second apart from each other, so round up to expectancy value
// also see https://github.com/muety/wakapi/issues/340
if d.Duration == 0 {
d.Duration = HeartbeatDiffThreshold
d.Duration = 500 * time.Millisecond
}
durations = append(durations, d)
}
}
if len(heartbeats) == 1 && len(durations) == 1 {
durations[0].Duration = HeartbeatDiffThreshold
}
return durations.Sorted(), nil
}

View File

@ -65,6 +65,17 @@ func (suite *DurationServiceTestSuite) SetupSuite() {
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)), // 0:30
},
// duplicate of previous one
{
ID: rand.Uint64(),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)), // 0:30
},
{
ID: rand.Uint64(),
UserID: TestUserId,
@ -160,7 +171,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() {
assert.Equal(suite.T(), TestEditorGoland, durations[0].Editor)
assert.Equal(suite.T(), TestEditorGoland, durations[1].Editor)
assert.Equal(suite.T(), TestEditorVscode, durations[2].Editor)
assert.Equal(suite.T(), 2, durations[0].NumHeartbeats)
assert.Equal(suite.T(), 3, durations[0].NumHeartbeats)
assert.Equal(suite.T(), 1, durations[1].NumHeartbeats)
assert.Equal(suite.T(), 3, durations[2].NumHeartbeats)
}

View File

@ -2,10 +2,10 @@ package services
import (
"fmt"
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"strings"
"sync"
@ -54,14 +54,18 @@ func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
}
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
hashes := make(map[string]bool)
if len(heartbeats) == 0 {
return nil
}
hashes := datastructure.NewSet[string]()
// https://github.com/muety/wakapi/issues/139
filteredHeartbeats := make([]*models.Heartbeat, 0, len(heartbeats))
for _, hb := range heartbeats {
if _, ok := hashes[hb.Hash]; !ok {
if !hashes.Contain(hb.Hash) {
filteredHeartbeats = append(filteredHeartbeats, hb)
hashes[hb.Hash] = true
hashes.Add(hb.Hash)
}
go srv.updateEntityUserCacheByHeartbeat(hb)
}
@ -73,12 +77,12 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
return err
}
func (srv *HeartbeatService) Count() (int64, error) {
func (srv *HeartbeatService) Count(approximate bool) (int64, error) {
result, ok := srv.cache.Get(srv.countTotalCacheKey())
if ok {
return result.(int64), nil
}
count, err := srv.repository.Count()
count, err := srv.repository.Count(approximate)
if err == nil {
srv.cache.Set(srv.countTotalCacheKey(), count, srv.countCacheTtl())
}
@ -159,7 +163,7 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
if results, found := srv.cache.Get(cacheKey); found {
srv.entityCacheLock.RLock()
defer srv.entityCacheLock.RUnlock()
return utils.SetToStrings(results.(map[string]bool)), nil
return results.(datastructure.Set[string]).Values(), nil
}
results, err := srv.repository.GetEntitySetByUser(entityType, user)
@ -174,14 +178,20 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
}
}
srv.cache.Set(cacheKey, utils.StringsToSet(filtered), cache.NoExpiration)
srv.cache.Set(cacheKey, datastructure.NewSet(filtered...), cache.NoExpiration)
return filtered, nil
}
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
go srv.cache.Flush()
return srv.repository.DeleteBefore(t)
}
func (srv *HeartbeatService) DeleteByUser(user *models.User) error {
go srv.cache.Flush()
return srv.repository.DeleteByUser(user)
}
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
if err != nil {
@ -202,13 +212,13 @@ func (srv *HeartbeatService) getEntityUserCacheKey(entityType uint8, user *model
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
cacheKey := srv.getEntityUserCacheKey(entityType, user)
if entities, found := srv.cache.Get(cacheKey); found {
entitySet := entities.(map[string]bool)
entitySet := entities.(datastructure.Set[string])
srv.entityCacheLock.Lock()
defer srv.entityCacheLock.Unlock()
if _, ok := entitySet[entityKey]; !ok {
entitySet[entityKey] = true
if !entitySet.Contain(entityKey) {
entitySet.Add(entityKey)
// new project / language / ..., which is not yet present in cache, arrived as part of a heartbeats
// -> update cache instead of just invalidating it, because rebuilding is expensive here
srv.cache.Set(cacheKey, entitySet, cache.NoExpiration)
@ -248,7 +258,7 @@ func (srv *HeartbeatService) countCacheTtl() time.Duration {
func (srv *HeartbeatService) filtersToColumnMap(filters *models.Filters) map[string][]string {
columnMap := map[string][]string{}
for _, t := range models.SummaryTypes() {
for _, t := range models.NativeSummaryTypes() {
f := filters.ResolveEntity(t)
if len(*f) > 0 {
columnMap[models.GetEntityColumn(t)] = *f

View File

@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"github.com/muety/wakapi/utils"
"net/http"
"time"
@ -13,7 +15,6 @@ import (
"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"
)
@ -154,8 +155,9 @@ func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.
return notime, notime, err
}
var allTimeData wakatime.AllTimeViewModel
if err := json.NewDecoder(res.Body).Decode(&allTimeData); err != nil {
// see https://github.com/muety/wakapi/issues/370
allTimeData, err := utils.ParseJsonDropKeys[wakatime.AllTimeViewModel](res.Body, "text")
if err != nil {
return notime, notime, err
}
@ -295,8 +297,8 @@ func mapHeartbeat(
func generateDays(from, to time.Time) []time.Time {
days := make([]time.Time, 0)
from = utils.StartOfDay(from)
to = utils.StartOfDay(to.AddDate(0, 0, 1))
from = datetime.BeginOfDay(from)
to = datetime.BeginOfDay(to.AddDate(0, 0, 1))
for d := from; d.Before(to); d = d.AddDate(0, 0, 1) {
days = append(days, d)

View File

@ -38,13 +38,8 @@ type CountTotalTimeResult struct {
}
func (srv *MiscService) ScheduleCountTotalTime() {
// Run once initially
if err := srv.runCountTotalTime(); err != nil {
logbuch.Fatal("failed to run CountTotalTimeJob: %v", err)
}
s := gocron.NewScheduler(time.Local)
s.Every(1).Hour().Do(srv.runCountTotalTime)
s.Every(1).Hour().WaitForSchedule().Do(srv.runCountTotalTime)
s.StartBlocking()
}

View File

@ -2,6 +2,7 @@ package services
import (
"errors"
"github.com/duke-git/lancet/v2/slice"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@ -45,38 +46,26 @@ func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel
// GetByUserGrouped returns lists of project labels, grouped by their project key
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
labelsByProject := make(map[string][]*models.ProjectLabel)
userLabels, err := srv.GetByUser(userId)
if err != nil {
return nil, err
}
for _, l := range userLabels {
if _, ok := labelsByProject[l.ProjectKey]; !ok {
labelsByProject[l.ProjectKey] = []*models.ProjectLabel{l}
} else {
labelsByProject[l.ProjectKey] = append(labelsByProject[l.ProjectKey], l)
}
}
return labelsByProject, nil
mappedLabels := slice.GroupWith[*models.ProjectLabel, string](userLabels, func(l *models.ProjectLabel) string {
return l.ProjectKey
})
return mappedLabels, nil
}
// GetByUserGroupedInverted returns lists of project labels, grouped by their label key
func (srv *ProjectLabelService) GetByUserGroupedInverted(userId string) (map[string][]*models.ProjectLabel, error) {
projectsByLabel := make(map[string][]*models.ProjectLabel)
userLabels, err := srv.GetByUser(userId)
if err != nil {
return nil, err
}
for _, l := range userLabels {
if _, ok := projectsByLabel[l.Label]; !ok {
projectsByLabel[l.Label] = []*models.ProjectLabel{l}
} else {
projectsByLabel[l.Label] = append(projectsByLabel[l.Label], l)
}
}
return projectsByLabel, nil
mappedLabels := slice.GroupWith[*models.ProjectLabel, string](userLabels, func(l *models.ProjectLabel) string {
return l.Label
})
return mappedLabels, nil
}
func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.ProjectLabel, error) {

View File

@ -1,13 +1,14 @@
package services
import (
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/muety/wakapi/models"
"time"
)
type IAggregationService interface {
Schedule()
Run(map[string]bool) error
Run(set datastructure.Set[string]) error
}
type IMiscService interface {
@ -29,7 +30,7 @@ type IAliasService interface {
type IHeartbeatService interface {
Insert(*models.Heartbeat) error
InsertBatch([]*models.Heartbeat) error
Count() (int64, error)
Count(bool) (int64, error)
CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
@ -39,6 +40,7 @@ type IHeartbeatService interface {
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
GetEntitySetByUser(uint8, *models.User) ([]string, error)
DeleteBefore(time.Time) error
DeleteByUser(*models.User) error
}
type IDiagnosticsService interface {

View File

@ -2,7 +2,7 @@ package services
import (
"errors"
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
@ -40,12 +40,7 @@ func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationServ
sub1 := srv.eventBus.Subscribe(0, config.TopicProjectLabel)
go func(sub *hub.Subscription) {
for m := range sub.Receiver {
userId := m.Fields[config.FieldUserId].(string)
for key := range srv.cache.Items() {
if strings.HasSuffix(key, fmt.Sprintf("__%s__--aliased", userId)) {
srv.cache.Delete(key)
}
}
srv.invalidateUserCache(m.Fields[config.FieldUserId].(string))
}
}(&sub1)
@ -113,9 +108,15 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User, filte
}
// Generate missing slots (especially before and after existing summaries) from durations (formerly raw heartbeats)
missingIntervals := srv.getMissingIntervals(from, to, summaries)
missingIntervals := srv.getMissingIntervals(from, to, summaries, false)
for _, interval := range missingIntervals {
if s, err := srv.Summarize(interval.Start, interval.End, user, filters); err == nil {
if len(missingIntervals) > 2 && s.FromTime.T().Equal(s.ToTime.T()) {
// little hack here: GetAllWithin will query for >= from_date
// however, for "in-between" / intra-day missing intervals, we want strictly > from_date to prevent double-counting
// to not have to rewrite many interfaces, we skip these summaries here
continue
}
summaries = append(summaries, s)
} else {
return nil, err
@ -368,7 +369,7 @@ func (srv *SummaryService) mergeSummaryItems(existing []*models.SummaryItem, new
return itemList
}
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary, precise bool) []*models.Interval {
if len(summaries) == 0 {
return []*models.Interval{{from, to}}
}
@ -377,37 +378,43 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
// Pre
if from.Before(summaries[0].FromTime.T()) {
intervals = append(intervals, &models.Interval{from, summaries[0].FromTime.T()})
intervals = append(intervals, &models.Interval{Start: from, End: summaries[0].FromTime.T()})
}
// Between
for i := 0; i < len(summaries)-1; i++ {
t1, t2 := summaries[i].ToTime.T(), summaries[i+1].FromTime.T()
if t1.Equal(t2) {
if t1.Equal(t2) || t1.Equal(to) || t1.After(to) {
continue
}
td1 := t1
td2 := t2
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
// we assume that, if summary for any time range within a day is present, no further heartbeats exist on that day before 'from' and after 'to' time of that summary
// this requires that a summary exists for every single day in a year and none is skipped, which shouldn't ever happen
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// non-precise mode is mainly for speed when fetching summaries over large intervals and trades speed for summary accuracy / comprehensiveness
if !precise {
td1 = datetime.BeginOfDay(t1).AddDate(0, 0, 1)
td2 = datetime.BeginOfDay(t2)
// we always want to jump to beginning of next day
// however, if left summary ends already at midnight, we would instead jump to beginning of second-next day -> go back again
if td1.Sub(t1) == 24*time.Hour {
td1 = td1.Add(-1 * time.Hour)
// we always want to jump to beginning of next day
// however, if left summary ends already at midnight, we would instead jump to beginning of second-next day -> go back again
if td1.AddDate(0, 0, 1).Equal(t1) {
td1 = td1.Add(-1 * time.Hour)
}
}
// one or more day missing in between?
if td1.Before(td2) {
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
intervals = append(intervals, &models.Interval{Start: t1, End: t2})
}
}
// Post
if to.After(summaries[len(summaries)-1].ToTime.T()) {
intervals = append(intervals, &models.Interval{summaries[len(summaries)-1].ToTime.T(), to})
intervals = append(intervals, &models.Interval{Start: summaries[len(summaries)-1].ToTime.T(), End: to})
}
return intervals

View File

@ -318,7 +318,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
assert.Equal(suite.T(), 200, result.NumHeartbeats)
suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1+1)
suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1)
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
@ -485,6 +485,45 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Filters() {
assert.Contains(suite.T(), effectiveFilters.Label, TestProjectLabel3)
}
func (suite *SummaryServiceTestSuite) TestSummaryService_getMissingIntervals() {
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
from1, _ := time.Parse(time.RFC822, "25 Mar 22 11:00 UTC")
to1, _ := time.Parse(time.RFC822, "25 Mar 22 13:00 UTC")
from2, _ := time.Parse(time.RFC822, "25 Mar 22 15:00 UTC")
to2, _ := time.Parse(time.RFC822, "26 Mar 22 00:00 UTC")
summaries := []*models.Summary{
{FromTime: models.CustomTime(from1), ToTime: models.CustomTime(to1)},
{FromTime: models.CustomTime(from2), ToTime: models.CustomTime(to2)},
}
r1 := sut.getMissingIntervals(from1, to1, summaries, true)
assert.Empty(suite.T(), r1)
r2 := sut.getMissingIntervals(from1, from1, summaries, true)
assert.Empty(suite.T(), r2)
// non-precise mode will not return intra-day intervals
// we might want to change this ...
r3 := sut.getMissingIntervals(from1, to2, summaries, false)
assert.Len(suite.T(), r3, 0)
r4 := sut.getMissingIntervals(from1, to2, summaries, true)
assert.Len(suite.T(), r4, 1)
assert.Equal(suite.T(), to1, r4[0].Start)
assert.Equal(suite.T(), from2, r4[0].End)
r5 := sut.getMissingIntervals(from1.Add(-time.Hour), to2.Add(time.Hour), summaries, true)
assert.Len(suite.T(), r5, 3)
assert.Equal(suite.T(), from1.Add(-time.Hour), r5[0].Start)
assert.Equal(suite.T(), from1, r5[0].End)
assert.Equal(suite.T(), to1, r5[1].Start)
assert.Equal(suite.T(), from2, r5[1].End)
assert.Equal(suite.T(), to2, r5[2].Start)
assert.Equal(suite.T(), to2.Add(time.Hour), r5[2].End)
}
func filterDurations(from, to time.Time, durations models.Durations) models.Durations {
filtered := make([]*models.Duration, 0, len(durations))
for _, d := range durations {

View File

@ -2,6 +2,7 @@ package services
import (
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
@ -100,9 +101,9 @@ func (srv *UserService) GetAllByReports(reportsEnabled bool) ([]*models.User, er
}
func (srv *UserService) GetActive(exact bool) ([]*models.User, error) {
minDate := time.Now().Add(-24 * time.Hour * time.Duration(srv.config.App.InactiveDays))
minDate := time.Now().AddDate(0, 0, -1*srv.config.App.InactiveDays)
if !exact {
minDate = utils.FloorDateHour(minDate)
minDate = datetime.BeginOfHour(minDate)
}
cacheKey := fmt.Sprintf("%s--active", minDate.String())

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -2,6 +2,7 @@ PetiteVue.createApp({
//$delimiters: ['${', '}'], // https://github.com/vuejs/petite-vue/pull/100
activeTab: defaultTab,
selectedTimezone: userTimeZone,
vibrantColorsEnabled: JSON.parse(localStorage.getItem('wakapi_vibrant_colors') || false),
get tzOptions() {
return [defaultTzOption, ...tzs.sort().map(tz => ({ value: tz, text: tz }))]
},
@ -21,13 +22,21 @@ PetiteVue.createApp({
document.querySelector('#form-import-wakatime').submit()
}
},
confirmClearData() {
if (confirm('Are you sure? This can not be undone!')) {
document.querySelector('#form-clear-data').submit()
}
},
confirmDeleteAccount() {
if (confirm('Are you sure? This can not be undone!')) {
document.querySelector('#form-delete-user').submit()
}
},
onToggleVibrantColors() {
localStorage.setItem('wakapi_vibrant_colors', this.vibrantColorsEnabled)
},
mounted() {
this.updateTab()
window.addEventListener('hashchange', () => this.updateTab())
}
}).mount('#settings-page')
}).mount('#settings-page')

View File

@ -1,3 +1,9 @@
PetiteVue.createApp({
$delimiters: ['${', '}'],
get currentInterval() {
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('interval')) return urlParams.get('interval')
if (!urlParams.has('from') && !urlParams.has('to')) return 'today'
return null
}
}).mount('#summary-page')

View File

@ -87,6 +87,8 @@ function draw(subselection) {
.filter((c, i) => shouldUpdate(i))
.forEach(c => c.destroy())
const vibrantColors = JSON.parse(window.localStorage.getItem('wakapi_vibrant_colors') || false);
let projectChart = projectsCanvas && !projectsCanvas.classList.contains('hidden') && shouldUpdate(0)
? new Chart(projectsCanvas.getContext('2d'), {
//type: 'horizontalBar',
@ -97,11 +99,11 @@ function draw(subselection) {
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.projects.map((p, i) => {
const c = hexToRgb(getColor(p.key, i % baseColors.length))
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.projects.map((p, i) => {
const c = hexToRgb(getColor(p.key, i % baseColors.length))
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
}],
@ -152,11 +154,11 @@ function draw(subselection) {
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.operatingSystems.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
const c = hexToRgb(vibrantColors ? (osColors[p.key.toLowerCase()] || getRandomColor(p.key)) : getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.operatingSystems.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
const c = hexToRgb(vibrantColors ? (osColors[p.key.toLowerCase()] || getRandomColor(p.key)) : getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderWidth: 0
@ -189,11 +191,11 @@ function draw(subselection) {
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.editors.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
const c = hexToRgb(vibrantColors ? (editorColors[p.key.toLowerCase()] || getRandomColor(p.key)) : getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.editors.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
const c = hexToRgb(vibrantColors ? (editorColors[p.key.toLowerCase()] || getRandomColor(p.key)) : getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderWidth: 0
@ -266,11 +268,11 @@ function draw(subselection) {
.slice(0, Math.min(showTopN[4], wakapiData.machines.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.machines.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.machines.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderWidth: 0
@ -303,11 +305,11 @@ function draw(subselection) {
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.labels.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.labels.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderWidth: 0
@ -340,11 +342,11 @@ function draw(subselection) {
.slice(0, Math.min(showTopN[6], wakapiData.branches.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.branches.map((p, i) => {
const c = hexToRgb(getColor(p.key, i % baseColors.length))
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.branches.map((p, i) => {
const c = hexToRgb(getColor(p.key, i % baseColors.length))
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
}],
@ -455,4 +457,3 @@ window.addEventListener('load', function () {
togglePlaceholders(getPresentDataMask())
draw()
})

View File

@ -1,22 +1,14 @@
// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
// Package docs GENERATED BY SWAG; DO NOT EDIT
// This file was generated by swaggo/swag
package docs
import (
"bytes"
"encoding/json"
"strings"
import "github.com/swaggo/swag"
"github.com/alecthomas/template"
"github.com/swaggo/swag"
)
var doc = `{
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{.Description}}",
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {
"name": "Ferdinand Mütsch",
@ -612,11 +604,6 @@ var doc = `{
},
"/plugins/errors": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
@ -1341,9 +1328,6 @@ var doc = `{
"machine_name_id": {
"type": "string"
},
"modified_at": {
"type": "string"
},
"project": {
"type": "string"
},
@ -1697,49 +1681,18 @@ var doc = `{
}
}`
type swaggerInfo struct {
Version string
Host string
BasePath string
Schemes []string
Title string
Description string
}
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = swaggerInfo{
Version: "1.0",
Host: "",
BasePath: "/api",
Schemes: []string{},
Title: "Wakapi API",
Description: "REST API to interact with [Wakapi](https://wakapi.dev)\n\n## Authentication\nSet header `Authorization` to your API Key encoded as Base64 and prefixed with `Basic`\n**Example:** `Basic ODY2NDhkNzQtMTljNS00NTJiLWJhMDEtZmIzZWM3MGQ0YzJmCg==`",
}
type s struct{}
func (s *s) ReadDoc() string {
sInfo := SwaggerInfo
sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1)
t, err := template.New("swagger_info").Funcs(template.FuncMap{
"marshal": func(v interface{}) string {
a, _ := json.Marshal(v)
return string(a)
},
}).Parse(doc)
if err != nil {
return doc
}
var tpl bytes.Buffer
if err := t.Execute(&tpl, sInfo); err != nil {
return doc
}
return tpl.String()
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "",
BasePath: "/api",
Schemes: []string{},
Title: "Wakapi API",
Description: "REST API to interact with [Wakapi](https://wakapi.dev)\n\n## Authentication\nSet header `Authorization` to your API Key encoded as Base64 and prefixed with `Basic`\n**Example:** `Basic ODY2NDhkNzQtMTljNS00NTJiLWJhMDEtZmIzZWM3MGQ0YzJmCg==`",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
}
func init() {
swag.Register(swag.Name, &s{})
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}

View File

@ -596,11 +596,6 @@
},
"/plugins/errors": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
@ -1325,9 +1320,6 @@
"machine_name_id": {
"type": "string"
},
"modified_at": {
"type": "string"
},
"project": {
"type": "string"
},

View File

@ -166,8 +166,6 @@ definitions:
type: string
machine_name_id:
type: string
modified_at:
type: string
project:
type: string
time:
@ -808,8 +806,6 @@ paths:
responses:
"201":
description: ""
security:
- ApiKeyAuth: []
summary: Push a new diagnostics object
tags:
- diagnostics

View File

@ -1033,7 +1033,7 @@
" pm.expect(jsonData.timezone).to.eql(pm.collectionVariables.get('TZ'));",
" var date = new Date(\"2022-01-01T00:00:00+0100\")",
" pm.expect(new Date(jsonData.start)).to.eql(date);",
" pm.expect(new Date(jsonData.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24));",
" pm.expect(new Date(jsonData.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24 - 1000));",
" pm.expect(jsonData.data.length).to.eql(2);",
"});"
],
@ -2097,7 +2097,7 @@
"",
"pm.test(\"Correct content\", function () {",
" const jsonData = pm.response.json();",
" pm.expect(jsonData.data.text).to.eql('0 hrs 8 mins');",
" pm.expect(jsonData.data.text).to.eql('0 hrs 4 mins');",
"});"
],
"type": "text/javascript"

View File

@ -1,9 +0,0 @@
package utils
func GetMapValues(m map[string]interface{}) []interface{} {
values := make([]interface{}, 0, len(m))
for _, v := range m {
values = append(values, v)
}
return values
}

View File

@ -2,93 +2,41 @@ package utils
import (
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"time"
)
func StartOfDay(date time.Time) time.Time {
return FloorDate(date)
func BeginOfToday(tz *time.Location) time.Time {
return datetime.BeginOfDay(time.Now().In(tz))
}
func StartOfToday(tz *time.Location) time.Time {
return StartOfDay(FloorDate(time.Now().In(tz)))
func BeginOfThisWeek(tz *time.Location) time.Time {
return datetime.BeginOfWeek(time.Now().In(tz))
}
func EndOfDay(date time.Time) time.Time {
floored := FloorDate(date)
if floored == date {
date = date.Add(1 * time.Second)
}
return CeilDate(date)
func BeginOfThisMonth(tz *time.Location) time.Time {
return datetime.BeginOfMonth(time.Now().In(tz))
}
func EndOfToday(tz *time.Location) time.Time {
return EndOfDay(time.Now().In(tz))
}
func StartOfThisWeek(tz *time.Location) time.Time {
return StartOfWeek(time.Now().In(tz))
}
func StartOfWeek(date time.Time) time.Time {
year, week := date.ISOWeek()
return firstDayOfISOWeek(year, week, date.Location())
}
func StartOfThisMonth(tz *time.Location) time.Time {
return StartOfMonth(time.Now().In(tz))
}
func StartOfMonth(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
}
func StartOfThisYear(tz *time.Location) time.Time {
return StartOfYear(time.Now().In(tz))
}
func StartOfYear(date time.Time) time.Time {
return time.Date(date.Year(), time.January, 1, 0, 0, 0, 0, date.Location())
}
// FloorDate rounds date down to the start of the day and keeps the time zone
func FloorDate(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
}
// FloorDateHour rounds date down to the start of the current hour and keeps the time zone
func FloorDateHour(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), date.Hour(), 0, 0, 0, date.Location())
func BeginOfThisYear(tz *time.Location) time.Time {
return datetime.BeginOfYear(time.Now().In(tz))
}
// CeilDate rounds date up to the start of next day if date is not already a start (00:00:00)
func CeilDate(date time.Time) time.Time {
floored := FloorDate(date)
floored := datetime.BeginOfDay(date)
if floored == date {
return floored
}
return floored.AddDate(0, 0, 1)
}
// SetLocation resets the time zone information of a date without converting it, i.e. 19:00 UTC will result in 19:00 CET, for instance
func SetLocation(date time.Time, tz *time.Location) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, tz)
}
// WithOffset adds the time zone difference between Local and tz to a date, i.e. 19:00 UTC will result in 21:00 CET (or 22:00 CEST), for instance
func WithOffset(date time.Time, tz *time.Location) time.Time {
now := time.Now()
_, localOffset := now.Zone()
_, targetOffset := now.In(tz).Zone()
dateTz := date.Add(time.Duration((targetOffset - localOffset) * int(time.Second)))
return time.Date(dateTz.Year(), dateTz.Month(), dateTz.Day(), dateTz.Hour(), dateTz.Minute(), dateTz.Second(), dateTz.Nanosecond(), dateTz.Location()).In(tz)
}
// SplitRangeByDays creates a slice of intervals between from and to, each of which is at max of 24 hours length and has its split at midnight
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
intervals := make([][]time.Time, 0)
for t1 := from; t1.Before(to); {
t2 := StartOfDay(t1).AddDate(0, 0, 1)
t2 := datetime.BeginOfDay(t1).AddDate(0, 0, 1)
if t2.After(to) {
t2 = to
}
@ -112,22 +60,3 @@ func LocalTZOffset() time.Duration {
_, offset := time.Now().Zone()
return time.Duration(offset * int(time.Second))
}
// https://stackoverflow.com/a/18632496
func firstDayOfISOWeek(year int, week int, timezone *time.Location) time.Time {
date := time.Date(year, 0, 0, 0, 0, 0, 0, timezone)
isoYear, isoWeek := date.ISOWeek()
for date.Weekday() != time.Monday { // iterate back to Monday
date = date.AddDate(0, 0, -1)
isoYear, isoWeek = date.ISOWeek()
}
for isoYear < year { // iterate forward to the first day of the first week
date = date.AddDate(0, 0, 1)
isoYear, isoWeek = date.ISOWeek()
}
for isoWeek < week { // iterate forward to the first day of the given week
date = date.AddDate(0, 0, 1)
isoYear, isoWeek = date.ISOWeek()
}
return date
}

View File

@ -1,6 +1,7 @@
package utils
import (
"github.com/duke-git/lancet/v2/datetime"
"github.com/muety/wakapi/config"
"github.com/stretchr/testify/assert"
"testing"
@ -21,100 +22,11 @@ func init() {
tzPst, _ = time.LoadLocation("America/Los_Angeles")
}
func TestDate_Ceil(t *testing.T) {
tests := []struct {
in string
out string
}{
{
"02 Jan 06 15:04 MST",
"03 Jan 06 00:00 MST",
},
{
"03 Jan 06 00:00 MST",
"03 Jan 06 00:00 MST",
},
}
for _, test := range tests {
inDate, _ := time.Parse(time.RFC822, test.in)
outDate, _ := time.Parse(time.RFC822, test.out)
out := CeilDate(inDate)
assert.Equal(t, outDate, out)
}
}
func TestDate_StartOfDay(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 00:00:00", tzCet)
assert.Equal(t, t1, StartOfDay(d1))
assert.Equal(t, t2, StartOfDay(d2))
assert.Equal(t, t3, StartOfDay(d3))
assert.Equal(t, t4, StartOfDay(d4))
assert.Equal(t, tzLocal, StartOfDay(d1).Location())
assert.Equal(t, tzUtc, StartOfDay(d2).Location())
assert.Equal(t, tzPst, StartOfDay(d3).Location())
assert.Equal(t, tzCet, StartOfDay(d4).Location())
}
func TestDate_EndOfDay(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-26 00:00:00", tzCet)
assert.Equal(t, t1, EndOfDay(d1))
assert.Equal(t, t2, EndOfDay(d2))
assert.Equal(t, t3, EndOfDay(d3))
assert.Equal(t, t4, EndOfDay(d4))
assert.Equal(t, tzLocal, EndOfDay(d1).Location())
assert.Equal(t, tzUtc, EndOfDay(d2).Location())
assert.Equal(t, tzPst, EndOfDay(d3).Location())
assert.Equal(t, tzCet, EndOfDay(d4).Location())
}
func TestDate_StartOfWeek(t *testing.T) {
d1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzLocal)
d2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzUtc)
d3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzPst)
d4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-25 20:25:00", tzCet)
t1, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzLocal)
t2, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzUtc)
t3, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzPst)
t4, _ := time.ParseInLocation(config.SimpleDateTimeFormat, "2021-04-19 00:00:00", tzCet)
assert.Equal(t, t1, StartOfWeek(d1))
assert.Equal(t, t2, StartOfWeek(d2))
assert.Equal(t, t3, StartOfWeek(d3))
assert.Equal(t, t4, StartOfWeek(d4))
assert.Equal(t, tzLocal, StartOfWeek(d1).Location())
assert.Equal(t, tzUtc, StartOfWeek(d2).Location())
assert.Equal(t, tzPst, StartOfWeek(d3).Location())
assert.Equal(t, tzCet, StartOfWeek(d4).Location())
}
func TestDate_SplitRangeByDays(t *testing.T) {
df1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-25 20:25:00")
dt1, _ := time.Parse(config.SimpleDateTimeFormat, "2021-04-28 06:45:00")
df2 := df1
dt2 := CeilDate(df1)
dt2 := datetime.EndOfDay(df1)
df3 := df1
dt3 := df1.Add(10 * time.Second)
df4 := df1

View File

@ -4,8 +4,24 @@ import (
"encoding/json"
"github.com/muety/wakapi/config"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
const (
cacheMaxAgePattern = `max-age=(\d+)`
)
var (
cacheMaxAgeRe *regexp.Regexp
)
func init() {
cacheMaxAgeRe = regexp.MustCompile(cacheMaxAgePattern)
}
func RespondJSON(w http.ResponseWriter, r *http.Request, status int, object interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@ -13,3 +29,16 @@ func RespondJSON(w http.ResponseWriter, r *http.Request, status int, object inte
config.Log().Request(r).Error("error while writing json response: %v", err)
}
}
func IsNoCache(r *http.Request, cacheTtl time.Duration) bool {
cacheControl := r.Header.Get("cache-control")
if strings.Contains(cacheControl, "no-cache") {
return true
}
if match := cacheMaxAgeRe.FindStringSubmatch(cacheControl); match != nil && len(match) > 1 {
if maxAge, _ := strconv.Atoi(match[1]); time.Duration(maxAge)*time.Second <= cacheTtl {
return true
}
}
return false
}

34
utils/json.go Normal file
View File

@ -0,0 +1,34 @@
package utils
import (
"bytes"
"encoding/json"
"io"
)
// ParseJsonDropKeys parses the given JSON input object to an object of given type, while omitting the specified keys on the way.
// This can be useful if parsing would normally fail due to ambiguous typing of some key, but that key is not of interest and can be dropped to avoid parse errors.
// Dropping keys only works on top level of the object.
func ParseJsonDropKeys[T any](r io.Reader, dropKeys ...string) (T, error) {
var (
result T
resultTmp map[string]interface{}
resultTmpBuf = new(bytes.Buffer)
)
if err := json.NewDecoder(r).Decode(&resultTmp); err != nil {
return result, err
}
for _, k := range dropKeys {
delete(resultTmp, k)
}
if err := json.NewEncoder(resultTmpBuf).Encode(resultTmp); err != nil {
return result, err
}
if err := json.NewDecoder(resultTmpBuf).Decode(&result); err != nil {
return result, err
}
return result, nil
}

View File

@ -1,17 +0,0 @@
package utils
func StringsToSet(slice []string) map[string]bool {
set := make(map[string]bool, len(slice))
for _, e := range slice {
set[e] = true
}
return set
}
func SetToStrings(set map[string]bool) []string {
slice := make([]string, 0, len(set))
for k := range set {
slice = append(slice, k)
}
return slice
}

View File

@ -8,12 +8,3 @@ import (
func Capitalize(s string) string {
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
}
func FindString(needle string, haystack []string, defaultVal string) string {
for _, s := range haystack {
if s == needle {
return s
}
}
return defaultVal
}

View File

@ -35,27 +35,27 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
switch interval {
case models.IntervalToday:
from = StartOfToday(tz)
from = BeginOfToday(tz)
case models.IntervalYesterday:
from = StartOfToday(tz).Add(-24 * time.Hour)
to = StartOfToday(tz)
from = BeginOfToday(tz).Add(-24 * time.Hour)
to = BeginOfToday(tz)
case models.IntervalThisWeek:
from = StartOfThisWeek(tz)
from = BeginOfThisWeek(tz)
case models.IntervalLastWeek:
from = StartOfThisWeek(tz).AddDate(0, 0, -7)
to = StartOfThisWeek(tz)
from = BeginOfThisWeek(tz).AddDate(0, 0, -7)
to = BeginOfThisWeek(tz)
case models.IntervalThisMonth:
from = StartOfThisMonth(tz)
from = BeginOfThisMonth(tz)
case models.IntervalLastMonth:
from = StartOfThisMonth(tz).AddDate(0, -1, 0)
to = StartOfThisMonth(tz)
from = BeginOfThisMonth(tz).AddDate(0, -1, 0)
to = BeginOfThisMonth(tz)
case models.IntervalThisYear:
from = StartOfThisYear(tz)
from = BeginOfThisYear(tz)
case models.IntervalPast7Days:
from = now.AddDate(0, 0, -7)
case models.IntervalPast7DaysYesterday:
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = StartOfToday(tz).AddDate(0, 0, -1)
from = BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = BeginOfToday(tz).AddDate(0, 0, -1)
case models.IntervalPast14Days:
from = now.AddDate(0, 0, -14)
case models.IntervalPast30Days:

View File

@ -1 +1 @@
2.2.6
2.3.5

View File

@ -58,7 +58,7 @@
</p>
<div class="flex justify-center my-8">
<img alt="App screenshot" src="assets/images/screenshot.webp">
<img alt="App screenshot" src="assets/images/screenshot.webp" width="800px" height="513px" loading="lazy">
</div>
<div class="flex flex-col items-center mt-10">
@ -81,11 +81,11 @@
</div>
<div class="flex justify-center space-x-2 mt-12">
<img alt="License badge"
<img alt="License badge" loading="lazy"
src="https://badges.fw-web.space/github/license/muety/wakapi?color=%232F855A&style=flat-square">
<img alt="Go version badge"
<img alt="Go version badge" loading="lazy"
src="https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi?color=%232F855A&style=flat-square">
<img alt="Wakapi coding time badge"
<img alt="Wakapi coding time badge" loading="lazy"
src="https://badges.fw-web.space/endpoint?color=%232F855A&style=flat-square&label=wakapi&url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi">
</div>
</div>

View File

@ -1,3 +1,3 @@
<a id="logo-container" class="text-2xl font-semibold text-white inline-block align-middle" href="">
<img src="assets/images/logo.svg" width="110px" alt="Logo">
<img src="assets/images/logo.svg" width="110px" height="42px" alt="Logo">
</a>

View File

@ -354,6 +354,29 @@
</div>
</div>
</div>
<div class="w-full">
<hr class="border-t border-gray-800 my-4">
</div>
<!-- Colors -->
<div class="w-full">
<div class="flex flex-wrap md:flex-nowrap mb-8 gap-x-4">
<div class="w-full md:w-1/3 mb-4 md:mb-0 inline-block">
<span class="font-semibold text-gray-300 text-lg">Vibrant Colors</span>
<p class="block text-sm text-gray-600">You can view the entire summary in vibrant colors similar to the "Languages" chart. Note that this setting is saved in your web browser only.</p>
</div>
<div class="w-full md:w-2/3 inline-block">
<div class="flex-col items-center w-full text-gray-500 text-sm">
<select autocomplete="off" id="vibrant-color-toggle" class="select-default" style="max-width: 6rem" v-model="vibrantColorsEnabled" @change="onToggleVibrantColors">
<option value="false" class="cursor-pointer">Disabled</option>
<option value="true" class="cursor-pointer">Enabled</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div v-cloak id="permissions" class="tab flex flex-col space-y-4" v-if="isActive('permissions')">
@ -375,7 +398,7 @@
<label class="font-semibold text-gray-300" for="max_days">Time Range</label>
<span class="block text-sm text-gray-600">(in days; 0 = not public, -1 = unlimited)</span>
</div>
<div >
<div>
<input class="input-default"
style="max-width: 80px" type="number" id="max_days" name="max_days" min="-1" required
value="{{ .User.ShareDataMaxDays }}">
@ -386,7 +409,7 @@
<div class="flex-grow">
<label class="font-semibold text-gray-300" for="share_projects">Share Projects</label>
</div>
<div >
<div>
<select autocomplete="off" id="share_projects" name="share_projects" class="select-default flex-grow">
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No
</option>
@ -400,7 +423,7 @@
<div class="flex-grow">
<label class="font-semibold text-gray-300" for="share_languages">Share Languages</label>
</div>
<div >
<div>
<select autocomplete="off" id="share_languages" name="share_languages" class="select-default flex-grow">
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No
</option>
@ -414,7 +437,7 @@
<div class="flex-grow">
<label class="font-semibold text-gray-300" for="share_editors">Share Editors</label>
</div>
<div >
<div>
<select autocomplete="off" id="share_editors" name="share_editors" class="select-default flex-grow">
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No
</option>
@ -428,7 +451,7 @@
<div class="flex-grow">
<label class="font-semibold text-gray-300" for="share_oss">Share OS'</label>
</div>
<div >
<div>
<select autocomplete="off" id="share_oss" name="share_oss" class="select-default flex-grow">
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No
</option>
@ -442,7 +465,7 @@
<div class="flex-grow">
<label class="font-semibold text-gray-300" for="share_machines">Share Machines</label>
</div>
<div >
<div>
<select autocomplete="off" id="share_machines" name="share_machines" class="select-default flex-grow">
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No
</option>
@ -456,7 +479,7 @@
<div class="flex-grow">
<label class="font-semibold text-gray-300" for="share_labels">Share Project Labels</label>
</div>
<div >
<div>
<select autocomplete="off" id="share_labels" name="share_labels" class="select-default flex-grow">
<option value="false" class="cursor-pointer" {{ if not .User.ShareLabels }} selected {{ end }}>No
</option>
@ -525,41 +548,53 @@
<div class="w-full lg:w-3/4">
<div class="flex flex-wrap md:flex-nowrap mb-8 gap-x-4">
<div class="w-full md:w-1/2 mb-4 md:mb-0 inline-block">
<label class="font-semibold text-gray-300" for="select-timezone">Badges (Shields.IO)</label>
<label class="font-semibold text-gray-300" for="select-timezone">Badges</label>
<span class="block text-sm text-gray-600">
The integration with <a class="link" href="https://shields.io" target="_blank" rel="noreferrer noopener">Shields.IO</a> allows to generate badges for README pages or forums. To enable this feature, you need to grant public, unauthorized access to the respective endpoints. See <a class="link" href="settings#permissions">Permissions</a>.<br><br>
Only available on public instances, not on localhost.
This integration with allows to generate badges for README pages or forums. To enable this feature, you need to grant public, unauthorized access to the respective endpoints. See <a class="link" href="settings#permissions">Permissions</a>. Adapt the URL's <i>label</i> and <i>color</i> parameters for customized badges.<br><br>
In addition, there is an endpoint compatible with <a class="link" href="https://shields.io" target="_blank" rel="noreferrer noopener">Shields.IO</a> to allow for even more customization (e.g. different <a class="link" href="https://shields.io/#styles" target="_blank" rel="noreferrer noopener">styles</a>). Only available on public instances, not on localhost.
</span>
</div>
<div class="w-full md:w-1/2 ml-4">
{{ if ne .User.ShareDataMaxDays 0 }}
<div class="flex space-x-4 mb-4">
<div class="flex items-center">
<div class="flex items-center w-1/3">
<img class="with-url-src"
src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=2F855A&label=today"
alt="Shields.io badge"
style="width: 128px; max-width: inherit;"
src="api/badge/{{ .User.ID }}/interval:today?label=today"
alt="Badge"
/>
</div>
<input
class="with-url-value flex-shrink w-full font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=2F855A&label=today"
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="%s/api/badge/{{ .User.ID }}/interval:today?label=today"
readonly
>
</div>
<div class="flex space-x-4 mt-4">
<div class="flex items-center">
<div class="flex items-center w-1/3">
<img class="with-url-src"
src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=2F855A&label=last 30d"
alt="Shields.io badge"
style="width: 128px; max-width: inherit;"
src="api/badge/{{ .User.ID }}/interval:30_days?label=last 30d"
alt="Badge"
/>
</div>
<input
class="with-url-value flex-shrink w-full font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=2F855A&label=last 30d"
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="%s/api/badge/{{ .User.ID }}/{{ .User.ID }}/interval:30_days?label=last 30d"
readonly
>
</div>
<div class="flex space-x-4 mt-4">
<div class="flex items-center w-1/3">
<img class="with-url-src"
src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&label=last 30d"
alt="Shields.io badge"
/>
</div>
<input
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&label=last 30d"
readonly
>
</div>
@ -609,7 +644,7 @@
Regenerate all pre-computed summaries from raw heartbeat data. This may be useful if, for some reason, summaries are faulty or preconditions have change (e.g. you modified language mappings retrospectively). This may take some time. Be careful and only run this action if you know, what your are doing, as data loss might occur.
</span>
</div>
<div class="w-1/2 ml-4">
<div class="w-1/2 ml-4 flex items-center">
<button type="button" class="btn-danger ml-1" @click.stop="confirmRegenerate">Clear and regenerate</button>
</div>
</form>
@ -623,11 +658,25 @@
Please note that resetting your API key requires you to update your .wakatime.cfg files on all of your computers to make the WakaTime client send heartbeats again.
</span>
</div>
<div class="w-1/2 ml-4">
<div class="w-1/2 ml-4 flex items-center">
<button type="submit" class="btn-danger ml-1">Reset API key</button>
</div>
</form>
<form action="" method="post" class="flex mb-8" id="form-clear-data">
<input type="hidden" name="action" value="clear_data">
<div class="w-1/2 mr-4 inline-block">
<span class="font-semibold text-gray-300">Clear Data</span>
<span class="block text-sm text-gray-600">
Clear all your time tracking data from Wakapi. This cannot be undone. Be careful!
</span>
</div>
<div class="w-1/2 ml-4 flex items-center">
<button type="button" class="btn-danger ml-1" @click.stop="confirmClearData">Clear data</button>
</div>
</form>
<form action="" method="post" class="flex mb-8" id="form-delete-user">
<input type="hidden" name="action" value="delete_account">
@ -637,7 +686,7 @@
Deleting your account will cause all data, including all your heartbeats, to be erased from the server immediately. This action is irreversible. Be careful!
</span>
</div>
<div class="w-1/2 ml-4">
<div class="w-1/2 ml-4 flex items-center">
<button type="button" class="btn-danger ml-1" @click.stop="confirmDeleteAccount">Delete account</button>
</div>
</form>
@ -652,4 +701,3 @@
</body>
</html>

View File

@ -6,7 +6,7 @@
<script src="assets/js/components/time-picker.js"></script>
<script type="module" src="assets/js/components/summary.js"></script>
<body class="relative bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<body class="relative bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto">
{{ template "menu-main.tpl.html" . }}
@ -16,200 +16,209 @@
{{ if .User.HasData }}
<div class="flex justify-end mt-12 relative" id="summary-page" v-scope>
<div v-scope="TimePicker({
fromDate: '{{ .From | simpledate }}',
toDate: '{{ .To | ceildate | simpledate }}',
timeSelection: '{{ .From | datetime }} - {{ .To | ceildate | datetime }}'
})" @vue:mounted="mounted"></div>
<div id="summary-page" class="flex-grow" v-scope>
<div class="flex justify-end mt-12 relative">
<div v-scope="TimePicker({
fromDate: '{{ .From | simpledate }}',
toDate: '{{ .To | ceildate | simpledate }}',
timeSelection: '{{ .From | datetime }} - {{ .To | ceildate | datetime }}'
})" @vue:mounted="mounted"></div>
</div>
{{ end }}
<main class="flex flex-col items-center mt-10 flex-grow">
{{ if .User.HasData }}
{{ if not .IsProjectDetails }}
<!-- KPIs -->
<div class="flex gap-x-6 gap-y-6 w-full mb-4 flex-wrap">
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Total Time</span>
<span class="font-semibold text-xl truncate" title="{{ .TotalTime | duration }}">{{ .TotalTime | duration }}</span>
</div>
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Total Heartbeats</span>
<span class="font-semibold text-xl truncate" title="{{ .NumHeartbeats }}">{{ .NumHeartbeats }}</span>
</div>
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top Project</span>
<span class="font-semibold text-xl truncate" title="{{ .MaxByToString 0 }}">{{ .MaxByToString 0 }}</span>
</div>
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top Language</span>
<span class="font-semibold text-xl truncate" title="{{ .MaxByToString 1 }}">{{ .MaxByToString 1 }}</span>
</div>
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top OS</span>
<span class="font-semibold text-xl truncate" title="{{ .MaxByToString 3 }}">{{ .MaxByToString 3 }}</span>
</div>
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top Editor</span>
<span class="font-semibold text-xl truncate" title="{{ .MaxByToString 2 }}">{{ .MaxByToString 2 }}</span>
</div>
</div>
{{ else }}
<div class="mb-8 w-full">
<h1 class="font-semibold text-3xl text-white">Project "{{ .GetProjectFilter }}"</h1>
<div class="flex space-x-4 items-center">
<h4 class="font-semibold text-lg text-gray-500">{{ .TotalTime | duration }}</h4>
<div v-cloak v-show="currentInterval">
<img :src="'api/badge/{{ .User.ID }}/interval:' + currentInterval + '/project:{{ .GetProjectFilter }}'" alt="Coding Time Badge">
</div>
</div>
</div>
{{ end }}
<div class="grid gap-2 grid-cols-1 md:grid-cols-2 w-full mt-4">
<div class="row-span-2 p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col {{ if .IsProjectDetails }} hidden {{ end }}" id="project-container" style="max-height: 608px; max-width: 100vw">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap">Projects</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="project-top-picker" data-entity="0" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-projects" class="mt-2"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
<div class="row-span-2 p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col {{ if not .IsProjectDetails }} hidden {{ end }}" id="branch-container" style="max-height: 608px; max-width: 100vw">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap">Branches</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="branch-top-picker" data-entity="6" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-branches" class="mt-2"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="language-container" style="max-height: 300px">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap">Languages</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="language-top-picker" data-entity="3" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-language" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="editor-container" style="max-height: 300px">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap">Editors</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="editor-top-picker" data-entity="2" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-editor" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
<div class="{{ if .IsProjectDetails }} hidden {{ end }}" style="max-width: 100vw;">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="os-container" style="max-height: 300px">
<div class="flex justify-between">
<div>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap mr-1 cursor-pointer">Operating Systems</span>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap ml-1 cursor-pointer text-gray-600" onclick="swapCharts('machine', 'os')">Machines</span>
</div>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="os-top-picker" data-entity="1" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-os" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
<div class="hidden" style="max-width: 100vw;">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="machine-container" style="max-height: 300px">
<div class="flex justify-between">
<div>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap mr-1 cursor-pointer text-gray-600" onclick="swapCharts('os', 'machine')">Operating Systems</span>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap ml-1 cursor-pointer">Machines</span>
</div>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="machine-top-picker" data-entity="4" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-machine" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
<div style="max-width: 100vw;">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col {{ if .IsProjectDetails }} hidden {{ end }}" id="label-container" style="max-height: 300px">
<div class="flex justify-between text-lg" style="margin-bottom: -10px">
<span class="font-semibold whitespace-nowrap">Labels</span>
<a href="settings#data" class="ml-4 inline p-2 hover:bg-gray-800 rounded" style="margin-top: -5px">
<span class="iconify inline" data-icon="twemoji:gear"></span>
</a>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="label-top-picker" data-entity="5" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-label" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
</div>
{{ else }}
<div class="max-w-screen-sm flex flex-col items-center mt-12 space-y-8 text-gray-300">
<div class="pb-4">
<img src="assets/images/welcome.svg" width="200px" alt="User welcome illustration">
</div>
<h1 class="font-semibold text-3xl text-white m-0 w-full">Welcome to Wakapi!</h1>
<p>
It looks like there is no data available for the specified time range.<br>If you logged in to Wakapi for the first time, see the setup instructions below on how to get started.
</p>
<div class="w-full pt-10 flex flex-col space-y-4">
<h1 class="font-semibold text-3xl text-white m-0 mb-2">Setup Instructions</h1>
<div class="w-full bg-gray-850 text-left rounded-md py-4 px-8 text-xs font-mono shadow-md">
# <strong>Step 1:</strong> Download WakaTime plugin for your IDE<br>
# See: https://wakatime.com/plugins<br><br>
# <strong>Step 2:</strong> Set your ~/.wakatime.cfg to this:<br><br>
<!-- https://github.com/muety/wakapi/issues/224#issuecomment-890855563 -->
[settings]<br>
api_url = <span class="with-url-inner">%s/api</span><br>
api_key = <span id="api-key-instruction">{{ .ApiKey }}</span><br><br>
# <strong>Step 3:</strong> Start coding and then check back here!
</div>
</div>
</div>
{{ end }}
</main>
</div>
{{ end }}
<main class="flex flex-col items-center mt-10 flex-grow">
{{ if .User.HasData }}
{{ if not .IsProjectDetails }}
<!-- KPIs -->
<div class="flex gap-x-6 gap-y-6 w-full mb-4 flex-wrap">
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Total Time</span>
<span class="font-semibold text-xl truncate" title="{{ .TotalTime | duration }}">{{ .TotalTime | duration }}</span>
</div>
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Total Heartbeats</span>
<span class="font-semibold text-xl truncate" title="{{ .NumHeartbeats }}">{{ .NumHeartbeats }}</span>
</div>
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top Project</span>
<span class="font-semibold text-xl truncate" title="{{ .MaxByToString 0 }}">{{ .MaxByToString 0 }}</span>
</div>
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top Language</span>
<span class="font-semibold text-xl truncate" title="{{ .MaxByToString 1 }}">{{ .MaxByToString 1 }}</span>
</div>
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top OS</span>
<span class="font-semibold text-xl truncate" title="{{ .MaxByToString 3 }}">{{ .MaxByToString 3 }}</span>
</div>
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top Editor</span>
<span class="font-semibold text-xl truncate" title="{{ .MaxByToString 2 }}">{{ .MaxByToString 2 }}</span>
</div>
</div>
{{ else }}
<div class="mb-8 w-full">
<h1 class="font-semibold text-3xl text-white">Project "{{ .GetProjectFilter }}"</h1>
<h4 class="font-semibold text-lg text-gray-500">{{ .TotalTime | duration }}</h4>
</div>
{{ end }}
<div class="grid gap-2 grid-cols-1 md:grid-cols-2 w-full mt-4">
<div class="row-span-2 p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col {{ if .IsProjectDetails }} hidden {{ end }}" id="project-container" style="max-height: 608px; max-width: 100vw">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap">Projects</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="project-top-picker" data-entity="0" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-projects" class="mt-2"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
<div class="row-span-2 p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col {{ if not .IsProjectDetails }} hidden {{ end }}" id="branch-container" style="max-height: 608px; max-width: 100vw">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap">Branches</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="branch-top-picker" data-entity="6" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-branches" class="mt-2"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="language-container" style="max-height: 300px">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap">Languages</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="language-top-picker" data-entity="3" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-language" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="editor-container" style="max-height: 300px">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap">Editors</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="editor-top-picker" data-entity="2" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-editor" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
<div class="{{ if .IsProjectDetails }} hidden {{ end }}" style="max-width: 100vw;">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="os-container" style="max-height: 300px">
<div class="flex justify-between">
<div>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap mr-1 cursor-pointer">Operating Systems</span>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap ml-1 cursor-pointer text-gray-600" onclick="swapCharts('machine', 'os')">Machines</span>
</div>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="os-top-picker" data-entity="1" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-os" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
<div class="hidden" style="max-width: 100vw;">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="machine-container" style="max-height: 300px">
<div class="flex justify-between">
<div>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap mr-1 cursor-pointer text-gray-600" onclick="swapCharts('os', 'machine')">Operating Systems</span>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap ml-1 cursor-pointer">Machines</span>
</div>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="machine-top-picker" data-entity="4" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-machine" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
<div style="max-width: 100vw;">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col {{ if .IsProjectDetails }} hidden {{ end }}" id="label-container" style="max-height: 300px">
<div class="flex justify-between text-lg" style="margin-bottom: -10px">
<span class="font-semibold whitespace-nowrap">Labels</span>
<a href="settings#data" class="ml-4 inline p-2 hover:bg-gray-800 rounded" style="margin-top: -5px">
<span class="iconify inline" data-icon="twemoji:gear"></span>
</a>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="label-top-picker" data-entity="5" class="top-picker bg-gray-800 rounded-md text-center w-12" value="10">
</div>
</div>
<canvas id="chart-label" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
</div>
{{ else }}
<div class="max-w-screen-sm flex flex-col items-center mt-12 space-y-8 text-gray-300">
<div class="pb-4">
<img src="assets/images/welcome.svg" width="200px" alt="User welcome illustration">
</div>
<h1 class="font-semibold text-3xl text-white m-0 w-full">Welcome to Wakapi!</h1>
<p>
It looks like there is no data available for the specified time range.<br>If you logged in to Wakapi for the first time, see the setup instructions below on how to get started.
</p>
<div class="w-full pt-10 flex flex-col space-y-4">
<h1 class="font-semibold text-3xl text-white m-0 mb-2">Setup Instructions</h1>
<div class="w-full bg-gray-850 text-left rounded-md py-4 px-8 text-xs font-mono shadow-md">
# <strong>Step 1:</strong> Download WakaTime plugin for your IDE<br>
# See: https://wakatime.com/plugins<br><br>
# <strong>Step 2:</strong> Set your ~/.wakatime.cfg to this:<br><br>
<!-- https://github.com/muety/wakapi/issues/224#issuecomment-890855563 -->
[settings]<br>
api_url = <span class="with-url-inner">%s/api</span><br>
api_key = <span id="api-key-instruction">{{ .ApiKey }}</span><br><br>
# <strong>Step 3:</strong> Start coding and then check back here!
</div>
</div>
</div>
{{ end }}
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
<script>
const editorColors = {{ .EditorColors | json }}
const languageColors = {{ .LanguageColors | json }}
const osColors = {{ .OSColors | json }}
const wakapiData = {}
wakapiData.projects = {{ .Projects | json }}
@ -228,4 +237,4 @@
</body>
</html>
</html>