diff --git a/.env.example b/.env.example index 580ea18..d788a66 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -APP_ENV = development -SONG_SPOTIFY_CLIENT_ID = your_client_id -SONG_SPOTIFY_CLIENT_SECRET = your_client_secret +APP_ENV= +SONG_SPOTIFY_CLIENT_ID= +SONG_SPOTIFY_CLIENT_SECRET= diff --git a/.githooks/pre-commit b/.githooks/pre-commit index dfd1704..908d561 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -3,6 +3,6 @@ echo "Linting" golangci-lint run if [ $? -ne 0 ]; then - echo "golangci-lint failed. Please fix the errors before committing." - exit 1 + echo "golangci-lint failed." + exit 1 fi diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 54f3d7e..ae8a9ca 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -11,7 +11,7 @@ permissions: pull-requests: read jobs: - golangci: + lint: name: lint runs-on: ubuntu-latest @@ -22,9 +22,9 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 1.23.1 + go-version: 1.25.0 - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: v1.60 + version: v2.4.0 diff --git a/.github/workflows/sqlc-diff.yml b/.github/workflows/sqlc-diff.yml index d5b3ce0..7a01341 100644 --- a/.github/workflows/sqlc-diff.yml +++ b/.github/workflows/sqlc-diff.yml @@ -5,8 +5,8 @@ jobs: diff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: sqlc-dev/setup-sqlc@v3 - with: - sqlc-version: '1.27.0' - - run: sqlc diff + - uses: actions/checkout@v4 + - uses: sqlc-dev/setup-sqlc@v3 + with: + sqlc-version: "1.29.0" + - run: sqlc diff diff --git a/.golangci.yml b/.golangci.yml index a0c6843..d279320 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,35 +1,65 @@ +version: "2" + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + fix: true + run: timeout: 5m modules-download-mode: readonly linters: enable: - - bodyclose # checks whether HTTP response body is closed successfully - - copyloopvar # detects copy loop variable - - errcheck # checks for unchecked errors in go programs - - errname # checks that sentinel errors are prefixed with the `Err` and error types are suffixed with the `Error` - - gochecknoinits # checks that no init functions are present in Go code - - goimports # check import statements are formatted correctly - - gosimple # checks for code simplifications in Go code - - govet # runs the go vet tool - - importas # enforces consistent import aliases - - ineffassign # detects when assignments to existing variables are not used - - noctx # finds sending http request without context.Context - - paralleltest # detects missing usage of t.Parallel() method in go tests - - prealloc # finds slice declarations that could potentially be preallocated - - revive # checks for golang coding style - - rowserrcheck # checks whether Err of rows is checked successfully - - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed - - staticcheck # Applies static code analysis - - tenv # detects using os.Setenv instead of t.Setenv - - testpackage # makes you use a separate _test package - - thelper # detects golang test helpers without t.Helper() call and checks consistency of test helpers - - unconvert # removes unnecessary type conversions - - unparam # removes unused function parameters - - unused # finds unused variables - fast: true + - copyloopvar # https://github.com/karamaru-alpha/copyloopvar?tab=readme-ov-file + - errchkjson # https://github.com/breml/errchkjson + - errname # https://github.com/Antonboom/errname + - errorlint # https://github.com/polyfloyd/go-errorlint + - exhaustive # https://github.com/nishanths/exhaustive + - exptostd # https://github.com/ldez/exptostd + - gocritic # https://github.com/go-critic/go-critic?tab=readme-ov-file + - loggercheck # https://github.com/timonwong/loggercheck + - perfsprint # https://github.com/catenacyber/perfsprint + - prealloc # https://github.com/alexkohler/prealloc + - revive # https://github.com/mgechev/revive?tab=readme-ov-file#available-rules + - unconvert # https://github.com/mdempsky/unconvert + - unparam # https://github.com/mvdan/unparam -issues: - exclude-use-default: false - max-issues-per-linter: 0 - max-same-issues: 0 + settings: + copyloopvar: + check-alias: true + errchkjson: + report-no-exported: true + exhaustive: + default-signifies-exhaustive: true + loggercheck: + kitlog: false + klog: false + logr: false + slog: false + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: dot-imports + - name: empty-block + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: increment-decrement + - name: indent-error-flow + - name: range + - name: receiver-naming + - name: redefines-builtin-id + - name: superfluous-else + - name: time-naming + - name: unexported-return + - name: unreachable-code + - name: unused-parameter + - name: var-declaration + - name: var-naming + arguments: + - [] + - [] + - - skip-package-name-checks: true diff --git a/.tool-versions b/.tool-versions index c9953ae..3a6ce55 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ -golang 1.23.1 +golang 1.25.0 +golangci-lint 2.4.0 diff --git a/README.md b/README.md index 8efdf8f..d6ed9f0 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,81 @@ # Screen Cammie Chat -Displays the cammie chat along with some other statistics. +A terminal based dashboard for viewing the Cammie chat messages and other Zeus related data. -## Development Setup +## Overview -### Prerequisites +The project has 2 main parts: -1. Go: Check the [.tool-versions](.tool-versions) file for the required Go version. -2. Pre-commit hooks: `git config --local core.hooksPath .githooks/`. -3. Goose (DB migrations): `go install github.com/pressly/goose/v3/cmd/goose@latest`. -4. SQLC (Statically typed queries): `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest` -5. Make (Optional) +### 1. TUI -### Configuration +A terminal interface built with [bubbletea](https://github.com/charmbracelet/bubbletea). +It displays the Cammie chat messages and other Zeus data in a screen-based layout. -1. Create a `.env` file specifying - - `APP_ENV`. Available options are: - - `development` - - `production` - - `BACKEND_SONG_SPOTIFY_CLIENT_ID` - - `BACKEND_SONG_SPOTIFY_CLIENT_SECRET` -2. Configure the appropriate settings in the corresponding configuration file located in the [config directory](./config). you can either set them as environment variables or inside the configuration file. +- **Views**: Reusable components responsible for rendering a single type of data (e.g. the [TAP](github.com/zeusWPI/tap) statistics). +- **Screens**: A combination of multiple views forming a single terminal screen. -## DB +Each view implements a shared interface, exposing methods for initialization, updating, and rendering. +When a screen is loaded: -This project uses a postgres database. -SQLC is used to generate statically typed queries and goose is responsible for the database migrations. +- Each view’s initial data is fetched automatically. +- Each view’s update loop runs in a goroutine, periodically checking for new data. +- The update loop communicates state changes back to the TUI. -### Usefull commands +This design allows each view to be self-contained and independently refresh its data. -- `make db`: Start the database -- `make migrate`: Run database migrations to update your database schema (watch out, migrations might result in minor data loss). -- `make create-migration`: Create a new migration in the [db/migrations](./db/migrations/) directory. -- `make sqlc`: Generate statically typed queries based on the .sql files in the [db/queries](./db/queries/) directory. Add new queries to this directory as needed. +However, not all data can be retrieved directly from persistent external services and that’s where the backend comes in. -## Backend +### 2. Backend -The backend is responsible for fetching and processing external data, which is then stored in the database. -Data can either received by exposing an API or by actively fetching them. +The backend handles data aggregation and persistence. -### Running the backend +- Exposes an API for incoming data. +- Maintaines websockets. +- Makes use of external API's. -To build and run the backend, use the following commands: +Provides persistence data using Postgres for data that the external services do not retain. -- `make build-backend`: Build the backend binary. -- `make run-backend`: Run the backend. +## Development -### Logs +### Prerequisites -Backend logs are saved to `./logs/backend.log` (created on first start) and written to `stdout`. +1. Download the golang version [.tool-versions](.tool-versions). +2. Install make. +3. Install the go tools: `make setup`. +4. (Optional) Install the pre-commit hooks: `git config --local core.hooksPath .githooks/`. -## TUI +### Configuration -The TUI (Text User Interface) displays data retrieved from the database. This flexibility allows for running multiple instances of the TUI, each displaying different data. +1. Copy `.env.example` to `.env`, set `ENV=development` and populate the remaining keys. +2. (Optional) Edit the [config file](./config/development.yml). The defaults work. -### Running the TUI +### Database -To build and run the TUI, use the following commands: +A Postgres database instance is provided via docker compose and started automatically when needed by the [makefile](./makefile). +To use a custom database, update the config and edit the makefile. -- `make build-tui`: Build the TUI binary. -- `make run-tui`: Run the TUI. -- -The TUI requires one argument: the screen name to display. You can create new screens in the [screens directory](./tui/screen/), and you must add them to the startup command list in [tui.go](./internal/cmd/tui.go). +### Run -A screen is responsible for creating and managing the layout, consisting of various [views](./tui/view/). +1. Migrate the database `make migrate`. +2. Start the backend `make backend`. +3. Start a TUI `make tui` and enter the desired screen name (if you're not using the makefile use the `-screen` flag to specify the screen). ### Logs -TUI logs are written to `./logs/tui.log` and _not_ to `stdout`. +- Backend: written to `./logs/backend.log` and to stdout. +- TUI: only written to `./logs/{screen}.log`. + +### Useful commands + +- `make create-migration`: Create a new migration in the [db/migrations](./db/migrations/) directory. +- `make query`: Generate statically typed queries based on the .sql files in the [db/queries](./db/queries/) directory. Add new queries to this directory as needed. +- `make goose`: Migrate one version up or down. +- `make dead`: Check for unreachable code. + +## Production + +1. Set `ENV=production` in `.env`. +2. Provide a Postgres database. +3. Populate the [production config file](./config/production.yml). +4. Build the binaries `make build`. +5. Run both binaries with the desired flags. diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go index 6c464bc..825d537 100644 --- a/cmd/backend/backend.go +++ b/cmd/backend/backend.go @@ -3,8 +3,11 @@ package main import ( "github.com/zeusWPI/scc/internal/cmd" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/internal/server" + "github.com/zeusWPI/scc/internal/server/service" "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/db" "github.com/zeusWPI/scc/pkg/logger" "go.uber.org/zap" ) @@ -26,29 +29,39 @@ func main() { zap.S().Info("Initializing backend") // Database - db, err := db.New() + db, err := db.NewPSQL() if err != nil { - zap.S().Fatal("DB: Fatal error\n", err) + zap.S().Fatalf("DB: Fatal error %v", err) } + // Repository + repo := repository.New(db) + + var dones []chan bool + // Tap - _, _ = cmd.Tap(db) + _, done := cmd.Tap(*repo) + dones = append(dones, done) // Zess - _, _, _ = cmd.Zess(db) + _, done = cmd.Zess(*repo) + dones = append(dones, done) - // Gamification - _, _ = cmd.Gamification(db) + // Song + if err := cmd.Song(); err != nil { + zap.S().Fatalf("Initialize song %v", err) + } - // Event - _, _ = cmd.Event(db) + // API + service := service.New(*repo) + api := server.New(*service) - // Spotify - spotify, err := cmd.Song(db) - if err != nil { - zap.S().Error("Spotify: Initiating error, integration will not work.\n", err) + zap.S().Infof("Server is running on %s", api.Addr) + if err := api.Listen(api.Addr); err != nil { + zap.S().Fatalf("Failure while running the server %v", err) } - // API - cmd.API(db, spotify) + for _, done := range dones { + done <- true + } } diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index b2d6669..7ef87b8 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -2,9 +2,12 @@ package main import ( + "flag" + "github.com/zeusWPI/scc/internal/cmd" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/db" "github.com/zeusWPI/scc/pkg/logger" "go.uber.org/zap" ) @@ -16,8 +19,16 @@ func main() { panic(err) } + screen := flag.String("screen", "", "TUI screen to start") + flag.Parse() + + if *screen == "" { + flag.PrintDefaults() + return + } + // Logger - zapLogger, err := logger.New("tui", false) + zapLogger, err := logger.New(*screen, false) if err != nil { panic(err) } @@ -26,13 +37,15 @@ func main() { zap.S().Info("Initializing TUI") // Database - db, err := db.New() + db, err := db.NewPSQL() if err != nil { zap.S().Fatal("DB: Fatal error\n", err) } + repo := repository.New(db) + // TUI - err = cmd.TUI(db) + err = cmd.TUI(*repo, *screen) if err != nil { zap.S().Fatal("TUI: Fatal error\n", err) } diff --git a/config/development.yaml b/config/development.yaml deleted file mode 100644 index 50ddcb3..0000000 --- a/config/development.yaml +++ /dev/null @@ -1,114 +0,0 @@ -server: - host: "0.0.0.0" - port: 3000 - -db: - host: "localhost" - port: 5432 - user: "postgres" - password: "postgres" - -backend: - buzzer: - song: - - "-n" - - "-f880" - - "-l100" - - "-d0" - - "-n" - - "-f988" - - "-l100" - - "-d0" - - "-n" - - "-f588" - - "-l100" - - "-d0" - - "-n" - - "-f989" - - "-l100" - - "-d0" - - "-n" - - "-f660" - - "-l200" - - "-d0" - - "-n" - - "-f660" - - "-l200" - - "-d0" - - "-n" - - "-f588" - - "-l100" - - "-d0" - - "-n" - - "-f555" - - "-l100" - - "-d0" - - "-n" - - "-f495" - - "-l100" - - "-d0" - - event: - website: "https://zeus.gent/events/" - website_poster: "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master" - interval_s: 3600 - - gamification: - api: "https://gamification.zeus.gent" - interval_s: 3600 - - song: - spotify_api: "https://api.spotify.com/v1" - spotify_api_account: "https://accounts.spotify.com/api/token" - lrclib_api: "https://lrclib.net/api" - - tap: - api: "https://tap.zeus.gent" - beers: - - "Schelfaut" - - "Duvel" - - "Fourchette" - - "Jupiler" - - "Karmeliet" - - "Kriek" - - "Chouffe" - - "Maes" - - "Somersby" - - "Sportzot" - - "Stella" - interval_s: 60 - - zess: - api: "https://zess.zeus.gent/api" - interval_season_s: 300 - interval_scan_s: 60 - - -tui: - screen: - cammie: - interval_s: 300 - - view: - event: - interval_s: 3600 - - gamification: - interval_s: 3600 - - message: - interval_s: 1 - - song: - interval_current_s: 5 - interval_history_s: 5 - interval_monthly_stats_s: 300 - interval_stats_s: 3600 - - tap: - interval_s: 60 - - zess: - weeks: 10 - interval_scan_s: 60 - interval_season_s: 3600 diff --git a/config/development.yml b/config/development.yml new file mode 100644 index 0000000..16e3bd4 --- /dev/null +++ b/config/development.yml @@ -0,0 +1,102 @@ +server: + host: "0.0.0.0" + port: 3000 + +db: + host: "localhost" + port: 5432 + user: "postgres" + password: "postgres" + database: "scc" + +backend: + buzzer: + song: + - "-n" + - "-f880" + - "-l100" + - "-d0" + - "-n" + - "-f988" + - "-l100" + - "-d0" + - "-n" + - "-f588" + - "-l100" + - "-d0" + - "-n" + - "-f989" + - "-l100" + - "-d0" + - "-n" + - "-f660" + - "-l200" + - "-d0" + - "-n" + - "-f660" + - "-l200" + - "-d0" + - "-n" + - "-f588" + - "-l100" + - "-d0" + - "-n" + - "-f555" + - "-l100" + - "-d0" + - "-n" + - "-f495" + - "-l100" + - "-d0" + + tap: + url: "https://tap.zeus.gent" + beers: + - "Schelfaut" + - "Duvel" + - "Fourchette" + - "Jupiler" + - "Karmeliet" + - "Kriek" + - "Chouffe" + - "Maes" + - "Somersby" + - "Sportzot" + - "Stella" + interval_s: 60 + + zess: + url: "https://zess.zeus.gent/api" + interval_season_s: 3600 + interval_scan_s: 60 + +tui: + screen: + cammie: + interval_s: 300 + + view: + event: + url: "https://events.zeus.gent/api/v1" + interval_s: 86400 + + gamification: + url: "https://gamification.zeus.gent" + interval_s: 3600 + + message: + interval_s: 1 + + song: + interval_current_s: 5 + interval_history_s: 5 + interval_monthly_stats_s: 300 + interval_stats_s: 3600 + stat_amount: 3 + + tap: + interval_s: 60 + + zess: + weeks: 10 + interval_s: 60 diff --git a/config/production.yaml b/config/production.yml similarity index 100% rename from config/production.yaml rename to config/production.yml diff --git a/db/migrations/20241125113707_add_gamification_table.sql b/db/migrations/20241125113707_add_gamification_table.sql index 2da01bf..080a8a0 100644 --- a/db/migrations/20241125113707_add_gamification_table.sql +++ b/db/migrations/20241125113707_add_gamification_table.sql @@ -1,6 +1,6 @@ -- +goose Up -- +goose StatementBegin -CREATE TABLE IF NOT EXISTS gamification ( +CREATE TABLE gamification ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, score INTEGER NOT NULL, @@ -10,5 +10,5 @@ CREATE TABLE IF NOT EXISTS gamification ( -- +goose Down -- +goose StatementBegin -DROP TABLE IF EXISTS gamification; +DROP TABLE gamification; -- +goose StatementEnd diff --git a/db/migrations/20241127133125_add_events_table.sql b/db/migrations/20241127133125_add_events_table.sql index 11369d4..af16f11 100644 --- a/db/migrations/20241127133125_add_events_table.sql +++ b/db/migrations/20241127133125_add_events_table.sql @@ -1,6 +1,6 @@ -- +goose Up -- +goose StatementBegin -CREATE TABLE IF NOT EXISTS event ( +CREATE TABLE event ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, date TIMESTAMP WITH TIME ZONE NOT NULL, @@ -11,5 +11,5 @@ CREATE TABLE IF NOT EXISTS event ( -- +goose Down -- +goose StatementBegin -DROP TABLE IF EXISTS event; +DROP TABLE event; -- +goose StatementEnd diff --git a/db/migrations/20250822221717_alter_tap_add_category_enum.sql b/db/migrations/20250822221717_alter_tap_add_category_enum.sql new file mode 100644 index 0000000..6a750fc --- /dev/null +++ b/db/migrations/20250822221717_alter_tap_add_category_enum.sql @@ -0,0 +1,40 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TYPE TAP_CATEGORY AS ENUM ('soft', 'mate', 'beer', 'food', 'unknown'); + +ALTER TABLE tap +ADD COLUMN new_category TAP_CATEGORY NOT NULL DEFAULT 'unknown'; + +UPDATE tap +SET new_category = CASE + WHEN LOWER(category) IN ('soft', 'mate', 'beer', 'food', 'unknown') + THEN LOWER(category)::TAP_CATEGORY + ELSE 'unknown'::TAP_CATEGORY +END; + +ALTER TABLE tap +DROP COLUMN category; + +ALTER TABLE tap +RENAME new_category TO category; + +ALTER TABLE tap +ALTER COLUMN category DROP DEFAULT +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE tap +ADD COLUMN old_category TEXT; + +UPDATE tap +SET old_category = category::TEXT; + +ALTER TABLE tap +DROP COLUMN category; + +ALTER TABLE tap +RENAME COLUMN old_category TO category; + +DROP TYPE TAP_CATEGORY; +-- +goose StatementEnd diff --git a/db/migrations/20250823192319_drop_event_table.sql b/db/migrations/20250823192319_drop_event_table.sql new file mode 100644 index 0000000..3674923 --- /dev/null +++ b/db/migrations/20250823192319_drop_event_table.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +DROP TABLE event; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +CREATE TABLE event ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + date TIMESTAMP WITH TIME ZONE NOT NULL, + academic_year TEXT NOT NULL, + location TEXT NOT NULL, + poster BYTES +); +-- +goose StatementEnd diff --git a/db/migrations/20250823211949_drop_gamification_table.sql b/db/migrations/20250823211949_drop_gamification_table.sql new file mode 100644 index 0000000..9cf6b83 --- /dev/null +++ b/db/migrations/20250823211949_drop_gamification_table.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +DROP TABLE gamification; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +CREATE TABLE gamification ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + score INTEGER NOT NULL, + avatar BYTEA +); + +-- +goose StatementEnd diff --git a/db/migrations/20251030105953_refactor_song.sql b/db/migrations/20251030105953_refactor_song.sql new file mode 100644 index 0000000..9d3e623 --- /dev/null +++ b/db/migrations/20251030105953_refactor_song.sql @@ -0,0 +1,49 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE song_artist +DROP COLUMN followers; + +ALTER TABLE song_artist +DROP COLUMN popularity; + +CREATE TYPE lyrics_type_enum AS ENUM ('plain', 'synced', 'instrumental', 'missing'); + +UPDATE song +SET lyrics_type = 'missing' +WHERE lyrics_type = ''; + +ALTER TABLE song +ADD COLUMN lyrics_type_new lyrics_type_enum NOT NULL DEFAULT 'missing'; + +UPDATE song +SET lyrics_type_new = lyrics_type::lyrics_type_enum; + +ALTER TABLE song +DROP COLUMN lyrics_type; + +ALTER TABLE song +RENAME COLUMN lyrics_type_new TO lyrics_type; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE song +ADD COLUMN lyrics_type_text TEXT; + +UPDATE song +SET lyrics_type_text = lyrics_type::text; + +ALTER TABLE song +DROP COLUMN lyrics_type; + +ALTER TABLE song +RENAME COLUMN lyrics_type_text TO lyrics_type; + +DROP TYPE lyrics_type_enum; + +ALTER TABLE song_artist +ADD COLUMN popularity INT; + +ALTER TABLE song_artist +ADD COLUMN followers INT; +-- +goose StatementEnd diff --git a/db/queries/event.sql b/db/queries/event.sql deleted file mode 100644 index 9646c2b..0000000 --- a/db/queries/event.sql +++ /dev/null @@ -1,37 +0,0 @@ --- CRUD - - --- name: GetAllEvents :many -SELECT * -FROM event; - --- name: CreateEvent :one -INSERT INTO event (name, date, academic_year, location, poster) -VALUES ($1, $2, $3, $4, $5) -RETURNING *; - --- name: DeleteEvent :exec -DELETE FROM event -WHERE id = $1; - - --- Other - - --- name: GetEventByAcademicYear :many -SELECT * -FROM event -WHERE academic_year = $1; - --- name: DeleteEventByAcademicYear :exec -DELETE FROM event -WHERE academic_year = $1; - --- name: GetEventsCurrentAcademicYear :many -SELECT * -FROM event -WHERE academic_year = ( - SELECT MAX(academic_year) - FROM event -) -ORDER BY date ASC; diff --git a/db/queries/gamification.sql b/db/queries/gamification.sql deleted file mode 100644 index 3113a19..0000000 --- a/db/queries/gamification.sql +++ /dev/null @@ -1,32 +0,0 @@ --- CRUD - --- name: GetAllGamification :many -SELECT * -FROM gamification; - --- name: CreateGamification :one -INSERT INTO gamification (name, score, avatar) -VALUES ($1, $2, $3) -RETURNING *; - --- name: DeleteGamification :execrows -DELETE FROM gamification -WHERE id = $1; - --- name: DeleteGamificationAll :execrows -DELETE FROM gamification; - - --- Other - - --- name: UpdateGamificationScore :one -UPDATE gamification -SET score = $1 -WHERE id = $2 -RETURNING *; - --- name: GetAllGamificationByScore :many -SELECT * -FROM gamification -ORDER BY score DESC; diff --git a/db/queries/message.sql b/db/queries/message.sql index 2b615ed..61a8e29 100644 --- a/db/queries/message.sql +++ b/db/queries/message.sql @@ -1,41 +1,10 @@ --- CRUD - --- name: GetAllMessages :many -SELECT * -FROM message; - --- name: GetMessageByID :one +-- name: MessageGetSinceID :many SELECT * FROM message -WHERE id = $1; +WHERE id > $1 +ORDER BY created_at ASC; --- name: CreateMessage :one +-- name: MessageCreate :one INSERT INTO message (name, ip, message) VALUES ($1, $2, $3) -RETURNING *; - --- name: UpdateMessage :one -UPDATE message -SET name = $1, ip = $2, message = $3 -WHERE id = $4 -RETURNING *; - --- name: DeleteMessage :execrows -DELETE FROM message -WHERE id = $1; - - --- Other - - --- name: GetLastMessage :one -SELECT * -FROM message -ORDER BY id DESC -LIMIT 1; - --- name: GetMessageSinceID :many -SELECT * -FROM message -WHERE id > $1 -ORDER BY created_at ASC; +RETURNING id; diff --git a/db/queries/scan.sql b/db/queries/scan.sql index 706ffc5..3a37aa9 100644 --- a/db/queries/scan.sql +++ b/db/queries/scan.sql @@ -1,47 +1,24 @@ --- CRUD - --- name: GetAllScans :many -SELECT * -FROM scan; - --- name: GetScanByID :one -SELECT * -FROM scan -WHERE id = $1; - --- name: CreateScan :one -INSERT INTO scan (scan_id, scan_time) -VALUES ($1, $2) -RETURNING *; - --- name: UpdateScan :one -UPDATE scan -SET scan_id = $1, scan_time = $2 -WHERE id = $3 -RETURNING *; - --- name: DeleteScan :execrows -DELETE FROM scan -WHERE id = $1; - - --- Other - - --- name: GetLastScan :one +-- name: ScanGetLast :one SELECT * FROM scan ORDER BY id DESC LIMIT 1; --- name: GetAllScansSinceID :many +-- name: ScanGetAllSinceID :many SELECT * FROM scan WHERE id > $1 ORDER BY scan_id, scan_time ASC; --- name: GetScansInCurrentSeason :one -SELECT COUNT(*) AS amount -FROM scan -WHERE scan_time >= (SELECT start_date FROM season WHERE current = true) AND - scan_time <= (SELECT end_date FROM season WHERE current = true); +-- name: ScanGetInSeason :many +SELECT sc.* +FROM scan sc +LEFT JOIN season se ON se.start <= sc.scan_time AND sc.scan_time < se.end +WHERE se.id = $1 +ORDER BY sc.scan_time ASC; + +-- name: ScanCreate :one +INSERT INTO scan (scan_id, scan_time) +VALUES ($1, $2) +RETURNING id; + diff --git a/db/queries/season.sql b/db/queries/season.sql index 94287be..e7e7952 100644 --- a/db/queries/season.sql +++ b/db/queries/season.sql @@ -1,38 +1,22 @@ --- CRUD - --- name: GetAllSeasons :many +-- name: SeasonGetAll :many SELECT * FROM season; --- name: GetSeasonByID :one +-- name: SeasonGetCurrent :one SELECT * FROM season -WHERE id = $1; +WHERE current = true; --- name: CreateSeason :one +-- name: SeasonCreate :one INSERT INTO season (name, start, "end", current) VALUES ($1, $2, $3, $4) -RETURNING *; +RETURNING id; --- name: UpdateSeason :one +-- name: SeasonUpdate :exec UPDATE season SET name = $1, start = $2, "end" = $3, current = $4 WHERE id = $5 RETURNING *; --- name: DeleteSeason :execrows -DELETE FROM season -WHERE id = $1; - --- name: DeleteSeasonAll :execrows +-- name: SeasonDeleteAll :exec DELETE FROM season; - - - --- Other - - --- name: GetSeasonCurrent :one -SELECT * -FROM season -WHERE current = true; diff --git a/db/queries/song.sql b/db/queries/song.sql index 210f367..37d1b8f 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -1,138 +1,75 @@ --- CRUD - --- name: CreateSong :one -INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING *; - --- name: CreateSongHistory :one -INSERT INTO song_history (song_id) -VALUES ($1) -RETURNING *; - --- name: CreateSongGenre :one -INSERT INTO song_genre (genre) -VALUES ($1) -RETURNING *; - --- name: CreateSongArtist :one -INSERT INTO song_artist (name, spotify_id, followers, popularity) -VALUES ($1, $2, $3, $4) -RETURNING *; - --- name: CreateSongArtistSong :one -INSERT INTO song_artist_song (artist_id, song_id) -VALUES ($1, $2) -RETURNING *; - --- name: CreateSongArtistGenre :one -INSERT INTO song_artist_genre (artist_id, genre_id) -VALUES ($1, $2) -RETURNING *; - - --- Other - --- name: GetSongBySpotifyID :one -SELECT * -FROM song -WHERE spotify_id = $1; - --- name: GetSongArtistBySpotifyID :one -SELECT * -FROM song_artist -WHERE spotify_id = $1; - --- name: GetLastSongHistory :one -SELECT * -FROM song_history -ORDER BY created_at DESC -LIMIT 1; - --- name: GetSongGenreByName :one -SELECT * -FROM song_genre -WHERE genre = $1; - --- name: GetSongArtistByName :one -SELECT * -FROM song_artist -WHERE name = $1; - --- name: GetLastSongFull :many -SELECT s.id, s.title AS song_title, s.spotify_id, s.album, s.duration_ms, s.lyrics_type, s.lyrics, sh.created_at, a.id AS artist_id, a.name AS artist_name, a.spotify_id AS artist_spotify_id, a.followers AS artist_followers, a.popularity AS artist_popularity, g.id AS genre_id, g.genre AS genre, sh.created_at -FROM song_history sh -JOIN song s ON sh.song_id = s.id -LEFT JOIN song_artist_song sa ON s.id = sa.song_id -LEFT JOIN song_artist a ON sa.artist_id = a.id -LEFT JOIN song_artist_genre ag ON ag.artist_id = a.id -LEFT JOIN song_genre g ON ag.genre_id = g.id -WHERE sh.created_at = (SELECT MAX(created_at) FROM song_history) -ORDER BY a.name, g.genre; - --- name: GetSongHistory :many -SELECT s.title, play_count, aggregated.created_at +-- name: SongGetLastPopulated :many +SELECT sqlc.embed(h), sqlc.embed(s), sqlc.embed(a) +FROM song_history h +JOIN song s ON s.id = h.song_id +LEFT JOIN song_artist_song sa ON sa.song_id = s.id +LEFT JOIN song_artist a ON a.id = sa.artist_id +WHERE h.created_at = (SELECT MAX(created_at) FROM song_history) +ORDER BY a.name; + +-- name: SongGetLast50 :many +SELECT sqlc.embed(s), play_count FROM ( SELECT sh.song_id, MAX(sh.created_at) AS created_at, COUNT(sh.song_id) AS play_count FROM song_history sh GROUP BY sh.song_id ) aggregated -JOIN song s ON aggregated.song_id = s.id +JOIN song s ON s.id = aggregated.song_id ORDER BY aggregated.created_at DESC LIMIT 50; --- name: GetTopSongs :many -SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count +-- name: SongGetTop50 :many +SELECT sqlc.embed(s), COUNT(sh.id) AS play_count FROM song_history sh JOIN song s ON sh.song_id = s.id GROUP BY s.id, s.title ORDER BY play_count DESC -LIMIT 10; +LIMIT 50; --- name: GetTopArtists :many -SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays +-- name: SongGetTop50Monthly :many +SELECT sqlc.embed(s), COUNT(sh.id) AS play_count FROM song_history sh JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -GROUP BY sa.id, sa.name -ORDER BY total_plays DESC -LIMIT 10; +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY s.id, s.title +ORDER BY play_count DESC +LIMIT 50; --- name: GetTopGenres :many -SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays +-- name: SongArtistGetTop50 :many +SELECT sqlc.embed(a), COUNT(sh.id) AS play_count FROM song_history sh JOIN song s ON sh.song_id = s.id JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -JOIN song_artist_genre sag ON sa.id = sag.artist_id -JOIN song_genre g ON sag.genre_id = g.id -GROUP BY g.genre -ORDER BY total_plays DESC -LIMIT 10; +JOIN song_artist a ON sas.artist_id = a.id +GROUP BY a.id, a.name +ORDER BY play_count DESC +LIMIT 50; --- name: GetTopMonthlySongs :many -SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count +-- name: SongArtistGetTop50Monthly :many +SELECT sqlc.embed(a), COUNT(sh.id) AS play_count FROM song_history sh JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist a ON sas.artist_id = a.id WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY s.id, s.title +GROUP BY a.id, a.name ORDER BY play_count DESC -LIMIT 10; +LIMIT 50; --- name: GetTopMonthlyArtists :many -SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays +-- name: SongGenreGetTop50 :many +SELECT sqlc.embed(g), COUNT(sh.id) AS play_count FROM song_history sh JOIN song s ON sh.song_id = s.id JOIN song_artist_song sas ON s.id = sas.song_id JOIN song_artist sa ON sas.artist_id = sa.id -WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY sa.id, sa.name -ORDER BY total_plays DESC -LIMIT 10; +JOIN song_artist_genre sag ON sa.id = sag.artist_id +JOIN song_genre g ON sag.genre_id = g.id +GROUP BY g.genre, g.id +ORDER BY play_count DESC +LIMIT 50; --- name: GetTopMonthlyGenres :many -SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays +-- name: SongGenreGetTop50Monthly :many +SELECT sqlc.embed(g), COUNT(sh.id) AS play_count FROM song_history sh JOIN song s ON sh.song_id = s.id JOIN song_artist_song sas ON s.id = sas.song_id @@ -140,6 +77,51 @@ JOIN song_artist sa ON sas.artist_id = sa.id JOIN song_artist_genre sag ON sa.id = sag.artist_id JOIN song_genre g ON sag.genre_id = g.id WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY g.genre -ORDER BY total_plays DESC -LIMIT 10; +GROUP BY g.genre, g.id +ORDER BY play_count DESC +LIMIT 50; + +-- name: SongGetBySpotify :one +SELECT * +FROM song +WHERE spotify_id = $1; + +-- name: SongArtistGetBySpotify :one +SELECT * +FROM song_artist +WHERE spotify_id = $1; + +-- name: SongGenreGetByGenre :one +SELECT * +FROM song_genre +WHERE genre = $1; + +-- name: SongCreate :one +INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id; + +-- name: SongArtistCreate :one +INSERT INTO song_artist (name, spotify_id) +VALUES ($1, $2) +RETURNING id; + +-- name: SongArtistSongCreate :one +INSERT INTO song_artist_song (artist_id, song_id) +VALUES ($1, $2) +RETURNING id; + +-- name: SongGenreCreate :one +INSERT INTO song_genre (genre) +VALUES ($1) +RETURNING id; + +-- name: SongArtistGenreCreate :one +INSERT INTO song_artist_genre (artist_id, genre_id) +VALUES ($1, $2) +RETURNING id; + +-- name: SongHistoryCreate :one +INSERT INTO song_history (song_id) +VALUES ($1) +RETURNING id; diff --git a/db/queries/tap.sql b/db/queries/tap.sql index d2a4929..ef73a03 100644 --- a/db/queries/tap.sql +++ b/db/queries/tap.sql @@ -1,56 +1,16 @@ --- CRUD - --- name: GetAllTaps :many -SELECT * -FROM tap; - --- name: GetTapByID :one -SELECT * -FROM tap -WHERE id = $1; - --- name: CreateTap :one -INSERT INTO tap (order_id, order_created_at, name, category) -VALUES ($1, $2, $3, $4) -RETURNING *; - --- name: UpdateTap :one -UPDATE tap -SET order_id = $1, order_created_at = $2, name = $3, category = $4 -WHERE id = $5 -RETURNING *; - --- name: DeleteTap :execrows -DELETE FROM tap -WHERE id = $1; - - --- Other - - --- name: GetTapByOrderID :one -SELECT * -FROM tap -WHERE order_id = $1; - --- name: GetTapByCategory :many -SELECT * -FROM tap -WHERE category = $1; - --- name: GetLastOrderByOrderID :one +-- name: TapGetLast :one SELECT * FROM tap ORDER BY order_id DESC LIMIT 1; --- name: GetOrderCount :many -SELECT category, COUNT(*) -FROM tap -GROUP BY category; - --- name: GetOrderCountByCategorySinceOrderID :many +-- name: TapGetCountByCategory :many SELECT category, COUNT(*), MAX(order_created_at)::TIMESTAMP AS latest_order_created_at FROM tap -WHERE order_id >= $1 GROUP BY category; + +-- name: TapCreate :one +INSERT INTO tap (order_id, order_created_at, name, category) +VALUES ($1, $2, $3, $4) +RETURNING id; + diff --git a/go.mod b/go.mod index 0cac582..409164f 100644 --- a/go.mod +++ b/go.mod @@ -1,84 +1,141 @@ module github.com/zeusWPI/scc -go 1.23.1 +go 1.25.0 require ( github.com/NimbleMarkets/ntcharts v0.3.1 - github.com/charmbracelet/bubbletea v1.2.4 - github.com/charmbracelet/lipgloss v1.0.0 + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/lipgloss v1.1.0 github.com/disintegration/imaging v1.6.2 - github.com/go-playground/validator/v10 v10.23.0 - github.com/gocolly/colly v1.2.0 + github.com/go-playground/validator/v10 v10.27.0 github.com/gofiber/contrib/fiberzap v1.0.2 - github.com/gofiber/fiber/v2 v2.52.5 - github.com/jackc/pgx/v5 v5.7.2 + github.com/gofiber/fiber/v2 v2.52.9 + github.com/jackc/pgx/v5 v5.7.5 github.com/joho/godotenv v1.5.1 github.com/lucasb-eyer/go-colorful v1.2.0 - github.com/spf13/viper v1.19.0 + github.com/spf13/viper v1.20.1 go.uber.org/zap v1.27.0 ) require ( - github.com/PuerkitoBio/goquery v1.10.0 // indirect - github.com/andybalholm/brotli v1.1.1 // indirect - github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/antchfx/htmlquery v1.3.4 // indirect - github.com/antchfx/xmlquery v1.4.3 // indirect - github.com/antchfx/xpath v1.3.3 // indirect + cel.dev/expr v0.19.1 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/ClickHouse/ch-go v0.65.1 // indirect + github.com/ClickHouse/clickhouse-go/v2 v2.34.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.20.0 // indirect - github.com/charmbracelet/x/ansi v0.6.0 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/coder/websocket v1.8.13 // indirect + github.com/cubicdaiya/gonp v1.0.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elastic/go-sysinfo v1.15.3 // indirect + github.com/elastic/go-windows v1.0.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.7 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/gobwas/glob v0.2.3 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/golang/protobuf v1.5.4 // indirect + github.com/go-sql-driver/mysql v1.9.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/google/cel-go v0.24.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/kennygrant/sanitize v1.2.4 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lrstanley/bubblezone v0.0.0-20241221063659-0f12a2876fb2 // indirect - github.com/magiconair/properties v1.8.9 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/lrstanley/bubblezone v1.0.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/mfridman/xflag v0.1.0 // indirect + github.com/microsoft/go-mssqldb v1.8.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/paulmach/orb v0.11.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect + github.com/pingcap/log v1.1.0 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect + github.com/pressly/goose/v3 v3.24.3 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.6.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/riza-io/grpc-go v0.2.0 // indirect + github.com/sagikazarmark/locafero v0.10.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.14.0 // indirect + github.com/spf13/cast v1.9.2 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.7 // indirect + github.com/sqlc-dev/sqlc v1.29.0 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/temoto/robotstxt v1.1.2 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.58.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect + github.com/valyala/fasthttp v1.65.0 // indirect + github.com/vertica/vertica-sql-go v1.3.3 // indirect + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect + github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 // indirect + github.com/ziutek/mymysql v1.5.4 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect - golang.org/x/image v0.23.0 // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.36.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/image v0.30.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.36.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/grpc v1.71.1 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + howett.net/plist v1.0.1 // indirect + modernc.org/libc v1.65.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.10.0 // indirect + modernc.org/sqlite v1.37.0 // indirect +) + +tool ( + github.com/pressly/goose/v3/cmd/goose + github.com/sqlc-dev/sqlc/cmd/sqlc + golang.org/x/tools/cmd/deadcode ) diff --git a/go.sum b/go.sum index 6e012d2..a8d3a7f 100644 --- a/go.sum +++ b/go.sum @@ -1,321 +1,524 @@ -github.com/NimbleMarkets/ntcharts v0.2.0 h1:uVpvUL9fZk/LGsc8E00kdBLHwh60llfvci+2JpJ6EDI= -github.com/NimbleMarkets/ntcharts v0.2.0/go.mod h1:BLzvdpQAv4NpGbOTsi3fCRzeDk276PGezkp75gD73kY= +cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= +cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/ClickHouse/ch-go v0.65.1 h1:SLuxmLl5Mjj44/XbINsK2HFvzqup0s6rwKLFH347ZhU= +github.com/ClickHouse/ch-go v0.65.1/go.mod h1:bsodgURwmrkvkBe5jw1qnGDgyITsYErfONKAHn05nv4= +github.com/ClickHouse/clickhouse-go/v2 v2.34.0 h1:Y4rqkdrRHgExvC4o/NTbLdY5LFQ3LHS77/RNFxFX3Co= +github.com/ClickHouse/clickhouse-go/v2 v2.34.0/go.mod h1:yioSINoRLVZkLyDzdMXPLRIqhDvel8iLBlwh6Iefso8= github.com/NimbleMarkets/ntcharts v0.3.1 h1:EH4O80RMy5rqDmZM7aWjTbCSuRDDJ5fXOv/qAzdwOjk= github.com/NimbleMarkets/ntcharts v0.3.1/go.mod h1:zVeRqYkh2n59YPe1bflaSL4O2aD2ZemNmrbdEqZ70hk= -github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= -github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= -github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= -github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= -github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= -github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/antchfx/htmlquery v1.3.3 h1:x6tVzrRhVNfECDaVxnZi1mEGrQg3mjE/rxbH2Pe6dNE= -github.com/antchfx/htmlquery v1.3.3/go.mod h1:WeU3N7/rL6mb6dCwtE30dURBnBieKDC/fR8t6X+cKjU= -github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ= -github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM= -github.com/antchfx/xmlquery v1.4.2 h1:MZKd9+wblwxfQ1zd1AdrTsqVaMjMCwow3IqkCSe00KA= -github.com/antchfx/xmlquery v1.4.2/go.mod h1:QXhvf5ldTuGqhd1SHNvvtlhhdQLks4dD0awIVhXIDTA= -github.com/antchfx/xmlquery v1.4.3 h1:f6jhxCzANrWfa93O+NmRWvieVyLs+R2Szfpy+YrZaww= -github.com/antchfx/xmlquery v1.4.3/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc= -github.com/antchfx/xpath v1.3.2 h1:LNjzlsSjinu3bQpw9hWMY9ocB80oLOWuQqFvO6xt51U= -github.com/antchfx/xpath v1.3.2/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= -github.com/antchfx/xpath v1.3.3 h1:tmuPQa1Uye0Ym1Zn65vxPgfltWb/Lxu2jeqIGteJSRs= -github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= -github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= -github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= -github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= -github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= -github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA= -github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= +github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= +github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elastic/go-sysinfo v1.8.1/go.mod h1:JfllUnzoQV/JRYymbH3dO1yggI3mV2oTKSXsDHM+uIM= +github.com/elastic/go-sysinfo v1.15.3 h1:W+RnmhKFkqPTCRoFq2VCTmsT4p/fwpo+3gKNQsn1XU0= +github.com/elastic/go-sysinfo v1.15.3/go.mod h1:K/cNrqYTDrSoMh2oDkYEMS2+a72GRxMvNP+GC+vRIlo= +github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= +github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= +github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= -github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= -github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI= -github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= +github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofiber/contrib/fiberzap v1.0.2 h1:EQwhggtszVfIdBeXxN9Xrmld71es34Ufs+ef8VMqZxc= github.com/gofiber/contrib/fiberzap v1.0.2/go.mod h1:jGO8BHU4gRI9U0JtM6zj2CIhYfgVmW5JxziN8NTgVwE= -github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= -github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= +github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI= +github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= -github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= -github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= -github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= -github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= -github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= -github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e h1:OLwZ8xVaeVrru0xyeuOX+fne0gQTFEGlzfNjipCbxlU= -github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e/go.mod h1:NQ34EGeu8FAYGBMDzwhfNJL8YQYoWZP5xYJPRDAwN3E= -github.com/lrstanley/bubblezone v0.0.0-20241221063659-0f12a2876fb2 h1:iILPmPi4ytvFMzb90E7S7if5cdlyboFLXgBRe+7tLAA= -github.com/lrstanley/bubblezone v0.0.0-20241221063659-0f12a2876fb2/go.mod h1:NQ34EGeu8FAYGBMDzwhfNJL8YQYoWZP5xYJPRDAwN3E= +github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= +github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= -github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M= +github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= +github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw= +github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= +github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= +github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk= +github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= +github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= +github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= +github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM= +github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= -github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= -github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= +github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= +github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= +github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/sqlc-dev/sqlc v1.29.0 h1:HQctoD7y/i29Bao53qXO7CZ/BV9NcvpGpsJWvz9nKWs= +github.com/sqlc-dev/sqlc v1.29.0/go.mod h1:BavmYw11px5AdPOjAVHmb9fctP5A8GTziC38wBF9tp0= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= -github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= +github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE= -github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= +github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= +github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= +github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 h1:LY6cI8cP4B9rrpTleZk95+08kl2gF4rixG7+V/dwL6Q= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 h1:ixAiqjj2S/dNuJqrz4AxSqgw2P5OBMXp68hB5nNriUk= +github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok9Sjc16tEC8AXGbwrk+ho= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= -golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= -golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= -golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= +golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488 h1:3doPGa+Gg4snce233aCWnbZVFsyFMo/dR40KK/6skyE= +golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= +google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= -google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= +modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= +modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= +modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= +modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/api.go b/internal/api/api.go deleted file mode 100644 index ab2b292..0000000 --- a/internal/api/api.go +++ /dev/null @@ -1,17 +0,0 @@ -// Package api provides all the API endpoints -package api - -import ( - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/api/message" - apiSong "github.com/zeusWPI/scc/internal/api/song" - "github.com/zeusWPI/scc/internal/pkg/buzzer" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/song" -) - -// New creates a new API instance -func New(router fiber.Router, db *db.DB, buzz *buzzer.Buzzer, song *song.Song) { - message.New(router, db, buzz) - apiSong.New(router, db, song) -} diff --git a/internal/api/message/message.go b/internal/api/message/message.go deleted file mode 100644 index fc663c2..0000000 --- a/internal/api/message/message.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package message provides the API regarding the cammie chat messages -package message - -import ( - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/buzzer" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/pkg/util" - "go.uber.org/zap" -) - -// Router is the message API router -type Router struct { - router fiber.Router - db *db.DB - buzz *buzzer.Buzzer -} - -// New creates a new message API instance -func New(router fiber.Router, db *db.DB, buzz *buzzer.Buzzer) *Router { - api := &Router{ - router: router.Group("/messages"), - db: db, - buzz: buzz, - } - api.createRoutes() - - return api -} - -func (r *Router) createRoutes() { - r.router.Get("/", r.getAll) - r.router.Post("/", r.create) -} - -func (r *Router) getAll(c *fiber.Ctx) error { - messages, err := r.db.Queries.GetAllMessages(c.Context()) - if err != nil { - zap.S().Error("DB: Get all messages\n", err) - return c.SendStatus(fiber.StatusInternalServerError) - } - - return c.JSON(util.SliceMap(messages, dto.MessageDTO)) -} - -func (r *Router) create(c *fiber.Ctx) error { - message := new(dto.Message) - - if err := c.BodyParser(message); err != nil { - zap.S().Error("API: Message body parser\n", err) - return c.SendStatus(fiber.StatusBadRequest) - } - - if err := dto.Validate.Struct(message); err != nil { - zap.S().Error("API: Message validation\n", err) - return c.SendStatus(fiber.StatusBadRequest) - } - - messageDB, err := r.db.Queries.CreateMessage(c.Context(), message.CreateParams()) - if err != nil { - zap.S().Error("DB: Create message\n", err) - return c.SendStatus(fiber.StatusInternalServerError) - } - - r.buzz.Play() - - return c.Status(fiber.StatusCreated).JSON(dto.MessageDTO(messageDB)) -} diff --git a/internal/api/song/song.go b/internal/api/song/song.go deleted file mode 100644 index 220663f..0000000 --- a/internal/api/song/song.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package song provides the API regarding songs integration -package song - -import ( - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/internal/pkg/song" - "go.uber.org/zap" -) - -// Router is the song API router -type Router struct { - router fiber.Router - db *db.DB - song *song.Song -} - -// New creates a new song API instance -func New(router fiber.Router, db *db.DB, song *song.Song) *Router { - api := &Router{ - router: router.Group("/song"), - db: db, - song: song, - } - api.createRoutes() - - return api -} - -func (r *Router) createRoutes() { - r.router.Post("/", r.new) -} - -func (r *Router) new(c *fiber.Ctx) error { - song := new(dto.Song) - - if err := c.BodyParser(song); err != nil { - zap.S().Error("API: Song body parser\n", err) - return c.SendStatus(fiber.StatusBadRequest) - } - - if err := dto.Validate.Struct(song); err != nil { - zap.S().Error("API: Song validation\n", err) - return c.SendStatus(fiber.StatusBadRequest) - } - - go func() { - err := r.song.Track(song) - if err != nil { - zap.S().Error("Song: Get Track\n", err) - } - }() - - return c.SendStatus(fiber.StatusOK) -} diff --git a/internal/pkg/buzzer/buzzer.go b/internal/buzzer/buzzer.go similarity index 50% rename from internal/pkg/buzzer/buzzer.go rename to internal/buzzer/buzzer.go index 2a20b6b..73a0cda 100644 --- a/internal/pkg/buzzer/buzzer.go +++ b/internal/buzzer/buzzer.go @@ -8,9 +8,10 @@ import ( "go.uber.org/zap" ) -// Buzzer represents a buzzer -type Buzzer struct { - Song []string +// Client represents a buzzer +type Client struct { + hasBuzzer bool + song []string } var defaultSong = []string{ @@ -26,19 +27,33 @@ var defaultSong = []string{ } // New returns a new buzzer instance -func New() *Buzzer { - return &Buzzer{ - Song: config.GetDefaultStringSlice("backend.buzzer.song", defaultSong), +func New() *Client { + hasBuzzer := false + if _, err := exec.LookPath("beep"); err == nil { + hasBuzzer = true + } + + if !hasBuzzer { + zap.S().Debug("No beep executable found.\nMock messages will be used instead") + } + + return &Client{ + hasBuzzer: hasBuzzer, + song: config.GetDefaultStringSlice("backend.buzzer.song", defaultSong), } } // Play plays the buzzer -func (b *Buzzer) Play() { +func (c *Client) Play() { + if !c.hasBuzzer { + zap.S().Info("BEEEEEEEP") + return + } + // See `man beep` for more information - cmd := exec.Command("beep", b.Song...) + cmd := exec.Command("beep", c.song...) err := cmd.Run() - if err != nil { - zap.L().Error("Error running command 'beep'", zap.Error(err)) + zap.S().Error("Error running command 'beep' %v", err) } } diff --git a/internal/cmd/api.go b/internal/cmd/api.go deleted file mode 100644 index 52f2803..0000000 --- a/internal/cmd/api.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/gofiber/contrib/fiberzap" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/zeusWPI/scc/internal/api" - "github.com/zeusWPI/scc/internal/pkg/buzzer" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/song" - "github.com/zeusWPI/scc/pkg/config" - "go.uber.org/zap" -) - -// API starts the API server -func API(db *db.DB, song *song.Song) { - app := fiber.New(fiber.Config{ - BodyLimit: 1024 * 1024 * 1024, - }) - app.Use( - fiberzap.New(fiberzap.Config{ - Logger: zap.L(), - }), - cors.New(cors.Config{ - AllowOrigins: "*", - AllowHeaders: "Origin, Content-Type, Accept, Access-Control-Allow-Origin", - }), - ) - - buzz := buzzer.New() - - apiGroup := app.Group("/api") - api.New(apiGroup, db, buzz, song) - - host := config.GetDefaultString("server.host", "localhost") - port := config.GetDefaultInt("server.port", 3000) - - zap.S().Fatal("API: Fatal server error", app.Listen(fmt.Sprintf("%s:%d", host, port))) -} diff --git a/internal/cmd/event.go b/internal/cmd/event.go deleted file mode 100644 index 6957ac2..0000000 --- a/internal/cmd/event.go +++ /dev/null @@ -1,48 +0,0 @@ -package cmd - -import ( - "time" - - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/event" - "github.com/zeusWPI/scc/pkg/config" - "go.uber.org/zap" -) - -// Event starts the event instance -func Event(db *db.DB) (*event.Event, chan bool) { - ev := event.New(db) - done := make(chan bool) - interval := config.GetDefaultInt("backend.event.interval_s", 3600) - - go eventPeriodicUpdate(ev, done, interval) - - return ev, done -} - -func eventPeriodicUpdate(ev *event.Event, done chan bool, interval int) { - zap.S().Info("Event: Starting periodic leaderboard update with an interval of ", interval, " seconds") - - ticker := time.NewTimer(time.Duration(interval) * time.Second) - defer ticker.Stop() - - // Run immediatly once - zap.S().Info("Event: Updating events") - if err := ev.Update(); err != nil { - zap.S().Error("Event: Error updating events\n", err) - } - - for { - select { - case <-done: - zap.S().Info("Event: Stopping periodic leaderboard update") - return - case <-ticker.C: - // Update leaderboard - zap.S().Info("Event: Updating events") - if err := ev.Update(); err != nil { - zap.S().Error("Event: Error updating events\n", err) - } - } - } -} diff --git a/internal/cmd/gamification.go b/internal/cmd/gamification.go deleted file mode 100644 index 8561625..0000000 --- a/internal/cmd/gamification.go +++ /dev/null @@ -1,48 +0,0 @@ -package cmd - -import ( - "time" - - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/gamification" - "github.com/zeusWPI/scc/pkg/config" - "go.uber.org/zap" -) - -// Gamification starts the gamification instance -func Gamification(db *db.DB) (*gamification.Gamification, chan bool) { - gam := gamification.New(db) - done := make(chan bool) - interval := config.GetDefaultInt("backend.gamification.interval_s", 3600) - - go gamificationPeriodicUpdate(gam, done, interval) - - return gam, done -} - -func gamificationPeriodicUpdate(gam *gamification.Gamification, done chan bool, interval int) { - zap.S().Info("Gamification: Starting periodic leaderboard update with an interval of ", interval, " seconds") - - ticker := time.NewTicker(time.Duration(interval) * time.Second) - defer ticker.Stop() - - // Run immediatly once - zap.S().Info("Gamification: Updating leaderboard") - if err := gam.Update(); err != nil { - zap.S().Error("gamification: Error updating leaderboard\n", err) - } - - for { - select { - case <-done: - zap.S().Info("Gamification: Stopping periodic leaderboard update") - return - case <-ticker.C: - // Update leaderboard - zap.S().Info("Gamification: Updating leaderboard") - if err := gam.Update(); err != nil { - zap.S().Error("gamification: Error updating leaderboard\n", err) - } - } - } -} diff --git a/internal/cmd/song.go b/internal/cmd/song.go index 198d84c..ef22c2a 100644 --- a/internal/cmd/song.go +++ b/internal/cmd/song.go @@ -1,13 +1,9 @@ package cmd import ( - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/song" + "github.com/zeusWPI/scc/internal/song" ) -// Song starts the Song integration -func Song(db *db.DB) (*song.Song, error) { - song, err := song.New(db) - - return song, err +func Song() error { + return song.Init() } diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go index 00e97f5..f476f7b 100644 --- a/internal/cmd/tap.go +++ b/internal/cmd/tap.go @@ -3,46 +3,25 @@ package cmd import ( "time" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/tap" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/internal/tap" "github.com/zeusWPI/scc/pkg/config" - "go.uber.org/zap" + "github.com/zeusWPI/scc/pkg/utils" ) // Tap starts the tap instance -func Tap(db *db.DB) (*tap.Tap, chan bool) { - tap := tap.New(db) +func Tap(repo repository.Repository) (*tap.Tap, chan bool) { + tap := tap.New(repo) + done := make(chan bool) interval := config.GetDefaultInt("backend.tap.interval_s", 60) - go tapPeriodicUpdate(tap, done, interval) + go utils.Periodic( + "Tap", + time.Duration(interval)*time.Second, + tap.Update, + done, + ) return tap, done } - -func tapPeriodicUpdate(tap *tap.Tap, done chan bool, interval int) { - zap.S().Info("Tap: Starting periodic update with an interval of ", interval, " seconds") - - ticker := time.NewTicker(time.Duration(interval) * time.Second) - defer ticker.Stop() - - // Run immediatly once - zap.S().Info("Tap: Updating tap") - if err := tap.Update(); err != nil { - zap.S().Error("Tap: Error updating tap\n", err) - } - - for { - select { - case <-done: - zap.S().Info("Tap: Stopping periodic update") - return - case <-ticker.C: - // Update tap - zap.S().Info("Tap: Updating tap") - if err := tap.Update(); err != nil { - zap.S().Error("Tap: Error updating tap\n", err) - } - } - } -} diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index ea097d5..065542d 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -2,49 +2,39 @@ package cmd import ( + "context" "fmt" - "os" + "maps" "time" tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/pkg/util" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/pkg/utils" "github.com/zeusWPI/scc/tui" "github.com/zeusWPI/scc/tui/screen" "github.com/zeusWPI/scc/tui/screen/cammie" songScreen "github.com/zeusWPI/scc/tui/screen/song" "github.com/zeusWPI/scc/tui/view" - "go.uber.org/zap" ) -var screens = map[string]func(*db.DB) screen.Screen{ +var screens = map[string]func(repo repository.Repository) screen.Screen{ "cammie": cammie.New, "song": songScreen.New, } -// TUI starts the terminal user interface -func TUI(db *db.DB) error { - args := os.Args - if len(args) < 2 { - return fmt.Errorf("No screen specified. Options are %v", util.Keys(screens)) - } - - selectedScreen := args[1] - - val, ok := screens[selectedScreen] +func TUI(repo repository.Repository, screenName string) error { + val, ok := screens[screenName] if !ok { - return fmt.Errorf("Screen %s not found. Options are %v", selectedScreen, util.Keys(screens)) + return fmt.Errorf("screen %s not found. Options are %v", screenName, maps.Keys(screens)) } - screen := val(db) + screen := val(repo) tui := tui.New(screen) p := tea.NewProgram(tui, tea.WithAltScreen()) dones := make([]chan bool, 0, len(screen.GetUpdateViews())) - for _, updateData := range screen.GetUpdateViews() { - done := make(chan bool) - dones = append(dones, done) - go tuiPeriodicUpdates(p, updateData, done) + for _, data := range screen.GetUpdateViews() { + dones = append(dones, periodicUpdate(p, data)) } _, err := p.Run() @@ -56,37 +46,28 @@ func TUI(db *db.DB) error { return err } -func tuiPeriodicUpdates(p *tea.Program, updateData view.UpdateData, done chan bool) { - zap.S().Info("TUI: Starting periodic update for ", updateData.Name, " with an interval of ", updateData.Interval, " seconds") +func periodicUpdate(p *tea.Program, data view.UpdateData) chan bool { + done := make(chan bool) - ticker := time.NewTicker(time.Duration(updateData.Interval) * time.Second) - defer ticker.Stop() + update := func(ctx context.Context) error { + msg, err := data.Update(ctx, data.View) + if err != nil { + return err + } - // Immediatly update once - msg, err := updateData.Update(updateData.View) - if err != nil { - zap.S().Error("TUI: Error updating ", updateData.Name, "\n", err) - } + if msg != nil { + p.Send(msg) + } - if msg != nil { - p.Send(msg) + return nil } - for { - select { - case <-done: - zap.S().Info("TUI: Stopping periodic update for ", updateData.Name) - return - case <-ticker.C: - // Update - msg, err := updateData.Update(updateData.View) - if err != nil { - zap.S().Error("TUI: Error updating ", updateData.Name, "\n", err) - } + go utils.Periodic( + data.Name, + time.Duration(data.Interval)*time.Second, + update, + done, + ) - if msg != nil { - p.Send(msg) - } - } - } + return done } diff --git a/internal/cmd/zess.go b/internal/cmd/zess.go index 03e3888..be751e3 100644 --- a/internal/cmd/zess.go +++ b/internal/cmd/zess.go @@ -3,78 +3,33 @@ package cmd import ( "time" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/zess" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/internal/zess" "github.com/zeusWPI/scc/pkg/config" - "go.uber.org/zap" + "github.com/zeusWPI/scc/pkg/utils" ) // Zess starts the zess instance -func Zess(db *db.DB) (*zess.Zess, chan bool, chan bool) { - zess := zess.New(db) +func Zess(repo repository.Repository) (*zess.Zess, chan bool) { + zess := zess.New(repo) - doneSeason := make(chan bool) + done := make(chan bool) intervalSeason := config.GetDefaultInt("backend.zess.interval_season_s", 300) - - doneScan := make(chan bool) intervalScan := config.GetDefaultInt("backend.zess.interval_scan_s", 60) - go zessPeriodicSeasonUpdate(zess, doneSeason, intervalSeason) - go zessPeriodicScanUpdate(zess, doneScan, intervalScan) - - return zess, doneSeason, doneScan -} - -func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool, interval int) { - zap.S().Info("Zess: Starting periodic season update with an interval of ", interval, " seconds") - - ticker := time.NewTicker(time.Duration(interval) * time.Second) - defer ticker.Stop() - - // Run immediatly once - zap.S().Info("Zess: Updating seasons") - if err := zess.UpdateSeasons(); err != nil { - zap.S().Error("Zess: Error updating seasons\n", err) - } - - for { - select { - case <-done: - zap.S().Info("Zess: Stopping periodic season update") - return - case <-ticker.C: - // Update seasons - zap.S().Info("Zess: Updating seasons") - if err := zess.UpdateSeasons(); err != nil { - zap.S().Error("Zess: Error updating seasons\n", err) - } - } - } -} - -func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool, interval int) { - zap.S().Info("Zess: Starting periodic scan update with an interval of ", interval, " seconds") - - ticker := time.NewTicker(time.Duration(interval) * time.Second) - defer ticker.Stop() - - // Run immediatly once - zap.S().Info("Zess: Updating scans") - if err := zess.UpdateScans(); err != nil { - zap.S().Error("Zess: Error updating scans\n", err) - } - - for { - select { - case <-done: - zap.S().Info("Zess: Stopping periodic scan update") - return - case <-ticker.C: - // Update scans - zap.S().Info("Zess: Updating scans") - if err := zess.UpdateScans(); err != nil { - zap.S().Error("Zess: Error updating scans\n", err) - } - } - } + go utils.Periodic( + "Zess season", + time.Duration(intervalSeason)*time.Second, + zess.UpdateSeasons, + done, + ) + + go utils.Periodic( + "Zess scans", + time.Duration(intervalScan)*time.Second, + zess.UpdateScans, + done, + ) + + return zess, done } diff --git a/internal/database/model/message.go b/internal/database/model/message.go new file mode 100644 index 0000000..0831544 --- /dev/null +++ b/internal/database/model/message.go @@ -0,0 +1,25 @@ +package model + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/sqlc" +) + +type Message struct { + ID int + Name string + IP string + Message string + CreatedAt time.Time +} + +func MessageModel(m sqlc.Message) *Message { + return &Message{ + ID: int(m.ID), + Name: m.Name, + IP: m.Ip, + Message: m.Message, + CreatedAt: m.CreatedAt.Time, + } +} diff --git a/internal/database/model/scan.go b/internal/database/model/scan.go new file mode 100644 index 0000000..b916f73 --- /dev/null +++ b/internal/database/model/scan.go @@ -0,0 +1,21 @@ +package model + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/sqlc" +) + +type Scan struct { + ID int + ScanID int + ScanTime time.Time +} + +func ScanModel(s sqlc.Scan) *Scan { + return &Scan{ + ID: int(s.ID), + ScanID: int(s.ScanID), + ScanTime: s.ScanTime.Time, + } +} diff --git a/internal/database/model/season.go b/internal/database/model/season.go new file mode 100644 index 0000000..d3ae248 --- /dev/null +++ b/internal/database/model/season.go @@ -0,0 +1,24 @@ +package model + +import ( + "github.com/zeusWPI/scc/internal/database/sqlc" + "github.com/zeusWPI/scc/pkg/date" +) + +type Season struct { + ID int + Name string + Start date.Date + End date.Date + Current bool +} + +func SeasonModel(s sqlc.Season) *Season { + return &Season{ + ID: int(s.ID), + Name: s.Name, + Start: date.Date(s.Start.Time), + End: date.Date(s.End.Time), + Current: s.Current, + } +} diff --git a/internal/database/model/song.go b/internal/database/model/song.go new file mode 100644 index 0000000..532ae6e --- /dev/null +++ b/internal/database/model/song.go @@ -0,0 +1,81 @@ +package model + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/sqlc" +) + +type LyricsType string + +const ( + LyricsPlain LyricsType = "plain" + LyricsSynced LyricsType = "synced" + LyricsInstrumental LyricsType = "instrumental" + LyricsMissing LyricsType = "missing" +) + +type Song struct { + ID int + Title string + Album string + SpotifyID string + DurationMS int + LyricsType LyricsType + Lyrics string + Artists []Artist + + // History fields + PlayedAt time.Time + PlayCount int +} + +type Artist struct { + ID int + Name string + SpotifyID string + Genres []Genre + + // History fields + PlayCount int +} + +type Genre struct { + ID int + Genre string + + // History fields + PlayCount int +} + +func SongModel(s sqlc.Song) *Song { + lyrics := "" + if s.Lyrics.Valid { + lyrics = s.Lyrics.String + } + + return &Song{ + ID: int(s.ID), + Title: s.Title, + Album: s.Album, + SpotifyID: s.SpotifyID, + DurationMS: int(s.DurationMs), + LyricsType: LyricsType(s.LyricsType), + Lyrics: lyrics, + } +} + +func ArtistModel(a sqlc.SongArtist) *Artist { + return &Artist{ + ID: int(a.ID), + Name: a.Name, + SpotifyID: a.SpotifyID, + } +} + +func GenreModel(g sqlc.SongGenre) *Genre { + return &Genre{ + ID: int(g.ID), + Genre: g.Genre, + } +} diff --git a/internal/database/model/tap.go b/internal/database/model/tap.go new file mode 100644 index 0000000..54961c8 --- /dev/null +++ b/internal/database/model/tap.go @@ -0,0 +1,50 @@ +package model + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/sqlc" +) + +type TapCategory string + +const ( + Soft TapCategory = "soft" + Mate TapCategory = "mate" + Beer TapCategory = "beer" + Food TapCategory = "food" + Unknown TapCategory = "unknown" +) + +type Tap struct { + ID int + OrderID int + Name string + Category TapCategory + CreatedAt time.Time +} + +func TapModel(t sqlc.Tap) *Tap { + return &Tap{ + ID: int(t.ID), + OrderID: int(t.OrderID), + Name: t.Name, + Category: TapCategory(t.Category), + CreatedAt: t.CreatedAt.Time, + } +} + +type TapCount struct { + Category TapCategory + LastOrder time.Time + + Count int +} + +func TapCountModel(t sqlc.TapGetCountByCategoryRow) *TapCount { + return &TapCount{ + Category: TapCategory(t.Category), + LastOrder: t.LatestOrderCreatedAt.Time, + Count: int(t.Count), + } +} diff --git a/internal/database/repository/message.go b/internal/database/repository/message.go new file mode 100644 index 0000000..0104b5e --- /dev/null +++ b/internal/database/repository/message.go @@ -0,0 +1,49 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/sqlc" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Message struct { + repo Repository +} + +func (r *Repository) NewMessage() *Message { + return &Message{ + repo: *r, + } +} + +func (m *Message) GetSinceID(ctx context.Context, id int) ([]*model.Message, error) { + messages, err := m.repo.queries(ctx).MessageGetSinceID(ctx, int32(id)) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get messages since id %d | %w", id, err) + } + return nil, nil + } + + return utils.SliceMap(messages, model.MessageModel), nil +} + +func (m *Message) Create(ctx context.Context, message *model.Message) error { + id, err := m.repo.queries(ctx).MessageCreate(ctx, sqlc.MessageCreateParams{ + Name: message.Name, + Ip: message.IP, + Message: message.Message, + }) + if err != nil { + return fmt.Errorf("create message %w", err) + } + + message.ID = int(id) + + return nil +} diff --git a/internal/database/repository/repository.go b/internal/database/repository/repository.go new file mode 100644 index 0000000..7fa02d8 --- /dev/null +++ b/internal/database/repository/repository.go @@ -0,0 +1,40 @@ +// Package repository interacts with the databank and returns models +package repository + +import ( + "context" + + "github.com/zeusWPI/scc/internal/database/sqlc" + "github.com/zeusWPI/scc/pkg/db" +) + +type Repository struct { + db db.DB +} + +type contextKey string + +const queryKey = contextKey("queries") + +func New(db db.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) queries(ctx context.Context) *sqlc.Queries { + if q, ok := ctx.Value(queryKey).(*sqlc.Queries); ok { + return q + } + + return r.db.Queries() +} + +func (r *Repository) WithRollback(ctx context.Context, fn func(ctx context.Context) error) error { + if _, ok := ctx.Value(queryKey).(*sqlc.Queries); ok { + return fn(ctx) + } + + return r.db.WithRollback(ctx, func(q *sqlc.Queries) error { + txCtx := context.WithValue(ctx, queryKey, q) + return fn(txCtx) + }) +} diff --git a/internal/database/repository/scan.go b/internal/database/repository/scan.go new file mode 100644 index 0000000..55d5531 --- /dev/null +++ b/internal/database/repository/scan.go @@ -0,0 +1,73 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/sqlc" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Scan struct { + repo Repository +} + +func (r *Repository) NewScan() *Scan { + return &Scan{ + repo: *r, + } +} + +func (s *Scan) GetLast(ctx context.Context) (*model.Scan, error) { + scan, err := s.repo.queries(ctx).ScanGetLast(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get last scan %w", err) + } + return nil, nil + } + + return model.ScanModel(scan), nil +} + +func (s *Scan) GetAllSinceID(ctx context.Context, id int) ([]*model.Scan, error) { + scans, err := s.repo.queries(ctx).ScanGetAllSinceID(ctx, int32(id)) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get scans since id %d | %w", id, err) + } + return nil, nil + } + + return utils.SliceMap(scans, model.ScanModel), nil +} + +func (s *Scan) GetInSeason(ctx context.Context, season model.Season) ([]*model.Scan, error) { + scans, err := s.repo.queries(ctx).ScanGetInSeason(ctx, int32(season.ID)) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get scans in season %+v | %w", season, err) + } + return nil, nil + } + + return utils.SliceMap(scans, model.ScanModel), nil +} + +func (s *Scan) Create(ctx context.Context, scan *model.Scan) error { + id, err := s.repo.queries(ctx).ScanCreate(ctx, sqlc.ScanCreateParams{ + ScanID: int32(scan.ScanID), + ScanTime: pgtype.Timestamptz{Time: scan.ScanTime, Valid: !scan.ScanTime.IsZero()}, + }) + if err != nil { + return fmt.Errorf("create scan %+v | %w", *scan, err) + } + + scan.ID = int(id) + + return nil +} diff --git a/internal/database/repository/season.go b/internal/database/repository/season.go new file mode 100644 index 0000000..92cc2cb --- /dev/null +++ b/internal/database/repository/season.go @@ -0,0 +1,73 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/sqlc" +) + +type Season struct { + repo Repository +} + +func (r *Repository) NewSeason() *Season { + return &Season{ + repo: *r, + } +} + +func (s *Season) GetCurrent(ctx context.Context) (*model.Season, error) { + season, err := s.repo.queries(ctx).SeasonGetCurrent(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("season get current %w", err) + } + return nil, nil + } + + return model.SeasonModel(season), nil +} + +func (s *Season) Create(ctx context.Context, season *model.Season) error { + id, err := s.repo.queries(ctx).SeasonCreate(ctx, sqlc.SeasonCreateParams{ + Name: season.Name, + Start: pgtype.Timestamp{Time: time.Time(season.Start), Valid: !time.Time(season.Start).IsZero()}, + End: pgtype.Timestamp{Time: time.Time(season.End), Valid: !time.Time(season.End).IsZero()}, + Current: season.Current, + }) + if err != nil { + return fmt.Errorf("create season %+v | %w", *season, err) + } + + season.ID = int(id) + + return nil +} + +func (s *Season) Update(ctx context.Context, season model.Season) error { + if err := s.repo.queries(ctx).SeasonUpdate(ctx, sqlc.SeasonUpdateParams{ + ID: int32(season.ID), + Name: season.Name, + Start: pgtype.Timestamp{Time: time.Time(season.Start), Valid: !time.Time(season.Start).IsZero()}, + End: pgtype.Timestamp{Time: time.Time(season.End), Valid: !time.Time(season.End).IsZero()}, + Current: season.Current, + }); err != nil { + return fmt.Errorf("update season %+v | %w", season, err) + } + + return nil +} + +func (s *Season) DeleteAll(ctx context.Context) error { + if err := s.repo.queries(ctx).SeasonDeleteAll(ctx); err != nil { + return fmt.Errorf("delete all seasons %w", err) + } + + return nil +} diff --git a/internal/database/repository/song.go b/internal/database/repository/song.go new file mode 100644 index 0000000..1d4bdda --- /dev/null +++ b/internal/database/repository/song.go @@ -0,0 +1,298 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/sqlc" +) + +type Song struct { + repo Repository +} + +func (r *Repository) NewSong() *Song { + return &Song{ + repo: *r, + } +} + +func (s *Song) GetLastPopulated(ctx context.Context) (*model.Song, error) { + last, err := s.repo.queries(ctx).SongGetLastPopulated(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get last song populated %w", err) + } + + song := model.SongModel(last[0].Song) + song.PlayedAt = last[0].SongHistory.CreatedAt.Time + + for _, s := range last { + song.Artists = append(song.Artists, *model.ArtistModel(s.SongArtist)) + } + + return song, nil +} + +func (s *Song) GetLast50(ctx context.Context) ([]*model.Song, error) { + lasts, err := s.repo.queries(ctx).SongGetLast50(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get last 50 songs %w", err) + } + + songs := make([]*model.Song, 0, len(lasts)) + for _, last := range lasts { + song := model.SongModel(last.Song) + song.PlayCount = int(last.PlayCount) + + songs = append(songs, song) + } + + return songs, nil +} + +func (s *Song) GetTopSongs(ctx context.Context) ([]*model.Song, error) { + tops, err := s.repo.queries(ctx).SongGetTop50(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top songs %w", err) + } + + songs := make([]*model.Song, 0, len(tops)) + for _, top := range tops { + song := model.SongModel(top.Song) + song.PlayCount = int(top.PlayCount) + + songs = append(songs, song) + } + + return songs, nil +} + +func (s *Song) GetTopArtists(ctx context.Context) ([]*model.Artist, error) { + tops, err := s.repo.queries(ctx).SongArtistGetTop50(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top artists %w", err) + } + + artists := make([]*model.Artist, 0, len(tops)) + for _, top := range tops { + artist := model.ArtistModel(top.SongArtist) + artist.PlayCount = int(top.PlayCount) + + artists = append(artists, artist) + } + + return artists, nil +} + +func (s *Song) GetTopGenres(ctx context.Context) ([]*model.Genre, error) { + tops, err := s.repo.queries(ctx).SongGenreGetTop50(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top genres %w", err) + } + + genres := make([]*model.Genre, 0, len(tops)) + for _, top := range tops { + genre := model.GenreModel(top.SongGenre) + genre.PlayCount = int(top.PlayCount) + + genres = append(genres, genre) + } + + return genres, nil +} + +func (s *Song) GetTopSongsMonthly(ctx context.Context) ([]*model.Song, error) { + tops, err := s.repo.queries(ctx).SongGetTop50Monthly(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top songs monthly %w", err) + } + + songs := make([]*model.Song, 0, len(tops)) + for _, top := range tops { + song := model.SongModel(top.Song) + song.PlayCount = int(top.PlayCount) + + songs = append(songs, song) + } + + return songs, nil +} + +func (s *Song) GetTopArtistsMonthly(ctx context.Context) ([]*model.Artist, error) { + tops, err := s.repo.queries(ctx).SongArtistGetTop50Monthly(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top artists montlhy %w", err) + } + + artists := make([]*model.Artist, 0, len(tops)) + for _, top := range tops { + artist := model.ArtistModel(top.SongArtist) + artist.PlayCount = int(top.PlayCount) + + artists = append(artists, artist) + } + + return artists, nil +} + +func (s *Song) GetTopGenresMonthly(ctx context.Context) ([]*model.Genre, error) { + tops, err := s.repo.queries(ctx).SongGenreGetTop50Monthly(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get top genres monthly %w", err) + } + + genres := make([]*model.Genre, 0, len(tops)) + for _, top := range tops { + genre := model.GenreModel(top.SongGenre) + genre.PlayCount = int(top.PlayCount) + + genres = append(genres, genre) + } + + return genres, nil +} + +func (s *Song) GetBySpotify(ctx context.Context, spotifyID string) (*model.Song, error) { + song, err := s.repo.queries(ctx).SongGetBySpotify(ctx, spotifyID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get song by spotify id %s | %w", spotifyID, err) + } + + return model.SongModel(song), nil +} + +func (s *Song) GetArtistBySpotify(ctx context.Context, spotifyID string) (*model.Artist, error) { + artist, err := s.repo.queries(ctx).SongArtistGetBySpotify(ctx, spotifyID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get song artist by spotify id %s | %w", spotifyID, err) + } + + return model.ArtistModel(artist), nil +} + +func (s *Song) GetGenreByGenre(ctx context.Context, genre string) (*model.Genre, error) { + genreDB, err := s.repo.queries(ctx).SongGenreGetByGenre(ctx, genre) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get song genre by genre %s | %w", genre, err) + } + + return model.GenreModel(genreDB), nil +} + +func (s *Song) Create(ctx context.Context, song *model.Song) error { + return s.repo.WithRollback(ctx, func(ctx context.Context) error { + id, err := s.repo.queries(ctx).SongCreate(ctx, sqlc.SongCreateParams{ + Title: song.Title, + Album: song.Album, + SpotifyID: song.SpotifyID, + DurationMs: int32(song.DurationMS), + LyricsType: sqlc.LyricsTypeEnum(song.LyricsType), + Lyrics: pgtype.Text{String: song.Lyrics, Valid: song.Lyrics != ""}, + }) + if err != nil { + return fmt.Errorf("create song %+v | %w", *song, err) + } + + song.ID = int(id) + + for i, artist := range song.Artists { + artistDB, err := s.GetArtistBySpotify(ctx, artist.SpotifyID) + if err != nil { + return err + } + + if artistDB != nil { + song.Artists[i] = *artistDB + } else { + id, err := s.repo.queries(ctx).SongArtistCreate(ctx, sqlc.SongArtistCreateParams{ + Name: artist.Name, + SpotifyID: artist.SpotifyID, + }) + if err != nil { + return fmt.Errorf("create song artist %+v | %+v | %w", artist, *song, err) + } + + song.Artists[i].ID = int(id) + } + + if _, err := s.repo.queries(ctx).SongArtistSongCreate(ctx, sqlc.SongArtistSongCreateParams{ + ArtistID: int32(song.Artists[i].ID), + SongID: int32(song.ID), + }); err != nil { + return fmt.Errorf("create song artist song %+v | %+v | %w", artist, *song, err) + } + + for j, genre := range artist.Genres { + genreDB, err := s.GetGenreByGenre(ctx, genre.Genre) + if err != nil { + return err + } + + if genreDB != nil { + song.Artists[i].Genres[j] = *genreDB + } else { + id, err := s.repo.queries(ctx).SongGenreCreate(ctx, genre.Genre) + if err != nil { + return fmt.Errorf("create song genre %+v | %+v | %+v | %w", genre, artist, *song, err) + } + + song.Artists[i].Genres[j].ID = int(id) + } + + if _, err := s.repo.queries(ctx).SongArtistGenreCreate(ctx, sqlc.SongArtistGenreCreateParams{ + ArtistID: int32(song.Artists[i].ID), + GenreID: int32(song.Artists[i].Genres[j].ID), + }); err != nil { + return fmt.Errorf("create song artist genre %+v | %+v | %+v | %w", genre, artist, *song, err) + } + } + } + + return nil + }) +} + +func (s *Song) CreateHistory(ctx context.Context, song model.Song) error { + if _, err := s.repo.queries(ctx).SongHistoryCreate(ctx, int32(song.ID)); err != nil { + return fmt.Errorf("create song history %+v | %w", song, err) + } + + return nil +} diff --git a/internal/database/repository/tap.go b/internal/database/repository/tap.go new file mode 100644 index 0000000..f4cc02f --- /dev/null +++ b/internal/database/repository/tap.go @@ -0,0 +1,63 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/sqlc" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Tap struct { + repo Repository +} + +func (r *Repository) NewTap() *Tap { + return &Tap{ + repo: *r, + } +} + +func (t *Tap) GetLast(ctx context.Context) (*model.Tap, error) { + tap, err := t.repo.queries(ctx).TapGetLast(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get last tap %w", err) + } + return nil, nil + } + + return model.TapModel(tap), nil +} + +func (t *Tap) GetCountByCategory(ctx context.Context) ([]*model.TapCount, error) { + counts, err := t.repo.queries(ctx).TapGetCountByCategory(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get tap count by category %w", err) + } + return nil, nil + } + + return utils.SliceMap(counts, model.TapCountModel), nil +} + +func (t *Tap) Create(ctx context.Context, tap *model.Tap) error { + id, err := t.repo.queries(ctx).TapCreate(ctx, sqlc.TapCreateParams{ + Name: tap.Name, + Category: sqlc.TapCategory(tap.Category), + OrderID: int32(tap.OrderID), + OrderCreatedAt: pgtype.Timestamptz{Time: tap.CreatedAt, Valid: !tap.CreatedAt.IsZero()}, + }) + if err != nil { + return fmt.Errorf("create tap %+v | %w", *tap, err) + } + + tap.ID = int(id) + + return nil +} diff --git a/internal/pkg/db/sqlc/db.go b/internal/database/sqlc/db.go similarity index 96% rename from internal/pkg/db/sqlc/db.go rename to internal/database/sqlc/db.go index b931bc5..2725108 100644 --- a/internal/pkg/db/sqlc/db.go +++ b/internal/database/sqlc/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 package sqlc diff --git a/internal/database/sqlc/message.sql.go b/internal/database/sqlc/message.sql.go new file mode 100644 index 0000000..a602e1f --- /dev/null +++ b/internal/database/sqlc/message.sql.go @@ -0,0 +1,62 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: message.sql + +package sqlc + +import ( + "context" +) + +const messageCreate = `-- name: MessageCreate :one +INSERT INTO message (name, ip, message) +VALUES ($1, $2, $3) +RETURNING id +` + +type MessageCreateParams struct { + Name string + Ip string + Message string +} + +func (q *Queries) MessageCreate(ctx context.Context, arg MessageCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, messageCreate, arg.Name, arg.Ip, arg.Message) + var id int32 + err := row.Scan(&id) + return id, err +} + +const messageGetSinceID = `-- name: MessageGetSinceID :many +SELECT id, name, ip, message, created_at +FROM message +WHERE id > $1 +ORDER BY created_at ASC +` + +func (q *Queries) MessageGetSinceID(ctx context.Context, id int32) ([]Message, error) { + rows, err := q.db.Query(ctx, messageGetSinceID, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Message + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Ip, + &i.Message, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/database/sqlc/models.go b/internal/database/sqlc/models.go new file mode 100644 index 0000000..e1f5dc9 --- /dev/null +++ b/internal/database/sqlc/models.go @@ -0,0 +1,171 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package sqlc + +import ( + "database/sql/driver" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" +) + +type LyricsTypeEnum string + +const ( + LyricsTypeEnumPlain LyricsTypeEnum = "plain" + LyricsTypeEnumSynced LyricsTypeEnum = "synced" + LyricsTypeEnumInstrumental LyricsTypeEnum = "instrumental" + LyricsTypeEnumMissing LyricsTypeEnum = "missing" +) + +func (e *LyricsTypeEnum) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LyricsTypeEnum(s) + case string: + *e = LyricsTypeEnum(s) + default: + return fmt.Errorf("unsupported scan type for LyricsTypeEnum: %T", src) + } + return nil +} + +type NullLyricsTypeEnum struct { + LyricsTypeEnum LyricsTypeEnum + Valid bool // Valid is true if LyricsTypeEnum is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullLyricsTypeEnum) Scan(value interface{}) error { + if value == nil { + ns.LyricsTypeEnum, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.LyricsTypeEnum.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullLyricsTypeEnum) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.LyricsTypeEnum), nil +} + +type TapCategory string + +const ( + TapCategorySoft TapCategory = "soft" + TapCategoryMate TapCategory = "mate" + TapCategoryBeer TapCategory = "beer" + TapCategoryFood TapCategory = "food" + TapCategoryUnknown TapCategory = "unknown" +) + +func (e *TapCategory) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = TapCategory(s) + case string: + *e = TapCategory(s) + default: + return fmt.Errorf("unsupported scan type for TapCategory: %T", src) + } + return nil +} + +type NullTapCategory struct { + TapCategory TapCategory + Valid bool // Valid is true if TapCategory is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullTapCategory) Scan(value interface{}) error { + if value == nil { + ns.TapCategory, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.TapCategory.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullTapCategory) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.TapCategory), nil +} + +type Message struct { + ID int32 + Name string + Ip string + Message string + CreatedAt pgtype.Timestamptz +} + +type Scan struct { + ID int32 + ScanTime pgtype.Timestamptz + ScanID int32 +} + +type Season struct { + ID int32 + Name string + Start pgtype.Timestamp + End pgtype.Timestamp + Current bool +} + +type Song struct { + ID int32 + Title string + SpotifyID string + DurationMs int32 + Album string + Lyrics pgtype.Text + LyricsType LyricsTypeEnum +} + +type SongArtist struct { + ID int32 + Name string + SpotifyID string +} + +type SongArtistGenre struct { + ID int32 + ArtistID int32 + GenreID int32 +} + +type SongArtistSong struct { + ID int32 + ArtistID int32 + SongID int32 +} + +type SongGenre struct { + ID int32 + Genre string +} + +type SongHistory struct { + ID int32 + SongID int32 + CreatedAt pgtype.Timestamptz +} + +type Tap struct { + ID int32 + OrderID int32 + OrderCreatedAt pgtype.Timestamptz + Name string + CreatedAt pgtype.Timestamptz + Category TapCategory +} diff --git a/internal/database/sqlc/scan.sql.go b/internal/database/sqlc/scan.sql.go new file mode 100644 index 0000000..71d96f9 --- /dev/null +++ b/internal/database/sqlc/scan.sql.go @@ -0,0 +1,99 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: scan.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const scanCreate = `-- name: ScanCreate :one +INSERT INTO scan (scan_id, scan_time) +VALUES ($1, $2) +RETURNING id +` + +type ScanCreateParams struct { + ScanID int32 + ScanTime pgtype.Timestamptz +} + +func (q *Queries) ScanCreate(ctx context.Context, arg ScanCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, scanCreate, arg.ScanID, arg.ScanTime) + var id int32 + err := row.Scan(&id) + return id, err +} + +const scanGetAllSinceID = `-- name: ScanGetAllSinceID :many +SELECT id, scan_time, scan_id +FROM scan +WHERE id > $1 +ORDER BY scan_id, scan_time ASC +` + +func (q *Queries) ScanGetAllSinceID(ctx context.Context, id int32) ([]Scan, error) { + rows, err := q.db.Query(ctx, scanGetAllSinceID, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Scan + for rows.Next() { + var i Scan + if err := rows.Scan(&i.ID, &i.ScanTime, &i.ScanID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const scanGetInSeason = `-- name: ScanGetInSeason :many +SELECT sc.id, sc.scan_time, sc.scan_id +FROM scan sc +LEFT JOIN season se ON se.start <= sc.scan_time AND sc.scan_time < se.end +WHERE se.id = $1 +ORDER BY sc.scan_time ASC +` + +func (q *Queries) ScanGetInSeason(ctx context.Context, id int32) ([]Scan, error) { + rows, err := q.db.Query(ctx, scanGetInSeason, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Scan + for rows.Next() { + var i Scan + if err := rows.Scan(&i.ID, &i.ScanTime, &i.ScanID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const scanGetLast = `-- name: ScanGetLast :one +SELECT id, scan_time, scan_id +FROM scan +ORDER BY id DESC +LIMIT 1 +` + +func (q *Queries) ScanGetLast(ctx context.Context) (Scan, error) { + row := q.db.QueryRow(ctx, scanGetLast) + var i Scan + err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) + return i, err +} diff --git a/internal/database/sqlc/season.sql.go b/internal/database/sqlc/season.sql.go new file mode 100644 index 0000000..721e20e --- /dev/null +++ b/internal/database/sqlc/season.sql.go @@ -0,0 +1,122 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: season.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const seasonCreate = `-- name: SeasonCreate :one +INSERT INTO season (name, start, "end", current) +VALUES ($1, $2, $3, $4) +RETURNING id +` + +type SeasonCreateParams struct { + Name string + Start pgtype.Timestamp + End pgtype.Timestamp + Current bool +} + +func (q *Queries) SeasonCreate(ctx context.Context, arg SeasonCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, seasonCreate, + arg.Name, + arg.Start, + arg.End, + arg.Current, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + +const seasonDeleteAll = `-- name: SeasonDeleteAll :exec +DELETE FROM season +` + +func (q *Queries) SeasonDeleteAll(ctx context.Context) error { + _, err := q.db.Exec(ctx, seasonDeleteAll) + return err +} + +const seasonGetAll = `-- name: SeasonGetAll :many +SELECT id, name, start, "end", current +FROM season +` + +func (q *Queries) SeasonGetAll(ctx context.Context) ([]Season, error) { + rows, err := q.db.Query(ctx, seasonGetAll) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Season + for rows.Next() { + var i Season + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Start, + &i.End, + &i.Current, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const seasonGetCurrent = `-- name: SeasonGetCurrent :one +SELECT id, name, start, "end", current +FROM season +WHERE current = true +` + +func (q *Queries) SeasonGetCurrent(ctx context.Context) (Season, error) { + row := q.db.QueryRow(ctx, seasonGetCurrent) + var i Season + err := row.Scan( + &i.ID, + &i.Name, + &i.Start, + &i.End, + &i.Current, + ) + return i, err +} + +const seasonUpdate = `-- name: SeasonUpdate :exec +UPDATE season +SET name = $1, start = $2, "end" = $3, current = $4 +WHERE id = $5 +RETURNING id, name, start, "end", current +` + +type SeasonUpdateParams struct { + Name string + Start pgtype.Timestamp + End pgtype.Timestamp + Current bool + ID int32 +} + +func (q *Queries) SeasonUpdate(ctx context.Context, arg SeasonUpdateParams) error { + _, err := q.db.Exec(ctx, seasonUpdate, + arg.Name, + arg.Start, + arg.End, + arg.Current, + arg.ID, + ) + return err +} diff --git a/internal/database/sqlc/song.sql.go b/internal/database/sqlc/song.sql.go new file mode 100644 index 0000000..15a97c8 --- /dev/null +++ b/internal/database/sqlc/song.sql.go @@ -0,0 +1,511 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: song.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const songArtistCreate = `-- name: SongArtistCreate :one +INSERT INTO song_artist (name, spotify_id) +VALUES ($1, $2) +RETURNING id +` + +type SongArtistCreateParams struct { + Name string + SpotifyID string +} + +func (q *Queries) SongArtistCreate(ctx context.Context, arg SongArtistCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, songArtistCreate, arg.Name, arg.SpotifyID) + var id int32 + err := row.Scan(&id) + return id, err +} + +const songArtistGenreCreate = `-- name: SongArtistGenreCreate :one +INSERT INTO song_artist_genre (artist_id, genre_id) +VALUES ($1, $2) +RETURNING id +` + +type SongArtistGenreCreateParams struct { + ArtistID int32 + GenreID int32 +} + +func (q *Queries) SongArtistGenreCreate(ctx context.Context, arg SongArtistGenreCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, songArtistGenreCreate, arg.ArtistID, arg.GenreID) + var id int32 + err := row.Scan(&id) + return id, err +} + +const songArtistGetBySpotify = `-- name: SongArtistGetBySpotify :one +SELECT id, name, spotify_id +FROM song_artist +WHERE spotify_id = $1 +` + +func (q *Queries) SongArtistGetBySpotify(ctx context.Context, spotifyID string) (SongArtist, error) { + row := q.db.QueryRow(ctx, songArtistGetBySpotify, spotifyID) + var i SongArtist + err := row.Scan(&i.ID, &i.Name, &i.SpotifyID) + return i, err +} + +const songArtistGetTop50 = `-- name: SongArtistGetTop50 :many +SELECT a.id, a.name, a.spotify_id, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist a ON sas.artist_id = a.id +GROUP BY a.id, a.name +ORDER BY play_count DESC +LIMIT 50 +` + +type SongArtistGetTop50Row struct { + SongArtist SongArtist + PlayCount int64 +} + +func (q *Queries) SongArtistGetTop50(ctx context.Context) ([]SongArtistGetTop50Row, error) { + rows, err := q.db.Query(ctx, songArtistGetTop50) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongArtistGetTop50Row + for rows.Next() { + var i SongArtistGetTop50Row + if err := rows.Scan( + &i.SongArtist.ID, + &i.SongArtist.Name, + &i.SongArtist.SpotifyID, + &i.PlayCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songArtistGetTop50Monthly = `-- name: SongArtistGetTop50Monthly :many +SELECT a.id, a.name, a.spotify_id, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist a ON sas.artist_id = a.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY a.id, a.name +ORDER BY play_count DESC +LIMIT 50 +` + +type SongArtistGetTop50MonthlyRow struct { + SongArtist SongArtist + PlayCount int64 +} + +func (q *Queries) SongArtistGetTop50Monthly(ctx context.Context) ([]SongArtistGetTop50MonthlyRow, error) { + rows, err := q.db.Query(ctx, songArtistGetTop50Monthly) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongArtistGetTop50MonthlyRow + for rows.Next() { + var i SongArtistGetTop50MonthlyRow + if err := rows.Scan( + &i.SongArtist.ID, + &i.SongArtist.Name, + &i.SongArtist.SpotifyID, + &i.PlayCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songArtistSongCreate = `-- name: SongArtistSongCreate :one +INSERT INTO song_artist_song (artist_id, song_id) +VALUES ($1, $2) +RETURNING id +` + +type SongArtistSongCreateParams struct { + ArtistID int32 + SongID int32 +} + +func (q *Queries) SongArtistSongCreate(ctx context.Context, arg SongArtistSongCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, songArtistSongCreate, arg.ArtistID, arg.SongID) + var id int32 + err := row.Scan(&id) + return id, err +} + +const songCreate = `-- name: SongCreate :one +INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id +` + +type SongCreateParams struct { + Title string + Album string + SpotifyID string + DurationMs int32 + LyricsType LyricsTypeEnum + Lyrics pgtype.Text +} + +func (q *Queries) SongCreate(ctx context.Context, arg SongCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, songCreate, + arg.Title, + arg.Album, + arg.SpotifyID, + arg.DurationMs, + arg.LyricsType, + arg.Lyrics, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + +const songGenreCreate = `-- name: SongGenreCreate :one +INSERT INTO song_genre (genre) +VALUES ($1) +RETURNING id +` + +func (q *Queries) SongGenreCreate(ctx context.Context, genre string) (int32, error) { + row := q.db.QueryRow(ctx, songGenreCreate, genre) + var id int32 + err := row.Scan(&id) + return id, err +} + +const songGenreGetByGenre = `-- name: SongGenreGetByGenre :one +SELECT id, genre +FROM song_genre +WHERE genre = $1 +` + +func (q *Queries) SongGenreGetByGenre(ctx context.Context, genre string) (SongGenre, error) { + row := q.db.QueryRow(ctx, songGenreGetByGenre, genre) + var i SongGenre + err := row.Scan(&i.ID, &i.Genre) + return i, err +} + +const songGenreGetTop50 = `-- name: SongGenreGetTop50 :many +SELECT g.id, g.genre, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist sa ON sas.artist_id = sa.id +JOIN song_artist_genre sag ON sa.id = sag.artist_id +JOIN song_genre g ON sag.genre_id = g.id +GROUP BY g.genre, g.id +ORDER BY play_count DESC +LIMIT 50 +` + +type SongGenreGetTop50Row struct { + SongGenre SongGenre + PlayCount int64 +} + +func (q *Queries) SongGenreGetTop50(ctx context.Context) ([]SongGenreGetTop50Row, error) { + rows, err := q.db.Query(ctx, songGenreGetTop50) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGenreGetTop50Row + for rows.Next() { + var i SongGenreGetTop50Row + if err := rows.Scan(&i.SongGenre.ID, &i.SongGenre.Genre, &i.PlayCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songGenreGetTop50Monthly = `-- name: SongGenreGetTop50Monthly :many +SELECT g.id, g.genre, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +JOIN song_artist_song sas ON s.id = sas.song_id +JOIN song_artist sa ON sas.artist_id = sa.id +JOIN song_artist_genre sag ON sa.id = sag.artist_id +JOIN song_genre g ON sag.genre_id = g.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY g.genre, g.id +ORDER BY play_count DESC +LIMIT 50 +` + +type SongGenreGetTop50MonthlyRow struct { + SongGenre SongGenre + PlayCount int64 +} + +func (q *Queries) SongGenreGetTop50Monthly(ctx context.Context) ([]SongGenreGetTop50MonthlyRow, error) { + rows, err := q.db.Query(ctx, songGenreGetTop50Monthly) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGenreGetTop50MonthlyRow + for rows.Next() { + var i SongGenreGetTop50MonthlyRow + if err := rows.Scan(&i.SongGenre.ID, &i.SongGenre.Genre, &i.PlayCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songGetBySpotify = `-- name: SongGetBySpotify :one +SELECT id, title, spotify_id, duration_ms, album, lyrics, lyrics_type +FROM song +WHERE spotify_id = $1 +` + +func (q *Queries) SongGetBySpotify(ctx context.Context, spotifyID string) (Song, error) { + row := q.db.QueryRow(ctx, songGetBySpotify, spotifyID) + var i Song + err := row.Scan( + &i.ID, + &i.Title, + &i.SpotifyID, + &i.DurationMs, + &i.Album, + &i.Lyrics, + &i.LyricsType, + ) + return i, err +} + +const songGetLast50 = `-- name: SongGetLast50 :many +SELECT s.id, s.title, s.spotify_id, s.duration_ms, s.album, s.lyrics, s.lyrics_type, play_count +FROM ( + SELECT sh.song_id, MAX(sh.created_at) AS created_at, COUNT(sh.song_id) AS play_count + FROM song_history sh + GROUP BY sh.song_id +) aggregated +JOIN song s ON s.id = aggregated.song_id +ORDER BY aggregated.created_at DESC +LIMIT 50 +` + +type SongGetLast50Row struct { + Song Song + PlayCount int64 +} + +func (q *Queries) SongGetLast50(ctx context.Context) ([]SongGetLast50Row, error) { + rows, err := q.db.Query(ctx, songGetLast50) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGetLast50Row + for rows.Next() { + var i SongGetLast50Row + if err := rows.Scan( + &i.Song.ID, + &i.Song.Title, + &i.Song.SpotifyID, + &i.Song.DurationMs, + &i.Song.Album, + &i.Song.Lyrics, + &i.Song.LyricsType, + &i.PlayCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songGetLastPopulated = `-- name: SongGetLastPopulated :many +SELECT h.id, h.song_id, h.created_at, s.id, s.title, s.spotify_id, s.duration_ms, s.album, s.lyrics, s.lyrics_type, a.id, a.name, a.spotify_id +FROM song_history h +JOIN song s ON s.id = h.song_id +LEFT JOIN song_artist_song sa ON sa.song_id = s.id +LEFT JOIN song_artist a ON a.id = sa.artist_id +WHERE h.created_at = (SELECT MAX(created_at) FROM song_history) +ORDER BY a.name +` + +type SongGetLastPopulatedRow struct { + SongHistory SongHistory + Song Song + SongArtist SongArtist +} + +func (q *Queries) SongGetLastPopulated(ctx context.Context) ([]SongGetLastPopulatedRow, error) { + rows, err := q.db.Query(ctx, songGetLastPopulated) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGetLastPopulatedRow + for rows.Next() { + var i SongGetLastPopulatedRow + if err := rows.Scan( + &i.SongHistory.ID, + &i.SongHistory.SongID, + &i.SongHistory.CreatedAt, + &i.Song.ID, + &i.Song.Title, + &i.Song.SpotifyID, + &i.Song.DurationMs, + &i.Song.Album, + &i.Song.Lyrics, + &i.Song.LyricsType, + &i.SongArtist.ID, + &i.SongArtist.Name, + &i.SongArtist.SpotifyID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songGetTop50 = `-- name: SongGetTop50 :many +SELECT s.id, s.title, s.spotify_id, s.duration_ms, s.album, s.lyrics, s.lyrics_type, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +GROUP BY s.id, s.title +ORDER BY play_count DESC +LIMIT 50 +` + +type SongGetTop50Row struct { + Song Song + PlayCount int64 +} + +func (q *Queries) SongGetTop50(ctx context.Context) ([]SongGetTop50Row, error) { + rows, err := q.db.Query(ctx, songGetTop50) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGetTop50Row + for rows.Next() { + var i SongGetTop50Row + if err := rows.Scan( + &i.Song.ID, + &i.Song.Title, + &i.Song.SpotifyID, + &i.Song.DurationMs, + &i.Song.Album, + &i.Song.Lyrics, + &i.Song.LyricsType, + &i.PlayCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songGetTop50Monthly = `-- name: SongGetTop50Monthly :many +SELECT s.id, s.title, s.spotify_id, s.duration_ms, s.album, s.lyrics, s.lyrics_type, COUNT(sh.id) AS play_count +FROM song_history sh +JOIN song s ON sh.song_id = s.id +WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' +GROUP BY s.id, s.title +ORDER BY play_count DESC +LIMIT 50 +` + +type SongGetTop50MonthlyRow struct { + Song Song + PlayCount int64 +} + +func (q *Queries) SongGetTop50Monthly(ctx context.Context) ([]SongGetTop50MonthlyRow, error) { + rows, err := q.db.Query(ctx, songGetTop50Monthly) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SongGetTop50MonthlyRow + for rows.Next() { + var i SongGetTop50MonthlyRow + if err := rows.Scan( + &i.Song.ID, + &i.Song.Title, + &i.Song.SpotifyID, + &i.Song.DurationMs, + &i.Song.Album, + &i.Song.Lyrics, + &i.Song.LyricsType, + &i.PlayCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const songHistoryCreate = `-- name: SongHistoryCreate :one +INSERT INTO song_history (song_id) +VALUES ($1) +RETURNING id +` + +func (q *Queries) SongHistoryCreate(ctx context.Context, songID int32) (int32, error) { + row := q.db.QueryRow(ctx, songHistoryCreate, songID) + var id int32 + err := row.Scan(&id) + return id, err +} diff --git a/internal/database/sqlc/tap.sql.go b/internal/database/sqlc/tap.sql.go new file mode 100644 index 0000000..365d818 --- /dev/null +++ b/internal/database/sqlc/tap.sql.go @@ -0,0 +1,90 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: tap.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const tapCreate = `-- name: TapCreate :one +INSERT INTO tap (order_id, order_created_at, name, category) +VALUES ($1, $2, $3, $4) +RETURNING id +` + +type TapCreateParams struct { + OrderID int32 + OrderCreatedAt pgtype.Timestamptz + Name string + Category TapCategory +} + +func (q *Queries) TapCreate(ctx context.Context, arg TapCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, tapCreate, + arg.OrderID, + arg.OrderCreatedAt, + arg.Name, + arg.Category, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + +const tapGetCountByCategory = `-- name: TapGetCountByCategory :many +SELECT category, COUNT(*), MAX(order_created_at)::TIMESTAMP AS latest_order_created_at +FROM tap +GROUP BY category +` + +type TapGetCountByCategoryRow struct { + Category TapCategory + Count int64 + LatestOrderCreatedAt pgtype.Timestamp +} + +func (q *Queries) TapGetCountByCategory(ctx context.Context) ([]TapGetCountByCategoryRow, error) { + rows, err := q.db.Query(ctx, tapGetCountByCategory) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TapGetCountByCategoryRow + for rows.Next() { + var i TapGetCountByCategoryRow + if err := rows.Scan(&i.Category, &i.Count, &i.LatestOrderCreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const tapGetLast = `-- name: TapGetLast :one +SELECT id, order_id, order_created_at, name, created_at, category +FROM tap +ORDER BY order_id DESC +LIMIT 1 +` + +func (q *Queries) TapGetLast(ctx context.Context) (Tap, error) { + row := q.db.QueryRow(ctx, tapGetLast) + var i Tap + err := row.Scan( + &i.ID, + &i.OrderID, + &i.OrderCreatedAt, + &i.Name, + &i.CreatedAt, + &i.Category, + ) + return i, err +} diff --git a/internal/pkg/db/dto/dto.go b/internal/pkg/db/dto/dto.go deleted file mode 100644 index 08133bd..0000000 --- a/internal/pkg/db/dto/dto.go +++ /dev/null @@ -1,9 +0,0 @@ -// Package dto provides the data transfer objects for the database -package dto - -import ( - "github.com/go-playground/validator/v10" -) - -// Validate is a validator instance for JSON transferable objects -var Validate = validator.New(validator.WithRequiredStructEnabled()) diff --git a/internal/pkg/db/dto/event.go b/internal/pkg/db/dto/event.go deleted file mode 100644 index dabf0c5..0000000 --- a/internal/pkg/db/dto/event.go +++ /dev/null @@ -1,47 +0,0 @@ -package dto - -import ( - "bytes" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Event represents the DTO object for event -type Event struct { - ID int32 - Name string - Date time.Time - AcademicYear string - Location string - Poster []byte -} - -// EventDTO converts a sqlc Event object to a DTO Event -func EventDTO(e sqlc.Event) *Event { - return &Event{ - ID: e.ID, - Name: e.Name, - Date: e.Date.Time, - AcademicYear: e.AcademicYear, - Location: e.Location, - Poster: e.Poster, - } -} - -// Equal compares 2 events -func (e *Event) Equal(e2 Event) bool { - return e.Name == e2.Name && e.Date.Equal(e2.Date) && e.AcademicYear == e2.AcademicYear && e.Location == e2.Location && bytes.Equal(e.Poster, e2.Poster) -} - -// CreateParams converts a Event DTO to a sqlc CreateEventParams object -func (e *Event) CreateParams() sqlc.CreateEventParams { - return sqlc.CreateEventParams{ - Name: e.Name, - Date: pgtype.Timestamptz{Time: e.Date, Valid: true}, - AcademicYear: e.AcademicYear, - Location: e.Location, - Poster: e.Poster, - } -} diff --git a/internal/pkg/db/dto/gamification.go b/internal/pkg/db/dto/gamification.go deleted file mode 100644 index bb2181e..0000000 --- a/internal/pkg/db/dto/gamification.go +++ /dev/null @@ -1,47 +0,0 @@ -package dto - -import ( - "bytes" - - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Gamification represents the DTO object for gamification -type Gamification struct { - ID int32 `json:"id"` - Name string `json:"github_name"` - Score int32 `json:"score"` - Avatar []byte `json:"avatar"` -} - -// GamificationDTO converts a sqlc Gamification object to a DTO gamification -func GamificationDTO(gam sqlc.Gamification) *Gamification { - return &Gamification{ - ID: gam.ID, - Name: gam.Name, - Score: gam.Score, - Avatar: gam.Avatar, - } -} - -// Equal compares 2 Gamification objects for equality -func (g *Gamification) Equal(g2 Gamification) bool { - return g.Name == g2.Name && g.Score == g2.Score && bytes.Equal(g.Avatar, g2.Avatar) -} - -// CreateParams converts a Gamification DTO to a sqlc CreateGamificationParams object -func (g *Gamification) CreateParams() sqlc.CreateGamificationParams { - return sqlc.CreateGamificationParams{ - Name: g.Name, - Score: g.Score, - Avatar: g.Avatar, - } -} - -// UpdateScoreParams converts a Gamification DTO to a sqlc UpdateScoreParams object -func (g *Gamification) UpdateScoreParams() sqlc.UpdateGamificationScoreParams { - return sqlc.UpdateGamificationScoreParams{ - ID: g.ID, - Score: g.Score, - } -} diff --git a/internal/pkg/db/dto/message.go b/internal/pkg/db/dto/message.go deleted file mode 100644 index 75672a8..0000000 --- a/internal/pkg/db/dto/message.go +++ /dev/null @@ -1,36 +0,0 @@ -package dto - -import ( - "time" - - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Message is the DTO for the message -type Message struct { - ID int32 `json:"id"` - Name string `json:"name" validate:"required"` - IP string `json:"ip" validate:"required"` - Message string `json:"message" validate:"required"` - CreatedAt time.Time `json:"created_at"` -} - -// MessageDTO converts a sqlc.Message to a Message -func MessageDTO(message sqlc.Message) *Message { - return &Message{ - ID: message.ID, - Name: message.Name, - IP: message.Ip, - Message: message.Message, - CreatedAt: message.CreatedAt.Time, - } -} - -// CreateParams converts a Message to sqlc.CreateMessageParams -func (m *Message) CreateParams() sqlc.CreateMessageParams { - return sqlc.CreateMessageParams{ - Name: m.Name, - Ip: m.IP, - Message: m.Message, - } -} diff --git a/internal/pkg/db/dto/scan.go b/internal/pkg/db/dto/scan.go deleted file mode 100644 index 5622e24..0000000 --- a/internal/pkg/db/dto/scan.go +++ /dev/null @@ -1,41 +0,0 @@ -package dto - -import ( - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Scan is the DTO for the scan -type Scan struct { - ID int32 `json:"id"` - ScanID int32 `json:"scan_id"` - ScanTime time.Time `json:"scan_time" validate:"required"` -} - -// ScanDTO converts a sqlc.Scan to a Scan -func ScanDTO(scan sqlc.Scan) *Scan { - return &Scan{ - ID: scan.ID, - ScanID: scan.ScanID, - ScanTime: scan.ScanTime.Time, - } -} - -// CreateParams converts a Scan to sqlc.CreateScanParams -func (s *Scan) CreateParams() sqlc.CreateScanParams { - return sqlc.CreateScanParams{ - ScanID: s.ScanID, - ScanTime: pgtype.Timestamptz{Time: s.ScanTime, Valid: true}, - } -} - -// UpdateParams converts a Scan to sqlc.UpdateScanParams -func (s *Scan) UpdateParams() sqlc.UpdateScanParams { - return sqlc.UpdateScanParams{ - ID: s.ID, - ScanID: s.ScanID, - ScanTime: pgtype.Timestamptz{Time: s.ScanTime, Valid: true}, - } -} diff --git a/internal/pkg/db/dto/season.go b/internal/pkg/db/dto/season.go deleted file mode 100644 index ca75630..0000000 --- a/internal/pkg/db/dto/season.go +++ /dev/null @@ -1,58 +0,0 @@ -package dto - -import ( - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/date" -) - -// Season is the DTO for the season -type Season struct { - ID int32 `json:"id"` - Name string `json:"name" validate:"required"` - Start date.Date `json:"start" validate:"required"` - End date.Date `json:"end" validate:"required"` - Current bool `json:"is_current" validate:"boolean"` -} - -// SeasonDTO converts a sqlc.Season to a Season -func SeasonDTO(season sqlc.Season) *Season { - return &Season{ - ID: season.ID, - Name: season.Name, - Start: date.Date(season.Start.Time), - End: date.Date(season.End.Time), - Current: season.Current, - } -} - -// SeasonCmp compares two seasons -// Returns an int so it can be used in compare functions -func SeasonCmp(s1, s2 *Season) int { - if s1.ID == s2.ID && s1.Name == s2.Name && s1.Start == s2.Start && s1.End == s2.End && s1.Current == s2.Current { - return 0 - } - - return 1 -} - -// CreateParams converts a Season to sqlc.CreateSeasonParams -func (s *Season) CreateParams() sqlc.CreateSeasonParams { - return sqlc.CreateSeasonParams{ - Name: s.Name, - Start: pgtype.Timestamp{Time: s.Start.ToTime(), Valid: true}, - End: pgtype.Timestamp{Time: s.End.ToTime(), Valid: true}, - Current: s.Current, - } -} - -// UpdateParams converts a Season to sqlc.UpdateSeasonParams -func (s *Season) UpdateParams() sqlc.UpdateSeasonParams { - return sqlc.UpdateSeasonParams{ - ID: s.ID, - Name: s.Name, - End: pgtype.Timestamp{Time: s.End.ToTime(), Valid: true}, - Start: pgtype.Timestamp{Time: s.Start.ToTime(), Valid: true}, - Current: s.Current, - } -} diff --git a/internal/pkg/db/dto/song.go b/internal/pkg/db/dto/song.go deleted file mode 100644 index 401b3dc..0000000 --- a/internal/pkg/db/dto/song.go +++ /dev/null @@ -1,163 +0,0 @@ -package dto - -import ( - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Song is the DTO for a song -type Song struct { - ID int32 `json:"id"` - Title string `json:"title"` - Album string `json:"album"` - SpotifyID string `json:"spotify_id" validate:"required"` - DurationMS int32 `json:"duration_ms"` - LyricsType string `json:"lyrics_type"` // Either 'synced' ,'plain' or 'instrumental' - Lyrics string `json:"lyrics"` - CreatedAt time.Time `json:"created_at"` - Artists []SongArtist `json:"artists"` -} - -// SongArtist is the DTO for a song artist -type SongArtist struct { - ID int32 `json:"id"` - Name string `json:"name"` - SpotifyID string `json:"spotify_id"` - Followers int32 `json:"followers"` - Popularity int32 `json:"popularity"` - Genres []SongGenre `json:"genres"` -} - -// SongGenre is the DTO for a song genre -type SongGenre struct { - ID int32 `json:"id"` - Genre string `json:"genre"` -} - -// SongDTO converts a sqlc.Song to a Song -func SongDTO(song sqlc.Song) *Song { - var lyricsType string - if song.LyricsType.Valid { - lyricsType = song.Lyrics.String - } - var lyrics string - if song.Lyrics.Valid { - lyrics = song.Lyrics.String - } - - return &Song{ - ID: song.ID, - Title: song.Title, - Album: song.Album, - SpotifyID: song.SpotifyID, - DurationMS: song.DurationMs, - LyricsType: lyricsType, - Lyrics: lyrics, - } -} - -// SongDTOHistory converts a sqlc.GetLastSongFullRow array to a Song -func SongDTOHistory(songs []sqlc.GetLastSongFullRow) *Song { - if len(songs) == 0 { - return nil - } - - var lyricsType string - if songs[0].LyricsType.Valid { - lyricsType = songs[0].LyricsType.String - } - var lyrics string - if songs[0].Lyrics.Valid { - lyrics = songs[0].Lyrics.String - } - - artistsMap := make(map[int32]SongArtist) - for _, song := range songs { - if !song.ArtistID.Valid { - continue - } - - // Get artist - artist, ok := artistsMap[song.ArtistID.Int32] - if !ok { - // Artist doesn't exist yet, add him - artist = SongArtist{ - ID: song.ArtistID.Int32, - Name: song.ArtistName.String, - SpotifyID: song.ArtistSpotifyID.String, - Followers: song.ArtistFollowers.Int32, - Popularity: song.ArtistPopularity.Int32, - Genres: make([]SongGenre, 0), - } - artistsMap[song.ArtistID.Int32] = artist - } - - // Add genre - artist.Genres = append(artist.Genres, SongGenre{ - ID: song.GenreID.Int32, - Genre: song.Genre.String, - }) - } - - artists := make([]SongArtist, 0, len(artistsMap)) - for _, artist := range artistsMap { - artists = append(artists, artist) - } - - return &Song{ - ID: songs[0].ID, - Title: songs[0].SongTitle, - Album: songs[0].Album, - SpotifyID: songs[0].SpotifyID, - DurationMS: songs[0].DurationMs, - LyricsType: lyricsType, - Lyrics: lyrics, - CreatedAt: songs[0].CreatedAt.Time, - Artists: artists, - } -} - -// CreateSongParams converts a Song DTO to a sqlc CreateSongParams object -func (s *Song) CreateSongParams() *sqlc.CreateSongParams { - return &sqlc.CreateSongParams{ - Title: s.Title, - Album: s.Album, - SpotifyID: s.SpotifyID, - DurationMs: s.DurationMS, - LyricsType: pgtype.Text{String: s.LyricsType, Valid: true}, - Lyrics: pgtype.Text{String: s.Lyrics, Valid: true}, - } -} - -// CreateSongGenreParams converts a Song DTO to a string to create a new genre -func (s *Song) CreateSongGenreParams(idxArtist, idxGenre int) string { - return s.Artists[idxArtist].Genres[idxGenre].Genre -} - -// CreateSongArtistParams converts a Song DTO to a sqlc CreateSongArtistParams object -func (s *Song) CreateSongArtistParams(idxArtist int) *sqlc.CreateSongArtistParams { - return &sqlc.CreateSongArtistParams{ - Name: s.Artists[idxArtist].Name, - SpotifyID: s.Artists[idxArtist].SpotifyID, - Followers: s.Artists[idxArtist].Followers, - Popularity: s.Artists[idxArtist].Popularity, - } -} - -// CreateSongArtistSongParams converts a Song DTO to a sqlc CreateSongArtistSongParams object -func (s *Song) CreateSongArtistSongParams(idxArtist int) *sqlc.CreateSongArtistSongParams { - return &sqlc.CreateSongArtistSongParams{ - ArtistID: s.Artists[idxArtist].ID, - SongID: s.ID, - } -} - -// CreateSongArtistGenreParamas converts a Song DTO to a sqlc CreateSongArtistGenreParams object -func (s *Song) CreateSongArtistGenreParamas(idxArtist, idxGenre int) *sqlc.CreateSongArtistGenreParams { - return &sqlc.CreateSongArtistGenreParams{ - ArtistID: s.Artists[idxArtist].ID, - GenreID: s.Artists[idxArtist].Genres[idxGenre].ID, - } -} diff --git a/internal/pkg/db/sqlc/event.sql.go b/internal/pkg/db/sqlc/event.sql.go deleted file mode 100644 index 02a56b8..0000000 --- a/internal/pkg/db/sqlc/event.sql.go +++ /dev/null @@ -1,174 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: event.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createEvent = `-- name: CreateEvent :one -INSERT INTO event (name, date, academic_year, location, poster) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, name, date, academic_year, location, poster -` - -type CreateEventParams struct { - Name string - Date pgtype.Timestamptz - AcademicYear string - Location string - Poster []byte -} - -func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { - row := q.db.QueryRow(ctx, createEvent, - arg.Name, - arg.Date, - arg.AcademicYear, - arg.Location, - arg.Poster, - ) - var i Event - err := row.Scan( - &i.ID, - &i.Name, - &i.Date, - &i.AcademicYear, - &i.Location, - &i.Poster, - ) - return i, err -} - -const deleteEvent = `-- name: DeleteEvent :exec -DELETE FROM event -WHERE id = $1 -` - -func (q *Queries) DeleteEvent(ctx context.Context, id int32) error { - _, err := q.db.Exec(ctx, deleteEvent, id) - return err -} - -const deleteEventByAcademicYear = `-- name: DeleteEventByAcademicYear :exec -DELETE FROM event -WHERE academic_year = $1 -` - -func (q *Queries) DeleteEventByAcademicYear(ctx context.Context, academicYear string) error { - _, err := q.db.Exec(ctx, deleteEventByAcademicYear, academicYear) - return err -} - -const getAllEvents = `-- name: GetAllEvents :many - - -SELECT id, name, date, academic_year, location, poster -FROM event -` - -// CRUD -func (q *Queries) GetAllEvents(ctx context.Context) ([]Event, error) { - rows, err := q.db.Query(ctx, getAllEvents) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Event - for rows.Next() { - var i Event - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Date, - &i.AcademicYear, - &i.Location, - &i.Poster, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getEventByAcademicYear = `-- name: GetEventByAcademicYear :many - - -SELECT id, name, date, academic_year, location, poster -FROM event -WHERE academic_year = $1 -` - -// Other -func (q *Queries) GetEventByAcademicYear(ctx context.Context, academicYear string) ([]Event, error) { - rows, err := q.db.Query(ctx, getEventByAcademicYear, academicYear) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Event - for rows.Next() { - var i Event - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Date, - &i.AcademicYear, - &i.Location, - &i.Poster, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getEventsCurrentAcademicYear = `-- name: GetEventsCurrentAcademicYear :many -SELECT id, name, date, academic_year, location, poster -FROM event -WHERE academic_year = ( - SELECT MAX(academic_year) - FROM event -) -ORDER BY date ASC -` - -func (q *Queries) GetEventsCurrentAcademicYear(ctx context.Context) ([]Event, error) { - rows, err := q.db.Query(ctx, getEventsCurrentAcademicYear) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Event - for rows.Next() { - var i Event - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Date, - &i.AcademicYear, - &i.Location, - &i.Poster, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/pkg/db/sqlc/gamification.sql.go b/internal/pkg/db/sqlc/gamification.sql.go deleted file mode 100644 index 50d75df..0000000 --- a/internal/pkg/db/sqlc/gamification.sql.go +++ /dev/null @@ -1,149 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: gamification.sql - -package sqlc - -import ( - "context" -) - -const createGamification = `-- name: CreateGamification :one -INSERT INTO gamification (name, score, avatar) -VALUES ($1, $2, $3) -RETURNING id, name, score, avatar -` - -type CreateGamificationParams struct { - Name string - Score int32 - Avatar []byte -} - -func (q *Queries) CreateGamification(ctx context.Context, arg CreateGamificationParams) (Gamification, error) { - row := q.db.QueryRow(ctx, createGamification, arg.Name, arg.Score, arg.Avatar) - var i Gamification - err := row.Scan( - &i.ID, - &i.Name, - &i.Score, - &i.Avatar, - ) - return i, err -} - -const deleteGamification = `-- name: DeleteGamification :execrows -DELETE FROM gamification -WHERE id = $1 -` - -func (q *Queries) DeleteGamification(ctx context.Context, id int32) (int64, error) { - result, err := q.db.Exec(ctx, deleteGamification, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const deleteGamificationAll = `-- name: DeleteGamificationAll :execrows -DELETE FROM gamification -` - -func (q *Queries) DeleteGamificationAll(ctx context.Context) (int64, error) { - result, err := q.db.Exec(ctx, deleteGamificationAll) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllGamification = `-- name: GetAllGamification :many - -SELECT id, name, score, avatar -FROM gamification -` - -// CRUD -func (q *Queries) GetAllGamification(ctx context.Context) ([]Gamification, error) { - rows, err := q.db.Query(ctx, getAllGamification) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Gamification - for rows.Next() { - var i Gamification - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Score, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAllGamificationByScore = `-- name: GetAllGamificationByScore :many -SELECT id, name, score, avatar -FROM gamification -ORDER BY score DESC -` - -func (q *Queries) GetAllGamificationByScore(ctx context.Context) ([]Gamification, error) { - rows, err := q.db.Query(ctx, getAllGamificationByScore) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Gamification - for rows.Next() { - var i Gamification - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Score, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateGamificationScore = `-- name: UpdateGamificationScore :one - - -UPDATE gamification -SET score = $1 -WHERE id = $2 -RETURNING id, name, score, avatar -` - -type UpdateGamificationScoreParams struct { - Score int32 - ID int32 -} - -// Other -func (q *Queries) UpdateGamificationScore(ctx context.Context, arg UpdateGamificationScoreParams) (Gamification, error) { - row := q.db.QueryRow(ctx, updateGamificationScore, arg.Score, arg.ID) - var i Gamification - err := row.Scan( - &i.ID, - &i.Name, - &i.Score, - &i.Avatar, - ) - return i, err -} diff --git a/internal/pkg/db/sqlc/message.sql.go b/internal/pkg/db/sqlc/message.sql.go deleted file mode 100644 index 194b970..0000000 --- a/internal/pkg/db/sqlc/message.sql.go +++ /dev/null @@ -1,188 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: message.sql - -package sqlc - -import ( - "context" -) - -const createMessage = `-- name: CreateMessage :one -INSERT INTO message (name, ip, message) -VALUES ($1, $2, $3) -RETURNING id, name, ip, message, created_at -` - -type CreateMessageParams struct { - Name string - Ip string - Message string -} - -func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) { - row := q.db.QueryRow(ctx, createMessage, arg.Name, arg.Ip, arg.Message) - var i Message - err := row.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ) - return i, err -} - -const deleteMessage = `-- name: DeleteMessage :execrows -DELETE FROM message -WHERE id = $1 -` - -func (q *Queries) DeleteMessage(ctx context.Context, id int32) (int64, error) { - result, err := q.db.Exec(ctx, deleteMessage, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllMessages = `-- name: GetAllMessages :many - -SELECT id, name, ip, message, created_at -FROM message -` - -// CRUD -func (q *Queries) GetAllMessages(ctx context.Context) ([]Message, error) { - rows, err := q.db.Query(ctx, getAllMessages) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Message - for rows.Next() { - var i Message - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLastMessage = `-- name: GetLastMessage :one - - -SELECT id, name, ip, message, created_at -FROM message -ORDER BY id DESC -LIMIT 1 -` - -// Other -func (q *Queries) GetLastMessage(ctx context.Context) (Message, error) { - row := q.db.QueryRow(ctx, getLastMessage) - var i Message - err := row.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ) - return i, err -} - -const getMessageByID = `-- name: GetMessageByID :one -SELECT id, name, ip, message, created_at -FROM message -WHERE id = $1 -` - -func (q *Queries) GetMessageByID(ctx context.Context, id int32) (Message, error) { - row := q.db.QueryRow(ctx, getMessageByID, id) - var i Message - err := row.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ) - return i, err -} - -const getMessageSinceID = `-- name: GetMessageSinceID :many -SELECT id, name, ip, message, created_at -FROM message -WHERE id > $1 -ORDER BY created_at ASC -` - -func (q *Queries) GetMessageSinceID(ctx context.Context, id int32) ([]Message, error) { - rows, err := q.db.Query(ctx, getMessageSinceID, id) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Message - for rows.Next() { - var i Message - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateMessage = `-- name: UpdateMessage :one -UPDATE message -SET name = $1, ip = $2, message = $3 -WHERE id = $4 -RETURNING id, name, ip, message, created_at -` - -type UpdateMessageParams struct { - Name string - Ip string - Message string - ID int32 -} - -func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) (Message, error) { - row := q.db.QueryRow(ctx, updateMessage, - arg.Name, - arg.Ip, - arg.Message, - arg.ID, - ) - var i Message - err := row.Scan( - &i.ID, - &i.Name, - &i.Ip, - &i.Message, - &i.CreatedAt, - ) - return i, err -} diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go deleted file mode 100644 index f50e025..0000000 --- a/internal/pkg/db/sqlc/models.go +++ /dev/null @@ -1,97 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 - -package sqlc - -import ( - "github.com/jackc/pgx/v5/pgtype" -) - -type Event struct { - ID int32 - Name string - Date pgtype.Timestamptz - AcademicYear string - Location string - Poster []byte -} - -type Gamification struct { - ID int32 - Name string - Score int32 - Avatar []byte -} - -type Message struct { - ID int32 - Name string - Ip string - Message string - CreatedAt pgtype.Timestamptz -} - -type Scan struct { - ID int32 - ScanTime pgtype.Timestamptz - ScanID int32 -} - -type Season struct { - ID int32 - Name string - Start pgtype.Timestamp - End pgtype.Timestamp - Current bool -} - -type Song struct { - ID int32 - Title string - SpotifyID string - DurationMs int32 - Album string - LyricsType pgtype.Text - Lyrics pgtype.Text -} - -type SongArtist struct { - ID int32 - Name string - SpotifyID string - Followers int32 - Popularity int32 -} - -type SongArtistGenre struct { - ID int32 - ArtistID int32 - GenreID int32 -} - -type SongArtistSong struct { - ID int32 - ArtistID int32 - SongID int32 -} - -type SongGenre struct { - ID int32 - Genre string -} - -type SongHistory struct { - ID int32 - SongID int32 - CreatedAt pgtype.Timestamptz -} - -type Tap struct { - ID int32 - OrderID int32 - OrderCreatedAt pgtype.Timestamptz - Name string - Category string - CreatedAt pgtype.Timestamptz -} diff --git a/internal/pkg/db/sqlc/scan.sql.go b/internal/pkg/db/sqlc/scan.sql.go deleted file mode 100644 index 38a6499..0000000 --- a/internal/pkg/db/sqlc/scan.sql.go +++ /dev/null @@ -1,161 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: scan.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createScan = `-- name: CreateScan :one -INSERT INTO scan (scan_id, scan_time) -VALUES ($1, $2) -RETURNING id, scan_time, scan_id -` - -type CreateScanParams struct { - ScanID int32 - ScanTime pgtype.Timestamptz -} - -func (q *Queries) CreateScan(ctx context.Context, arg CreateScanParams) (Scan, error) { - row := q.db.QueryRow(ctx, createScan, arg.ScanID, arg.ScanTime) - var i Scan - err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) - return i, err -} - -const deleteScan = `-- name: DeleteScan :execrows -DELETE FROM scan -WHERE id = $1 -` - -func (q *Queries) DeleteScan(ctx context.Context, id int32) (int64, error) { - result, err := q.db.Exec(ctx, deleteScan, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllScans = `-- name: GetAllScans :many - -SELECT id, scan_time, scan_id -FROM scan -` - -// CRUD -func (q *Queries) GetAllScans(ctx context.Context) ([]Scan, error) { - rows, err := q.db.Query(ctx, getAllScans) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Scan - for rows.Next() { - var i Scan - if err := rows.Scan(&i.ID, &i.ScanTime, &i.ScanID); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAllScansSinceID = `-- name: GetAllScansSinceID :many -SELECT id, scan_time, scan_id -FROM scan -WHERE id > $1 -ORDER BY scan_id, scan_time ASC -` - -func (q *Queries) GetAllScansSinceID(ctx context.Context, id int32) ([]Scan, error) { - rows, err := q.db.Query(ctx, getAllScansSinceID, id) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Scan - for rows.Next() { - var i Scan - if err := rows.Scan(&i.ID, &i.ScanTime, &i.ScanID); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLastScan = `-- name: GetLastScan :one - - -SELECT id, scan_time, scan_id -FROM scan -ORDER BY id DESC -LIMIT 1 -` - -// Other -func (q *Queries) GetLastScan(ctx context.Context) (Scan, error) { - row := q.db.QueryRow(ctx, getLastScan) - var i Scan - err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) - return i, err -} - -const getScanByID = `-- name: GetScanByID :one -SELECT id, scan_time, scan_id -FROM scan -WHERE id = $1 -` - -func (q *Queries) GetScanByID(ctx context.Context, id int32) (Scan, error) { - row := q.db.QueryRow(ctx, getScanByID, id) - var i Scan - err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) - return i, err -} - -const getScansInCurrentSeason = `-- name: GetScansInCurrentSeason :one -SELECT COUNT(*) AS amount -FROM scan -WHERE scan_time >= (SELECT start_date FROM season WHERE current = true) AND - scan_time <= (SELECT end_date FROM season WHERE current = true) -` - -func (q *Queries) GetScansInCurrentSeason(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, getScansInCurrentSeason) - var amount int64 - err := row.Scan(&amount) - return amount, err -} - -const updateScan = `-- name: UpdateScan :one -UPDATE scan -SET scan_id = $1, scan_time = $2 -WHERE id = $3 -RETURNING id, scan_time, scan_id -` - -type UpdateScanParams struct { - ScanID int32 - ScanTime pgtype.Timestamptz - ID int32 -} - -func (q *Queries) UpdateScan(ctx context.Context, arg UpdateScanParams) (Scan, error) { - row := q.db.QueryRow(ctx, updateScan, arg.ScanID, arg.ScanTime, arg.ID) - var i Scan - err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) - return i, err -} diff --git a/internal/pkg/db/sqlc/season.sql.go b/internal/pkg/db/sqlc/season.sql.go deleted file mode 100644 index e40e0d8..0000000 --- a/internal/pkg/db/sqlc/season.sql.go +++ /dev/null @@ -1,176 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: season.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createSeason = `-- name: CreateSeason :one -INSERT INTO season (name, start, "end", current) -VALUES ($1, $2, $3, $4) -RETURNING id, name, start, "end", current -` - -type CreateSeasonParams struct { - Name string - Start pgtype.Timestamp - End pgtype.Timestamp - Current bool -} - -func (q *Queries) CreateSeason(ctx context.Context, arg CreateSeasonParams) (Season, error) { - row := q.db.QueryRow(ctx, createSeason, - arg.Name, - arg.Start, - arg.End, - arg.Current, - ) - var i Season - err := row.Scan( - &i.ID, - &i.Name, - &i.Start, - &i.End, - &i.Current, - ) - return i, err -} - -const deleteSeason = `-- name: DeleteSeason :execrows -DELETE FROM season -WHERE id = $1 -` - -func (q *Queries) DeleteSeason(ctx context.Context, id int32) (int64, error) { - result, err := q.db.Exec(ctx, deleteSeason, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const deleteSeasonAll = `-- name: DeleteSeasonAll :execrows -DELETE FROM season -` - -func (q *Queries) DeleteSeasonAll(ctx context.Context) (int64, error) { - result, err := q.db.Exec(ctx, deleteSeasonAll) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllSeasons = `-- name: GetAllSeasons :many - -SELECT id, name, start, "end", current -FROM season -` - -// CRUD -func (q *Queries) GetAllSeasons(ctx context.Context) ([]Season, error) { - rows, err := q.db.Query(ctx, getAllSeasons) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Season - for rows.Next() { - var i Season - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Start, - &i.End, - &i.Current, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getSeasonByID = `-- name: GetSeasonByID :one -SELECT id, name, start, "end", current -FROM season -WHERE id = $1 -` - -func (q *Queries) GetSeasonByID(ctx context.Context, id int32) (Season, error) { - row := q.db.QueryRow(ctx, getSeasonByID, id) - var i Season - err := row.Scan( - &i.ID, - &i.Name, - &i.Start, - &i.End, - &i.Current, - ) - return i, err -} - -const getSeasonCurrent = `-- name: GetSeasonCurrent :one - - -SELECT id, name, start, "end", current -FROM season -WHERE current = true -` - -// Other -func (q *Queries) GetSeasonCurrent(ctx context.Context) (Season, error) { - row := q.db.QueryRow(ctx, getSeasonCurrent) - var i Season - err := row.Scan( - &i.ID, - &i.Name, - &i.Start, - &i.End, - &i.Current, - ) - return i, err -} - -const updateSeason = `-- name: UpdateSeason :one -UPDATE season -SET name = $1, start = $2, "end" = $3, current = $4 -WHERE id = $5 -RETURNING id, name, start, "end", current -` - -type UpdateSeasonParams struct { - Name string - Start pgtype.Timestamp - End pgtype.Timestamp - Current bool - ID int32 -} - -func (q *Queries) UpdateSeason(ctx context.Context, arg UpdateSeasonParams) (Season, error) { - row := q.db.QueryRow(ctx, updateSeason, - arg.Name, - arg.Start, - arg.End, - arg.Current, - arg.ID, - ) - var i Season - err := row.Scan( - &i.ID, - &i.Name, - &i.Start, - &i.End, - &i.Current, - ) - return i, err -} diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go deleted file mode 100644 index 16ff8de..0000000 --- a/internal/pkg/db/sqlc/song.sql.go +++ /dev/null @@ -1,561 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: song.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createSong = `-- name: CreateSong :one - -INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, title, spotify_id, duration_ms, album, lyrics_type, lyrics -` - -type CreateSongParams struct { - Title string - Album string - SpotifyID string - DurationMs int32 - LyricsType pgtype.Text - Lyrics pgtype.Text -} - -// CRUD -func (q *Queries) CreateSong(ctx context.Context, arg CreateSongParams) (Song, error) { - row := q.db.QueryRow(ctx, createSong, - arg.Title, - arg.Album, - arg.SpotifyID, - arg.DurationMs, - arg.LyricsType, - arg.Lyrics, - ) - var i Song - err := row.Scan( - &i.ID, - &i.Title, - &i.SpotifyID, - &i.DurationMs, - &i.Album, - &i.LyricsType, - &i.Lyrics, - ) - return i, err -} - -const createSongArtist = `-- name: CreateSongArtist :one -INSERT INTO song_artist (name, spotify_id, followers, popularity) -VALUES ($1, $2, $3, $4) -RETURNING id, name, spotify_id, followers, popularity -` - -type CreateSongArtistParams struct { - Name string - SpotifyID string - Followers int32 - Popularity int32 -} - -func (q *Queries) CreateSongArtist(ctx context.Context, arg CreateSongArtistParams) (SongArtist, error) { - row := q.db.QueryRow(ctx, createSongArtist, - arg.Name, - arg.SpotifyID, - arg.Followers, - arg.Popularity, - ) - var i SongArtist - err := row.Scan( - &i.ID, - &i.Name, - &i.SpotifyID, - &i.Followers, - &i.Popularity, - ) - return i, err -} - -const createSongArtistGenre = `-- name: CreateSongArtistGenre :one -INSERT INTO song_artist_genre (artist_id, genre_id) -VALUES ($1, $2) -RETURNING id, artist_id, genre_id -` - -type CreateSongArtistGenreParams struct { - ArtistID int32 - GenreID int32 -} - -func (q *Queries) CreateSongArtistGenre(ctx context.Context, arg CreateSongArtistGenreParams) (SongArtistGenre, error) { - row := q.db.QueryRow(ctx, createSongArtistGenre, arg.ArtistID, arg.GenreID) - var i SongArtistGenre - err := row.Scan(&i.ID, &i.ArtistID, &i.GenreID) - return i, err -} - -const createSongArtistSong = `-- name: CreateSongArtistSong :one -INSERT INTO song_artist_song (artist_id, song_id) -VALUES ($1, $2) -RETURNING id, artist_id, song_id -` - -type CreateSongArtistSongParams struct { - ArtistID int32 - SongID int32 -} - -func (q *Queries) CreateSongArtistSong(ctx context.Context, arg CreateSongArtistSongParams) (SongArtistSong, error) { - row := q.db.QueryRow(ctx, createSongArtistSong, arg.ArtistID, arg.SongID) - var i SongArtistSong - err := row.Scan(&i.ID, &i.ArtistID, &i.SongID) - return i, err -} - -const createSongGenre = `-- name: CreateSongGenre :one -INSERT INTO song_genre (genre) -VALUES ($1) -RETURNING id, genre -` - -func (q *Queries) CreateSongGenre(ctx context.Context, genre string) (SongGenre, error) { - row := q.db.QueryRow(ctx, createSongGenre, genre) - var i SongGenre - err := row.Scan(&i.ID, &i.Genre) - return i, err -} - -const createSongHistory = `-- name: CreateSongHistory :one -INSERT INTO song_history (song_id) -VALUES ($1) -RETURNING id, song_id, created_at -` - -func (q *Queries) CreateSongHistory(ctx context.Context, songID int32) (SongHistory, error) { - row := q.db.QueryRow(ctx, createSongHistory, songID) - var i SongHistory - err := row.Scan(&i.ID, &i.SongID, &i.CreatedAt) - return i, err -} - -const getLastSongFull = `-- name: GetLastSongFull :many -SELECT s.id, s.title AS song_title, s.spotify_id, s.album, s.duration_ms, s.lyrics_type, s.lyrics, sh.created_at, a.id AS artist_id, a.name AS artist_name, a.spotify_id AS artist_spotify_id, a.followers AS artist_followers, a.popularity AS artist_popularity, g.id AS genre_id, g.genre AS genre, sh.created_at -FROM song_history sh -JOIN song s ON sh.song_id = s.id -LEFT JOIN song_artist_song sa ON s.id = sa.song_id -LEFT JOIN song_artist a ON sa.artist_id = a.id -LEFT JOIN song_artist_genre ag ON ag.artist_id = a.id -LEFT JOIN song_genre g ON ag.genre_id = g.id -WHERE sh.created_at = (SELECT MAX(created_at) FROM song_history) -ORDER BY a.name, g.genre -` - -type GetLastSongFullRow struct { - ID int32 - SongTitle string - SpotifyID string - Album string - DurationMs int32 - LyricsType pgtype.Text - Lyrics pgtype.Text - CreatedAt pgtype.Timestamptz - ArtistID pgtype.Int4 - ArtistName pgtype.Text - ArtistSpotifyID pgtype.Text - ArtistFollowers pgtype.Int4 - ArtistPopularity pgtype.Int4 - GenreID pgtype.Int4 - Genre pgtype.Text - CreatedAt_2 pgtype.Timestamptz -} - -func (q *Queries) GetLastSongFull(ctx context.Context) ([]GetLastSongFullRow, error) { - rows, err := q.db.Query(ctx, getLastSongFull) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetLastSongFullRow - for rows.Next() { - var i GetLastSongFullRow - if err := rows.Scan( - &i.ID, - &i.SongTitle, - &i.SpotifyID, - &i.Album, - &i.DurationMs, - &i.LyricsType, - &i.Lyrics, - &i.CreatedAt, - &i.ArtistID, - &i.ArtistName, - &i.ArtistSpotifyID, - &i.ArtistFollowers, - &i.ArtistPopularity, - &i.GenreID, - &i.Genre, - &i.CreatedAt_2, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLastSongHistory = `-- name: GetLastSongHistory :one -SELECT id, song_id, created_at -FROM song_history -ORDER BY created_at DESC -LIMIT 1 -` - -func (q *Queries) GetLastSongHistory(ctx context.Context) (SongHistory, error) { - row := q.db.QueryRow(ctx, getLastSongHistory) - var i SongHistory - err := row.Scan(&i.ID, &i.SongID, &i.CreatedAt) - return i, err -} - -const getSongArtistByName = `-- name: GetSongArtistByName :one -SELECT id, name, spotify_id, followers, popularity -FROM song_artist -WHERE name = $1 -` - -func (q *Queries) GetSongArtistByName(ctx context.Context, name string) (SongArtist, error) { - row := q.db.QueryRow(ctx, getSongArtistByName, name) - var i SongArtist - err := row.Scan( - &i.ID, - &i.Name, - &i.SpotifyID, - &i.Followers, - &i.Popularity, - ) - return i, err -} - -const getSongArtistBySpotifyID = `-- name: GetSongArtistBySpotifyID :one -SELECT id, name, spotify_id, followers, popularity -FROM song_artist -WHERE spotify_id = $1 -` - -func (q *Queries) GetSongArtistBySpotifyID(ctx context.Context, spotifyID string) (SongArtist, error) { - row := q.db.QueryRow(ctx, getSongArtistBySpotifyID, spotifyID) - var i SongArtist - err := row.Scan( - &i.ID, - &i.Name, - &i.SpotifyID, - &i.Followers, - &i.Popularity, - ) - return i, err -} - -const getSongBySpotifyID = `-- name: GetSongBySpotifyID :one - -SELECT id, title, spotify_id, duration_ms, album, lyrics_type, lyrics -FROM song -WHERE spotify_id = $1 -` - -// Other -func (q *Queries) GetSongBySpotifyID(ctx context.Context, spotifyID string) (Song, error) { - row := q.db.QueryRow(ctx, getSongBySpotifyID, spotifyID) - var i Song - err := row.Scan( - &i.ID, - &i.Title, - &i.SpotifyID, - &i.DurationMs, - &i.Album, - &i.LyricsType, - &i.Lyrics, - ) - return i, err -} - -const getSongGenreByName = `-- name: GetSongGenreByName :one -SELECT id, genre -FROM song_genre -WHERE genre = $1 -` - -func (q *Queries) GetSongGenreByName(ctx context.Context, genre string) (SongGenre, error) { - row := q.db.QueryRow(ctx, getSongGenreByName, genre) - var i SongGenre - err := row.Scan(&i.ID, &i.Genre) - return i, err -} - -const getSongHistory = `-- name: GetSongHistory :many -SELECT s.title, play_count, aggregated.created_at -FROM ( - SELECT sh.song_id, MAX(sh.created_at) AS created_at, COUNT(sh.song_id) AS play_count - FROM song_history sh - GROUP BY sh.song_id -) aggregated -JOIN song s ON aggregated.song_id = s.id -ORDER BY aggregated.created_at DESC -LIMIT 50 -` - -type GetSongHistoryRow struct { - Title string - PlayCount int64 - CreatedAt interface{} -} - -func (q *Queries) GetSongHistory(ctx context.Context) ([]GetSongHistoryRow, error) { - rows, err := q.db.Query(ctx, getSongHistory) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetSongHistoryRow - for rows.Next() { - var i GetSongHistoryRow - if err := rows.Scan(&i.Title, &i.PlayCount, &i.CreatedAt); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopArtists = `-- name: GetTopArtists :many -SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -GROUP BY sa.id, sa.name -ORDER BY total_plays DESC -LIMIT 10 -` - -type GetTopArtistsRow struct { - ArtistID int32 - ArtistName string - TotalPlays int64 -} - -func (q *Queries) GetTopArtists(ctx context.Context) ([]GetTopArtistsRow, error) { - rows, err := q.db.Query(ctx, getTopArtists) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopArtistsRow - for rows.Next() { - var i GetTopArtistsRow - if err := rows.Scan(&i.ArtistID, &i.ArtistName, &i.TotalPlays); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopGenres = `-- name: GetTopGenres :many -SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -JOIN song_artist_genre sag ON sa.id = sag.artist_id -JOIN song_genre g ON sag.genre_id = g.id -GROUP BY g.genre -ORDER BY total_plays DESC -LIMIT 10 -` - -type GetTopGenresRow struct { - GenreName string - TotalPlays int64 -} - -func (q *Queries) GetTopGenres(ctx context.Context) ([]GetTopGenresRow, error) { - rows, err := q.db.Query(ctx, getTopGenres) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopGenresRow - for rows.Next() { - var i GetTopGenresRow - if err := rows.Scan(&i.GenreName, &i.TotalPlays); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopMonthlyArtists = `-- name: GetTopMonthlyArtists :many -SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY sa.id, sa.name -ORDER BY total_plays DESC -LIMIT 10 -` - -type GetTopMonthlyArtistsRow struct { - ArtistID int32 - ArtistName string - TotalPlays int64 -} - -func (q *Queries) GetTopMonthlyArtists(ctx context.Context) ([]GetTopMonthlyArtistsRow, error) { - rows, err := q.db.Query(ctx, getTopMonthlyArtists) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopMonthlyArtistsRow - for rows.Next() { - var i GetTopMonthlyArtistsRow - if err := rows.Scan(&i.ArtistID, &i.ArtistName, &i.TotalPlays); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopMonthlyGenres = `-- name: GetTopMonthlyGenres :many -SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays -FROM song_history sh -JOIN song s ON sh.song_id = s.id -JOIN song_artist_song sas ON s.id = sas.song_id -JOIN song_artist sa ON sas.artist_id = sa.id -JOIN song_artist_genre sag ON sa.id = sag.artist_id -JOIN song_genre g ON sag.genre_id = g.id -WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY g.genre -ORDER BY total_plays DESC -LIMIT 10 -` - -type GetTopMonthlyGenresRow struct { - GenreName string - TotalPlays int64 -} - -func (q *Queries) GetTopMonthlyGenres(ctx context.Context) ([]GetTopMonthlyGenresRow, error) { - rows, err := q.db.Query(ctx, getTopMonthlyGenres) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopMonthlyGenresRow - for rows.Next() { - var i GetTopMonthlyGenresRow - if err := rows.Scan(&i.GenreName, &i.TotalPlays); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopMonthlySongs = `-- name: GetTopMonthlySongs :many -SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count -FROM song_history sh -JOIN song s ON sh.song_id = s.id -WHERE sh.created_at > CURRENT_TIMESTAMP - INTERVAL '1 month' -GROUP BY s.id, s.title -ORDER BY play_count DESC -LIMIT 10 -` - -type GetTopMonthlySongsRow struct { - SongID int32 - Title string - PlayCount int64 -} - -func (q *Queries) GetTopMonthlySongs(ctx context.Context) ([]GetTopMonthlySongsRow, error) { - rows, err := q.db.Query(ctx, getTopMonthlySongs) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopMonthlySongsRow - for rows.Next() { - var i GetTopMonthlySongsRow - if err := rows.Scan(&i.SongID, &i.Title, &i.PlayCount); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTopSongs = `-- name: GetTopSongs :many -SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count -FROM song_history sh -JOIN song s ON sh.song_id = s.id -GROUP BY s.id, s.title -ORDER BY play_count DESC -LIMIT 10 -` - -type GetTopSongsRow struct { - SongID int32 - Title string - PlayCount int64 -} - -func (q *Queries) GetTopSongs(ctx context.Context) ([]GetTopSongsRow, error) { - rows, err := q.db.Query(ctx, getTopSongs) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTopSongsRow - for rows.Next() { - var i GetTopSongsRow - if err := rows.Scan(&i.SongID, &i.Title, &i.PlayCount); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/pkg/db/sqlc/tap.sql.go b/internal/pkg/db/sqlc/tap.sql.go deleted file mode 100644 index 56ac2eb..0000000 --- a/internal/pkg/db/sqlc/tap.sql.go +++ /dev/null @@ -1,287 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: tap.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createTap = `-- name: CreateTap :one -INSERT INTO tap (order_id, order_created_at, name, category) -VALUES ($1, $2, $3, $4) -RETURNING id, order_id, order_created_at, name, category, created_at -` - -type CreateTapParams struct { - OrderID int32 - OrderCreatedAt pgtype.Timestamptz - Name string - Category string -} - -func (q *Queries) CreateTap(ctx context.Context, arg CreateTapParams) (Tap, error) { - row := q.db.QueryRow(ctx, createTap, - arg.OrderID, - arg.OrderCreatedAt, - arg.Name, - arg.Category, - ) - var i Tap - err := row.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ) - return i, err -} - -const deleteTap = `-- name: DeleteTap :execrows -DELETE FROM tap -WHERE id = $1 -` - -func (q *Queries) DeleteTap(ctx context.Context, id int32) (int64, error) { - result, err := q.db.Exec(ctx, deleteTap, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllTaps = `-- name: GetAllTaps :many - -SELECT id, order_id, order_created_at, name, category, created_at -FROM tap -` - -// CRUD -func (q *Queries) GetAllTaps(ctx context.Context) ([]Tap, error) { - rows, err := q.db.Query(ctx, getAllTaps) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Tap - for rows.Next() { - var i Tap - if err := rows.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLastOrderByOrderID = `-- name: GetLastOrderByOrderID :one -SELECT id, order_id, order_created_at, name, category, created_at -FROM tap -ORDER BY order_id DESC -LIMIT 1 -` - -func (q *Queries) GetLastOrderByOrderID(ctx context.Context) (Tap, error) { - row := q.db.QueryRow(ctx, getLastOrderByOrderID) - var i Tap - err := row.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ) - return i, err -} - -const getOrderCount = `-- name: GetOrderCount :many -SELECT category, COUNT(*) -FROM tap -GROUP BY category -` - -type GetOrderCountRow struct { - Category string - Count int64 -} - -func (q *Queries) GetOrderCount(ctx context.Context) ([]GetOrderCountRow, error) { - rows, err := q.db.Query(ctx, getOrderCount) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetOrderCountRow - for rows.Next() { - var i GetOrderCountRow - if err := rows.Scan(&i.Category, &i.Count); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getOrderCountByCategorySinceOrderID = `-- name: GetOrderCountByCategorySinceOrderID :many -SELECT category, COUNT(*), MAX(order_created_at)::TIMESTAMP AS latest_order_created_at -FROM tap -WHERE order_id >= $1 -GROUP BY category -` - -type GetOrderCountByCategorySinceOrderIDRow struct { - Category string - Count int64 - LatestOrderCreatedAt pgtype.Timestamp -} - -func (q *Queries) GetOrderCountByCategorySinceOrderID(ctx context.Context, orderID int32) ([]GetOrderCountByCategorySinceOrderIDRow, error) { - rows, err := q.db.Query(ctx, getOrderCountByCategorySinceOrderID, orderID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetOrderCountByCategorySinceOrderIDRow - for rows.Next() { - var i GetOrderCountByCategorySinceOrderIDRow - if err := rows.Scan(&i.Category, &i.Count, &i.LatestOrderCreatedAt); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTapByCategory = `-- name: GetTapByCategory :many -SELECT id, order_id, order_created_at, name, category, created_at -FROM tap -WHERE category = $1 -` - -func (q *Queries) GetTapByCategory(ctx context.Context, category string) ([]Tap, error) { - rows, err := q.db.Query(ctx, getTapByCategory, category) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Tap - for rows.Next() { - var i Tap - if err := rows.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTapByID = `-- name: GetTapByID :one -SELECT id, order_id, order_created_at, name, category, created_at -FROM tap -WHERE id = $1 -` - -func (q *Queries) GetTapByID(ctx context.Context, id int32) (Tap, error) { - row := q.db.QueryRow(ctx, getTapByID, id) - var i Tap - err := row.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ) - return i, err -} - -const getTapByOrderID = `-- name: GetTapByOrderID :one - - -SELECT id, order_id, order_created_at, name, category, created_at -FROM tap -WHERE order_id = $1 -` - -// Other -func (q *Queries) GetTapByOrderID(ctx context.Context, orderID int32) (Tap, error) { - row := q.db.QueryRow(ctx, getTapByOrderID, orderID) - var i Tap - err := row.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ) - return i, err -} - -const updateTap = `-- name: UpdateTap :one -UPDATE tap -SET order_id = $1, order_created_at = $2, name = $3, category = $4 -WHERE id = $5 -RETURNING id, order_id, order_created_at, name, category, created_at -` - -type UpdateTapParams struct { - OrderID int32 - OrderCreatedAt pgtype.Timestamptz - Name string - Category string - ID int32 -} - -func (q *Queries) UpdateTap(ctx context.Context, arg UpdateTapParams) (Tap, error) { - row := q.db.QueryRow(ctx, updateTap, - arg.OrderID, - arg.OrderCreatedAt, - arg.Name, - arg.Category, - arg.ID, - ) - var i Tap - err := row.Scan( - &i.ID, - &i.OrderID, - &i.OrderCreatedAt, - &i.Name, - &i.Category, - &i.CreatedAt, - ) - return i, err -} diff --git a/internal/pkg/event/api.go b/internal/pkg/event/api.go deleted file mode 100644 index b0b89e2..0000000 --- a/internal/pkg/event/api.go +++ /dev/null @@ -1,117 +0,0 @@ -package event - -import ( - "errors" - "fmt" - "strconv" - "strings" - "time" - - "github.com/gocolly/colly" - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "go.uber.org/zap" -) - -var layout = "Monday 02 January, 15:04 2006" - -func (e *Event) getEvents() ([]dto.Event, error) { - zap.S().Info("Events: Getting all events") - - var events []dto.Event - var errs []error - c := colly.NewCollector() - - c.OnHTML(".event-tile", func(el *colly.HTMLElement) { - event := dto.Event{} - - // Name - event.Name = el.ChildText(".is-size-4-mobile") - - // Date & Location - dateLoc := el.DOM.Find(".event-time-loc").Contents() - dateLocStr := strings.Split(dateLoc.Text(), " ") - - if len(dateLocStr) != 2 { - errs = append(errs, fmt.Errorf("Event: Unable to scrape date and location %s", dateLocStr)) - return - } - - // Location - event.Location = strings.TrimSpace(dateLocStr[1]) - - // Date - date := strings.TrimSpace(dateLocStr[0]) - - yearString := el.Attr("href") - yearParts := strings.Split(yearString, "/") - if len(yearParts) == 5 { - rangeParts := strings.Split(yearParts[2], "-") - if len(rangeParts) == 2 { - partIdx := 0 - if time.Now().Month() < time.September { - partIdx = 1 - } - - dateWithYear := fmt.Sprintf("%s 20%s", date, rangeParts[partIdx]) - parsedDate, err := time.Parse(layout, dateWithYear) - if err == nil { - event.AcademicYear = yearParts[2] - event.Date = parsedDate - } - } - } - // Check for error - if event.Date.IsZero() { - errs = append(errs, fmt.Errorf("Event: Unable to parse date %s %s", date, yearString)) - return - } - - events = append(events, event) - }) - - err := c.Visit(e.website) - if err != nil { - return nil, err - } - - c.Wait() - - return events, errors.Join(errs...) -} - -func (e *Event) getPoster(event *dto.Event) error { - zap.S().Info("Events: Getting poster for ", event.Name) - - yearParts := strings.Split(event.AcademicYear, "-") - if len(yearParts) != 2 { - return fmt.Errorf("Event: Academic year not properly formatted %s", event.AcademicYear) - } - - yearStart, err := strconv.Atoi(yearParts[0]) - if err != nil { - return fmt.Errorf("Event: Unable to convert academic year to int %v", yearParts) - } - yearEnd, err := strconv.Atoi(yearParts[1]) - if err != nil { - return fmt.Errorf("Event: Unable to convert academic year to int %v", yearParts) - } - - year := fmt.Sprintf("20%d-20%d", yearStart, yearEnd) - - url := fmt.Sprintf("%s/%s/%s/scc.png", e.websitePoster, year, event.Name) - - req := fiber.Get(url) - status, body, errs := req.Bytes() - if len(errs) != 0 { - return errors.Join(append(errs, errors.New("Event: Download poster request failed"))...) - } - if status != fiber.StatusOK { - // No poster for event - return nil - } - - event.Poster = body - - return nil -} diff --git a/internal/pkg/event/event.go b/internal/pkg/event/event.go deleted file mode 100644 index 9ccbb5f..0000000 --- a/internal/pkg/event/event.go +++ /dev/null @@ -1,105 +0,0 @@ -// Package event provides all logic regarding the events of the website -package event - -import ( - "context" - "errors" - "slices" - "sync" - - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/config" -) - -// Event represents a event instance -type Event struct { - db *db.DB - website string - websitePoster string -} - -// New creates a new event instance -func New(db *db.DB) *Event { - return &Event{ - db: db, - website: config.GetDefaultString("backend.event.website", "https://zeus.gent/events/"), - websitePoster: config.GetDefaultString("backend.event.website_poster", "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master"), - } -} - -// Update gets all events from the website of this academic year -func (e *Event) Update() error { - events, err := e.getEvents() - if err != nil { - return err - } - if len(events) == 0 { - return nil - } - - eventsDBSQL, err := e.db.Queries.GetEventByAcademicYear(context.Background(), events[0].AcademicYear) - if err != nil { - return err - } - - eventsDB := make([]*dto.Event, 0, len(eventsDBSQL)) - - var wg sync.WaitGroup - var errs []error - for _, event := range eventsDBSQL { - wg.Add(1) - - go func(event sqlc.Event) { - defer wg.Done() - - ev := dto.EventDTO(event) - eventsDB = append(eventsDB, ev) - err := e.getPoster(ev) - if err != nil { - errs = append(errs, err) - } - }(event) - } - wg.Wait() - - // Check if there are any new events - equal := true - for _, event := range eventsDB { - found := slices.ContainsFunc(events, func(ev dto.Event) bool { return ev.Equal(*event) }) - if !found { - equal = false - break - } - } - - if len(events) != len(eventsDB) { - equal = false - } - - // Both are equal, nothing to be done - if equal { - return nil - } - - // They differ, remove the old ones and insert the new once - err = e.db.Queries.DeleteEventByAcademicYear(context.Background(), events[0].AcademicYear) - if err != nil { - return err - } - - for _, event := range events { - err = e.getPoster(&event) - if err != nil { - errs = append(errs, err) - // Don't return / continue. We can still enter it without a poster - } - _, err = e.db.Queries.CreateEvent(context.Background(), event.CreateParams()) - if err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} diff --git a/internal/pkg/gamification/api.go b/internal/pkg/gamification/api.go deleted file mode 100644 index 6e3f1a3..0000000 --- a/internal/pkg/gamification/api.go +++ /dev/null @@ -1,78 +0,0 @@ -package gamification - -import ( - "errors" - "fmt" - - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "go.uber.org/zap" -) - -type gamificationItem struct { - ID int32 `json:"id"` - Name string `json:"github_name"` - Score int32 `json:"score"` - AvatarURL string `json:"avatar_url"` -} - -func (g *Gamification) getLeaderboard() ([]dto.Gamification, error) { - zap.S().Info("Gamification: Getting leaderboard") - - req := fiber.Get(g.api+"/top4").Set("Accept", "application/json") - - res := new([]gamificationItem) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return nil, errors.Join(append(errs, errors.New("Gamification: Leaderboard API request failed"))...) - } - if status != fiber.StatusOK { - return nil, fmt.Errorf("Gamification: Leaderboard API request returned bad status code %d", status) - } - - errs = make([]error, 0) - for _, gam := range *res { - if err := dto.Validate.Struct(gam); err != nil { - errs = append(errs, err) - } - } - - if len(errs) != 0 { - return nil, errors.Join(errs...) - } - - gams := make([]dto.Gamification, 0, 4) - for _, item := range *res { - gam, err := downloadAvatar(item) - if err != nil { - errs = append(errs, err) - continue - } - - gams = append(gams, gam) - } - - return gams, errors.Join(errs...) -} - -func downloadAvatar(gam gamificationItem) (dto.Gamification, error) { - zap.S().Info("Gamification: Getting avatar for ", gam.Name) - - req := fiber.Get(gam.AvatarURL) - status, body, errs := req.Bytes() - if len(errs) != 0 { - return dto.Gamification{}, errors.Join(append(errs, errors.New("Gamification: Download avatar request failed"))...) - } - if status != fiber.StatusOK { - return dto.Gamification{}, fmt.Errorf("Gamification: Download avatar returned bad status code %d", status) - } - - g := dto.Gamification{ - ID: gam.ID, - Name: gam.Name, - Score: gam.Score, - Avatar: body, - } - - return g, nil -} diff --git a/internal/pkg/gamification/gamification.go b/internal/pkg/gamification/gamification.go deleted file mode 100644 index aeb2ed4..0000000 --- a/internal/pkg/gamification/gamification.go +++ /dev/null @@ -1,45 +0,0 @@ -// Package gamification provides all gamification related logic -package gamification - -import ( - "context" - "errors" - - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/pkg/config" -) - -// Gamification represents a gamification instance -type Gamification struct { - db *db.DB - api string -} - -// New creates a new gamification instance -func New(db *db.DB) *Gamification { - return &Gamification{ - db: db, - api: config.GetDefaultString("backend.gamification.api", "https://gamification.zeus.gent"), - } -} - -// Update gets the current leaderboard from gamification -func (g *Gamification) Update() error { - if _, err := g.db.Queries.DeleteGamificationAll(context.Background()); err != nil { - return err - } - - leaderboard, err := g.getLeaderboard() - if err != nil { - return err - } - - var errs []error - for _, item := range leaderboard { - if _, err := g.db.Queries.CreateGamification(context.Background(), item.CreateParams()); err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} diff --git a/internal/pkg/lyrics/lyrics.go b/internal/pkg/lyrics/lyrics.go deleted file mode 100644 index 2c3cb23..0000000 --- a/internal/pkg/lyrics/lyrics.go +++ /dev/null @@ -1,45 +0,0 @@ -// Package lyrics provides a way to work with both synced and plain lyrics -package lyrics - -import ( - "time" - - "github.com/zeusWPI/scc/internal/pkg/db/dto" -) - -// Lyrics is the common interface for different lyric types -type Lyrics interface { - GetSong() dto.Song - Previous(int) []Lyric - Current() (Lyric, bool) - Next() (Lyric, bool) - Upcoming(int) []Lyric - Progress() float64 -} - -// Lyric represents a single lyric line. -type Lyric struct { - Text string - Duration time.Duration -} - -// New returns a new object that implements the Lyrics interface -func New(song dto.Song) Lyrics { - // Basic sync - if song.LyricsType == "synced" { - return newLRC(song) - } - - // Plain - if song.LyricsType == "plain" { - return newPlain(song) - } - - // Instrumental - if song.LyricsType == "instrumental" { - return newInstrumental(song) - } - - // No lyrics found - return newMissing(song) -} diff --git a/internal/pkg/lyrics/missing.go b/internal/pkg/lyrics/missing.go deleted file mode 100644 index 1602d2c..0000000 --- a/internal/pkg/lyrics/missing.go +++ /dev/null @@ -1,55 +0,0 @@ -package lyrics - -import ( - "time" - - "github.com/zeusWPI/scc/internal/pkg/db/dto" -) - -// Missing represents lyrics that are absent -type Missing struct { - song dto.Song - lyrics Lyric -} - -func newMissing(song dto.Song) Lyrics { - lyric := Lyric{ - Text: "Missing lyrics\n\nHelp the open source community by adding them to\nhttps://lrclib.net/", - Duration: time.Duration(song.DurationMS) * time.Millisecond, - } - - return &Missing{song: song, lyrics: lyric} -} - -// GetSong returns the song associated to the lyrics -func (m *Missing) GetSong() dto.Song { - return m.song -} - -// Previous provides the previous `amount` of lyrics without affecting the current lyric -// In this case it's alway nothing -func (m *Missing) Previous(_ int) []Lyric { - return []Lyric{} -} - -// Current provides the current lyric if any. -func (m *Missing) Current() (Lyric, bool) { - return m.lyrics, true -} - -// Next provides the next lyric. -// In this case it's always nothing -func (m *Missing) Next() (Lyric, bool) { - return Lyric{}, false -} - -// Upcoming provides the next `amount` lyrics without affecting the current lyric -// In this case it's always empty -func (m *Missing) Upcoming(_ int) []Lyric { - return []Lyric{} -} - -// Progress shows the fraction of lyrics that have been used. -func (m *Missing) Progress() float64 { - return 1 -} diff --git a/internal/pkg/lyrics/plain.go b/internal/pkg/lyrics/plain.go deleted file mode 100644 index 2771969..0000000 --- a/internal/pkg/lyrics/plain.go +++ /dev/null @@ -1,54 +0,0 @@ -package lyrics - -import ( - "time" - - "github.com/zeusWPI/scc/internal/pkg/db/dto" -) - -// Plain represents lyrics that don't have timestamps or songs without lyrics -type Plain struct { - song dto.Song - lyrics Lyric -} - -func newPlain(song dto.Song) Lyrics { - lyric := Lyric{ - Text: song.Lyrics, - Duration: time.Duration(song.DurationMS) * time.Millisecond, - } - return &Plain{song: song, lyrics: lyric} -} - -// GetSong returns the song associated to the lyrics -func (p *Plain) GetSong() dto.Song { - return p.song -} - -// Previous provides the previous `amount` of lyrics without affecting the current lyric -// In this case it's always nothing -func (p *Plain) Previous(_ int) []Lyric { - return []Lyric{} -} - -// Current provides the current lyric if any. -func (p *Plain) Current() (Lyric, bool) { - return p.lyrics, true -} - -// Next provides the next lyric. -// In this case it's alway nothing -func (p *Plain) Next() (Lyric, bool) { - return Lyric{}, false -} - -// Upcoming provides the next `amount` lyrics without affecting the current lyric -// In this case it's always empty -func (p *Plain) Upcoming(_ int) []Lyric { - return []Lyric{} -} - -// Progress shows the fraction of lyrics that have been used. -func (p *Plain) Progress() float64 { - return 1 -} diff --git a/internal/pkg/song/account.go b/internal/pkg/song/account.go deleted file mode 100644 index 774953a..0000000 --- a/internal/pkg/song/account.go +++ /dev/null @@ -1,38 +0,0 @@ -package song - -import ( - "errors" - "time" - - "github.com/gofiber/fiber/v2" - "go.uber.org/zap" -) - -type accountResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` -} - -func (s *Song) refreshToken() error { - zap.S().Info("Song: Refreshing spotify access token") - - form := &fiber.Args{} - form.Add("grant_type", "client_credentials") - - req := fiber.Post(s.apiAccount).Form(form).BasicAuth(s.ClientID, s.ClientSecret) - - res := new(accountResponse) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return errors.Join(append([]error{errors.New("Song: Spotify token refresh request failed")}, errs...)...) - } - if status != fiber.StatusOK { - return errors.New("Song: Error getting access token") - } - - s.AccessToken = res.AccessToken - s.ExpiresTime = time.Now().Unix() + res.ExpiresIn - - return nil -} diff --git a/internal/pkg/song/api.go b/internal/pkg/song/api.go deleted file mode 100644 index 1758e46..0000000 --- a/internal/pkg/song/api.go +++ /dev/null @@ -1,158 +0,0 @@ -package song - -import ( - "errors" - "fmt" - "net/url" - - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "go.uber.org/zap" -) - -type trackArtist struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type trackAlbum struct { - Name string `json:"name"` -} - -type trackResponse struct { - Name string `json:"name"` - Album trackAlbum `json:"album"` - Artists []trackArtist `json:"artists"` - DurationMS int32 `json:"duration_ms"` -} - -func (s *Song) getTrack(track *dto.Song) error { - zap.S().Info("Song: Getting track info for id: ", track.SpotifyID) - - req := fiber.Get(fmt.Sprintf("%s/%s/%s", s.api, "tracks", track.SpotifyID)). - Set("Authorization", fmt.Sprintf("Bearer %s", s.AccessToken)) - - res := new(trackResponse) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return errors.Join(append([]error{errors.New("Song: Track request failed")}, errs...)...) - } - if status != fiber.StatusOK { - return fmt.Errorf("Song: Track request wrong status code %d", status) - } - - track.Title = res.Name - track.Album = res.Album.Name - track.DurationMS = res.DurationMS - - for _, a := range res.Artists { - track.Artists = append(track.Artists, dto.SongArtist{ - Name: a.Name, - SpotifyID: a.ID, - }) - } - - return nil -} - -type artistFollowers struct { - Total int `json:"total"` -} - -type artistResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Genres []string `json:"genres"` - Popularity int `json:"popularity"` - Followers artistFollowers `json:"followers"` -} - -func (s *Song) getArtist(artist *dto.SongArtist) error { - zap.S().Info("Song: Getting artists info for ", artist.SpotifyID) - - req := fiber.Get(fmt.Sprintf("%s/%s/%s", s.api, "artists", artist.SpotifyID)). - Set("Authorization", fmt.Sprintf("Bearer %s", s.AccessToken)) - - res := new(artistResponse) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return errors.Join(append([]error{errors.New("Song: Artist request failed")}, errs...)...) - } - if status != fiber.StatusOK { - return fmt.Errorf("Song: Artist request wrong status code %d", status) - } - - artist.Popularity = int32(res.Popularity) - artist.Followers = int32(res.Followers.Total) - - for _, genre := range res.Genres { - artist.Genres = append(artist.Genres, dto.SongGenre{Genre: genre}) - } - - return nil -} - -type lyricsResponse struct { - Instrumental bool `json:"instrumental"` - PlainLyrics string `json:"plainLyrics"` - SyncedLyrics string `json:"SyncedLyrics"` -} - -func (s *Song) getLyrics(track *dto.Song) error { - zap.S().Info("Song: Getting lyrics for ", track.Title) - - // Get most popular artist - if len(track.Artists) == 0 { - return fmt.Errorf("Song: No artists for track: %v", track) - } - artist := track.Artists[0] - for _, a := range track.Artists { - if a.Followers > artist.Followers { - artist = a - } - } - - // Construct url - params := url.Values{} - params.Set("artist_name", artist.Name) - params.Set("track_name", track.Title) - params.Set("album_name", track.Album) - params.Set("duration", fmt.Sprintf("%d", track.DurationMS/1000)) - - req := fiber.Get(fmt.Sprintf("%s/get?%s", s.apiLrclib, params.Encode())) - - res := new(lyricsResponse) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return errors.Join(append([]error{errors.New("Song: Lyrics request failed")}, errs...)...) - } - if status != fiber.StatusOK { - if status == fiber.StatusNotFound { - // Lyrics not found - return nil - } - - return fmt.Errorf("Song: Lyrics request wrong status code %d", status) - } - if (res == &lyricsResponse{}) { - return errors.New("Song: Lyrics request returned empty struct") - } - - zap.S().Info(res) - - if res.SyncedLyrics != "" { - // Synced lyrics ? - track.LyricsType = "synced" - track.Lyrics = res.SyncedLyrics - } else if res.PlainLyrics != "" { - // Plain lyrics ? - track.LyricsType = "plain" - track.Lyrics = res.PlainLyrics - } else if res.Instrumental { - // Instrumental ? - track.LyricsType = "instrumental" - track.Lyrics = "" - } - - return nil -} diff --git a/internal/pkg/song/song.go b/internal/pkg/song/song.go deleted file mode 100644 index 5967d51..0000000 --- a/internal/pkg/song/song.go +++ /dev/null @@ -1,181 +0,0 @@ -// Package song provides all song related logic -package song - -import ( - "context" - "errors" - "time" - - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/config" -) - -// Song represents a song instance -type Song struct { - db *db.DB - ClientID string - ClientSecret string - AccessToken string - ExpiresTime int64 - - api string - apiAccount string - apiLrclib string -} - -// New creates a new song instance -func New(db *db.DB) (*Song, error) { - clientID := config.GetDefaultString("backend.song.spotify_client_id", "") - clientSecret := config.GetDefaultString("backend.song.spotify_client_secret", "") - - if clientID == "" || clientSecret == "" { - return &Song{}, errors.New("Song: Spotify client id or secret not set") - } - - return &Song{ - db: db, - ClientID: clientID, - ClientSecret: clientSecret, - ExpiresTime: 0, - api: config.GetDefaultString("backend.song.spotify_api", "https://api.spotify.com/v1"), - apiAccount: config.GetDefaultString("backend.song.spotify_api_account", "https://accounts.spotify.com/api/token"), - apiLrclib: config.GetDefaultString("backend.song.lrclib_api", "https://lrclib.net/api"), - }, nil -} - -// Track gets information about the current track and stores it in the database -func (s *Song) Track(track *dto.Song) error { - var errs []error - - if s.ClientID == "" || s.ClientSecret == "" { - return errors.New("Song: Spotify client id or secret not set") - } - - // Check if song is already in DB - trackDB, err := s.db.Queries.GetSongBySpotifyID(context.Background(), track.SpotifyID) - if err != nil && err != pgx.ErrNoRows { - return err - } - - if (trackDB != sqlc.Song{}) { - // Already in DB - // Add to song history if it's not the latest song - songHistory, err := s.db.Queries.GetLastSongHistory(context.Background()) - if err != nil && err != pgx.ErrNoRows { - return err - } - - if (songHistory != sqlc.SongHistory{}) && songHistory.SongID == trackDB.ID { - // Song is already the latest, don't add it again - return nil - } - - _, err = s.db.Queries.CreateSongHistory(context.Background(), trackDB.ID) - return err - } - - // Not in database yet, add it - - // Refresh token if needed - if s.ExpiresTime <= time.Now().Unix() { - err := s.refreshToken() - if err != nil { - return err - } - } - - // Get track info - if err = s.getTrack(track); err != nil { - return err - } - - // Get lyrics - if err = s.getLyrics(track); err != nil { - errs = append(errs, err) - } - - // Store track in DB - trackDB, err = s.db.Queries.CreateSong(context.Background(), *track.CreateSongParams()) - if err != nil { - errs = append(errs, err) - return errors.Join(errs...) - } - track.ID = trackDB.ID - - // Handle artists - for i, artist := range track.Artists { - a, err := s.db.Queries.GetSongArtistBySpotifyID(context.Background(), artist.SpotifyID) - if err != nil && err != pgx.ErrNoRows { - errs = append(errs, err) - continue - } - - if (a != sqlc.SongArtist{}) { - // Artist already exists - // Add it as an artist for this track - track.Artists[i].ID = a.ID - if _, err := s.db.Queries.CreateSongArtistSong(context.Background(), *track.CreateSongArtistSongParams(i)); err != nil { - errs = append(errs, err) - } - continue - } - - // Get artist data - if err := s.getArtist(&track.Artists[i]); err != nil { - errs = append(errs, err) - continue - } - - // Insert artist in DB - a, err = s.db.Queries.CreateSongArtist(context.Background(), *track.CreateSongArtistParams(i)) - if err != nil { - errs = append(errs, err) - continue - } - track.Artists[i].ID = a.ID - - // Add artist as an artist for this song - if _, err := s.db.Queries.CreateSongArtistSong(context.Background(), *track.CreateSongArtistSongParams(i)); err != nil { - errs = append(errs, err) - continue - } - - // Check if the artists genres are in db - for j, genre := range track.Artists[i].Genres { - g, err := s.db.Queries.GetSongGenreByName(context.Background(), genre.Genre) - if err != nil && err != pgx.ErrNoRows { - errs = append(errs, err) - continue - } - - if (g != sqlc.SongGenre{}) { - // Genre already exists - continue - } - - // Insert genre in DB - g, err = s.db.Queries.CreateSongGenre(context.Background(), track.CreateSongGenreParams(i, j)) - if err != nil { - errs = append(errs, err) - continue - } - track.Artists[i].Genres[j].ID = g.ID - - // Add genre as a genre for this artist - if _, err := s.db.Queries.CreateSongArtistGenre(context.Background(), *track.CreateSongArtistGenreParamas(i, j)); err != nil { - errs = append(errs, err) - } - } - } - - // Add to song history - if _, err = s.db.Queries.CreateSongHistory(context.Background(), trackDB.ID); err != nil { - errs = append(errs, err) - } - - return errors.Join(errs...) - -} diff --git a/internal/pkg/tap/api.go b/internal/pkg/tap/api.go deleted file mode 100644 index 522b16e..0000000 --- a/internal/pkg/tap/api.go +++ /dev/null @@ -1,37 +0,0 @@ -package tap - -import ( - "errors" - "time" - - "github.com/gofiber/fiber/v2" - "go.uber.org/zap" -) - -type orderResponseItem struct { - OrderID int32 `json:"order_id"` - OrderCreatedAt time.Time `json:"order_created_at"` - ProductName string `json:"product_name"` - ProductCategory string `json:"product_category"` -} - -type orderResponse struct { - Orders []orderResponseItem `json:"orders"` -} - -func (t *Tap) getOrders() ([]orderResponseItem, error) { - zap.S().Info("Tap: Getting orders") - - req := fiber.Get(t.api + "/recent") - - res := new(orderResponse) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return nil, errors.Join(append(errs, errors.New("Tap: Order API request failed"))...) - } - if status != fiber.StatusOK { - return nil, errors.New("error getting orders") - } - - return res.Orders, nil -} diff --git a/internal/pkg/tap/tap.go b/internal/pkg/tap/tap.go deleted file mode 100644 index d629566..0000000 --- a/internal/pkg/tap/tap.go +++ /dev/null @@ -1,113 +0,0 @@ -// Package tap provides all tap related logic -package tap - -import ( - "context" - "errors" - "slices" - "strings" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/pkg/util" -) - -// Tap represents a tap instance -type Tap struct { - db *db.DB - beers []string - api string -} - -var defaultBeers = []string{ - "Schelfaut", - "Duvel", - "Fourchette", - "Jupiler", - "Karmeliet", - "Kriek", - "Chouffe", - "Maes", - "Somersby", - "Sportzot", - "Stella", -} - -// New creates a new tap instance -func New(db *db.DB) *Tap { - return &Tap{ - db: db, - beers: config.GetDefaultStringSlice("backend.tap.beers", defaultBeers), - api: config.GetDefaultString("backend.tap.api", "https://tap.zeus.gent"), - } -} - -// Update gets all new orders from tap -func (t *Tap) Update() error { - // Get latest order - lastOrder, err := t.db.Queries.GetLastOrderByOrderID(context.Background()) - if err != nil { - if err != pgx.ErrNoRows { - return err - } - - lastOrder = sqlc.Tap{OrderID: -1} - } - - // Get all orders - allOrders, err := t.getOrders() - if err != nil { - return err - } - - // Only keep the new orders - orders := util.SliceFilter(allOrders, func(o orderResponseItem) bool { return o.OrderID > lastOrder.OrderID }) - - if len(orders) == 0 { - return nil - } - - // Adjust categories - t.adjustCategories(orders) - - // Insert all new orders - errs := make([]error, 0) - for _, order := range orders { - _, err := t.db.Queries.CreateTap(context.Background(), sqlc.CreateTapParams{ - OrderID: order.OrderID, - OrderCreatedAt: pgtype.Timestamptz{Time: order.OrderCreatedAt, Valid: true}, - Name: order.ProductName, - Category: order.ProductCategory, - }) - if err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} - -// adjustCategories changes the categories of the orders to the custom ones -func (t *Tap) adjustCategories(orders []orderResponseItem) { - for i := range orders { - order := &orders[i] // Take a pointer to the struct to modify it directly - switch order.ProductCategory { - case "food": - order.ProductCategory = "Food" - case "other": - order.ProductCategory = "Other" - case "beverages": - // Atm only beverages get special categories - if strings.Contains(order.ProductName, "Mate") || strings.Contains(order.ProductName, "Mio Mio") { - order.ProductCategory = "Mate" - } else if slices.ContainsFunc(t.beers, func(beer string) bool { return strings.Contains(order.ProductName, beer) }) { - order.ProductCategory = "Beer" - } else { - order.ProductCategory = "Soft" - } - } - } -} diff --git a/internal/pkg/zess/api.go b/internal/pkg/zess/api.go deleted file mode 100644 index 3b26ff1..0000000 --- a/internal/pkg/zess/api.go +++ /dev/null @@ -1,58 +0,0 @@ -package zess - -import ( - "errors" - "fmt" - - "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "go.uber.org/zap" -) - -func (z *Zess) getSeasons() (*[]*dto.Season, error) { - zap.S().Info("Zess: Getting seasons") - - req := fiber.Get(z.api + "/seasons") - - res := new([]*dto.Season) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return nil, errors.Join(append([]error{errors.New("Zess: Season API request failed")}, errs...)...) - } - if status != fiber.StatusOK { - return nil, errors.New("error getting seasons") - } - - errs = make([]error, 0) - for _, season := range *res { - if err := dto.Validate.Struct(season); err != nil { - errs = append(errs, err) - } - } - - return res, errors.Join(errs...) -} - -func (z *Zess) getScans() (*[]*dto.Scan, error) { - zap.S().Info("Zess: Getting scans") - - req := fiber.Get(z.api + "/recent_scans") - - res := new([]*dto.Scan) - status, _, errs := req.Struct(res) - if len(errs) > 0 { - return nil, errors.Join(append(errs, errors.New("Zess: Scan API request failed"))...) - } - if status != fiber.StatusOK { - return nil, fmt.Errorf("Zess: Scan API returned bad status code %d", status) - } - - errs = make([]error, 0) - for _, scan := range *res { - if err := dto.Validate.Struct(scan); err != nil { - errs = append(errs, err) - } - } - - return res, errors.Join(errs...) -} diff --git a/internal/pkg/zess/zess.go b/internal/pkg/zess/zess.go deleted file mode 100644 index 469b4c2..0000000 --- a/internal/pkg/zess/zess.go +++ /dev/null @@ -1,94 +0,0 @@ -// Package zess provides all zess related logic -package zess - -import ( - "context" - "errors" - "slices" - - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/pkg/util" -) - -// Zess represents a zess instance -type Zess struct { - db *db.DB - api string -} - -// New creates a new zess instance -func New(db *db.DB) *Zess { - return &Zess{ - db: db, - api: config.GetDefaultString("backend.zess.api", "https://zess.zeus.gent/api"), - } -} - -// UpdateSeasons updates the seasons -func (z *Zess) UpdateSeasons() error { - seasons, err := z.db.Queries.GetAllSeasons(context.Background()) - if err != nil && err != pgx.ErrNoRows { - return err - } - - // Get all seasons from zess - zessSeasons, err := z.getSeasons() - if err != nil { - return err - } - - if slices.CompareFunc(util.SliceMap(seasons, dto.SeasonDTO), *zessSeasons, dto.SeasonCmp) == 0 { - return nil - } - - // The seasons differ - // Delete all existing and enter the new ones - if _, err := z.db.Queries.DeleteSeasonAll(context.Background()); err != nil { - return err - } - - var errs []error - for _, season := range *zessSeasons { - if _, err := z.db.Queries.CreateSeason(context.Background(), season.CreateParams()); err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} - -// UpdateScans updates the scans -func (z *Zess) UpdateScans() error { - lastScan, err := z.db.Queries.GetLastScan(context.Background()) - if err != nil { - if err != pgx.ErrNoRows { - return err - } - - lastScan = sqlc.Scan{ScanID: -1} - } - - // Get all scans - zessScans, err := z.getScans() - if err != nil { - return err - } - - errs := make([]error, 0) - for _, scan := range *zessScans { - if scan.ScanID <= lastScan.ScanID { - continue - } - - _, err := z.db.Queries.CreateScan(context.Background(), scan.CreateParams()) - if err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} diff --git a/internal/server/api/message.go b/internal/server/api/message.go new file mode 100644 index 0000000..69aaa2b --- /dev/null +++ b/internal/server/api/message.go @@ -0,0 +1,45 @@ +package api + +import ( + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/server/dto" + "github.com/zeusWPI/scc/internal/server/service" +) + +type Message struct { + router fiber.Router + message service.Message +} + +func NewMessage(router fiber.Router, service service.Service) *Message { + api := &Message{ + router: router.Group("/messages"), + message: *service.NewMessage(), + } + + api.createRoutes() + + return api +} + +func (m *Message) createRoutes() { + m.router.Post("/", m.create) +} + +func (m *Message) create(c *fiber.Ctx) error { + var message dto.Message + + if err := c.BodyParser(&message); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if err := dto.Validate.Struct(message); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + newMessage, err := m.message.Create(c.Context(), message) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated).JSON(newMessage) +} diff --git a/internal/server/api/song.go b/internal/server/api/song.go new file mode 100644 index 0000000..6919731 --- /dev/null +++ b/internal/server/api/song.go @@ -0,0 +1,44 @@ +package api + +import ( + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/server/dto" + "github.com/zeusWPI/scc/internal/server/service" +) + +type Song struct { + router fiber.Router + song service.Song +} + +func NewSong(router fiber.Router, service service.Service) *Song { + api := &Song{ + router: router.Group("/song"), + song: *service.NewSong(), + } + + api.createRoutes() + + return api +} + +func (s *Song) createRoutes() { + s.router.Post("/", s.new) +} + +func (s *Song) new(c *fiber.Ctx) error { + var song dto.Song + + if err := c.BodyParser(&song); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if err := dto.Validate.Struct(song); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err := s.song.New(c.Context(), song); err != nil { + return err + } + + return c.SendStatus(fiber.StatusOK) +} diff --git a/internal/server/dto/dto.go b/internal/server/dto/dto.go new file mode 100644 index 0000000..4d3a29f --- /dev/null +++ b/internal/server/dto/dto.go @@ -0,0 +1,6 @@ +// Package dto forms the bridge between the api data and the internal models +package dto + +import "github.com/go-playground/validator/v10" + +var Validate = validator.New(validator.WithRequiredStructEnabled()) diff --git a/internal/server/dto/message.go b/internal/server/dto/message.go new file mode 100644 index 0000000..9ea1a84 --- /dev/null +++ b/internal/server/dto/message.go @@ -0,0 +1,25 @@ +package dto + +import "github.com/zeusWPI/scc/internal/database/model" + +type Message struct { + Name string `json:"name" validate:"required"` + IP string `json:"ip" validate:"required"` + Message string `json:"message" validate:"required"` +} + +func MessageDTO(msg *model.Message) Message { + return Message{ + Name: msg.Name, + IP: msg.IP, + Message: msg.Message, + } +} + +func (m *Message) ToModel() *model.Message { + return &model.Message{ + Name: m.Name, + IP: m.IP, + Message: m.Message, + } +} diff --git a/internal/server/dto/song.go b/internal/server/dto/song.go new file mode 100644 index 0000000..c150fcc --- /dev/null +++ b/internal/server/dto/song.go @@ -0,0 +1,13 @@ +package dto + +import "github.com/zeusWPI/scc/internal/database/model" + +type Song struct { + SpotifyID string `json:"spotify_id"` +} + +func (s *Song) ToModel() *model.Song { + return &model.Song{ + SpotifyID: s.SpotifyID, + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..e04f19d --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,61 @@ +// Package server starts the API server +package server + +import ( + "fmt" + + "github.com/gofiber/contrib/fiberzap" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + routers "github.com/zeusWPI/scc/internal/server/api" + "github.com/zeusWPI/scc/internal/server/service" + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +type Server struct { + *fiber.App + Addr string +} + +func New(service service.Service) *Server { + env := config.GetDefaultString("app.env", "development") + + // Construct app + app := fiber.New(fiber.Config{ + BodyLimit: 16 * 1024 * 1024, + ReadBufferSize: 8096, + }) + + app.Use(fiberzap.New(fiberzap.Config{ + Logger: zap.L(), + })) + if env != "production" { + app.Use(cors.New(cors.Config{ + AllowOrigins: "http://localhost:3000", + AllowHeaders: "Origin, Content-Type, Accept, Access-Control-Allow-Origin", + AllowCredentials: true, + })) + } + + // Register routes + api := app.Group("/api") + + routers.NewMessage(api, service) + routers.NewSong(api, service) + + // Fallback + app.All("/api*", func(c *fiber.Ctx) error { + return c.SendStatus(404) + }) + + port := config.GetDefaultInt("server.port", 4000) + host := config.GetDefaultString("server.host", "0.0.0.0") + + srv := &Server{ + Addr: fmt.Sprintf("%s:%d", host, port), + App: app, + } + + return srv +} diff --git a/internal/server/service/message.go b/internal/server/service/message.go new file mode 100644 index 0000000..e4fb62e --- /dev/null +++ b/internal/server/service/message.go @@ -0,0 +1,36 @@ +package service + +import ( + "context" + + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/buzzer" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/internal/server/dto" + "go.uber.org/zap" +) + +type Message struct { + message repository.Message + + buzzer buzzer.Client +} + +func (s *Service) NewMessage() *Message { + return &Message{ + message: *s.repo.NewMessage(), + buzzer: *buzzer.New(), + } +} + +func (m *Message) Create(ctx context.Context, msgSave dto.Message) (dto.Message, error) { + msg := msgSave.ToModel() + if err := m.message.Create(ctx, msg); err != nil { + zap.S().Error(err) + return dto.Message{}, fiber.ErrInternalServerError + } + + m.buzzer.Play() + + return dto.MessageDTO(msg), nil +} diff --git a/internal/server/service/service.go b/internal/server/service/service.go new file mode 100644 index 0000000..ef091f6 --- /dev/null +++ b/internal/server/service/service.go @@ -0,0 +1,15 @@ +package service + +import ( + "github.com/zeusWPI/scc/internal/database/repository" +) + +type Service struct { + repo repository.Repository +} + +func New(repo repository.Repository) *Service { + return &Service{ + repo: repo, + } +} diff --git a/internal/server/service/song.go b/internal/server/service/song.go new file mode 100644 index 0000000..4804542 --- /dev/null +++ b/internal/server/service/song.go @@ -0,0 +1,70 @@ +package service + +import ( + "context" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/internal/server/dto" + songClient "github.com/zeusWPI/scc/internal/song" + "go.uber.org/zap" +) + +type Song struct { + song repository.Song +} + +func (s *Service) NewSong() *Song { + return &Song{ + song: *s.repo.NewSong(), + } +} + +func (s *Song) New(_ context.Context, songSave dto.Song) error { + song := songSave.ToModel() + + // Run in the background as it can take some time + go func(ctx context.Context, song *model.Song) { + if err := s.save(ctx, song); err != nil { + zap.S().Error(err) + return + } + + if err := s.saveHistory(ctx, *song); err != nil { + zap.S().Error(err) + return + } + }(context.Background(), song) + + return nil +} + +func (s *Song) save(ctx context.Context, song *model.Song) error { + songDB, err := s.song.GetBySpotify(ctx, song.SpotifyID) + if err != nil { + return err + } + if songDB != nil { + // Song is already in the database + *song = *songDB + return nil + } + + if err := songClient.C.Populate(song); err != nil { + return err + } + + if err := s.song.Create(ctx, song); err != nil { + return err + } + + return nil +} + +func (s *Song) saveHistory(ctx context.Context, song model.Song) error { + if err := s.song.CreateHistory(ctx, song); err != nil { + return err + } + + return nil +} diff --git a/internal/song/lrc.go b/internal/song/lrc.go new file mode 100644 index 0000000..52b0462 --- /dev/null +++ b/internal/song/lrc.go @@ -0,0 +1,71 @@ +package song + +import ( + "errors" + "fmt" + "net/url" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/database/model" + "go.uber.org/zap" +) + +type lyricsResponse struct { + Instrumental bool `json:"instrumental"` + PlainLyrics string `json:"plainLyrics"` + SyncedLyrics string `json:"SyncedLyrics"` +} + +func (c *client) getLyrics(song *model.Song) error { + zap.S().Info("Song: Getting lyrics for ", song.Title) + + // Get most popular artist + if len(song.Artists) == 0 { + return fmt.Errorf("no artists for track: %v", song) + } + artist := song.Artists[0] + + // Construct url + params := url.Values{} + params.Set("artist_name", artist.Name) + params.Set("track_name", song.Title) + params.Set("album_name", song.Album) + params.Set("duration", strconv.Itoa(song.DurationMS/1000)) + + req := fiber.Get(fmt.Sprintf("%s/get?%s", apiLrc, params.Encode())) + + res := new(lyricsResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(append([]error{errors.New("lyrics request failed")}, errs...)...) + } + if status != fiber.StatusOK { + if status == fiber.StatusNotFound { + // Lyrics not found + song.LyricsType = model.LyricsMissing + return nil + } + + return fmt.Errorf("lyrics request wrong status code %d", status) + } + if (res == &lyricsResponse{}) { + return errors.New("lyrics request returned empty struct") + } + + switch { + case res.SyncedLyrics != "": + song.LyricsType = model.LyricsSynced + song.Lyrics = res.SyncedLyrics + case res.PlainLyrics != "": + song.LyricsType = model.LyricsPlain + song.Lyrics = res.PlainLyrics + case res.Instrumental: + song.LyricsType = model.LyricsInstrumental + song.Lyrics = "" + default: + song.LyricsType = model.LyricsMissing + } + + return nil +} diff --git a/internal/song/song.go b/internal/song/song.go new file mode 100644 index 0000000..00030cf --- /dev/null +++ b/internal/song/song.go @@ -0,0 +1,74 @@ +package song + +import ( + "errors" + "time" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +const ( + apiSpotify = "https://api.spotify.com/v1" + apiAccount = "https://accounts.spotify.com/api/token" + apiLrc = "https://lrclib.net/api" +) + +type client struct { + clientID string + clientSecret string + + accessToken string + expiresTime int64 +} + +var C *client + +func Init() error { + clientID := config.GetDefaultString("backend.song.spotify_client_id", "") + clientSecret := config.GetDefaultString("backend.song.spotify_client_secret", "") + + zap.S().Info(clientID) + + if clientID == "" || clientSecret == "" { + return errors.New("spotify client id or secret not set") + } + + C = &client{ + clientID: clientID, + clientSecret: clientSecret, + expiresTime: 0, + } + + return nil +} + +func (c *client) Populate(song *model.Song) error { + zap.S().Info("Populating song") + + if c.expiresTime <= time.Now().Unix() { + err := c.refreshToken() + if err != nil { + return err + } + } + + if err := c.populateSong(song); err != nil { + return err + } + + for i := range song.Artists { + if err := c.populateArtist(&song.Artists[i]); err != nil { + return err + } + } + + if err := c.getLyrics(song); err != nil { + return err + } + + zap.S().Info("Populated song") + + return nil +} diff --git a/internal/song/spotify.go b/internal/song/spotify.go new file mode 100644 index 0000000..7c4a02a --- /dev/null +++ b/internal/song/spotify.go @@ -0,0 +1,113 @@ +package song + +import ( + "errors" + "fmt" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/database/model" + "go.uber.org/zap" +) + +type accountResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +func (c *client) refreshToken() error { + zap.S().Info("Song: Refreshing spotify access token") + + form := &fiber.Args{} + form.Add("grant_type", "client_credentials") + + req := fiber.Post(apiAccount).Form(form).BasicAuth(c.clientID, c.clientSecret) + + res := new(accountResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(append([]error{errors.New("spotify token refresh request failed")}, errs...)...) + } + if status != fiber.StatusOK { + return errors.New("getting spotify account access token") + } + + c.accessToken = res.AccessToken + c.expiresTime = time.Now().Unix() + res.ExpiresIn + + return nil +} + +type trackArtist struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type trackAlbum struct { + Name string `json:"name"` +} + +type trackResponse struct { + Name string `json:"name"` + Album trackAlbum `json:"album"` + Artists []trackArtist `json:"artists"` + DurationMS int32 `json:"duration_ms"` +} + +func (c *client) populateSong(song *model.Song) error { + zap.S().Info("Song: Getting track info for id: ", song.SpotifyID) + + req := fiber.Get(fmt.Sprintf("%s/%s/%s", apiSpotify, "tracks", song.SpotifyID)). + Set("Authorization", "Bearer "+c.accessToken) + + res := new(trackResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(append([]error{errors.New("track request failed")}, errs...)...) + } + if status != fiber.StatusOK { + return fmt.Errorf("track request wrong status code %d", status) + } + + song.Title = res.Name + song.Album = res.Album.Name + song.DurationMS = int(res.DurationMS) + + for _, a := range res.Artists { + song.Artists = append(song.Artists, model.Artist{ + Name: a.Name, + SpotifyID: a.ID, + }) + } + + return nil +} + +type artistResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Genres []string `json:"genres"` +} + +func (c *client) populateArtist(artist *model.Artist) error { + zap.S().Info("Song: Getting artists info for ", artist.SpotifyID) + + req := fiber.Get(fmt.Sprintf("%s/%s/%s", apiSpotify, "artists", artist.SpotifyID)). + Set("Authorization", "Bearer "+c.accessToken) + + res := new(artistResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(append([]error{errors.New("artist request failed")}, errs...)...) + } + if status != fiber.StatusOK { + return fmt.Errorf("artist request wrong status code %d", status) + } + + for _, genre := range res.Genres { + artist.Genres = append(artist.Genres, model.Genre{Genre: genre}) + } + + return nil +} diff --git a/internal/tap/api.go b/internal/tap/api.go new file mode 100644 index 0000000..bf575af --- /dev/null +++ b/internal/tap/api.go @@ -0,0 +1,80 @@ +package tap + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/utils" +) + +type orderResponseItem struct { + OrderID int `json:"order_id"` + OrderCreatedAt time.Time `json:"order_created_at"` + ProductName string `json:"product_name"` + ProductCategory string `json:"product_category"` +} + +type orderResponse struct { + Orders []orderResponseItem `json:"orders"` +} + +func (o orderResponse) ToModel(beers []string) []model.Tap { + taps := make([]model.Tap, 0, len(o.Orders)) + + for _, order := range o.Orders { + var category model.TapCategory = "unknown" + switch order.ProductCategory { + case "food": + category = model.Food + case "beverages": + switch { + case strings.Contains(order.ProductName, "Mate") || strings.Contains(order.ProductName, "Mio Mio"): + category = model.Mate + case slices.ContainsFunc(beers, func(beer string) bool { return strings.Contains(order.ProductName, beer) }): + category = model.Beer + default: + category = model.Soft + } + } + + taps = append(taps, model.Tap{ + OrderID: order.OrderID, + CreatedAt: order.OrderCreatedAt, + Name: order.ProductName, + Category: category, + }) + } + + return taps +} + +func (t *Tap) getOrders(ctx context.Context) ([]model.Tap, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: t.url + "/recent", + }) + if err != nil { + return nil, fmt.Errorf("get all tap orders %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code %s", resp.Status) + } + + var orders orderResponse + + if err := json.NewDecoder(resp.Body).Decode(&orders); err != nil { + return nil, fmt.Errorf("decode tap order response %w", err) + } + + return orders.ToModel(t.beers), nil +} diff --git a/internal/tap/tap.go b/internal/tap/tap.go new file mode 100644 index 0000000..a2bf758 --- /dev/null +++ b/internal/tap/tap.go @@ -0,0 +1,70 @@ +// Package tap provides all tap related logic +package tap + +import ( + "context" + "slices" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Tap struct { + tap repository.Tap + url string + beers []string +} + +var defaultBeers = []string{ + "Schelfaut", + "Duvel", + "Fourchette", + "Jupiler", + "Karmeliet", + "Kriek", + "Chouffe", + "Maes", + "Somersby", + "Sportzot", + "Stella", +} + +func New(repo repository.Repository) *Tap { + return &Tap{ + tap: *repo.NewTap(), + url: config.GetDefaultString("backend.tap.url", "https://tap.zeus.gent"), + beers: config.GetDefaultStringSlice("backend.tap.beers", defaultBeers), + } +} + +// Update gets all new orders from tap +func (t *Tap) Update(ctx context.Context) error { + // Get latest order + lastOrder, err := t.tap.GetLast(ctx) + if err != nil { + return err + } + if lastOrder == nil { + lastOrder = &model.Tap{OrderID: -1} + } + + // Get all orders + allOrders, err := t.getOrders(ctx) + if err != nil { + return err + } + + // Only keep the new orders + orders := utils.SliceFilter(allOrders, func(o model.Tap) bool { return o.OrderID > lastOrder.OrderID }) + slices.SortFunc(orders, func(a, b model.Tap) int { return a.OrderID - b.OrderID }) + + for _, order := range orders { + if err := t.tap.Create(ctx, &order); err != nil { + return err + } + } + + return nil +} diff --git a/internal/zess/api.go b/internal/zess/api.go new file mode 100644 index 0000000..64146ca --- /dev/null +++ b/internal/zess/api.go @@ -0,0 +1,90 @@ +package zess + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/date" + "github.com/zeusWPI/scc/pkg/utils" +) + +type seasonAPI struct { + Name string `json:"name"` + Start date.Date `json:"start"` + End date.Date `json:"end"` + Current bool `json:"is_current"` +} + +func (s seasonAPI) toModel() model.Season { + return model.Season{ + Name: s.Name, + Start: s.Start, + End: s.End, + Current: s.Current, + } +} + +func (z *Zess) getSeasons(ctx context.Context) ([]model.Season, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: z.url + "/seasons", + }) + if err != nil { + return nil, fmt.Errorf("http get all zess seasons %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code %s", resp.Status) + } + + var seasons []seasonAPI + if err := json.NewDecoder(resp.Body).Decode(&seasons); err != nil { + return nil, fmt.Errorf("decode http zess seasons %w", err) + } + + return utils.SliceMap(seasons, func(s seasonAPI) model.Season { return s.toModel() }), nil +} + +type scanAPI struct { + ScanID int `json:"scan_id"` + ScanTime time.Time `json:"scan_time"` +} + +func (s scanAPI) toModel() model.Scan { + return model.Scan{ + ScanID: s.ScanID, + ScanTime: s.ScanTime, + } +} + +func (z *Zess) getScans(ctx context.Context) ([]model.Scan, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: z.url + "/recent_scans", + }) + if err != nil { + return nil, fmt.Errorf("http get recent zess scans %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code %s", resp.Status) + } + + var scans []scanAPI + if err := json.NewDecoder(resp.Body).Decode(&scans); err != nil { + return nil, fmt.Errorf("decode http zess scans %w", err) + } + + return utils.SliceMap(scans, func(s scanAPI) model.Scan { return s.toModel() }), nil +} diff --git a/internal/zess/zess.go b/internal/zess/zess.go new file mode 100644 index 0000000..d22cedc --- /dev/null +++ b/internal/zess/zess.go @@ -0,0 +1,76 @@ +// Package zess provides all zess related logic +package zess + +import ( + "context" + "slices" + + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/repository" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/utils" +) + +type Zess struct { + repo repository.Repository + scan repository.Scan + seasons repository.Season + url string +} + +func New(repo repository.Repository) *Zess { + return &Zess{ + repo: repo, + scan: *repo.NewScan(), + seasons: *repo.NewSeason(), + url: config.GetDefaultString("backend.zess.url", "https://zess.zeus.gent/api"), + } +} + +func (z *Zess) UpdateSeasons(ctx context.Context) error { + zessSeasons, err := z.getSeasons(ctx) + if err != nil { + return err + } + + return z.repo.WithRollback(ctx, func(ctx context.Context) error { + if err := z.seasons.DeleteAll(ctx); err != nil { + return err + } + + for _, season := range zessSeasons { + if err := z.seasons.Create(ctx, &season); err != nil { + return err + } + } + + return nil + }) +} + +func (z *Zess) UpdateScans(ctx context.Context) error { + lastScan, err := z.scan.GetLast(ctx) + if err != nil { + return err + } + if lastScan == nil { + lastScan = &model.Scan{ScanID: -1} + } + + // Get all scans + zessScans, err := z.getScans(ctx) + if err != nil { + return err + } + + scans := utils.SliceFilter(zessScans, func(s model.Scan) bool { return s.ScanID > lastScan.ScanID }) + slices.SortFunc(scans, func(a, b model.Scan) int { return a.ScanID - b.ScanID }) + + for _, scan := range scans { + if err := z.scan.Create(ctx, &scan); err != nil { + return err + } + } + + return nil +} diff --git a/makefile b/makefile index cda5bb3..d39c449 100644 --- a/makefile +++ b/makefile @@ -8,13 +8,11 @@ DB_USER := postgres DB_PASSWORD := postgres DB_NAME := scc -# Phony targets -.PHONY: all build build-backed build-tui clean run run-backend run-tui db sqlc create-migration goose migrate watch - -# Default target: build everything all: build -# Build targets +setup: + @go get tool + build: clean build-backend build-tui build-backend: clean-backend @@ -25,23 +23,6 @@ build-tui: clean-tui @echo "Building $(TUI_BIN)..." @go build -o $(TUI_BIN) $(TUI_SRC) -# Run targets -run: run-backend run-tui - -run-backend: - @[ -f $(BACKEND_BIN) ] || $(MAKE) build-backend - @./$(BACKEND_BIN) - -run-tui: - @[ -f $(TUI_BIN) ] || $(MAKE) build-tui - @read -p "Enter screen name: " screen; \ - ./$(TUI_BIN) $$screen - -# Run db -db: - @docker compose up - -# Clean targets clean: clean-backend clean-tui clean-backend: @@ -56,17 +37,38 @@ clean-tui: rm -f "$(TUI_BIN)"; \ fi -# SQL and migration targets -sqlc: - sqlc generate +backend: + @docker compose up -d + @go run $(BACKEND_SRC) + @docker compose down + +tui: + @read -p "Enter screen name: " screen; \ + go run $(TUI_SRC) -screen $$screen + +goose: + @docker compose down + @docker compose up db -d + @docker compose exec db bash -c 'until pg_isready -U postgres; do sleep 1; done' + @read -p "Action: " action; \ + go tool goose -dir ./db/migrations postgres "user=postgres password=postgres host=localhost port=5432 dbname=scc sslmode=disable" $$action + @docker compose down db + +migrate: + @docker compose down + @docker compose up db -d + @docker compose exec db bash -c 'until pg_isready -U postgres; do sleep 1; done' + @go tool goose -dir ./db/migrations postgres "user=postgres password=postgres host=localhost port=5432 dbname=scc sslmode=disable" up + @docker compose down db create-migration: @read -p "Enter migration name: " name; \ - goose -dir $(DB_DIR) create $$name sql + go tool goose -dir ./db/migrations create $$name sql -migrate: - @goose -dir $(DB_DIR) postgres "user=$(DB_USER) password=$(DB_PASSWORD) dbname=$(DB_NAME) host=localhost sslmode=disable" up +query: + @go tool sqlc generate -goose: - @read -p "Action: " action; \ - goose -dir $(DB_DIR) postgres "user=$(DB_USER) password=$(DB_PASSWORD) dbname=$(DB_NAME) host=localhost sslmode=disable" $$action +dead: + @go tool deadcode ./... + +.PHONY: all setup build build-backed build-tui clean clean-backend clean-tui backend tui create-migration goose query dead diff --git a/pkg/config/config.go b/pkg/config/config.go index b8ace59..19ae653 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,4 +1,4 @@ -// Package config provides all configuration related functions +// Package config lets you retrieve config variables package config import ( @@ -11,81 +11,70 @@ import ( func bindEnv(key string) { envName := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) - - _ = viper.BindEnv(key, envName) + // nolint:errcheck // we do not care if it binds + viper.BindEnv(key, envName) } -// Init initializes the configuration func Init() error { if err := godotenv.Load(); err != nil { - return err + fmt.Println("Failed to load .env file", err) } viper.AutomaticEnv() env := GetDefaultString("app.env", "development") - viper.SetConfigName(fmt.Sprintf("%s.yaml", strings.ToLower(env))) + viper.SetConfigName(strings.ToLower(env) + ".yml") viper.SetConfigType("yaml") viper.AddConfigPath("./config") return viper.ReadInConfig() } -// GetString returns the value of the key in string func GetString(key string) string { bindEnv(key) return viper.GetString(key) } -// GetDefaultString returns the value of the key in string or a default value func GetDefaultString(key, defaultValue string) string { viper.SetDefault(key, defaultValue) return GetString(key) } -// GetStringSlice returns the value of the key in string slice func GetStringSlice(key string) []string { bindEnv(key) return viper.GetStringSlice(key) } -// GetDefaultStringSlice returns the value of the key in string slice or a default value func GetDefaultStringSlice(key string, defaultValue []string) []string { viper.SetDefault(key, defaultValue) return GetStringSlice(key) } -// GetInt returns the value of the key in int func GetInt(key string) int { bindEnv(key) return viper.GetInt(key) } -// GetDefaultInt returns the value of the key in int or a default value func GetDefaultInt(key string, defaultVal int) int { viper.SetDefault(key, defaultVal) return GetInt(key) } -// GetUint16 returns the value of the key in uint16 func GetUint16(key string) uint16 { bindEnv(key) return viper.GetUint16(key) } -// GetDefaultUint16 returns the value of the key in uint16 or a default value func GetDefaultUint16(key string, defaultVal uint16) uint16 { viper.SetDefault(key, defaultVal) return GetUint16(key) } -// GetBool returns the value of the key in bool func GetBool(key string) bool { bindEnv(key) return viper.GetBool(key) } -// GetDefaultBool returns the value of the key in bool or a default value func GetDefaultBool(key string, defaultVal bool) bool { viper.SetDefault(key, defaultVal) return GetBool(key) diff --git a/pkg/db/db.go b/pkg/db/db.go new file mode 100644 index 0000000..d4e7e03 --- /dev/null +++ b/pkg/db/db.go @@ -0,0 +1,15 @@ +// Package db connects with the databank +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/zeusWPI/scc/internal/database/sqlc" +) + +type DB interface { + WithRollback(ctx context.Context, fn func(q *sqlc.Queries) error) error + Pool() *pgxpool.Pool + Queries() *sqlc.Queries +} diff --git a/internal/pkg/db/db.go b/pkg/db/psql.go similarity index 51% rename from internal/pkg/db/db.go rename to pkg/db/psql.go index 35b2223..83e9cc8 100644 --- a/internal/pkg/db/db.go +++ b/pkg/db/psql.go @@ -1,22 +1,23 @@ -// Package db provides a database connection package db import ( "context" + "fmt" "github.com/jackc/pgx/v5/pgxpool" - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + "github.com/zeusWPI/scc/internal/database/sqlc" "github.com/zeusWPI/scc/pkg/config" ) -// DB represents a database connection -type DB struct { - Pool *pgxpool.Pool - Queries *sqlc.Queries +type psql struct { + pool *pgxpool.Pool + queries *sqlc.Queries } -// New creates a new database connection -func New() (*DB, error) { +// Interface compliance +var _ DB = (*psql)(nil) + +func NewPSQL() (DB, error) { pgConfig, err := pgxpool.ParseConfig("") if err != nil { return nil, err @@ -24,7 +25,7 @@ func New() (*DB, error) { pgConfig.ConnConfig.Host = config.GetDefaultString("db.host", "localhost") pgConfig.ConnConfig.Port = config.GetDefaultUint16("db.port", 5432) - pgConfig.ConnConfig.Database = config.GetDefaultString("db.database", "scc") + pgConfig.ConnConfig.Database = config.GetDefaultString("db.database", "website") pgConfig.ConnConfig.User = config.GetDefaultString("db.user", "postgres") pgConfig.ConnConfig.Password = config.GetDefaultString("db.password", "postgres") @@ -39,5 +40,31 @@ func New() (*DB, error) { queries := sqlc.New(pool) - return &DB{Pool: pool, Queries: queries}, nil + return &psql{pool: pool, queries: queries}, nil +} + +func (p *psql) WithRollback(ctx context.Context, fn func(q *sqlc.Queries) error) error { + tx, err := p.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + queries := sqlc.New(tx) + + if err := fn(queries); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (p *psql) Pool() *pgxpool.Pool { + return p.pool +} + +func (p *psql) Queries() *sqlc.Queries { + return p.queries } diff --git a/pkg/logger/logger.go b/pkg/logger/zap.go similarity index 52% rename from pkg/logger/logger.go rename to pkg/logger/zap.go index 6dde1cf..6f4790a 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/zap.go @@ -1,4 +1,4 @@ -// Package logger provides a logger instance +// Package logger initiates a zap logger package logger import ( @@ -9,21 +9,10 @@ import ( "go.uber.org/zap" ) -// New returns a new logger instance func New(logFile string, console bool) (*zap.Logger, error) { - // Create logs directory err := os.Mkdir("logs", os.ModePerm) if err != nil && !os.IsExist(err) { - return nil, err - } - - // Create logger - var zapConfig zap.Config - env := config.GetDefaultString("app.env", "development") - if env == "development" { - zapConfig = zap.NewDevelopmentConfig() - } else { - zapConfig = zap.NewProductionConfig() + return nil, fmt.Errorf("create logs directory %w", err) } outputPaths := []string{fmt.Sprintf("logs/%s.log", logFile)} @@ -36,10 +25,25 @@ func New(logFile string, console bool) (*zap.Logger, error) { errorOutputPaths = append(errorOutputPaths, "stderr") } - zapConfig.OutputPaths = outputPaths - zapConfig.ErrorOutputPaths = errorOutputPaths + var logger *zap.Logger + env := config.GetDefaultString("app.env", "development") + + if env != "production" { + cfg := zap.NewDevelopmentConfig() + cfg.OutputPaths = outputPaths + cfg.ErrorOutputPaths = errorOutputPaths + + logger = zap.Must(cfg.Build(zap.AddStacktrace(zap.WarnLevel))) + } else { + cfg := zap.NewProductionConfig() + cfg.Level.SetLevel(zap.WarnLevel) + cfg.OutputPaths = outputPaths + cfg.ErrorOutputPaths = errorOutputPaths + + logger = zap.Must(cfg.Build()) + } - logger := zap.Must(zapConfig.Build()) + logger = logger.With(zap.String("env", env)) return logger, nil } diff --git a/internal/pkg/lyrics/instrumental.go b/pkg/lyrics/instrumental.go similarity index 95% rename from internal/pkg/lyrics/instrumental.go rename to pkg/lyrics/instrumental.go index a34d73e..f2ca075 100644 --- a/internal/pkg/lyrics/instrumental.go +++ b/pkg/lyrics/instrumental.go @@ -5,26 +5,23 @@ import ( "math/rand/v2" "time" - "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/internal/database/model" ) -// Instrumental represents the lyrics for an instrumental song type Instrumental struct { - song dto.Song + song model.Song lyrics []Lyric i int } -func newInstrumental(song dto.Song) Lyrics { +func newInstrumental(song model.Song) Lyrics { return &Instrumental{song: song, lyrics: generateInstrumental(time.Duration(song.DurationMS) * time.Millisecond), i: 0} } -// GetSong returns the song associated to the lyrics -func (i *Instrumental) GetSong() dto.Song { +func (i *Instrumental) GetSong() model.Song { return i.song } -// Previous provides the previous `amount` of lyrics without affecting the current lyric func (i *Instrumental) Previous(amount int) []Lyric { lyrics := make([]Lyric, 0, amount) @@ -39,7 +36,6 @@ func (i *Instrumental) Previous(amount int) []Lyric { return lyrics } -// Current provides the current lyric if any. func (i *Instrumental) Current() (Lyric, bool) { if i.i >= len(i.lyrics) { return Lyric{}, false @@ -48,8 +44,6 @@ func (i *Instrumental) Current() (Lyric, bool) { return i.lyrics[i.i], true } -// Next provides the next lyric. -// In this case it's always nothing func (i *Instrumental) Next() (Lyric, bool) { if i.i+1 >= len(i.lyrics) { return Lyric{}, false @@ -59,12 +53,10 @@ func (i *Instrumental) Next() (Lyric, bool) { return i.lyrics[i.i-1], true } -// Upcoming provides the next `amount` lyrics without affecting the current lyric -// In this case it's always empty func (i *Instrumental) Upcoming(amount int) []Lyric { lyrics := make([]Lyric, 0, amount) - for j := 0; j < amount; j++ { + for j := range amount { if i.i+j >= len(i.lyrics) { break } @@ -75,7 +67,6 @@ func (i *Instrumental) Upcoming(amount int) []Lyric { return lyrics } -// Progress shows the fraction of lyrics that have been used. func (i *Instrumental) Progress() float64 { return float64(i.i) / float64(len(i.lyrics)) } diff --git a/internal/pkg/lyrics/lrc.go b/pkg/lyrics/lrc.go similarity index 77% rename from internal/pkg/lyrics/lrc.go rename to pkg/lyrics/lrc.go index 02f9b1f..14f5c34 100644 --- a/internal/pkg/lyrics/lrc.go +++ b/pkg/lyrics/lrc.go @@ -6,28 +6,25 @@ import ( "strings" "time" - "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/internal/database/model" ) var re = regexp.MustCompile(`^\[(\d{2}):(\d{2})\.(\d{2})\]`) -// LRC represents synced lyrics type LRC struct { - song dto.Song + song model.Song lyrics []Lyric i int } -func newLRC(song dto.Song) Lyrics { +func newLRC(song model.Song) Lyrics { return &LRC{song: song, lyrics: parseLRC(song.Lyrics, time.Duration(song.DurationMS)), i: 0} } -// GetSong returns the song associated to the lyrics -func (l *LRC) GetSong() dto.Song { +func (l *LRC) GetSong() model.Song { return l.song } -// Previous provides the previous `amount` of lyrics without affecting the current lyric func (l *LRC) Previous(amount int) []Lyric { lyrics := make([]Lyric, 0, amount) @@ -42,8 +39,6 @@ func (l *LRC) Previous(amount int) []Lyric { return lyrics } -// Current provides the current lyric if any. -// If the song is finished the boolean is set to false func (l *LRC) Current() (Lyric, bool) { if l.i >= len(l.lyrics) { return Lyric{}, false @@ -52,8 +47,6 @@ func (l *LRC) Current() (Lyric, bool) { return l.lyrics[l.i], true } -// Next provides the next lyric if any. -// If the song is finished the boolean is set to false func (l *LRC) Next() (Lyric, bool) { if l.i+1 >= len(l.lyrics) { return Lyric{}, false @@ -63,7 +56,6 @@ func (l *LRC) Next() (Lyric, bool) { return l.lyrics[l.i-1], true } -// Upcoming provides the next `amount` lyrics without affecting the current lyric func (l *LRC) Upcoming(amount int) []Lyric { lyrics := make([]Lyric, 0, amount) @@ -78,7 +70,6 @@ func (l *LRC) Upcoming(amount int) []Lyric { return lyrics } -// Progress shows the fraction of lyrics that have been used. func (l *LRC) Progress() float64 { return float64(l.i) / float64(len(l.lyrics)) } diff --git a/pkg/lyrics/lyrics.go b/pkg/lyrics/lyrics.go new file mode 100644 index 0000000..b5bcddc --- /dev/null +++ b/pkg/lyrics/lyrics.go @@ -0,0 +1,38 @@ +// Package lyrics provides a way to work with both synced and plain lyrics +package lyrics + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/model" +) + +// Lyrics is the common interface for different lyric types +type Lyrics interface { + GetSong() model.Song // GetSong returns the song associated to the lyrics + Previous(int) []Lyric // Previous returns the previous 'int amount' of lyrics without affecting the current lyric + Current() (Lyric, bool) // Current provides the current lyric and a bool indicating if there is one + Next() (Lyric, bool) // Next returns the next lyric and a bool if there are any + Upcoming(int) []Lyric // Upcoming returns the next `int amount` of lyrics without affecting the current lyric + Progress() float64 // Progress returns the fraction of lyrics that have been used +} + +// Lyric represents a single lyric line. +type Lyric struct { + Text string + Duration time.Duration +} + +// New returns a new object that implements the Lyrics interface +func New(song model.Song) Lyrics { + switch song.LyricsType { + case model.LyricsSynced: + return newLRC(song) + case model.LyricsInstrumental: + return newInstrumental(song) + case model.LyricsPlain: + return newPlain(song) + default: + return newMissing(song) + } +} diff --git a/pkg/lyrics/missing.go b/pkg/lyrics/missing.go new file mode 100644 index 0000000..b31dd6c --- /dev/null +++ b/pkg/lyrics/missing.go @@ -0,0 +1,45 @@ +package lyrics + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/model" +) + +type Missing struct { + song model.Song + lyrics Lyric +} + +func newMissing(song model.Song) Lyrics { + lyric := Lyric{ + Text: "Missing lyrics\n\nHelp the open source community by adding them to\nhttps://lrclib.net/", + Duration: time.Duration(song.DurationMS) * time.Millisecond, + } + + return &Missing{song: song, lyrics: lyric} +} + +func (m *Missing) GetSong() model.Song { + return m.song +} + +func (m *Missing) Previous(_ int) []Lyric { + return []Lyric{} +} + +func (m *Missing) Current() (Lyric, bool) { + return m.lyrics, true +} + +func (m *Missing) Next() (Lyric, bool) { + return Lyric{}, false +} + +func (m *Missing) Upcoming(_ int) []Lyric { + return []Lyric{} +} + +func (m *Missing) Progress() float64 { + return 1 +} diff --git a/pkg/lyrics/plain.go b/pkg/lyrics/plain.go new file mode 100644 index 0000000..d5d098f --- /dev/null +++ b/pkg/lyrics/plain.go @@ -0,0 +1,44 @@ +package lyrics + +import ( + "time" + + "github.com/zeusWPI/scc/internal/database/model" +) + +type Plain struct { + song model.Song + lyrics Lyric +} + +func newPlain(song model.Song) Lyrics { + lyric := Lyric{ + Text: song.Lyrics, + Duration: time.Duration(song.DurationMS) * time.Millisecond, + } + return &Plain{song: song, lyrics: lyric} +} + +func (p *Plain) GetSong() model.Song { + return p.song +} + +func (p *Plain) Previous(_ int) []Lyric { + return []Lyric{} +} + +func (p *Plain) Current() (Lyric, bool) { + return p.lyrics, true +} + +func (p *Plain) Next() (Lyric, bool) { + return Lyric{}, false +} + +func (p *Plain) Upcoming(_ int) []Lyric { + return []Lyric{} +} + +func (p *Plain) Progress() float64 { + return 1 +} diff --git a/pkg/util/map.go b/pkg/util/map.go deleted file mode 100644 index 5e45f39..0000000 --- a/pkg/util/map.go +++ /dev/null @@ -1,10 +0,0 @@ -package util - -// Keys returns the keys of a map -func Keys[T comparable, U any](m map[T]U) []T { - keys := make([]T, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} diff --git a/pkg/util/slice.go b/pkg/util/slice.go deleted file mode 100644 index 1840f51..0000000 --- a/pkg/util/slice.go +++ /dev/null @@ -1,31 +0,0 @@ -// Package util provides utility functions -package util - -// SliceMap maps a slice of type T to a slice of type U -func SliceMap[T any, U any](input []T, mapFunc func(T) U) []U { - v := make([]U, len(input)) - for i, item := range input { - v[i] = mapFunc(item) - } - return v -} - -// SliceStringJoin joins a slice of type T to a string with a separator -func SliceStringJoin[T any](input []T, sep string, mapFunc func(T) string) string { - str := "" - for _, item := range input { - str += mapFunc(item) + sep - } - return str[:len(str)-len(sep)] -} - -// SliceFilter filters a slice of type T based on a filter function -func SliceFilter[T any](input []T, filterFunc func(T) bool) []T { - v := make([]T, 0) - for _, item := range input { - if filterFunc(item) { - v = append(v, item) - } - } - return v -} diff --git a/pkg/utils/http.go b/pkg/utils/http.go new file mode 100644 index 0000000..8a86176 --- /dev/null +++ b/pkg/utils/http.go @@ -0,0 +1,33 @@ +package utils + +import ( + "context" + "fmt" + "io" + "net/http" +) + +type DoRequestValues struct { + Method string + URL string + Body io.Reader + Headers map[string]string +} + +func DoRequest(ctx context.Context, values DoRequestValues) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, values.Method, values.URL, values.Body) + if err != nil { + return nil, fmt.Errorf("new http request %+v | %w", values, err) + } + + for k, v := range values.Headers { + req.Header.Set(k, v) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("do http request %+v | %w", values, err) + } + + return resp, nil +} diff --git a/pkg/utils/image.go b/pkg/utils/image.go new file mode 100644 index 0000000..d22d2fc --- /dev/null +++ b/pkg/utils/image.go @@ -0,0 +1,29 @@ +package utils + +import ( + "image" + "image/color" +) + +func ImageEqual(img1, img2 image.Image) bool { + if !img1.Bounds().Eq(img2.Bounds()) { + return false + } + + bounds := img1.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + if !colorsEqual(img1.At(x, y), img2.At(x, y)) { + return false + } + } + } + + return true +} + +func colorsEqual(c1, c2 color.Color) bool { + r1, g1, b1, a1 := c1.RGBA() + r2, g2, b2, a2 := c2.RGBA() + return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2 +} diff --git a/pkg/utils/map.go b/pkg/utils/map.go new file mode 100644 index 0000000..12e7fa6 --- /dev/null +++ b/pkg/utils/map.go @@ -0,0 +1,29 @@ +// Package utils contains various util functions +package utils + +import ( + "fmt" +) + +// MapGetKeyAsType retrieves a key from a map and converts it to a given type +func MapGetKeyAsType[T any](key string, m map[string]interface{}) (T, error) { + if valueRaw, found := m[key]; found { + if value, ok := valueRaw.(T); ok { + return value, nil + } + } + + var zero T + return zero, fmt.Errorf("unable to find %s key in %v", key, m) +} + +// MapValues returns a slice of all values +func MapValues[T comparable, U any](input map[T]U) []U { + result := make([]U, 0, len(input)) + + for _, v := range input { + result = append(result, v) + } + + return result +} diff --git a/pkg/utils/periodic.go b/pkg/utils/periodic.go new file mode 100644 index 0000000..3b7370f --- /dev/null +++ b/pkg/utils/periodic.go @@ -0,0 +1,40 @@ +package utils + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +func Periodic(name string, interval time.Duration, fn func(ctx context.Context) error, done chan bool) { + zap.S().Infof("Starting periodic task for %s", name) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + update := func() { + zap.S().Infof("Running %s", name) + if err := fn(ctx); err != nil { + zap.S().Errorf("Error %s | %v", name, err) + } + } + + // Run immediatly once + update() + +loop: + for { + select { + case <-done: + break loop + case <-ticker.C: + update() + } + } + + zap.S().Infof("Stopping periodic task for %s", name) +} diff --git a/pkg/utils/slice.go b/pkg/utils/slice.go new file mode 100644 index 0000000..7011c33 --- /dev/null +++ b/pkg/utils/slice.go @@ -0,0 +1,153 @@ +package utils + +// SliceMap maps a slice of type T to a slice of type U +func SliceMap[T any, U any](input []T, mapFunc func(T) U) []U { + result := make([]U, len(input)) + + for i, item := range input { + result[i] = mapFunc(item) + } + + return result +} + +// SliceFilter returns a new slice consisting of elements that passed the filter function +func SliceFilter[T any](input []T, filter func(T) bool) []T { + var result []T + + for _, item := range input { + if filter(item) { + result = append(result, item) + } + } + + return result +} + +// SliceFind returns a pointer to the first item determined by the equal function, nil if not found +// The second argument returns true if found, false otherwise +func SliceFind[T any](input []T, equal func(T) bool) (*T, bool) { + for _, item := range input { + if equal(item) { + return &item, true + } + } + + return nil, false +} + +// SliceUnique filters out all duplicate elements +func SliceUnique[T comparable](input []T) []T { + result := make([]T, 0, len(input)) + seen := make(map[T]struct{}, len(input)) + + for _, item := range input { + if _, ok := seen[item]; !ok { + seen[item] = struct{}{} + result = append(result, item) + } + } + + return result +} + +// SliceReference converts a []T slice to []*T +func SliceReference[T any](input []T) []*T { + result := make([]*T, len(input)) + + for i, item := range input { + result[i] = &item + } + + return result +} + +// SliceDereference converts a []*T slice to []T +func SliceDereference[T any](input []*T) []T { + result := make([]T, len(input)) + + for i, ptr := range input { + result[i] = *ptr + } + + return result +} + +// SliceToMap maps a slice to a map with +// key -> result of toKey function +// value -> slice entry +func SliceToMap[T any, U comparable](input []T, toKey func(T) U) map[U]T { + result := make(map[U]T, len(input)) + + for _, item := range input { + result[toKey(item)] = item + } + + return result +} + +// SliceRepeat constructs a slice of length `count` of element `value` +func SliceRepeat[T any](value T, count int) []T { + result := make([]T, count) + for i := range count { + result[i] = value + } + + return result +} + +// SliceFlatten flattens 2D slices to 1D +func SliceFlatten[T any](slice [][]T) []T { + var result []T + for _, s := range slice { + result = append(result, s...) + } + + return result +} + +// SliceMerge merges multiple slices together +func SliceMerge[T any](slices ...[]T) []T { + var result []T + for _, slice := range slices { + result = append(result, slice...) + } + + return result +} + +// SliceSanitize removes all null values according to T{} +func SliceSanitize[T comparable](slice []T) []T { + sanitized := []T{} + var zero T + + for _, item := range slice { + if item != zero { + sanitized = append(sanitized, item) + } + } + + return sanitized +} + +// Reduce applies a combining function to each element of a slice, +// accumulating a single result. +func Reduce[T any, U any](slice []T, combine func(U, T) U) U { + var accum U + for _, v := range slice { + accum = combine(accum, v) + } + return accum +} + +// SliceGet returns the first x items +// If the slice doesn't have enough items then it returns +// all items +func SliceGet[T any](slice []T, amount int) []T { + result := make([]T, 0, amount) + for i := range min(amount, len(slice)) { + result = append(result, slice[i]) + } + + return result +} diff --git a/pkg/utils/time.go b/pkg/utils/time.go new file mode 100644 index 0000000..7babcc1 --- /dev/null +++ b/pkg/utils/time.go @@ -0,0 +1,7 @@ +package utils + +import "time" + +func SameDay(t1, t2 time.Time) bool { + return t1.Year() == t2.Year() && t1.Month() == t2.Month() && t1.Day() == t2.Day() +} diff --git a/sqlc.yml b/sqlc.yml index a4821a6..d451e44 100644 --- a/sqlc.yml +++ b/sqlc.yml @@ -7,5 +7,5 @@ sql: gen: go: package: "sqlc" - out: "internal/pkg/db/sqlc" + out: "internal/database/sqlc" sql_package: "pgx/v5" diff --git a/tui/components/bar/bar.go b/tui/components/bar/bar.go index 66d1821..004348e 100644 --- a/tui/components/bar/bar.go +++ b/tui/components/bar/bar.go @@ -37,12 +37,13 @@ type Model struct { style lipgloss.Style } -// New creates a new progress +// Interface compliance +var _ tea.Model = (*Model)(nil) + func New(style lipgloss.Style) Model { return Model{id: nextID(), style: style} } -// Init initializes the progress component func (m Model) Init() tea.Cmd { return nil } @@ -61,7 +62,7 @@ func (m Model) Start(width int, runningTime time.Duration, duration time.Duratio } // Update handles the progress frame tick -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case FrameMsg: if msg.id != m.id { @@ -88,7 +89,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, nil } -// View of the progress bar component func (m Model) View() string { b := strings.Repeat("β–„", m.width/2) if m.width%2 == 1 { diff --git a/tui/components/stopwatch/stopwatch.go b/tui/components/stopwatch/stopwatch.go index 0d47840..46f67e8 100644 --- a/tui/components/stopwatch/stopwatch.go +++ b/tui/components/stopwatch/stopwatch.go @@ -29,8 +29,7 @@ type StartStopMsg struct { } // ResetMsg is a message that resets the stopwatch -type ResetMsg struct { -} +type ResetMsg struct{} // Model for the stopwatch component type Model struct { @@ -39,7 +38,9 @@ type Model struct { running bool } -// New creates a new stopwatch with a given interval +// Interface compliance +var _ tea.Model = (*Model)(nil) + func New() Model { return Model{ id: nextID(), @@ -48,7 +49,6 @@ func New() Model { } } -// Init initializes the stopwatch component func (m Model) Init() tea.Cmd { return nil } @@ -75,7 +75,7 @@ func (m Model) Reset() tea.Cmd { } // Update handles the stopwatch tick -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case TickMsg: if msg.id != m.id || !m.running { @@ -106,14 +106,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, nil } -// View of the stopwatch component func (m Model) View() string { duration := m.duration.Round(time.Second) - min := int(duration / time.Minute) - sec := int((duration % time.Minute) / time.Second) + minutes := int(duration / time.Minute) + seconds := int((duration % time.Minute) / time.Second) - return fmt.Sprintf("%02d:%02d", min, sec) + return fmt.Sprintf("%02d:%02d", minutes, seconds) } func tick(id int64) tea.Cmd { diff --git a/tui/screen/cammie/cammie.go b/tui/screen/cammie/cammie.go index dd7fa35..5584f43 100644 --- a/tui/screen/cammie/cammie.go +++ b/tui/screen/cammie/cammie.go @@ -7,7 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/tui/screen" "github.com/zeusWPI/scc/tui/view" @@ -18,33 +18,49 @@ import ( "github.com/zeusWPI/scc/tui/view/zess" ) -// Cammie represents the cammie screen type Cammie struct { - db *db.DB messages view.View top []view.View bottom view.View - indexTop int - width int - height int + + topIdx int // Index of the cycled top views + width int + height int } -// Message to update the bottomIndex -type msgIndex struct { - indexBottom int +// Interface compliance +var _ screen.Screen = (*Cammie)(nil) + +// Msg is the message to update the topIndex +type Msg struct { + topIdx int } -// New creates a new cammie screen -func New(db *db.DB) screen.Screen { - messages := message.NewModel(db) - top := event.NewModel(db) - bottom := []view.View{gamification.NewModel(db), tap.NewModel(db), zess.NewModel(db)} - return &Cammie{db: db, messages: messages, bottom: top, top: bottom, indexTop: 0, width: 0, height: 0} +// Interface compliance +var _ tea.Msg = (*Msg)(nil) + +func New(repo repository.Repository) screen.Screen { + messages := message.NewModel(repo) + bottom := event.NewModel() + top := []view.View{ + gamification.NewModel(), + tap.NewModel(repo), + zess.NewModel(repo), + } + + return &Cammie{ + messages: messages, + top: top, + bottom: bottom, + + topIdx: 0, + width: 0, + height: 0, + } } -// Init initializes the cammie screen func (c *Cammie) Init() tea.Cmd { - cmds := []tea.Cmd{updateBottomIndex(*c), c.messages.Init(), c.bottom.Init()} + cmds := []tea.Cmd{updateTopIndex(*c), c.messages.Init(), c.bottom.Init()} for _, view := range c.top { cmds = append(cmds, view.Init()) } @@ -52,7 +68,6 @@ func (c *Cammie) Init() tea.Cmd { return tea.Batch(cmds...) } -// Update updates the cammie screen func (c *Cammie) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -64,10 +79,10 @@ func (c *Cammie) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { sBottom = sBottom.Width(c.width/2 - sBottom.GetHorizontalFrameSize()).Height(c.height/2 - sBottom.GetVerticalFrameSize()) return c, c.GetSizeMsg - case msgIndex: - c.indexTop = msg.indexBottom + case Msg: + c.topIdx = msg.topIdx - return c, updateBottomIndex(*c) + return c, updateTopIndex(*c) } cmds := make([]tea.Cmd, 0) @@ -87,7 +102,6 @@ func (c *Cammie) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { return c, tea.Batch(cmds...) } -// View returns the cammie screen view func (c *Cammie) View() string { if c.width == 0 || c.height == 0 { return "Initialzing..." @@ -100,7 +114,7 @@ func (c *Cammie) View() string { // Render tabs var tabs []string for i, view := range c.top { - if i == c.indexTop { + if i == c.topIdx { tabs = append(tabs, sActiveTab.Render(view.Name())) } else { tabs = append(tabs, sTabNormal.Render(view.Name())) @@ -111,7 +125,7 @@ func (c *Cammie) View() string { tab = lipgloss.JoinHorizontal(lipgloss.Bottom, tab, tabLine) // Render top view - top := lipgloss.JoinVertical(lipgloss.Left, tab, c.top[c.indexTop].View()) + top := lipgloss.JoinVertical(lipgloss.Left, tab, c.top[c.topIdx].View()) top = sTop.Render(top) // Render bottom @@ -154,11 +168,11 @@ func (c *Cammie) GetSizeMsg() tea.Msg { return view.MsgSize{Sizes: sizes} } -func updateBottomIndex(cammie Cammie) tea.Cmd { +func updateTopIndex(cammie Cammie) tea.Cmd { timeout := time.Duration(config.GetDefaultInt("tui.screen.cammie.interval_s", 300) * int(time.Second)) return tea.Tick(timeout, func(_ time.Time) tea.Msg { - newIndex := (cammie.indexTop + 1) % len(cammie.top) + newIndex := (cammie.topIdx + 1) % len(cammie.top) - return msgIndex{indexBottom: newIndex} + return Msg{topIdx: newIndex} }) } diff --git a/tui/screen/screen.go b/tui/screen/screen.go index b6c0a37..cb555c6 100644 --- a/tui/screen/screen.go +++ b/tui/screen/screen.go @@ -1,4 +1,4 @@ -// Package screen provides difference screens for the tui +// Package screen contains the interface for a screen package screen import ( @@ -6,7 +6,6 @@ import ( "github.com/zeusWPI/scc/tui/view" ) -// Screen represents a screen type Screen interface { Init() tea.Cmd Update(tea.Msg) (Screen, tea.Cmd) diff --git a/tui/screen/song/song.go b/tui/screen/song/song.go index 8e6608b..1460d59 100644 --- a/tui/screen/song/song.go +++ b/tui/screen/song/song.go @@ -3,7 +3,7 @@ package song import ( tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/tui/screen" "github.com/zeusWPI/scc/tui/view" "github.com/zeusWPI/scc/tui/view/song" @@ -11,7 +11,6 @@ import ( // Song represents the song screen type Song struct { - db *db.DB song view.View width int @@ -19,8 +18,12 @@ type Song struct { } // New creates a new song screen -func New(db *db.DB) screen.Screen { - return &Song{db: db, song: song.New(db), width: 0, height: 0} +func New(repo repository.Repository) screen.Screen { + return &Song{ + song: song.New(repo), + width: 0, + height: 0, + } } // Init initializes the song screen @@ -38,6 +41,9 @@ func (s *Song) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { sSong = sSong.Width(s.width - view.GetOuterWidth(sSong)).Height(s.height - sSong.GetVerticalFrameSize() - sSong.GetVerticalPadding()) return s, s.GetSizeMsg + + default: + break } cmds := make([]tea.Cmd, 0) diff --git a/tui/theme/color.go b/tui/theme/color.go new file mode 100644 index 0000000..ce55229 --- /dev/null +++ b/tui/theme/color.go @@ -0,0 +1,14 @@ +// Package theme contains the tui theme variables +package theme + +import "github.com/charmbracelet/lipgloss" + +var ( + Zeus = lipgloss.Color("#FF7F00") + + Gold = lipgloss.Color("#FFBF00") + Bronze = lipgloss.Color("#CD7F32") + Red = lipgloss.Color("#EE4B2B") + + Border = lipgloss.Color("#383838") +) diff --git a/tui/tui.go b/tui/tui.go index eb7008e..5432088 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -38,9 +38,14 @@ func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC: zap.S().Info("Exiting") - // cmds = append(cmds, tea.ExitAltScreen) + cmds = append(cmds, tea.ExitAltScreen) cmds = append(cmds, tea.Quit) + + default: + break } + default: + break } return t, tea.Batch(cmds...) diff --git a/tui/view/event/event.go b/tui/view/event/event.go index ad7e32e..4b6a973 100644 --- a/tui/view/event/event.go +++ b/tui/view/event/event.go @@ -2,63 +2,65 @@ package event import ( - "context" + "image" + "slices" "time" tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/pkg/util" + "github.com/zeusWPI/scc/pkg/utils" "github.com/zeusWPI/scc/tui/view" ) -var ( - passedAmount = 3 - upcomingAmount = 7 -) - -// Model represents the model for the event view type Model struct { - db *db.DB - passed []dto.Event - upcoming []dto.Event - today *dto.Event + events []event width int height int + + url string // Url of the api } +// Interface compliance +var _ view.View = (*Model)(nil) + // Msg represents the message to update the event view type Msg struct { - upcoming []dto.Event - passed []dto.Event - today *dto.Event + events []event +} + +// event is the internal representation of a zeus event +type event struct { + ID int `json:"id"` + Name string `json:"name"` + Location string `json:"location"` + Start time.Time `json:"start_time"` + poster image.Image } -// NewModel creates a new event view -func NewModel(db *db.DB) view.View { - return &Model{db: db} +func NewModel() view.View { + return &Model{ + events: nil, + width: 0, + height: 0, + url: config.GetDefaultString("tui.view.event.url", "https://events.zeus.gent/api/v1"), + } } -// Init initializes the event model view func (m *Model) Init() tea.Cmd { return nil } -// Name returns the name of the view func (m *Model) Name() string { return "Events" } -// Update updates the event model view func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: // Size update! // Check if it's relevant for this view - entry, ok := msg.Sizes[m.Name()] - if ok { + if entry, ok := msg.Sizes[m.Name()]; ok { // Update all dependent styles m.width = entry.Width m.height = entry.Height @@ -69,24 +71,20 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil case Msg: - m.passed = msg.passed - m.upcoming = msg.upcoming - m.today = msg.today + m.events = msg.events } return m, nil } -// View returns the view for the event model func (m *Model) View() string { - if m.today != nil { + if idx := slices.IndexFunc(m.events, func(e event) bool { return utils.SameDay(e.Start, time.Now()) }); idx != -1 { return m.viewToday() } return m.viewOverview() } -// GetUpdateDatas returns all the update function for the event model func (m *Model) GetUpdateDatas() []view.UpdateData { return []view.UpdateData{ { @@ -97,42 +95,3 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { }, } } - -func updateEvents(view view.View) (tea.Msg, error) { - m := view.(*Model) - - eventsDB, err := m.db.Queries.GetEventsCurrentAcademicYear(context.Background()) - if err != nil { - return nil, err - } - - events := util.SliceMap(eventsDB, dto.EventDTO) - - passed := make([]dto.Event, 0) - upcoming := make([]dto.Event, 0) - var today *dto.Event - - now := time.Now() - for _, event := range events { - if event.Date.Before(now) { - passed = append(passed, *event) - } else { - upcoming = append(upcoming, *event) - } - - if event.Date.Year() == now.Year() && event.Date.YearDay() == now.YearDay() { - today = event - } - } - - // Truncate passed and upcoming slices - if len(passed) > passedAmount { - passed = passed[len(passed)-passedAmount:] - } - - if len(upcoming) > upcomingAmount { - upcoming = upcoming[:upcomingAmount] - } - - return Msg{passed: passed, upcoming: upcoming, today: today}, nil -} diff --git a/tui/view/event/style.go b/tui/view/event/style.go index 87a0f71..989ed78 100644 --- a/tui/view/event/style.go +++ b/tui/view/event/style.go @@ -2,17 +2,10 @@ package event import ( "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/theme" "github.com/zeusWPI/scc/tui/view" ) -// Color -var ( - cZeus = lipgloss.Color("#FF7F00") - cWarning = lipgloss.Color("#EE4B2B") - cBorder = lipgloss.Color("#383838") - cUpcoming = lipgloss.Color("#FFBF00") -) - // Base style var base = lipgloss.NewStyle() @@ -26,20 +19,20 @@ var ( sOvAll = base.Padding(0, 1) // Style for the overview and the poster sOvPoster = base.AlignVertical(lipgloss.Center) sOv = base.AlignVertical(lipgloss.Center).MarginRight(wOvGap) // Style for the overview of the events - sOvTitle = base.Bold(true).Foreground(cWarning).Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) + sOvTitle = base.Bold(true).Foreground(theme.Red).Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(theme.Border) // Styles for passed events sOvPassedDate = base.Width(wOvDate).Faint(true) - sOvPassedText = base.Foreground(cZeus).Faint(true) + sOvPassedText = base.Foreground(theme.Zeus).Faint(true) // Styles for next event sOvNextDate = base.Width(wOvDate).Bold(true) - sOvNextText = base.Bold(true).Foreground(cZeus) + sOvNextText = base.Bold(true).Foreground(theme.Zeus) sOvNextLoc = base.Italic(true) // Styles for the upcoming envets sOvUpcomingDate = base.Width(wOvDate).Faint(true) - sOvUpcomingText = base.Foreground(cUpcoming) + sOvUpcomingText = base.Foreground(theme.Gold) sOvUpcomingLoc = base.Italic(true).Faint(true) ) @@ -54,7 +47,7 @@ var ( sToday = base.AlignVertical(lipgloss.Center).MarginLeft(wOvGap).Padding(1, 0).Border(lipgloss.DoubleBorder(), true, false) // Style for the event sTodayDate = base.Align(lipgloss.Center) - sTodayText = base.Align(lipgloss.Center).Bold(true).Foreground(cZeus).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) + sTodayText = base.Align(lipgloss.Center).Bold(true).Foreground(theme.Zeus).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(theme.Border) sTodayeLoc = base.Align(lipgloss.Center).Italic(true).Faint(true) ) @@ -89,4 +82,6 @@ func (m *Model) updateStyles() { sTodayDate = sTodayDate.Width(wTodayEv) sTodayText = sTodayText.Width(wTodayEv) sTodayeLoc = sTodayeLoc.Width(wTodayEv) + + // Adjust the styles for no events } diff --git a/tui/view/event/update.go b/tui/view/event/update.go new file mode 100644 index 0000000..7d8710a --- /dev/null +++ b/tui/view/event/update.go @@ -0,0 +1,126 @@ +package event + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "image" + "io" + "slices" + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/pkg/utils" + "github.com/zeusWPI/scc/tui/view" +) + +func (e event) equal(e2 event) bool { + return e.ID == e2.ID && e.Name == e2.Name && e.Location == e2.Location && e.Start.Equal(e2.Start) && utils.ImageEqual(e.poster, e2.poster) +} + +func updateEvents(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + events, err := getEvents(ctx, m.url) + if err != nil { + return nil, err + } + + slices.SortFunc(events, func(a, b event) int { return a.Start.Compare(b.Start) }) + + if len(events) != len(m.events) { + return Msg{events: events}, nil + } + + for _, ev := range events { + if idx := slices.IndexFunc(m.events, func(e event) bool { return e.equal(ev) }); idx == -1 { + return Msg{events: events}, nil + } + } + + return nil, nil +} + +func getEvents(ctx context.Context, url string) ([]event, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: url + "/event", + }) + if err != nil { + return nil, fmt.Errorf("get events %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("bad response code for get events %s", resp.Status) + } + + var events []event + if err := json.NewDecoder(resp.Body).Decode(&events); err != nil { + return nil, fmt.Errorf("decode event api %w", err) + } + + var errs []error + + var mu sync.Mutex + var wg sync.WaitGroup + + for i := range events { + wg.Go(func() { + if err := getPoster(ctx, url, &events[i]); err != nil { + mu.Lock() + errs = append(errs, err) + mu.Unlock() + } + }) + } + + wg.Wait() + + if errs != nil { + return nil, errors.Join(errs...) + } + + return events, nil +} + +func getPoster(ctx context.Context, url string, event *event) error { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: fmt.Sprintf("%s/event/poster/%d?original=true&scc=true", url, event.ID), + }) + if err != nil { + return fmt.Errorf("get poster %+v | %w", *event, err) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != 200 { + if resp.StatusCode == 404 { + // Event doesn't have a poster + return nil + } + return fmt.Errorf("bad response code for event poster %s | %+v", resp.Status, *event) + } + + posterBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read poster bytes %+v | %w", *event, err) + } + + poster, _, err := image.Decode(bytes.NewReader(posterBytes)) + if err != nil { + return fmt.Errorf("decode poster for event %+v | %w", *event, err) + } + + event.poster = poster + + return nil +} diff --git a/tui/view/event/view.go b/tui/view/event/view.go index c6edcb9..0ab0ce9 100644 --- a/tui/view/event/view.go +++ b/tui/view/event/view.go @@ -1,26 +1,33 @@ package event import ( - "bytes" - "image" + "time" "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/pkg/utils" "github.com/zeusWPI/scc/tui/view" ) +const ( + amountOfPassed = 1 + amountOfFuture = 3 +) + func (m *Model) viewToday() string { + today, ok := utils.SliceFind(m.events, func(e event) bool { return utils.SameDay(e.Start, time.Now()) }) + if !ok { + return "" + } + // Render image poster := "" - if m.today.Poster != nil { - i, _, err := image.Decode(bytes.NewReader(m.today.Poster)) - if err == nil { - poster = view.ImageToString(i, wTodayPoster, 0) - } + if today.poster != nil { + poster = view.ImageToString(today.poster, wTodayPoster, 0) } - name := sTodayText.Render(m.today.Name) - date := sTodayDate.Render("πŸ•™ " + m.today.Date.Format("15:04")) - location := sTodayeLoc.Render("πŸ“ " + m.today.Location) + name := sTodayText.Render(today.Name) + date := sTodayDate.Render("πŸ•™ " + today.Start.Format("15:04")) + location := sTodayeLoc.Render("πŸ“ " + today.Location) event := lipgloss.JoinVertical(lipgloss.Left, name, date, location) event = sToday.Render(event) @@ -37,17 +44,17 @@ func (m *Model) viewToday() string { } func (m *Model) viewOverview() string { + passed := utils.SliceGet(utils.SliceFilter(m.events, func(e event) bool { return e.Start.Before(time.Now()) }), amountOfPassed) + upcoming := utils.SliceGet(utils.SliceFilter(m.events, func(e event) bool { return e.Start.After(time.Now()) }), amountOfFuture) + // Poster if present poster := "" - if len(m.upcoming) > 0 && m.upcoming[0].Poster != nil { - i, _, err := image.Decode(bytes.NewReader(m.upcoming[0].Poster)) - if err == nil { - poster = view.ImageToString(i, wOvPoster, 0) - } + if len(upcoming) > 0 && upcoming[0].poster != nil { + poster = view.ImageToString(upcoming[0].poster, wOvPoster, 0) } // Overview - events := m.viewGetEventOverview() + events := m.viewGetEventOverview(passed, upcoming) if lipgloss.Height(poster) > lipgloss.Height(events) { events = sOv.Height(lipgloss.Height(poster)).Render(events) @@ -61,26 +68,26 @@ func (m *Model) viewOverview() string { return sOvAll.Render(view) } -func (m *Model) viewGetEventOverview() string { - events := make([]string, 0, len(m.passed)+len(m.upcoming)+1) +func (m *Model) viewGetEventOverview(passed, upcoming []event) string { + events := make([]string, 0, len(passed)+len(upcoming)+1) title := sOvTitle.Render("Events") events = append(events, title) // Passed - for _, event := range m.passed { - date := sOvPassedDate.Render(event.Date.Format("02/01")) + for _, event := range passed { + date := sOvPassedDate.Render(event.Start.Format("02/01")) name := sOvPassedText.Render(event.Name) text := lipgloss.JoinHorizontal(lipgloss.Top, date, name) events = append(events, text) } - if len(m.upcoming) > 0 { + if len(upcoming) > 0 { // Next - date := sOvNextDate.Render(m.upcoming[0].Date.Format("02/01")) - name := sOvNextText.Render(m.upcoming[0].Name) - location := sOvNextLoc.Render("πŸ“ " + m.upcoming[0].Location) + date := sOvNextDate.Render(upcoming[0].Start.Format("02/01")) + name := sOvNextText.Render(upcoming[0].Name) + location := sOvNextLoc.Render("πŸ“ " + upcoming[0].Location) text := lipgloss.JoinVertical(lipgloss.Left, name, location) text = lipgloss.JoinHorizontal(lipgloss.Top, date, text) @@ -89,12 +96,12 @@ func (m *Model) viewGetEventOverview() string { } // Upcoming - for i := 1; i < len(m.upcoming); i++ { - date := sOvUpcomingDate.Render(m.upcoming[i].Date.Format("02/01")) - name := sOvUpcomingText.Render(m.upcoming[i].Name) + for i := 1; i < len(upcoming); i++ { + date := sOvUpcomingDate.Render(upcoming[i].Start.Format("02/01")) + name := sOvUpcomingText.Render(upcoming[i].Name) text := name if i < 3 { - location := sOvNextLoc.Render("πŸ“ " + m.upcoming[i].Location) + location := sOvNextLoc.Render("πŸ“ " + upcoming[i].Location) text = lipgloss.JoinVertical(lipgloss.Left, name, location) } diff --git a/tui/view/gamification/gamification.go b/tui/view/gamification/gamification.go index 27bdc42..90866f6 100644 --- a/tui/view/gamification/gamification.go +++ b/tui/view/gamification/gamification.go @@ -2,63 +2,63 @@ package gamification import ( - "bytes" - "context" "fmt" "image" "strconv" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/tui/view" ) -// Model represents the view model for gamification type Model struct { - db *db.DB - leaderboard []gamificationItem + leaderboard []gamification width int height int -} -type gamificationItem struct { - image image.Image - item dto.Gamification + url string // API url for gamification } +// Interface compliance +var _ view.View = (*Model)(nil) + // Msg contains the data to update the gamification model type Msg struct { - leaderboard []gamificationItem + leaderboard []gamification } -// NewModel initializes a new gamification model -func NewModel(db *db.DB) view.View { - return &Model{db: db, leaderboard: []gamificationItem{}} +type gamification struct { + Name string `json:"github_name"` + Score int `json:"score"` + AvatarURL string `json:"avatar_url"` + avatar image.Image +} + +func NewModel() view.View { + return &Model{ + leaderboard: nil, + width: 0, + height: 0, + url: config.GetDefaultString("tui.view.gamification.url", "https://gamification.zeus.gent"), + } } -// Init starts the gamification view func (m *Model) Init() tea.Cmd { return nil } -// Name returns the name of the view func (m *Model) Name() string { return "Gamification" } -// Update updates the gamification view func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: // Size update! // Check if it's relevant for this view - entry, ok := msg.Sizes[m.Name()] - if ok { + if entry, ok := msg.Sizes[m.Name()]; ok { // Update all dependent styles m.width = entry.Width m.height = entry.Height @@ -75,16 +75,15 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil } -// View draws the gamification view func (m *Model) View() string { columns := make([]string, 0, len(m.leaderboard)) for i, item := range m.leaderboard { user := lipgloss.JoinVertical(lipgloss.Left, - positions[i].Inherit(sName).Render(fmt.Sprintf("%d. %s", i+1, item.item.Name)), - sScore.Render(strconv.Itoa(int(item.item.Score))), + positions[i].Inherit(sName).Render(fmt.Sprintf("%d. %s", i+1, item.Name)), + sScore.Render(strconv.Itoa(item.Score)), ) - im := sAvatar.Render(view.ImageToString(item.image, wColumn, sAll.GetHeight()-lipgloss.Height(user))) + im := sAvatar.Render(view.ImageToString(item.avatar, wColumn, sAll.GetHeight()-lipgloss.Height(user))) column := lipgloss.JoinVertical(lipgloss.Left, im, user) columns = append(columns, sColumn.Render(column)) @@ -106,43 +105,3 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { }, } } - -func updateLeaderboard(view view.View) (tea.Msg, error) { - m := view.(*Model) - - gams, err := m.db.Queries.GetAllGamificationByScore(context.Background()) - if err != nil { - if err == pgx.ErrNoRows { - err = nil - } - return nil, err - } - - // Check if both leaderboards are equal - equal := false - if len(m.leaderboard) == len(gams) { - equal = true - for i, l := range m.leaderboard { - if !l.item.Equal(*dto.GamificationDTO(gams[i])) { - equal = false - break - } - } - } - - if equal { - return nil, nil - } - - msg := Msg{leaderboard: []gamificationItem{}} - for _, gam := range gams { - im, _, err := image.Decode(bytes.NewReader(gam.Avatar)) - if err != nil { - return nil, err - } - - msg.leaderboard = append(msg.leaderboard, gamificationItem{image: im, item: *dto.GamificationDTO(gam)}) - } - - return msg, nil -} diff --git a/tui/view/gamification/styles.go b/tui/view/gamification/styles.go index 0f7bcf0..70f4d00 100644 --- a/tui/view/gamification/styles.go +++ b/tui/view/gamification/styles.go @@ -2,17 +2,10 @@ package gamification import ( "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/theme" "github.com/zeusWPI/scc/tui/view" ) -// Colors -var ( - cGold = lipgloss.Color("#FFD700") - cZeus = lipgloss.Color("#FF7F00") - cBronze = lipgloss.Color("#CD7F32") - cBorder = lipgloss.Color("#383838") -) - // Base style var base = lipgloss.NewStyle() @@ -32,9 +25,9 @@ var ( // Styles for the positions var positions = []lipgloss.Style{ - base.Foreground(cGold), - base.Foreground(cZeus), - base.Foreground(cBronze), + base.Foreground(theme.Gold), + base.Foreground(theme.Zeus), + base.Foreground(theme.Bronze), base, } @@ -45,7 +38,7 @@ func (m *Model) updateStyles() { // Adjust styles wColumn = (sAll.GetWidth() - view.GetOuterWidth(sAll) - view.GetOuterWidth(sColumn)*wAmount) / wAmount - sName = sName.Width(wColumn).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) + sName = sName.Width(wColumn).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(theme.Border) sScore = sScore.Width(wColumn) sAvatar = sAvatar.Width(wColumn) } diff --git a/tui/view/gamification/update.go b/tui/view/gamification/update.go new file mode 100644 index 0000000..2aac4ef --- /dev/null +++ b/tui/view/gamification/update.go @@ -0,0 +1,122 @@ +package gamification + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "image" + "slices" + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/pkg/utils" + "github.com/zeusWPI/scc/tui/view" +) + +func (g gamification) equal(g2 gamification) bool { + return g.Name == g2.Name && g.Score == g2.Score && g.AvatarURL == g2.AvatarURL +} + +func updateLeaderboard(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + leaderboard, err := getLeaderboard(ctx, m.url) + if err != nil { + return nil, err + } + + slices.SortFunc(leaderboard, func(a, b gamification) int { return b.Score - a.Score }) + + if len(leaderboard) != len(m.leaderboard) { + return Msg{leaderboard: leaderboard}, nil + } + + for idx, l := range leaderboard { + if !m.leaderboard[idx].equal(l) { + return Msg{leaderboard: leaderboard}, nil + } + } + + return nil, nil +} + +func getLeaderboard(ctx context.Context, url string) ([]gamification, error) { + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: url + "/top4", + Headers: map[string]string{ + "Accept": "application/json", + }, + }) + if err != nil { + return nil, fmt.Errorf("get top 4 gamification %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code %s", resp.Status) + } + + var gams []gamification + if err := json.NewDecoder(resp.Body).Decode(&gams); err != nil { + return nil, fmt.Errorf("decode gamification response %w", err) + } + + var errs []error + + var mu sync.Mutex + var wg sync.WaitGroup + + for i := range gams { + wg.Go(func() { + if err := getAvatar(ctx, &gams[i]); err != nil { + mu.Lock() + errs = append(errs, err) + mu.Unlock() + } + }) + } + + wg.Wait() + + if errs != nil { + return nil, errors.Join(errs...) + } + + return gams, nil +} + +func getAvatar(ctx context.Context, gam *gamification) error { + if gam.AvatarURL == "" { + return nil + } + + resp, err := utils.DoRequest(ctx, utils.DoRequestValues{ + Method: "GET", + URL: gam.AvatarURL, + }) + if err != nil { + return fmt.Errorf("get avatar url %+v | %w", *gam, err) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != 200 { + return fmt.Errorf("unexpected status code %s", resp.Status) + } + + img, _, err := image.Decode(resp.Body) + if err != nil { + return fmt.Errorf("decode gamification avatar %+v | %w", *gam, err) + } + + gam.avatar = img + + return nil +} diff --git a/tui/view/message/message.go b/tui/view/message/message.go index c87ed12..052a2b2 100644 --- a/tui/view/message/message.go +++ b/tui/view/message/message.go @@ -7,20 +7,28 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/tui/view" ) // Model represents the model for the message view type Model struct { - db *db.DB - lastMessageID int32 - messages []message + repo repository.Message + messages []message + + lastMessageID int + width int + height int +} + +// Interface compliance +var _ view.View = (*Model)(nil) - width int - height int +// Msg represents the message to update the message view +type Msg struct { + lastMessageID int + messages []message } type message struct { @@ -30,33 +38,31 @@ type message struct { date time.Time } -// Msg represents the message to update the message view -type Msg struct { - lastMessageID int32 - messages []message -} - -// NewModel creates a new message model view -func NewModel(db *db.DB) view.View { - return &Model{db: db, lastMessageID: -1, messages: []message{}} +func NewModel(repo repository.Repository) view.View { + return &Model{ + repo: *repo.NewMessage(), + messages: nil, + lastMessageID: -1, + width: 0, + height: 0, + } } -// Init initializes the message model view func (m *Model) Init() tea.Cmd { return nil } -// Name returns the name of the view func (m *Model) Name() string { return "Cammie Messages" } -// Update updates the message model view func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: - entry, ok := msg.Sizes[m.Name()] - if ok { + // Size update! + // Check if it's relevant for this view + if entry, ok := msg.Sizes[m.Name()]; ok { + // Update all dependent styles m.width = entry.Width m.height = entry.Height } @@ -93,15 +99,12 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { } } -func updateMessages(view view.View) (tea.Msg, error) { +func updateMessages(ctx context.Context, view view.View) (tea.Msg, error) { m := view.(*Model) lastMessageID := m.lastMessageID - messagesDB, err := m.db.Queries.GetMessageSinceID(context.Background(), lastMessageID) + messagesDB, err := m.repo.GetSinceID(ctx, lastMessageID) if err != nil { - if err == pgx.ErrNoRows { - err = nil - } return nil, err } @@ -120,7 +123,7 @@ func updateMessages(view view.View) (tea.Msg, error) { sender: m.Name, message: m.Message, color: hashColor(m.Name), - date: m.CreatedAt.Time, + date: m.CreatedAt, }) } diff --git a/tui/view/song/song.go b/tui/view/song/song.go index 84eb01d..cac2202 100644 --- a/tui/view/song/song.go +++ b/tui/view/song/song.go @@ -2,24 +2,22 @@ package song import ( - "context" "time" tea "github.com/charmbracelet/bubbletea" - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/internal/pkg/lyrics" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/lyrics" "github.com/zeusWPI/scc/tui/components/bar" "github.com/zeusWPI/scc/tui/components/stopwatch" "github.com/zeusWPI/scc/tui/view" "go.uber.org/zap" ) -var ( - previousAmount = 5 // Amount of passed lyrics to show - upcomingAmount = 12 // Amount of upcoming lyrics to show +const ( + previousAmount = 2 + upcomingAmount = 4 ) type stat struct { @@ -33,7 +31,7 @@ type statEntry struct { } type playing struct { - song dto.Song + song model.Song playing bool lyrics lyrics.Lyrics previous []string // Lyrics already sang @@ -46,20 +44,24 @@ type progression struct { bar bar.Model } -// Model represents the view model for song type Model struct { - db *db.DB - current playing - progress progression + repo repository.Song + current playing + progress progression + history stat stats []stat statsMonthly []stat - width int - height int + statAmount int + + width int + height int } -// Msg triggers a song data update -// Required for the view interface +// Interface compliance +var _ view.View = (*Model)(nil) + +// Msg contains the data to update the gamification model type Msg struct{} type msgHistory struct { @@ -72,12 +74,12 @@ type msgStats struct { } type msgPlaying struct { - song dto.Song + song model.Song lyrics lyrics.Lyrics } type msgLyrics struct { - song dto.Song + song model.Song playing bool previous []string current string @@ -86,17 +88,21 @@ type msgLyrics struct { } // New initializes a new song model -func New(db *db.DB) view.View { +func New(repo repository.Repository) view.View { return &Model{ - db: db, - current: playing{}, - progress: progression{stopwatch: stopwatch.New(), bar: bar.New(sStatusBar)}, + repo: *repo.NewSong(), + progress: progression{ + stopwatch: stopwatch.New(), + bar: bar.New(sStatusBar), + }, stats: make([]stat, 4), statsMonthly: make([]stat, 4), + statAmount: config.GetDefaultInt("tui.view.song.stat_amount", 3), + width: 0, + height: 0, } } -// Init starts the song view func (m *Model) Init() tea.Cmd { return tea.Batch( m.progress.stopwatch.Init(), @@ -104,19 +110,16 @@ func (m *Model) Init() tea.Cmd { ) } -// Name returns the name of the view func (m *Model) Name() string { - return "Songs" + return "Song" } -// Update updates the song view func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: // Size update! // Check if it's relevant for this view - entry, ok := msg.Sizes[m.Name()] - if ok { + if entry, ok := msg.Sizes[m.Name()]; ok { // Update all dependent styles m.width = entry.Width m.height = entry.Height @@ -146,7 +149,7 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil } - startTime := m.current.song.CreatedAt.Add(lyric.Duration) // Start time of the next lyric + startTime := m.current.song.PlayedAt.Add(lyric.Duration) // Start time of the next lyric for startTime.Before(time.Now()) { // This lyric is already finished, onto the next! lyric, ok := m.current.lyrics.Next() @@ -166,8 +169,8 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // Start the update loop return m, tea.Batch( updateLyrics(m.current, startTime), - m.progress.stopwatch.Start(time.Since(m.current.song.CreatedAt)), - m.progress.bar.Start(view.GetWidth(sStatusBar), time.Since(m.current.song.CreatedAt), time.Duration(m.current.song.DurationMS)*time.Millisecond), + m.progress.stopwatch.Start(time.Since(m.current.song.PlayedAt)), + m.progress.bar.Start(view.GetWidth(sStatusBar), time.Since(m.current.song.PlayedAt), time.Duration(m.current.song.DurationMS)*time.Millisecond), ) case msgHistory: @@ -208,15 +211,20 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // Maybe a stopwatch message? var cmd tea.Cmd - m.progress.stopwatch, cmd = m.progress.stopwatch.Update(msg) + stopwatchNew, cmd := m.progress.stopwatch.Update(msg) + m.progress.stopwatch = stopwatchNew.(stopwatch.Model) if cmd != nil { return m, cmd } // Apparently not, lets try the bar! - m.progress.bar, cmd = m.progress.bar.Update(msg) + barNew, cmd := m.progress.bar.Update(msg) + m.progress.bar = barNew.(bar.Model) + if cmd != nil { + return m, cmd + } - return m, cmd + return m, nil } // View draws the song view @@ -257,178 +265,3 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { }, } } - -// updateCurrentSong checks if there's currently a song playing -func updateCurrentSong(view view.View) (tea.Msg, error) { - m := view.(*Model) - - songs, err := m.db.Queries.GetLastSongFull(context.Background()) - if err != nil { - if err == pgx.ErrNoRows { - err = nil - } - return nil, err - } - if len(songs) == 0 { - return nil, nil - } - - // Check if song is still playing - if songs[0].CreatedAt.Time.Add(time.Duration(songs[0].DurationMs) * time.Millisecond).Before(time.Now()) { - // Song is finished - return nil, nil - } - - if m.current.playing && songs[0].ID == m.current.song.ID { - // Song is already set to current - return nil, nil - } - - // Convert sqlc song to a dto song - song := *dto.SongDTOHistory(songs) - - return msgPlaying{song: song, lyrics: lyrics.New(song)}, nil -} - -// updateHistory updates the recently played list -func updateHistory(view view.View) (tea.Msg, error) { - m := view.(*Model) - - history, err := m.db.Queries.GetSongHistory(context.Background()) - if err != nil && err != pgx.ErrNoRows { - return nil, err - } - - stat := stat{title: tStatHistory, entries: []statEntry{}} - for _, h := range history { - stat.entries = append(stat.entries, statEntry{name: h.Title, amount: int(h.PlayCount)}) - } - - return msgHistory{history: stat}, nil -} - -// Update all monthly stats -func updateMonthlyStats(view view.View) (tea.Msg, error) { - m := view.(*Model) - - songs, err := m.db.Queries.GetTopMonthlySongs(context.Background()) - if err != nil && err != pgx.ErrNoRows { - return nil, err - } - - genres, err := m.db.Queries.GetTopMonthlyGenres(context.Background()) - if err != nil && err != pgx.ErrNoRows { - return nil, err - } - - artists, err := m.db.Queries.GetTopMonthlyArtists(context.Background()) - if err != nil && err != pgx.ErrNoRows { - return nil, err - } - - msg := msgStats{monthly: true, stats: []stat{}} - - // Songs - s := stat{title: tStatSong, entries: []statEntry{}} - for _, song := range songs { - s.entries = append(s.entries, statEntry{name: song.Title, amount: int(song.PlayCount)}) - } - msg.stats = append(msg.stats, s) - - // Genres - s = stat{title: tStatGenre, entries: []statEntry{}} - for _, genre := range genres { - s.entries = append(s.entries, statEntry{name: genre.GenreName, amount: int(genre.TotalPlays)}) - } - msg.stats = append(msg.stats, s) - - // Artists - s = stat{title: tStatArtist, entries: []statEntry{}} - for _, artist := range artists { - s.entries = append(s.entries, statEntry{name: artist.ArtistName, amount: int(artist.TotalPlays)}) - } - msg.stats = append(msg.stats, s) - - return msg, nil -} - -// Update all stats -func updateStats(view view.View) (tea.Msg, error) { - m := view.(*Model) - - songs, err := m.db.Queries.GetTopSongs(context.Background()) - if err != nil && err != pgx.ErrNoRows { - return nil, err - } - - genres, err := m.db.Queries.GetTopGenres(context.Background()) - if err != nil && err != pgx.ErrNoRows { - return nil, err - } - - artists, err := m.db.Queries.GetTopArtists(context.Background()) - if err != nil && err != pgx.ErrNoRows { - return nil, err - } - - // Don't bother checking if anything has changed - // A single extra refresh won't matter - - msg := msgStats{monthly: false, stats: []stat{}} - - // Songs - s := stat{title: tStatSong, entries: []statEntry{}} - for _, song := range songs { - s.entries = append(s.entries, statEntry{name: song.Title, amount: int(song.PlayCount)}) - } - msg.stats = append(msg.stats, s) - - // Genres - s = stat{title: tStatGenre, entries: []statEntry{}} - for _, genre := range genres { - s.entries = append(s.entries, statEntry{name: genre.GenreName, amount: int(genre.TotalPlays)}) - } - msg.stats = append(msg.stats, s) - - // Artists - s = stat{title: tStatArtist, entries: []statEntry{}} - for _, artist := range artists { - s.entries = append(s.entries, statEntry{name: artist.ArtistName, amount: int(artist.TotalPlays)}) - } - msg.stats = append(msg.stats, s) - - return msg, nil -} - -// Update the current lyric -func updateLyrics(song playing, start time.Time) tea.Cmd { - // How long do we need to wait until we can update the lyric? - timeout := time.Duration(0) - now := time.Now() - if start.After(now) { - timeout = start.Sub(now) - } - - return tea.Tick(timeout, func(_ time.Time) tea.Msg { - // Next lyric - lyric, ok := song.lyrics.Next() - if !ok { - // Song finished - return msgLyrics{song: song.song, playing: false} // Values in the other fields are not looked at when the song is finished - } - - previous := song.lyrics.Previous(previousAmount) - upcoming := song.lyrics.Upcoming(upcomingAmount) - - end := start.Add(lyric.Duration) - - return msgLyrics{ - song: song.song, - playing: true, - previous: lyricsToString(previous), - current: lyric.Text, - upcoming: lyricsToString(upcoming), - startNext: end, - } - }) -} diff --git a/tui/view/song/update.go b/tui/view/song/update.go new file mode 100644 index 0000000..7cc97c5 --- /dev/null +++ b/tui/view/song/update.go @@ -0,0 +1,186 @@ +package song + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/pkg/lyrics" + "github.com/zeusWPI/scc/pkg/utils" + "github.com/zeusWPI/scc/tui/view" +) + +// updateCurrentSong checks if there's currently a song playing +func updateCurrentSong(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + song, err := m.repo.GetLastPopulated(ctx) + if err != nil { + return nil, err + } + if song == nil { + return nil, nil + } + + // Check if song is still playing + if song.PlayedAt.Add(time.Duration(song.DurationMS) * time.Millisecond).Before(time.Now()) { + // Song is finished + return nil, nil + } + + if m.current.playing && song.ID == m.current.song.ID { + // Song is already set to current + return nil, nil + } + + return msgPlaying{song: *song, lyrics: lyrics.New(*song)}, nil +} + +// updateHistory updates the recently played list +func updateHistory(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + histories, err := m.repo.GetLast50(ctx) + if err != nil { + return nil, err + } + + stat := stat{title: tStatHistory, entries: []statEntry{}} + for _, h := range histories { + stat.entries = append(stat.entries, statEntry{name: h.Title, amount: h.PlayCount}) + } + + return msgHistory{history: stat}, nil +} + +// Update all monthly stats +func updateMonthlyStats(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + songs, err := m.repo.GetTopSongsMonthly(ctx) + if err != nil { + return nil, err + } + songs = utils.SliceGet(songs, m.statAmount) + + genres, err := m.repo.GetTopGenresMonthly(ctx) + if err != nil { + return nil, err + } + genres = utils.SliceGet(genres, m.statAmount) + + artists, err := m.repo.GetTopArtistsMonthly(ctx) + if err != nil { + return nil, err + } + artists = utils.SliceGet(artists, m.statAmount) + + msg := msgStats{monthly: true, stats: []stat{}} + + // Songs + s := stat{title: tStatSong, entries: []statEntry{}} + for _, song := range songs { + s.entries = append(s.entries, statEntry{name: song.Title, amount: song.PlayCount}) + } + msg.stats = append(msg.stats, s) + + // Genres + s = stat{title: tStatGenre, entries: []statEntry{}} + for _, genre := range genres { + s.entries = append(s.entries, statEntry{name: genre.Genre, amount: genre.PlayCount}) + } + msg.stats = append(msg.stats, s) + + // Artists + s = stat{title: tStatArtist, entries: []statEntry{}} + for _, artist := range artists { + s.entries = append(s.entries, statEntry{name: artist.Name, amount: artist.PlayCount}) + } + msg.stats = append(msg.stats, s) + + return msg, nil +} + +// Update all stats +func updateStats(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + songs, err := m.repo.GetTopSongs(ctx) + if err != nil { + return nil, err + } + songs = utils.SliceGet(songs, m.statAmount) + + genres, err := m.repo.GetTopGenres(ctx) + if err != nil { + return nil, err + } + genres = utils.SliceGet(genres, m.statAmount) + + artists, err := m.repo.GetTopArtists(ctx) + if err != nil { + return nil, err + } + artists = utils.SliceGet(artists, m.statAmount) + + // Don't bother checking if anything has changed + // A single extra refresh won't matter + + msg := msgStats{monthly: false, stats: []stat{}} + + // Songs + s := stat{title: tStatSong, entries: []statEntry{}} + for _, song := range songs { + s.entries = append(s.entries, statEntry{name: song.Title, amount: song.PlayCount}) + } + msg.stats = append(msg.stats, s) + + // Genres + s = stat{title: tStatGenre, entries: []statEntry{}} + for _, genre := range genres { + s.entries = append(s.entries, statEntry{name: genre.Genre, amount: genre.PlayCount}) + } + msg.stats = append(msg.stats, s) + + // Artists + s = stat{title: tStatArtist, entries: []statEntry{}} + for _, artist := range artists { + s.entries = append(s.entries, statEntry{name: artist.Name, amount: artist.PlayCount}) + } + msg.stats = append(msg.stats, s) + + return msg, nil +} + +// Update the current lyric +func updateLyrics(song playing, start time.Time) tea.Cmd { + // How long do we need to wait until we can update the lyric? + timeout := time.Duration(0) + now := time.Now() + if start.After(now) { + timeout = start.Sub(now) + } + + return tea.Tick(timeout, func(_ time.Time) tea.Msg { + // Next lyric + lyric, ok := song.lyrics.Next() + if !ok { + // Song finished + return msgLyrics{song: song.song, playing: false} // Values in the other fields are not looked at when the song is finished + } + + previous := song.lyrics.Previous(previousAmount) + upcoming := song.lyrics.Upcoming(upcomingAmount) + + end := start.Add(lyric.Duration) + + return msgLyrics{ + song: song.song, + playing: true, + previous: lyricsToString(previous), + current: lyric.Text, + upcoming: lyricsToString(upcoming), + startNext: end, + } + }) +} diff --git a/tui/view/song/util.go b/tui/view/song/util.go index c3808ac..fcd216a 100644 --- a/tui/view/song/util.go +++ b/tui/view/song/util.go @@ -1,8 +1,6 @@ package song -import ( - "github.com/zeusWPI/scc/internal/pkg/lyrics" -) +import "github.com/zeusWPI/scc/pkg/lyrics" func lyricsToString(lyrics []lyrics.Lyric) []string { text := make([]string, 0, len(lyrics)) diff --git a/tui/view/song/view.go b/tui/view/song/view.go index 058ddf7..ca47876 100644 --- a/tui/view/song/view.go +++ b/tui/view/song/view.go @@ -3,9 +3,11 @@ package song import ( "fmt" "math" + "strconv" "strings" "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/pkg/utils" ) func (m *Model) viewPlaying() string { @@ -78,7 +80,10 @@ func (m *Model) viewPlayingLyrics() string { func (m *Model) viewPlayingStats() string { columns := make([]string, 0, 4) - columns = append(columns, m.viewStatPlaying(m.history)) + history := m.history + history.entries = utils.SliceGet(history.entries, m.statAmount) + + columns = append(columns, m.viewStatPlaying(history)) columns = append(columns, m.viewStatPlaying(m.statsMonthly[0])) columns = append(columns, m.viewStatPlaying(m.statsMonthly[1])) columns = append(columns, m.viewStatPlaying(m.statsMonthly[2])) @@ -89,7 +94,7 @@ func (m *Model) viewPlayingStats() string { func (m *Model) viewNotPlaying() string { // Render stats rows := make([][]string, 0, 3) - for i := 0; i < 3; i++ { + for range 3 { rows = append(rows, make([]string, 0, 2)) } @@ -119,10 +124,10 @@ func (m *Model) viewNotPlaying() string { } items = append(items, sStatTitle.Render(m.history.title)) - for i, entry := range m.history.entries { + for i, entry := range utils.SliceGet(m.history.entries, m.statAmount*3+(4*2)) { enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) body := sStatEntry.Render(entry.name) - amount := sStatAmount.Render(fmt.Sprintf("%d", entry.amount)) + amount := sStatAmount.Render(strconv.Itoa(entry.amount)) items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, enum, body, amount)) } items = append(items, "") // HACK: Avoid the last item shifting to the right @@ -149,7 +154,7 @@ func (m *Model) viewStatPlaying(stat stat, titleOpt ...string) string { enum := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) body := sStatEntry.Render(stat.entries[i].name) - amount := sStatAmount.Render(fmt.Sprintf("%d", stat.entries[i].amount)) + amount := sStatAmount.Render(strconv.Itoa(stat.entries[i].amount)) items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, enum, body, amount)) } diff --git a/tui/view/tap/tap.go b/tui/view/tap/tap.go index 814a33c..4a7049f 100644 --- a/tui/view/tap/tap.go +++ b/tui/view/tap/tap.go @@ -2,79 +2,63 @@ package tap import ( - "context" - "slices" - "time" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/tui/view" ) -type category string - -const ( - mate category = "Mate" - soft category = "Soft" - beer category = "Beer" - food category = "Food" -) - -var categoryToStyle = map[category]lipgloss.Style{ - mate: sMate, - soft: sSoft, - beer: sBeer, - food: sFood, +var categoryToStyle = map[model.TapCategory]lipgloss.Style{ + model.Mate: sMate, + model.Soft: sSoft, + model.Beer: sBeer, + model.Food: sFood, } -// Model represents the tap model type Model struct { - db *db.DB - lastOrderID int32 - items []tapItem + repo repository.Tap + lastOrderID int + items []model.TapCount width int height int } +// Interface compliance +var _ view.View = (*Model)(nil) + // Msg represents a tap message type Msg struct { - lastOrderID int32 - items []tapItem -} - -type tapItem struct { - category category - amount int - last time.Time + lastOrderID int + items []model.TapCount } -// NewModel creates a new tap model -func NewModel(db *db.DB) view.View { - return &Model{db: db, lastOrderID: -1} +func NewModel(repo repository.Repository) view.View { + return &Model{ + repo: *repo.NewTap(), + lastOrderID: -1, + items: nil, + width: 0, + height: 0, + } } -// Init initializes the tap model func (m *Model) Init() tea.Cmd { return nil } -// Name returns the name of the view func (m *Model) Name() string { return "Tap" } -// Update updates the tap model func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: // Size update! // Check if it's relevant for this view - entry, ok := msg.Sizes[m.Name()] - if ok { + if entry, ok := msg.Sizes[m.Name()]; ok { // Update all dependent styles m.width = entry.Width m.height = entry.Height @@ -86,29 +70,7 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { case Msg: m.lastOrderID = msg.lastOrderID - - for _, msgItem := range msg.items { - found := false - for i, item := range m.items { - if item.category == msgItem.category { - m.items[i].amount += msgItem.amount - m.items[i].last = msgItem.last - found = true - break - } - } - - if !found { - m.items = append(m.items, msgItem) - } - } - - // Sort to display bars in order - slices.SortFunc(m.items, func(i, j tapItem) int { - return j.amount - i.amount - }) - - return m, nil + m.items = msg.items } return m, nil @@ -135,49 +97,3 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { }, } } - -func updateOrders(view view.View) (tea.Msg, error) { - m := view.(*Model) - lastOrderID := m.lastOrderID - - order, err := m.db.Queries.GetLastOrderByOrderID(context.Background()) - if err != nil { - if err == pgx.ErrNoRows { - err = nil - } - return nil, err - } - - if order.OrderID <= lastOrderID { - return nil, nil - } - - orders, err := m.db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) - if err != nil { - return nil, err - } - - counts := make(map[category]tapItem) - - for _, order := range orders { - if entry, ok := counts[category(order.Category)]; ok { - entry.amount += int(order.Count) - counts[category(order.Category)] = entry - continue - } - - counts[category(order.Category)] = tapItem{ - category: category(order.Category), - amount: int(order.Count), - last: order.LatestOrderCreatedAt.Time, - } - } - - items := make([]tapItem, 0, len(counts)) - - for _, v := range counts { - items = append(items, v) - } - - return Msg{lastOrderID: order.OrderID, items: items}, nil -} diff --git a/tui/view/tap/update.go b/tui/view/tap/update.go new file mode 100644 index 0000000..2f69cd0 --- /dev/null +++ b/tui/view/tap/update.go @@ -0,0 +1,37 @@ +package tap + +import ( + "context" + "slices" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/utils" + "github.com/zeusWPI/scc/tui/view" +) + +func updateOrders(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + lastOrder, err := m.repo.GetLast(ctx) + if err != nil { + return nil, err + } + if lastOrder == nil { + lastOrder = &model.Tap{OrderID: -1} + } + + if lastOrder.OrderID == m.lastOrderID { + return nil, nil + } + + counts, err := m.repo.GetCountByCategory(ctx) + if err != nil { + return nil, nil + } + + slices.SortFunc(counts, func(a, b *model.TapCount) int { return b.Count - a.Count }) + counts = utils.SliceFilter(counts, func(c *model.TapCount) bool { return c.Category != model.Unknown }) + + return Msg{items: utils.SliceDereference(counts)}, nil +} diff --git a/tui/view/tap/view.go b/tui/view/tap/view.go index d5236f8..2d47fe8 100644 --- a/tui/view/tap/view.go +++ b/tui/view/tap/view.go @@ -12,16 +12,16 @@ func (m *Model) viewChart() string { bars := make([]barchart.BarData, 0, len(m.items)) for _, item := range m.items { - style, ok := categoryToStyle[item.category] + style, ok := categoryToStyle[item.Category] if !ok { continue } bars = append(bars, barchart.BarData{ - Label: sBarLabel.Render(string(item.category)), + Label: sBarLabel.Render(string(item.Category)), Values: []barchart.BarValue{{ - Name: string(item.category), - Value: float64(item.amount), + Name: string(item.Category), + Value: float64(item.Count), Style: style.Inherit(sBarOne), }}, }) @@ -37,9 +37,9 @@ func (m *Model) viewStats() string { rows := make([]string, 0, len(m.items)) for _, item := range m.items { - amount := sStatAmount.Render(strconv.Itoa(item.amount)) - category := sStatCategory.Inherit(categoryToStyle[item.category]).Render(string(item.category)) - last := sStatLast.Render(item.last.Format("15:04 02/01")) + amount := sStatAmount.Render(strconv.Itoa(item.Count)) + category := sStatCategory.Inherit(categoryToStyle[item.Category]).Render(string(item.Category)) + last := sStatLast.Render(item.LastOrder.Format("15:04 02/01")) text := lipgloss.JoinHorizontal(lipgloss.Top, amount, category, last) rows = append(rows, text) diff --git a/tui/view/util.go b/tui/view/util.go index 3b78a04..d2a63b0 100644 --- a/tui/view/util.go +++ b/tui/view/util.go @@ -12,6 +12,10 @@ import ( // ImageToString converts an image to a string // If either widht or height is 0 then the aspect ratio is kept func ImageToString(img image.Image, width, height int) string { + if img == nil { + return "" + } + if width == 0 || height == 0 { return imageToString(imaging.Resize(img, width, height, imaging.Lanczos)) } @@ -35,7 +39,7 @@ func imageToString(img image.Image) string { str.WriteString(" ") } - for x := 0; x < imageWidth; x++ { + for x := range imageWidth { c1, _ := colorful.MakeColor(img.At(x, heightCounter)) color1 := lipgloss.Color(c1.Hex()) c2, _ := colorful.MakeColor(img.At(x, heightCounter+1)) diff --git a/tui/view/view.go b/tui/view/view.go index fa57bcc..322d3fc 100644 --- a/tui/view/view.go +++ b/tui/view/view.go @@ -2,6 +2,8 @@ package view import ( + "context" + tea "github.com/charmbracelet/bubbletea" ) @@ -9,7 +11,7 @@ import ( type UpdateData struct { Name string View View - Update func(view View) (tea.Msg, error) + Update func(ctx context.Context, view View) (tea.Msg, error) Interval int } diff --git a/tui/view/zess/style.go b/tui/view/zess/style.go index 790326c..a4cc3f0 100644 --- a/tui/view/zess/style.go +++ b/tui/view/zess/style.go @@ -35,7 +35,7 @@ var ( wStatAmount = 4 // Supports up to 9999 wStatGapMin = 3 // Minimum gap size between the date and amount - sStat = base.BorderStyle(lipgloss.ThickBorder()).BorderForeground(cBorder).BorderLeft(true).Margin(0, 1, 1, 1).PaddingLeft(1) //.Align(lipgloss.Center) + sStat = base.BorderStyle(lipgloss.ThickBorder()).BorderForeground(cBorder).BorderLeft(true).Margin(0, 1, 1, 1).PaddingLeft(1) // .Align(lipgloss.Center) sStatTitle = base.Foreground(cStatsTitle).Bold(true).BorderStyle(lipgloss.NormalBorder()).BorderForeground(cBorder).BorderBottom(true).Align(lipgloss.Center).MarginBottom(1) sStatDate = base.Width(wStatDate) sStatAmount = base.Width(wStatAmount).Align(lipgloss.Right) diff --git a/tui/view/zess/update.go b/tui/view/zess/update.go new file mode 100644 index 0000000..a69d4ab --- /dev/null +++ b/tui/view/zess/update.go @@ -0,0 +1,81 @@ +package zess + +import ( + "context" + "slices" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/database/model" + "github.com/zeusWPI/scc/pkg/utils" + "github.com/zeusWPI/scc/tui/view" +) + +func (w week) equal(w2 week) bool { + return w.scans == w2.scans && w.start.Equal(w2.start) +} + +func updateWeeks(ctx context.Context, view view.View) (tea.Msg, error) { + m := view.(*Model) + + season, err := m.repoSeason.GetCurrent(ctx) + if err != nil { + return nil, err + } + if season == nil { + return nil, nil + } + + scans, err := m.repoScan.GetInSeason(ctx, *season) + if err != nil { + return nil, err + } + if scans == nil { + return nil, nil + } + slices.SortFunc(scans, func(a, b *model.Scan) int { return a.ScanTime.Compare(b.ScanTime) }) + + weekMap := make(map[time.Time]week) + + for _, scan := range scans { + start := getStartOfWeek(scan.ScanTime) + + entry, ok := weekMap[start] + if !ok { + entry = week{ + start: start, + scans: 0, + } + } + + entry.scans++ + weekMap[start] = entry + } + + weeks := utils.MapValues(weekMap) + + if len(weeks) != len(m.weeks) { + return Msg{weeks: weeks}, nil + } + + for idx, week := range weeks { + if !week.equal(m.weeks[idx]) { + return Msg{weeks: weeks}, nil + } + } + + return nil, nil +} + +func getStartOfWeek(t time.Time) time.Time { + weekDay := int(t.Weekday()) + + if weekDay == 0 { + weekDay = 7 + } + + return time.Date( + t.Year(), t.Month(), t.Day()-weekDay+1, + 0, 0, 0, 0, t.Location(), + ) +} diff --git a/tui/view/zess/view.go b/tui/view/zess/view.go index 5b2f811..3b00c88 100644 --- a/tui/view/zess/view.go +++ b/tui/view/zess/view.go @@ -2,22 +2,26 @@ package zess import ( "fmt" + "slices" "strconv" "github.com/NimbleMarkets/ntcharts/barchart" "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/pkg/utils" ) func (m *Model) viewChart() string { chart := barchart.New(sBar.GetWidth(), sBar.GetHeight(), barchart.WithNoAutoBarWidth(), barchart.WithBarGap(wBarGap), barchart.WithBarWidth(wBar)) - for _, scan := range m.scans { + for _, week := range m.weeks { + weekNumber, _ := week.start.ISOWeek() + bar := barchart.BarData{ - Label: sBarLabel.Render(fmt.Sprintf("W%d", scan.time.week)), + Label: sBarLabel.Render(fmt.Sprintf("W%02d", weekNumber)), Values: []barchart.BarValue{{ - Name: scan.start, - Value: float64(scan.amount), - Style: sBarOne.Foreground(lipgloss.Color(scan.color)), + Name: week.start.String(), + Value: float64(week.scans), + Style: sBarOne.Foreground(lipgloss.Color(randomColor())), }}, } @@ -31,19 +35,25 @@ func (m *Model) viewChart() string { func (m *Model) viewStats() string { // Overview of each week - rows := make([]string, 0, len(m.scans)) + rows := make([]string, 0, len(m.weeks)) + + maxScans := 0 + if len(m.weeks) != 0 { + maxScans = slices.MaxFunc(m.weeks, func(a, b week) int { return a.scans - b.scans }).scans + } - for _, scan := range m.scans { - week := sStatDate.Render(fmt.Sprintf("W%02d - %s", scan.time.week, scan.start)) + for _, week := range m.weeks { + _, weekNumber := week.start.ISOWeek() + weekStr := sStatDate.Render(fmt.Sprintf("W%02d - %s", weekNumber, week.start.Format("01/02"))) var amount string - if scan.amount == m.maxWeekScans { - amount = sStatMax.Inherit(sStatAmount).Render(strconv.Itoa(int(scan.amount))) + if week.scans == maxScans { + amount = sStatMax.Inherit(sStatAmount).Render(strconv.Itoa(week.scans)) } else { - amount = sStatAmount.Render(strconv.Itoa(int(scan.amount))) + amount = sStatAmount.Render(strconv.Itoa(week.scans)) } - text := lipgloss.JoinHorizontal(lipgloss.Top, week, amount) + text := lipgloss.JoinHorizontal(lipgloss.Top, weekStr, amount) rows = append(rows, text) } @@ -54,7 +64,7 @@ func (m *Model) viewStats() string { // Total scans total := sStatTotalTitle.Render("Total") - amount := sStatTotalAmount.Render(strconv.Itoa(int(m.seasonScans))) + amount := sStatTotalAmount.Render(strconv.Itoa(utils.Reduce(m.weeks, func(accum int, w week) int { return accum + w.scans }))) total = lipgloss.JoinHorizontal(lipgloss.Top, total, amount) total = sStatTotal.Render(total) diff --git a/tui/view/zess/zess.go b/tui/view/zess/zess.go index 1eda17f..eabf1de 100644 --- a/tui/view/zess/zess.go +++ b/tui/view/zess/zess.go @@ -2,103 +2,58 @@ package zess import ( - "context" "math/rand/v2" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jackc/pgx/v5" - "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/database/repository" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/tui/view" - "go.uber.org/zap" ) -// yearWeek is used to represent a date by it's year and week -type yearWeek struct { - year int - week int -} - -type weekScan struct { - time yearWeek - amount int64 - start string // The date when the week starts - color string -} - -// Model represents the Model for the zess view type Model struct { - db *db.DB - lastScanID int32 - scans []weekScan // Scans per week - showWeeks int // Amount of weeks to show - maxWeekScans int64 - currentSeason yearWeek // Start week of the season - seasonScans int64 + repoSeason repository.Season + repoScan repository.Scan + weeks []week width int height int } -// Msg is the base message to indicate that something changed in the zess view -type Msg struct{} +// Interface compliance +var _ view.View = (*Model)(nil) -// scanMsg is used to indicate that the zess view should be updated with new scans -type scanMsg struct { - Msg - lastScanID int32 - scans []weekScan +type Msg struct { + weeks []week } -// seasonMsg is used to indicate that the current season changed. -type seasonMsg struct { - Msg - start yearWeek +type week struct { + start time.Time // Start of the week + + scans int } -// NewModel creates a new zess model view -func NewModel(db *db.DB) view.View { +func NewModel(repo repository.Repository) view.View { m := &Model{ - db: db, - lastScanID: -1, - scans: make([]weekScan, 0), - showWeeks: config.GetDefaultInt("tui.view.zess.weeks", 10), - maxWeekScans: -1, - currentSeason: yearWeek{year: -1, week: -1}, - seasonScans: 0, - } - - // Populate with data - // The order in which this is called is important! - msgScans, err := updateScans(m) - if err != nil { - zap.S().Error("TUI: Unable to update zess scans\n", err) - return m + repoSeason: *repo.NewSeason(), + repoScan: *repo.NewScan(), + weeks: nil, + width: 0, + height: 0, } - _, _ = m.Update(msgScans) - - msgSeason, err := updateSeason(m) - if err != nil { - zap.S().Error("TUI: Unable to update zess seasons\n", err) - return m - } - _, _ = m.Update(msgSeason) return m } -// Init created a new zess model func (m *Model) Init() tea.Cmd { return nil } -// Name returns the name of the view func (m *Model) Name() string { return "Zess" } -// Update updates the zess model func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case view.MsgSize: @@ -115,64 +70,8 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil - // New scan(s) - case scanMsg: - m.lastScanID = msg.lastScanID - // Add new scans - for _, newScan := range msg.scans { - found := false - for i, modelScan := range m.scans { - if newScan.time.equal(modelScan.time) { - m.scans[i].amount++ - // Check for maxWeekScans - if m.scans[i].amount > m.maxWeekScans { - m.maxWeekScans = modelScan.amount - } - - found = true - break - } - } - - if !found { - m.scans = append(m.scans, newScan) - // Check for maxWeekScans - if newScan.amount > m.maxWeekScans { - m.maxWeekScans = newScan.amount - } - // Make sure the array doesn't get too big - if len(m.scans) > m.showWeeks { - m.scans = m.scans[:1] - } - } - - // Update seasonScans - m.seasonScans += newScan.amount - } - - // New season! - // Update variables accordinly - case seasonMsg: - m.currentSeason = msg.start - m.seasonScans = 0 - m.maxWeekScans = 0 - - validScans := make([]weekScan, 0, len(m.scans)) - - for _, scan := range m.scans { - // Add scans if they happend after (or in the same week of) the season start - if scan.time.equal(m.currentSeason) || scan.time.after(m.currentSeason) { - validScans = append(validScans, scan) - - if scan.amount > m.maxWeekScans { - m.maxWeekScans = scan.amount - } - - m.seasonScans += scan.amount - } - } - - m.scans = validScans + case Msg: + m.weeks = msg.weeks } return m, nil @@ -192,112 +91,14 @@ func (m *Model) View() string { func (m *Model) GetUpdateDatas() []view.UpdateData { return []view.UpdateData{ { - Name: "zess scans", + Name: "zess weeks", View: m, - Update: updateScans, - Interval: config.GetDefaultInt("tui.view.zess.interval_scan_s", 60), - }, - { - Name: "zess season", - View: m, - Update: updateSeason, - Interval: config.GetDefaultInt("tui.view.zess.interval_season_s", 3600), + Update: updateWeeks, + Interval: config.GetDefaultInt("tui.view.zess.interval_s", 60), }, } } -// Check for any new scans -func updateScans(view view.View) (tea.Msg, error) { - m := view.(*Model) - lastScanID := m.lastScanID - - // Get new scans - scans, err := m.db.Queries.GetAllScansSinceID(context.Background(), lastScanID) - if err != nil { - if err == pgx.ErrNoRows { - // No rows shouldn't be considered an error - err = nil - } - return nil, err - } - - // No new scans - if len(scans) == 0 { - return nil, nil - } - - zessScanMsg := scanMsg{lastScanID: lastScanID, scans: make([]weekScan, 0)} - - // Add new scans to scan msg - for _, newScan := range scans { - yearNumber, weekNumber := newScan.ScanTime.Time.ISOWeek() - newTime := yearWeek{year: yearNumber, week: weekNumber} - - found := false - for i, scan := range zessScanMsg.scans { - if scan.time.equal(newTime) { - zessScanMsg.scans[i].amount++ - found = true - break - } - } - - if !found { - zessScanMsg.scans = append(zessScanMsg.scans, weekScan{ - time: newTime, - amount: 1, - start: newScan.ScanTime.Time.Format("02/01"), - color: randomColor(), - }) - } - - // Update scan ID - // Not necessarly the first or last entry in the scans slice - if newScan.ID > zessScanMsg.lastScanID { - zessScanMsg.lastScanID = newScan.ID - } - } - - return zessScanMsg, nil -} - -// Check if a new season started -func updateSeason(view view.View) (tea.Msg, error) { - m := view.(*Model) - - season, err := m.db.Queries.GetSeasonCurrent(context.Background()) - if err != nil { - if err == pgx.ErrNoRows { - // No rows shouldn't be considered an error - err = nil - } - return nil, err - } - - // Check if we have a new season - yearNumber, weekNumber := season.Start.Time.ISOWeek() - seasonStart := yearWeek{year: yearNumber, week: weekNumber} - if m.currentSeason.equal(seasonStart) { - // Same season - return nil, nil - } - - return seasonMsg{start: seasonStart}, nil -} - -func (z *yearWeek) equal(z2 yearWeek) bool { - return z.week == z2.week && z.year == z2.year -} -func (z *yearWeek) after(z2 yearWeek) bool { - if z.year > z2.year { - return true - } else if z.year < z2.year { - return false - } - - return z.week > z2.week -} - func randomColor() string { return colors[rand.IntN(len(colors))] }