mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
57 Commits
Author | SHA1 | Date | |
---|---|---|---|
d5a85639b1 | |||
b6a8185957 | |||
c5da5e4622 | |||
a0f69a371f | |||
2f0cb112dd | |||
2173954b84 | |||
991e64b961 | |||
affff0c386 | |||
099cdaddbc | |||
409405117e | |||
af89ecc9c1 | |||
be354fa790 | |||
a1c4c5da6b | |||
33509beaf7 | |||
ab6ccbdfbe | |||
77e6cd9faa | |||
34bc38cecf | |||
69d3e0494b | |||
a3136ebb13 | |||
4a4d0dad4b | |||
3b87511f48 | |||
f5fba04097 | |||
ad566993ad | |||
5f1e498454 | |||
2e0f79df3b | |||
4a4e19fcbd | |||
45d4ba89f5 | |||
29b3e619ca | |||
1a85ebc0f7 | |||
4bd58789f4 | |||
09d1124794 | |||
41584bdd82 | |||
1b7baf6fc9 | |||
a76db3e95f | |||
74a5226e73 | |||
d245b1e5d0 | |||
d5eff46651 | |||
30a65b4de9 | |||
9048a8eb7a | |||
1f19c5e93c | |||
4b0a3cf0d6 | |||
d778612242 | |||
ff7d595a86 | |||
9d7688957f | |||
179042f81b | |||
e6441f124c | |||
15c391d1d4 | |||
91c765202c | |||
5276f68918 | |||
e774039831 | |||
40067d252e | |||
1a47243f70 | |||
a1f6c2884b | |||
977420c68d | |||
3ae66a3898 | |||
5c5c462035 | |||
f6cc489425 |
100
.github/workflows/ci.yml
vendored
Normal file
100
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,100 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: 'Unit- & API tests'
|
||||
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: 'Automated pen-tests with 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:
|
||||
name: 'Build (Win, Linux, Mac)'
|
||||
|
||||
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 .
|
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
docker-publish:
|
||||
name: 'Build and publish Docker image'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
|
55
.github/workflows/linux-build-on-release.yml
vendored
55
.github/workflows/linux-build-on-release.yml
vendored
@ -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.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
|
||||
|
||||
- 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
|
56
.github/workflows/release.yml
vendored
Normal file
56
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: 'Build, package and release to GitHub'
|
||||
|
||||
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
|
51
.github/workflows/win-build-on-release.yml
vendored
51
.github/workflows/win-build-on-release.yml
vendored
@ -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.18
|
||||
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
|
@ -54,8 +54,6 @@ ENV WAKAPI_ALLOW_SIGNUP 'true'
|
||||
|
||||
COPY --from=build-env /app .
|
||||
|
||||
VOLUME /data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT /app/entrypoint.sh
|
||||
|
166
README.md
166
README.md
@ -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,39 +88,46 @@ $ 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.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)
|
||||
* 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
|
||||
|
||||
#### Compile & Run
|
||||
```bash
|
||||
# Build the executable
|
||||
$ go build -o wakapi
|
||||
# Build and install
|
||||
# Alternatively: go build -o wakapi
|
||||
$ go install github.com/muety/wakapi@latest
|
||||
|
||||
# Adapt config to your needs
|
||||
$ cp config.default.yml config.yml
|
||||
$ vi config.yml
|
||||
# Get default config and customize
|
||||
$ curl -o wakapi.yml https://raw.githubusercontent.com/muety/wakapi/master/config.default.yml
|
||||
$ vi wakapi.yml
|
||||
|
||||
# Run it
|
||||
$ ./wakapi
|
||||
$ ./wakapi -config wakapi.yml
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
💡 When running Wakapi standalone (without Docker), it is recommended to run it as a [SystemD service](etc/wakapi.service).
|
||||
|
||||
### 💻 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]
|
||||
|
||||
# Your Wakapi server URL or 'https://wakapi.dev' when using the cloud server
|
||||
api_url = http://localhost:3000/api/heartbeat
|
||||
# Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
|
||||
api_url = http://localhost:3000/api
|
||||
|
||||
# Your Wakapi API key (get it from the web interface after having created an account)
|
||||
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
||||
@ -122,19 +135,20 @@ 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.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)) |
|
||||
| `app.avatar_url_template` /<br>`WAKAPI_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 |
|
||||
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
|
||||
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
||||
@ -143,6 +157,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 |
|
||||
@ -166,34 +181,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
|
||||
@ -208,6 +229,7 @@ $ echo "<YOUR_API_KEY>" | base64
|
||||
```
|
||||
|
||||
#### Scrape config example
|
||||
|
||||
```yml
|
||||
# prometheus.yml
|
||||
# (assuming your Wakapi instance listens at localhost, port 3000)
|
||||
@ -221,15 +243,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).
|
||||
|
||||

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

|
||||
@ -237,20 +262,20 @@ Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra
|
||||
<details>
|
||||
<summary>Click to view code</summary>
|
||||
|
||||
```md
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
</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:
|
||||
|
||||

|
||||

|
||||
|
||||
<details>
|
||||
<summary>Click to view code</summary>
|
||||
@ -264,7 +289,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
|
||||
|
||||
```
|
||||
@ -272,20 +297,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.
|
||||
@ -293,6 +324,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
|
||||
@ -301,26 +333,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
|
||||
@ -336,35 +373,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>
|
||||
@ -377,11 +415,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>
|
||||
@ -391,13 +429,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
|
||||
@ -409,17 +447,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 you’ll 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.
|
||||
|
||||

|
||||
|
||||
## 📓 License
|
||||
|
||||
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
||||
|
@ -72,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
|
@ -70,7 +70,7 @@ type appConfig struct {
|
||||
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"`
|
||||
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
|
||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||
Colors map[string]map[string]string `yaml:"-"`
|
||||
}
|
||||
@ -142,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 {
|
||||
|
@ -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
391
data/colors.json
391
data/colors.json
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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: {}
|
||||
|
53
etc/wakapi.service
Normal file
53
etc/wakapi.service
Normal file
@ -0,0 +1,53 @@
|
||||
[Unit]
|
||||
Description=Wakapi
|
||||
StartLimitIntervalSec=400
|
||||
StartLimitBurst=3
|
||||
|
||||
# Optional, in case you're running MySQL / Postgres with Systemd, too
|
||||
Requires=mysql.service
|
||||
After=mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# Assuming Wakapi executable is under /opt/wakapi and config file at /etc
|
||||
# Feel free to change this
|
||||
WorkingDirectory=/opt/wakapi
|
||||
ExecStart=/opt/wakapi/wakapi -config /etc/wakapi.yml
|
||||
|
||||
# Environment variables, see README for more
|
||||
Environment=WAKAPI_DB_HOST=localhost
|
||||
Environment=WAKAPI_DB_USER=wakapi
|
||||
Environment=WAKAPI_DB_NAME=wakapi
|
||||
Environment=WAKAPI_DB_PASSWORD=secretpassword
|
||||
Environment=WAKAPI_PASSWORD_SALT=somerandomstring
|
||||
|
||||
# TODO: Use Systemd's credentials management (https://systemd.io/CREDENTIALS/) introduced in v247 (%d syntax in v250) once more established
|
||||
|
||||
# sudo groupadd wakapi
|
||||
# sudo useradd -g wakapi wakapi
|
||||
User=wakapi
|
||||
Group=wakapi
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=90
|
||||
|
||||
# Security hardening
|
||||
PrivateTmp=true
|
||||
PrivateUsers=true
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
PrivateDevices=true
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
ProtectClock=true
|
||||
RestrictSUIDSGID=true
|
||||
ProtectHostname=true
|
||||
ProtectProc=invisible
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
31
go.mod
31
go.mod
@ -5,7 +5,7 @@ go 1.18
|
||||
require (
|
||||
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
|
||||
github.com/duke-git/lancet/v2 v2.0.1
|
||||
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
|
||||
@ -20,31 +20,33 @@ require (
|
||||
github.com/leandro-lugaresi/hub v1.1.1
|
||||
github.com/lpar/gzipped/v2 v2.0.2
|
||||
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-20220321153916-2c7772ba3064
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
gorm.io/driver/mysql v1.3.2
|
||||
gorm.io/driver/postgres v1.3.1
|
||||
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-0.20220320010245-2d5cb997ed4d
|
||||
gorm.io/gorm v1.23.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.0.0 // indirect
|
||||
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.19.5 // indirect
|
||||
github.com/go-openapi/spec v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.19.13 // 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
|
||||
@ -62,11 +64,12 @@ require (
|
||||
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/mod v0.6.0-dev.0.20211013180041-c96bc1413d57 // indirect
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
|
||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 // 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.8-0.20211029000441-d6a9af8af023 // 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
|
||||
)
|
||||
|
54
go.sum
54
go.sum
@ -1,8 +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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
|
||||
github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
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=
|
||||
@ -23,8 +23,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
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/duke-git/lancet/v2 v2.0.1 h1:OKNGIw22Yhxeq9rjK5241nfO79zTtYiiC3YERzvLqvA=
|
||||
github.com/duke-git/lancet/v2 v2.0.1/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
|
||||
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=
|
||||
@ -49,18 +49,27 @@ github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34
|
||||
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/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/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/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
|
||||
@ -83,7 +92,6 @@ 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/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=
|
||||
@ -109,20 +117,17 @@ 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.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.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=
|
||||
@ -169,6 +174,8 @@ github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJK
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
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/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/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
@ -206,6 +213,8 @@ 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/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/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
@ -232,14 +241,17 @@ golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWP
|
||||
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-20220321153916-2c7772ba3064 h1:S25/rfnfsMVgORT4/J61MJ7rdyseOZOyvLIrZEZ7s6s=
|
||||
golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064/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/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@ -251,6 +263,8 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
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/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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
@ -268,8 +282,10 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs=
|
||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/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=
|
||||
@ -292,6 +308,8 @@ golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapK
|
||||
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
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=
|
||||
@ -313,13 +331,13 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
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.3.2 h1:QJryWiqQ91EvZ0jZL48NOpdlPdMjdip1hQ8bTgo4H7I=
|
||||
gorm.io/driver/mysql v1.3.2/go.mod h1:ChK6AHbHgDCFZyJp0F+BmVGb06PSIoh9uVYKAlRbb2U=
|
||||
gorm.io/driver/postgres v1.3.1 h1:Pyv+gg1Gq1IgsLYytj/S2k7ebII3CzEdpqQkPOdH24g=
|
||||
gorm.io/driver/postgres v1.3.1/go.mod h1:WwvWOuR9unCLpGWCL6Y3JOeBWvbKi6JLhayiVclSZZU=
|
||||
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-0.20220320010245-2d5cb997ed4d h1:jDg/QfjT3PwXwVbUdArbL4dlayfD/zE1tC04Zx+BAcI=
|
||||
gorm.io/gorm v1.23.4-0.20220320010245-2d5cb997ed4d/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=
|
||||
|
16
main.go
16
main.go
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/muety/wakapi/migrations"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
@ -16,7 +17,6 @@ import (
|
||||
"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"
|
||||
@ -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)
|
||||
@ -167,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()
|
||||
|
||||
@ -182,6 +182,7 @@ func main() {
|
||||
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)
|
||||
@ -240,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)
|
||||
|
@ -1,35 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20220319-add_user_project_idx"
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
if hasRun(name, db) {
|
||||
return nil
|
||||
}
|
||||
|
||||
idxName := "idx_user_project"
|
||||
|
||||
if !db.Migrator().HasIndex(&models.Heartbeat{}, idxName) {
|
||||
logbuch.Info("running migration '%s'", name)
|
||||
if err := db.Exec(fmt.Sprintf("create index %s on heartbeats (user_id, project)", idxName)).Error; err != nil {
|
||||
logbuch.Warn("failed to create %s", idxName)
|
||||
}
|
||||
}
|
||||
|
||||
setHasRun(name, db)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
40
migrations/20220403_drop_user_project_idx.go
Normal file
40
migrations/20220403_drop_user_project_idx.go
Normal 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)
|
||||
}
|
@ -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 {
|
||||
|
@ -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,idx_user_project"` // idx_user_project is for quickly fetching a user's project list (settings page)
|
||||
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,idx_user_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"`
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:"-"`
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
)
|
||||
|
||||
@ -23,7 +25,7 @@ func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.populateItems(summaries); err != nil {
|
||||
if err := r.populateItems(summaries, []clause.Interface{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -39,17 +41,26 @@ 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").
|
||||
// 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); err != nil {
|
||||
if err := r.populateItems(summaries, queryConditions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -76,28 +87,32 @@ func (r *SummaryRepository) DeleteByUser(userId string) error {
|
||||
}
|
||||
|
||||
// inplace
|
||||
func (r *SummaryRepository) populateItems(summaries []*models.Summary) error {
|
||||
summaryMap := map[uint]*models.Summary{}
|
||||
summaryIds := make([]uint, len(summaries))
|
||||
for i, s := range summaries {
|
||||
if s.NumHeartbeats == 0 {
|
||||
continue
|
||||
}
|
||||
summaryMap[s.ID] = s
|
||||
summaryIds[i] = s.ID
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) populateItems(summaries []*models.Summary, conditions []clause.Interface) error {
|
||||
var items []*models.SummaryItem
|
||||
|
||||
if err := r.db.
|
||||
Model(&models.SummaryItem{}).
|
||||
Where("summary_id in ?", summaryIds).
|
||||
Find(&items).Error; err != nil {
|
||||
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 {
|
||||
l := summaryMap[item.SummaryID].ItemsByType(item.Type)
|
||||
if _, ok := summaryMap[item.SummaryID]; !ok {
|
||||
continue
|
||||
}
|
||||
l := summaryMap[item.SummaryID][0].ItemsByType(item.Type)
|
||||
*l = append(*l, item)
|
||||
}
|
||||
|
||||
|
@ -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
98
routes/api/badge.go
Normal 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)
|
||||
}
|
@ -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,7 +36,6 @@ 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) {
|
||||
|
@ -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()))
|
||||
|
@ -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
|
||||
|
128
routes/compat/wakatime/v1/users_test.go
Normal file
128
routes/compat/wakatime/v1/users_test.go
Normal file
@ -0,0 +1,128 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/mocks"
|
||||
"github.com/muety/wakapi/models"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
adminUser = &models.User{
|
||||
ID: "AdminUser",
|
||||
ApiKey: "admin-user-api-key",
|
||||
Email: "admin@user.com",
|
||||
IsAdmin: true,
|
||||
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 1, time.UTC)),
|
||||
LastLoggedInAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 2, time.UTC)),
|
||||
}
|
||||
|
||||
basicUser = &models.User{
|
||||
ID: "BasicUser",
|
||||
ApiKey: "basic-user-api-key",
|
||||
Email: "basic@user.com",
|
||||
IsAdmin: false,
|
||||
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 3, time.UTC)),
|
||||
LastLoggedInAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 4, time.UTC)),
|
||||
}
|
||||
)
|
||||
|
||||
func TestUsersHandler_Get(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true)
|
||||
apiRouter.Use(middlewares.NewPrincipalMiddleware())
|
||||
|
||||
userServiceMock := new(mocks.UserServiceMock)
|
||||
userServiceMock.On("GetUserById", "AdminUser").Return(adminUser, nil)
|
||||
userServiceMock.On("GetUserByKey", "admin-user-api-key").Return(adminUser, nil)
|
||||
userServiceMock.On("GetUserById", "BasicUser").Return(basicUser, nil)
|
||||
userServiceMock.On("GetUserByKey", "basic-user-api-key").Return(basicUser, nil)
|
||||
|
||||
heartbeatServiceMock := new(mocks.HeartbeatServiceMock)
|
||||
heartbeatServiceMock.On("GetLatestByUser", adminUser).Return(&models.Heartbeat{
|
||||
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 5, time.UTC)),
|
||||
Time: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 6, time.UTC)),
|
||||
}, nil)
|
||||
heartbeatServiceMock.On("GetLatestByUser", basicUser).Return(&models.Heartbeat{
|
||||
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 5, time.UTC)),
|
||||
Time: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 6, time.UTC)),
|
||||
}, nil)
|
||||
|
||||
usersHandler := NewUsersHandler(userServiceMock, heartbeatServiceMock)
|
||||
usersHandler.RegisterRoutes(apiRouter)
|
||||
|
||||
t.Run("when requesting own user data", func(t *testing.T) {
|
||||
t.Run("should return own data", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/compat/wakatime/v1/users/AdminUser", nil)
|
||||
req.Header.Add(
|
||||
"Authorization",
|
||||
fmt.Sprintf("Bearer %s", base64.StdEncoding.EncodeToString([]byte(adminUser.ApiKey))),
|
||||
)
|
||||
requestRecorder := httptest.NewRecorder()
|
||||
apiRouter.ServeHTTP(requestRecorder, req)
|
||||
res := requestRecorder.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Errorf("unextected error. Error: %s", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), "\"username\":\"AdminUser\"") {
|
||||
t.Errorf("invalid response received. Expected json Received: %s", string(data))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("when requesting another users data", func(t *testing.T) {
|
||||
t.Run("should respond with '401 unauthorized' if not an admin user", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/compat/wakatime/v1/users/AdminUser", nil)
|
||||
req.Header.Add(
|
||||
"Authorization",
|
||||
fmt.Sprintf("Bearer %s", base64.StdEncoding.EncodeToString([]byte(basicUser.ApiKey))),
|
||||
)
|
||||
requestRecorder := httptest.NewRecorder()
|
||||
apiRouter.ServeHTTP(requestRecorder, req)
|
||||
res := requestRecorder.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Errorf("unextected error. Error: %s", err)
|
||||
}
|
||||
|
||||
if string(data) != "401 unauthorized" {
|
||||
t.Errorf("invalid response received. Expected: '401 unauthorized' Received: %s", string(data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should receive user data if requesting user is an admin", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/compat/wakatime/v1/users/BasicUser", nil)
|
||||
req.Header.Add(
|
||||
"Authorization",
|
||||
fmt.Sprintf("Bearer %s", base64.StdEncoding.EncodeToString([]byte(adminUser.ApiKey))),
|
||||
)
|
||||
requestRecorder := httptest.NewRecorder()
|
||||
apiRouter.ServeHTTP(requestRecorder, req)
|
||||
res := requestRecorder.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Errorf("unextected error. Error: %s", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), "\"username\":\"BasicUser\"") {
|
||||
t.Errorf("invalid response received. Expected 'BasicUser' info Received: %s", string(data))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
@ -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 {
|
||||
|
@ -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,
|
||||
}
|
||||
|
85
routes/utils/badge_utils.go
Normal file
85
routes/utils/badge_utils.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -35,12 +35,12 @@ func CheckEffectiveUser(w http.ResponseWriter, r *http.Request, userService serv
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if authorizedUser == nil || authorizedUser.ID != requestedUser.ID {
|
||||
if authorizedUser == nil || authorizedUser.ID != requestedUser.ID && !authorizedUser.IsAdmin {
|
||||
err := errors.New(conf.ErrUnauthorized)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authorizedUser, nil
|
||||
return requestedUser, nil
|
||||
}
|
||||
|
@ -45,13 +45,8 @@ 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(datastructure.NewSet[string]()); 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, datastructure.NewSet[string]())
|
||||
s.Every(1).Day().At(srv.config.App.AggregationTime).WaitForSchedule().Do(srv.Run, datastructure.NewSet[string]())
|
||||
s.StartBlocking()
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/duke-git/lancet/v2/mathutil"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"time"
|
||||
@ -59,13 +61,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 := datetime.BeginOfDay(d1.Time.T()) == datetime.BeginOfDay(latest.Time.T())
|
||||
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)
|
||||
|
@ -54,6 +54,10 @@ func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
if len(heartbeats) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hashes := datastructure.NewSet[string]()
|
||||
|
||||
// https://github.com/muety/wakapi/issues/139
|
||||
@ -254,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
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,6 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
@ -41,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)
|
||||
|
||||
@ -117,6 +111,12 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User, filte
|
||||
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
|
||||
@ -401,14 +401,14 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
|
||||
|
||||
// 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 {
|
||||
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{Start: summaries[i].ToTime.T(), End: summaries[i+1].FromTime.T()})
|
||||
intervals = append(intervals, &models.Interval{Start: t1, End: t2})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
|
File diff suppressed because one or more lines are too long
Binary file not shown.
@ -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 }))]
|
||||
},
|
||||
@ -31,8 +32,11 @@ PetiteVue.createApp({
|
||||
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')
|
||||
|
@ -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')
|
@ -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()
|
||||
})
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -12,6 +12,7 @@ server:
|
||||
app:
|
||||
aggregation_time: '02:15'
|
||||
report_time_weekly: 'fri,18:00'
|
||||
heartbeat_max_age: 87600h # 10 years
|
||||
inactive_days: 7
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "43639725-0458-40d7-a4d4-9f55a539a7f7",
|
||||
"_postman_id": "5c0749a5-6ddf-41ea-82f1-140578788bc3",
|
||||
"name": "Wakapi API Tests",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
@ -973,10 +973,9 @@
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"// 1640995199 Friday, 31 December 2021 11:59:59 PM (Jan 1st in +1, +2)",
|
||||
"// 1641074399 Saturday, 1 January 2022 9:59:59 PM (Jan 1st in +1, +2)",
|
||||
"// 1641081599 Saturday, 1 January 2022 11:59:59 PM (Jan 2nd in +1, +2)",
|
||||
""
|
||||
"pm.test(\"Status code is 201\", function () {",
|
||||
" pm.response.to.have.status(201);",
|
||||
"});"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
@ -997,7 +996,7 @@
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1640995199\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641074399\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641081599\n}]",
|
||||
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1640995200\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641074400\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641081600\n}]",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@ -1331,8 +1330,8 @@
|
||||
"",
|
||||
"pm.test(\"Correct dates\", function () {",
|
||||
" const jsonData = pm.response.json();",
|
||||
" pm.expect(moment(jsonData.from).unix()).to.gte(moment(pm.variables.get('tsStartOfDayDate')).unix())",
|
||||
" pm.expect(moment(jsonData.to).unix()).to.gte(moment(pm.variables.get('tsEndOfDayDate')).unix())",
|
||||
" pm.expect(moment(jsonData.from).unix()).to.gte(moment(pm.variables.get('tsStartOfDayIso')).unix())",
|
||||
" pm.expect(moment(jsonData.to).unix()).to.lte(moment(pm.variables.get('tsEndOfDayIso')).unix())",
|
||||
"});",
|
||||
""
|
||||
],
|
||||
@ -1358,7 +1357,7 @@
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE_URL}}/api/summary?from={{tsStartOfDayDate}}&to={{tsEndOfTomorrowDate}}",
|
||||
"raw": "{{BASE_URL}}/api/summary?from={{tsStartOfDayIso}}&to={{tsEndOfDayIso}}",
|
||||
"host": [
|
||||
"{{BASE_URL}}"
|
||||
],
|
||||
@ -1369,11 +1368,11 @@
|
||||
"query": [
|
||||
{
|
||||
"key": "from",
|
||||
"value": "{{tsStartOfDayDate}}"
|
||||
"value": "{{tsStartOfDayIso}}"
|
||||
},
|
||||
{
|
||||
"key": "to",
|
||||
"value": "{{tsEndOfTomorrowDate}}"
|
||||
"value": "{{tsEndOfDayIso}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -2097,7 +2096,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"
|
||||
@ -3371,46 +3370,60 @@
|
||||
"exec": [
|
||||
"const moment = require('moment')",
|
||||
"",
|
||||
"const now = moment()",
|
||||
"const startOfDay = moment().startOf('day')",
|
||||
"const endOfDay = moment().endOf('day')",
|
||||
"const endOfTomorrow = moment().add(1, 'd').endOf('day')",
|
||||
"// pretend we're in Berlin, as this is also the time zone configured for the user",
|
||||
"const userZone = 'Europe/Berlin'",
|
||||
"",
|
||||
"console.log(`Current timestamp is: ${now.format('x') / 1000}`)",
|
||||
"",
|
||||
"",
|
||||
"// Auth stuff",
|
||||
"const readApiKey = pm.variables.get('READUSER_API_KEY')",
|
||||
"const writeApiKey = pm.variables.get('WRITEUSER_API_KEY')",
|
||||
"",
|
||||
"if (!readApiKey || !writeApiKey) {",
|
||||
" throw new Error('no api key given')",
|
||||
"// postman doesn't have moment-timezone package included",
|
||||
"// and we can't just use utcOffset(2), because of summer / winter time",
|
||||
"// inspired by https://stackoverflow.com/a/56853085/3112139",
|
||||
"function getUtcOffset(cb) {",
|
||||
" let offset = pm.globals.get('utcOffset')",
|
||||
" if (offset) return cb(offset)",
|
||||
" pm.sendRequest(`https://worldtimeapi.org/api/timezone/${userZone}`, (err, res) => {",
|
||||
" offset = res.json().utc_offset",
|
||||
" pm.globals.set('utcOffset', offset)",
|
||||
" return cb(offset)",
|
||||
" })",
|
||||
"}",
|
||||
"",
|
||||
"pm.variables.set('READUSER_TOKEN', base64encode(readApiKey))",
|
||||
"pm.variables.set('WRITEUSER_TOKEN', base64encode(writeApiKey))",
|
||||
"getUtcOffset((utcOffset) => {",
|
||||
" const now = moment().utcOffset(utcOffset)",
|
||||
" const startOfDay = now.clone().startOf('day')",
|
||||
" const endOfDay = now.clone().endOf('day')",
|
||||
" const endOfTomorrow = now.clone().add(1, 'd').endOf('day')",
|
||||
"",
|
||||
"function base64encode(str) {",
|
||||
" return Buffer.from(str, 'utf-8').toString('base64')",
|
||||
"}",
|
||||
" // Auth stuff",
|
||||
" const readApiKey = pm.variables.get('READUSER_API_KEY')",
|
||||
" const writeApiKey = pm.variables.get('WRITEUSER_API_KEY')",
|
||||
"",
|
||||
"// Heartbeat stuff",
|
||||
"pm.variables.set('tsNow', now.format('x') / 1000)",
|
||||
"pm.variables.set('tsNowMinus1Min', now.add(-1, 'm').format('x') / 1000)",
|
||||
"pm.variables.set('tsNowMinus2Min', now.add(-2, 'm').format('x') / 1000)",
|
||||
"pm.variables.set('tsNowMinus3Min', now.add(-3, 'm').format('x') / 1000)",
|
||||
"pm.variables.set('tsStartOfDay', startOfDay.format('x') / 1000)",
|
||||
"pm.variables.set('tsEndOfDay', endOfDay.format('x') / 1000)",
|
||||
"pm.variables.set('tsEndOfTomorrow', endOfTomorrow.format('x') / 1000)",
|
||||
"pm.variables.set('tsStartOfDayIso', startOfDay.toISOString())",
|
||||
"pm.variables.set('tsEndOfDayIso', endOfDay.toISOString())",
|
||||
"pm.variables.set('tsEndOfTomorrowIso', endOfTomorrow.toISOString())",
|
||||
"pm.variables.set('tsStartOfDayDate', startOfDay.format('YYYY-MM-DD'))",
|
||||
"pm.variables.set('tsEndOfDayDate', endOfDay.format('YYYY-MM-DD'))",
|
||||
"pm.variables.set('tsEndOfTomorrowDate', endOfTomorrow.format('YYYY-MM-DD'))",
|
||||
"pm.variables.set('ts1', now.startOf('hour').format('x') / 1000)",
|
||||
"pm.variables.set('ts2', now.startOf('hour').add(1, 'm').format('x') / 1000)",
|
||||
"pm.variables.set('ts3', now.startOf('hour').add(2, 'm').format('x') / 1000)"
|
||||
" console.log(readApiKey)",
|
||||
"",
|
||||
" if (!readApiKey || !writeApiKey) {",
|
||||
" throw new Error('no api key given')",
|
||||
" }",
|
||||
"",
|
||||
" pm.variables.set('READUSER_TOKEN', base64encode(readApiKey))",
|
||||
" pm.variables.set('WRITEUSER_TOKEN', base64encode(writeApiKey))",
|
||||
"",
|
||||
" function base64encode(str) {",
|
||||
" return Buffer.from(str, 'utf-8').toString('base64')",
|
||||
" }",
|
||||
"",
|
||||
" // Heartbeat stuff",
|
||||
" pm.variables.set('tsNow', now.clone().format('x') / 1000)",
|
||||
" pm.variables.set('tsNowMinus1Min', now.clone().add(-1, 'm').format('x') / 1000)",
|
||||
" pm.variables.set('tsNowMinus2Min', now.clone().add(-2, 'm').format('x') / 1000)",
|
||||
" pm.variables.set('tsNowMinus3Min', now.clone().add(-3, 'm').format('x') / 1000)",
|
||||
" pm.variables.set('tsStartOfDay', startOfDay.format('x') / 1000)",
|
||||
" pm.variables.set('tsEndOfDay', endOfDay.format('x') / 1000)",
|
||||
" pm.variables.set('tsEndOfTomorrow', endOfTomorrow.format('x') / 1000)",
|
||||
" pm.variables.set('tsStartOfDayIso', startOfDay.toISOString())",
|
||||
" pm.variables.set('tsEndOfDayIso', endOfDay.toISOString())",
|
||||
" pm.variables.set('tsEndOfTomorrowIso', endOfTomorrow.toISOString())",
|
||||
" pm.variables.set('ts1', now.clone().startOf('hour').format('x') / 1000)",
|
||||
" pm.variables.set('ts2', now.clone().startOf('hour').add(1, 'm').format('x') / 1000)",
|
||||
" pm.variables.set('ts3', now.clone().startOf('hour').add(2, 'm').format('x') / 1000)",
|
||||
"})"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
@ -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
34
utils/json.go
Normal 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
|
||||
}
|
@ -1 +1 @@
|
||||
2.3.2
|
||||
2.3.5
|
@ -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>
|
||||
|
@ -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>
|
@ -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>
|
||||
@ -666,4 +701,3 @@
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user