diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..580ea18 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +APP_ENV = development +SONG_SPOTIFY_CLIENT_ID = your_client_id +SONG_SPOTIFY_CLIENT_SECRET = your_client_secret diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..dfd1704 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,8 @@ +#!/bin/bash + +echo "Linting" +golangci-lint run +if [ $? -ne 0 ]; then + echo "golangci-lint failed. Please fix the errors before committing." + exit 1 +fi diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index 85b5435..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,28 +0,0 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - -name: Go - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.22.0' - - - name: Build - run: go build -v ./... - - - name: Test - run: go test -v ./... diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..54f3d7e --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,30 @@ +name: golangci-lint + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + pull-requests: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.23.1 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.60 diff --git a/.github/workflows/sqlc-diff.yml b/.github/workflows/sqlc-diff.yml new file mode 100644 index 0000000..d5b3ce0 --- /dev/null +++ b/.github/workflows/sqlc-diff.yml @@ -0,0 +1,12 @@ +name: sqlc +on: [push] + +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 diff --git a/.gitignore b/.gitignore index 446b662..9191adc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# # Binaries for programs and plugins *.exe *.exe~ @@ -8,7 +5,7 @@ *.so *.dylib -# Test binary, built with `go test -c` +# Test binary, built with "go test -c" *.test # Output of the go coverage tool, specifically when used with LiteIDE @@ -19,12 +16,22 @@ # Go workspace file go.work +tmp/ -tmp.go +# IDE specific files +.vscode +.idea -config.yaml +# .env file +.env +.env.* +!.env.example -# Binary -scc -# Log file -scc.log +# Project build +backend_bin +tui_bin + +.data/ + +# Log files +logs/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..a0c6843 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,35 @@ +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 + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.tool-versions b/.tool-versions index f526442..c9953ae 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -golang 1.22.0 +golang 1.23.1 diff --git a/README.md b/README.md index 626b949..8efdf8f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,70 @@ # Screen Cammie Chat -Go 1.22.0 +Displays the cammie chat along with some other statistics. -- `go mod tidy` +## Development Setup -- `go run .` of `go build .` \ No newline at end of file +### Prerequisites + +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) + +### Configuration + +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. + +## DB + +This project uses a postgres database. +SQLC is used to generate statically typed queries and goose is responsible for the database migrations. + +### Usefull commands + +- `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. + +## 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. + +### Running the backend + +To build and run the backend, use the following commands: + +- `make build-backend`: Build the backend binary. +- `make run-backend`: Run the backend. + +### Logs + +Backend logs are saved to `./logs/backend.log` (created on first start) and written to `stdout`. + +## TUI + +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. + +### Running the TUI + +To build and run the TUI, use the following commands: + +- `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). + +A screen is responsible for creating and managing the layout, consisting of various [views](./tui/view/). + +### Logs + +TUI logs are written to `./logs/tui.log` and _not_ to `stdout`. diff --git a/api/api.go b/api/api.go deleted file mode 100644 index fe8ad40..0000000 --- a/api/api.go +++ /dev/null @@ -1,41 +0,0 @@ -package api - -import ( - "io" - "scc/screen" - - "github.com/gin-gonic/gin" -) - -// Wrapper for the handler functions to pass the screen application -func handlerWrapper(app *screen.ScreenApp, callback func(*screen.ScreenApp, *gin.Context)) func(*gin.Context) { - return func(ctx *gin.Context) { - callback(app, ctx) - } -} - -// Start the API -func Start(screenApp *screen.ScreenApp) { - gin.SetMode(gin.ReleaseMode) - gin.DefaultWriter = io.Discard - - r := gin.Default() - - // Routes - - // Cammie chat routes - r.GET("/message", handlerWrapper(screenApp, cammieGetMessage)) - r.POST("/message", handlerWrapper(screenApp, cammiePostMessage)) - - // Spotify routes - r.POST("/spotify", handlerWrapper(screenApp, spotifyGetMessage)) - - // Start Tap - go tapRunRequests(screenApp) - - // Start Zess - go zessRunRequests(screenApp) - - // Start API - r.Run() -} diff --git a/api/cammie.go b/api/cammie.go deleted file mode 100644 index 708f524..0000000 --- a/api/cammie.go +++ /dev/null @@ -1,84 +0,0 @@ -package api - -import ( - "fmt" - "net/http" - "scc/buzzer" - "scc/config" - "scc/screen" - "slices" - - gin "github.com/gin-gonic/gin" -) - -// cammieMessage struct -type cammieMessage struct { - Message string `form:"message" json:"message" xml:"message" binding:"required"` -} - -// cammieHeader struct -type cammieHeader struct { - Name string `header:"X-Username"` - IP string `header:"X-Real-IP"` -} - -var ( - cammieMessages uint64 = 0 - cammieBlockedNames = config.GetConfig().Cammie.BlockedNames // Blocked names - cammieBlockedIps = config.GetConfig().Cammie.BlockedIps // Blocked IPs - cammieMaxMessageLength = config.GetConfig().Cammie.MaxMessageLength // Maximum message length -) - -func cammieGetMessage(app *screen.ScreenApp, c *gin.Context) { - c.JSON(200, gin.H{"messages": cammieMessages}) -} - -func cammiePostMessage(app *screen.ScreenApp, c *gin.Context) { - // Get structs - header := &cammieHeader{} - message := &cammieMessage{} - cammieMessage := &screen.CammieMessage{} - - // Check Header - if err := c.ShouldBindHeader(header); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Check Data - if err := c.ShouldBindJSON(message); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Max message length - if len(message.Message) > cammieMaxMessageLength { - c.JSON(http.StatusBadRequest, gin.H{"error": "Message too long, maximum " + fmt.Sprint(cammieMaxMessageLength)}) - return - } - - // Check if sender is blocked and construct message - if header.Name != "" { - if slices.Contains(cammieBlockedNames, header.Name) { - c.JSON(http.StatusOK, gin.H{"message": "Message received"}) - return - } - cammieMessage.Sender = fmt.Sprintf("[%s[]", header.Name) - } else if header.IP != "" { - if slices.Contains(cammieBlockedIps, header.IP) { - c.JSON(http.StatusOK, gin.H{"message": "Message received"}) - return - } - cammieMessage.Sender = fmt.Sprintf("<%s>", header.IP) - } - - cammieMessage.Message = message.Message - - // Increment messages - cammieMessages++ - - app.Cammie.Update(cammieMessage) - go buzzer.PlayBuzzer() - - c.JSON(http.StatusOK, gin.H{"message": "Message received"}) -} diff --git a/api/common.go b/api/common.go deleted file mode 100644 index 15ce3ef..0000000 --- a/api/common.go +++ /dev/null @@ -1,40 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" -) - -type header struct { - Key string - Value string -} - -var jsonHeader = header{ - "Accept", - "application/json", -} - -func makeGetRequest[T any](url string, headers []header, response *T) error { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return err - } - - for _, header := range headers { - req.Header.Set(header.Key, header.Value) - } - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if err := json.NewDecoder(resp.Body).Decode(response); err != nil { - return err - } - - return nil -} diff --git a/api/spotify.go b/api/spotify.go deleted file mode 100644 index 1f24fd5..0000000 --- a/api/spotify.go +++ /dev/null @@ -1,119 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "net/url" - "scc/config" - "scc/screen" - "strings" - "time" - - "github.com/gin-gonic/gin" -) - -type spotifyMessage struct { - TrackID string `json:"track_id"` -} - -type spotifyTokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` -} - -type spotifyArtist struct { - Name string `json:"name"` -} - -type spotifyTrackResponse struct { - Name string `json:"name"` - Artists []spotifyArtist `json:"artists"` -} - -var ( - spotifyAccessToken = "" - spotifyExpiresOn int64 = 0 - spotifyClientID = config.GetConfig().Spotify.ClientID - spotifyClientSecret = config.GetConfig().Spotify.ClientSecret -) - -func spotifyGetMessage(app *screen.ScreenApp, ctx *gin.Context) { - message := &spotifyMessage{} - - if err := ctx.ShouldBindJSON(message); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - ctx.JSON(http.StatusOK, gin.H{"track_id": "Track ID received"}) - - if spotifyExpiresOn < time.Now().Unix() { - if err := spotifySetAccessToken(); err != nil { - log.Printf("Error: Unable to refresh spotify token: %s\n", err) - } - } - - track, err := spotifyGetTrackTitle(message.TrackID) - - if err != nil { - log.Printf("Error: Unable to get track information: %s\n", err) - } - - app.Spotify.Update(track) -} - -func spotifySetAccessToken() error { - data := url.Values{} - data.Set("grant_type", "client_credentials") - data.Set("client_id", spotifyClientID) - data.Set("client_secret", spotifyClientSecret) - - // Send the POST request - resp, err := http.PostForm("https://accounts.spotify.com/api/token", data) - if err != nil { - return err - } - defer resp.Body.Close() - - // Check for a successful response - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("error: received non-200 status code %d", resp.StatusCode) - } - - message := &spotifyTokenResponse{} - if err := json.NewDecoder(resp.Body).Decode(message); err != nil { - return err - } - - spotifyAccessToken = message.AccessToken - spotifyExpiresOn = time.Now().Unix() + message.ExpiresIn - - return nil -} - -func spotifyGetTrackTitle(trackID string) (string, error) { - url := fmt.Sprintf("https://api.spotify.com/v1/tracks/%s", trackID) - headers := []header{ - jsonHeader, - { - "Authorization", - "Bearer " + spotifyAccessToken, - }, - } - trackResponse := &spotifyTrackResponse{} - - if err := makeGetRequest(url, headers, trackResponse); err != nil { - return "", err - } - - trackTitle := trackResponse.Name - artistsNames := make([]string, len(trackResponse.Artists)) - for i, artist := range trackResponse.Artists { - artistsNames[i] = artist.Name - } - - return fmt.Sprintf("%s - %s", trackTitle, strings.Join(artistsNames, ", ")), nil -} diff --git a/api/tap.go b/api/tap.go deleted file mode 100644 index 6bb8298..0000000 --- a/api/tap.go +++ /dev/null @@ -1,42 +0,0 @@ -package api - -import ( - "log" - "scc/config" - "scc/screen" - "slices" - "time" -) - -type tapReponse struct { - Orders []screen.TapOrder `json:"orders"` -} - -var ( - tapURL = config.GetConfig().Tap.URL - tapLastOrderTimestamp = time.Time{} -) - -func tapRunRequests(app *screen.ScreenApp) { - headers := []header{jsonHeader} - - for { - recentOrders := &tapReponse{} - if err := makeGetRequest(tapURL, headers, recentOrders); err != nil { - log.Printf("Error: Unable to get recent orders: %s\n", err) - } - - slices.SortStableFunc(recentOrders.Orders, func(a, b screen.TapOrder) int { - return a.OrderCreatedAt.Compare(b.OrderCreatedAt) - }) - - for _, order := range recentOrders.Orders { - if order.OrderCreatedAt.After(tapLastOrderTimestamp) { - app.Tap.Update(&order) - tapLastOrderTimestamp = order.OrderCreatedAt - } - } - - time.Sleep(1 * time.Minute) - } -} diff --git a/api/zess.go b/api/zess.go deleted file mode 100644 index 0f0777e..0000000 --- a/api/zess.go +++ /dev/null @@ -1,42 +0,0 @@ -package api - -import ( - "log" - "scc/config" - "scc/screen" - "slices" - "time" -) - -type zessResponse struct { - Scans []screen.ZessScan `json:"scans"` -} - -var ( - zessURL = config.GetConfig().Zess.URL - zessLastOrderTimestamp = time.Time{} -) - -func zessRunRequests(app *screen.ScreenApp) { - headers := []header{jsonHeader} - - for { - recentScans := &zessResponse{} - if err := makeGetRequest(zessURL, headers, recentScans); err != nil { - log.Printf("Error: Unable to get recent scans: %s\n", err) - } - - slices.SortStableFunc(recentScans.Scans, func(a, b screen.ZessScan) int { - return a.ScanTime.Compare(b.ScanTime) - }) - - for _, order := range recentScans.Scans { - if order.ScanTime.After(zessLastOrderTimestamp) { - app.Zess.Update(&order) - zessLastOrderTimestamp = order.ScanTime - } - } - - time.Sleep(1 * time.Minute) - } -} diff --git a/buzzer/buzzer.go b/buzzer/buzzer.go deleted file mode 100644 index 9fb9a3e..0000000 --- a/buzzer/buzzer.go +++ /dev/null @@ -1,41 +0,0 @@ -package buzzer - -import ( - "log" - "os/exec" - "scc/config" -) - -var buzzerOptions = map[string]func(){ - "default": playMusic, -} - -func PlayBuzzer() { - buzzerSong := config.GetConfig().Buzzer.Song - fun, ok := buzzerOptions[buzzerSong] - if !ok { - log.Printf("Error: Selected buzzer song: %s does not exist\n", buzzerSong) - return - } - fun() -} - -func playMusic() { - // See 'man beep' - cmd := exec.Command( - "beep", - "-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", - ) - err := cmd.Run() - if err != nil { - log.Printf("Error running command 'beep': %s\n", err) - } -} diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go new file mode 100644 index 0000000..6c464bc --- /dev/null +++ b/cmd/backend/backend.go @@ -0,0 +1,54 @@ +// Main entry point for the backend +package main + +import ( + "github.com/zeusWPI/scc/internal/cmd" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/logger" + "go.uber.org/zap" +) + +func main() { + // Config + err := config.Init() + if err != nil { + panic(err) + } + + // Logger + zapLogger, err := logger.New("backend", true) + if err != nil { + panic(err) + } + zap.ReplaceGlobals(zapLogger) + + zap.S().Info("Initializing backend") + + // Database + db, err := db.New() + if err != nil { + zap.S().Fatal("DB: Fatal error\n", err) + } + + // Tap + _, _ = cmd.Tap(db) + + // Zess + _, _, _ = cmd.Zess(db) + + // Gamification + _, _ = cmd.Gamification(db) + + // Event + _, _ = cmd.Event(db) + + // Spotify + spotify, err := cmd.Song(db) + if err != nil { + zap.S().Error("Spotify: Initiating error, integration will not work.\n", err) + } + + // API + cmd.API(db, spotify) +} diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go new file mode 100644 index 0000000..b2d6669 --- /dev/null +++ b/cmd/tui/tui.go @@ -0,0 +1,39 @@ +// Main entry point for the tui +package main + +import ( + "github.com/zeusWPI/scc/internal/cmd" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/logger" + "go.uber.org/zap" +) + +func main() { + // Config + err := config.Init() + if err != nil { + panic(err) + } + + // Logger + zapLogger, err := logger.New("tui", false) + if err != nil { + panic(err) + } + zap.ReplaceGlobals(zapLogger) + + zap.S().Info("Initializing TUI") + + // Database + db, err := db.New() + if err != nil { + zap.S().Fatal("DB: Fatal error\n", err) + } + + // TUI + err = cmd.TUI(db) + if err != nil { + zap.S().Fatal("TUI: Fatal error\n", err) + } +} diff --git a/config.example.yaml b/config.example.yaml deleted file mode 100644 index b3ae3c5..0000000 --- a/config.example.yaml +++ /dev/null @@ -1,30 +0,0 @@ -cammie: - blocked_names: - - Name 1 - - Name 2 - blocked_ips: - - 10.0.0.10 - max_message_length: 200 -buzzer: - pin: 12 - song: default -spotify: - client_id: abcd - client_secret: dcba -tap: - url: "https://tap.zeus.gent/recent" - beer: - - Schelfaut - - Duvel - - Fourchette - - Jupiler - - Karmeliet - - Kriek - - Chouffe - - Maes - - Somersby - - Sportzot - - Stella -zess: - url: "https://zess.zeus.gent/recent_scans" - day_amount: 40 diff --git a/config.template.yaml b/config.template.yaml deleted file mode 100644 index 77d45ed..0000000 --- a/config.template.yaml +++ /dev/null @@ -1,16 +0,0 @@ -cammie: - blocked_names: [] # List of names that don't get shown - blocked_ips: [] # List of IP's that don't get shown - max_message_length: # Max message list -buzzer: - pin: # PWM pin used by the buzzer - song: # Options are - default -spotify: - client_id: # Client ID of the Zeus spotify account - client_secret: # Client secret of the Zeus spotify account -tap: - url: # URL of tap - beer: [] # List of beers in tap, can be found in tap -zess: - url: # URL of zess - day_amount: # Amount of days to keep track, should be the same as is displayed which depends on the screen size diff --git a/config/config.go b/config/config.go deleted file mode 100644 index f0262c4..0000000 --- a/config/config.go +++ /dev/null @@ -1,62 +0,0 @@ -package config - -import ( - "log" - "os" - "sync" - - "gopkg.in/yaml.v3" -) - -type cammieConfig struct { - BlockedNames []string `yaml:"blocked_names"` - BlockedIps []string `yaml:"blocked_ips"` - MaxMessageLength int `yaml:"max_message_length"` -} - -type buzzerConfig struct { - Song string `yaml:"song"` -} - -type spotifyConfig struct { - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` -} - -type tapConfig struct { - URL string `yaml:"url"` - Beers []string `yaml:"beer"` -} - -type zessConfig struct { - URL string `yaml:"url"` - DayAmount int `yaml:"day_amount"` -} - -type Config struct { - Cammie cammieConfig `yaml:"cammie"` - Buzzer buzzerConfig `yaml:"buzzer"` - Spotify spotifyConfig `yaml:"spotify"` - Tap tapConfig `yaml:"tap"` - Zess zessConfig `yaml:"zess"` -} - -var ( - configInstance *Config - once sync.Once -) - -func GetConfig() *Config { - once.Do(func() { - configInstance = &Config{} - data, err := os.ReadFile("config.yaml") - if err != nil { - log.Fatalf("Failed to read config file: %v", err) - } - if err := yaml.Unmarshal(data, configInstance); err != nil { - log.Fatalf("Failed to unmarshal config: %v", err) - } - }) - - return configInstance -} diff --git a/config/development.yaml b/config/development.yaml new file mode 100644 index 0000000..5cb3c2e --- /dev/null +++ b/config/development.yaml @@ -0,0 +1,113 @@ +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_top_s: 300 + + tap: + interval_s: 60 + + zess: + weeks: 10 + interval_scan_s: 60 + interval_season_s: 3600 diff --git a/config/production.yaml b/config/production.yaml new file mode 100644 index 0000000..72fe481 --- /dev/null +++ b/config/production.yaml @@ -0,0 +1,86 @@ +# [server] +# host = "localhost" +# port = 3000 + +# [db] +# host = "localhost" +# port = 5432 +# database = "scc" +# user = "postgres" +# password = "postgres" + +# [song] +# spotify_api = "https://api.spotify.com/v1" +# spotify_account = "https://accounts.spotify.com/api/token" +# lrclib_api = "https://lrclib.net/api" + +# [tap] +# api = "https://tap.zeus.gent" +# interval_s = 60 +# beers = [ +# "Schelfaut", +# "Duvel", +# "Fourchette", +# "Jupiler", +# "Karmeliet", +# "Kriek", +# "Chouffe", +# "Maes", +# "Somersby", +# "Sportzot", +# "Stella", +# ] + +# [zess] +# api = "http://localhost:4000/api" +# interval_season_s = 300 +# interval_scan_s = 60 + +# [gamification] +# api = "https://gamification.zeus.gent" +# interval_s = 3600 + +# [event] +# api = "https://zeus.gent/events" +# api_poster = "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master" +# interval_s = 86400 + +# [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", +# ] + +# [tui] + +# [tui.screen] +# cammie_interval_change_s = 300 + +# [tui.zess] +# weeks = 10 +# interval_scan_s = 60 +# interval_season_s = 3600 + +# [tui.message] +# interval_s = 1 + +# [tui.tap] +# interval_s = 60 + +# [tui.gamification] +# interval_s = 3600 + +# [tui.song] +# interval_current_s = 5 +# interval_history_s = 5 +# interval_top_s = 3600 + +# [tui.event] +# interval_s = 3600 diff --git a/db/migrations/20241114125504_add_messages_table.sql b/db/migrations/20241114125504_add_messages_table.sql new file mode 100644 index 0000000..8060357 --- /dev/null +++ b/db/migrations/20241114125504_add_messages_table.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS message ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + ip TEXT NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS message; +-- +goose StatementEnd diff --git a/db/migrations/20241114135818_add_spotify_table.sql b/db/migrations/20241114135818_add_spotify_table.sql new file mode 100644 index 0000000..35802e9 --- /dev/null +++ b/db/migrations/20241114135818_add_spotify_table.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS spotify ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + artists TEXT NOT NULL, + spotify_id TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS spotify; +-- +goose StatementEnd diff --git a/db/migrations/20241114152122_add_tap_table.sql b/db/migrations/20241114152122_add_tap_table.sql new file mode 100644 index 0000000..61d6a24 --- /dev/null +++ b/db/migrations/20241114152122_add_tap_table.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS tap ( + id SERIAL PRIMARY KEY, + order_id INTEGER NOT NULL, + order_created_at TIMESTAMP WITH TIME ZONE NOT NULL, + name TEXT NOT NULL, + category TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +) +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS tap; +-- +goose StatementEnd diff --git a/db/migrations/20241121141143_add_zess_table.sql b/db/migrations/20241121141143_add_zess_table.sql new file mode 100644 index 0000000..f202edc --- /dev/null +++ b/db/migrations/20241121141143_add_zess_table.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS season ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + start TIMESTAMP WITHOUT TIME ZONE NOT NULL, + "end" TIMESTAMP WITHOUT TIME ZONE NOT NULL +); + +CREATE TABLE IF NOT EXISTS scan ( + id SERIAL PRIMARY KEY, + scan_time TIMESTAMP WITH TIME ZONE NOT NULL +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS scan; + +DROP TABLE IF EXISTS season; +-- +goose StatementEnd diff --git a/db/migrations/20241121175601_add_column_zess_current.sql b/db/migrations/20241121175601_add_column_zess_current.sql new file mode 100644 index 0000000..72104c2 --- /dev/null +++ b/db/migrations/20241121175601_add_column_zess_current.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE season +ADD COLUMN current BOOLEAN NOT NULL DEFAULT FALSE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE season +DROP COLUMN current; +-- +goose StatementEnd diff --git a/db/migrations/20241125113707_add_gamification_table.sql b/db/migrations/20241125113707_add_gamification_table.sql new file mode 100644 index 0000000..2da01bf --- /dev/null +++ b/db/migrations/20241125113707_add_gamification_table.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS gamification ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + score INTEGER NOT NULL, + avatar VARCHAR(255) NOT NULL +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS gamification; +-- +goose StatementEnd diff --git a/db/migrations/20241127133125_add_events_table.sql b/db/migrations/20241127133125_add_events_table.sql new file mode 100644 index 0000000..11369d4 --- /dev/null +++ b/db/migrations/20241127133125_add_events_table.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS 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 +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS event; +-- +goose StatementEnd diff --git a/db/migrations/20241127162048_add_song_history_table.sql b/db/migrations/20241127162048_add_song_history_table.sql new file mode 100644 index 0000000..aedaaa9 --- /dev/null +++ b/db/migrations/20241127162048_add_song_history_table.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE spotify +DROP COLUMN created_at; + +ALTER TABLE spotify RENAME TO song; + +CREATE TABLE IF NOT EXISTS song_history ( + id SERIAL PRIMARY KEY, + song_id INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(song_id) REFERENCES song(id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS song_history; + +ALTER TABLE song RENAME TO spotify; + +ALTER TABLE spotify +ADD COLUMN created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP; +-- +goose StatementEnd diff --git a/db/migrations/20241127165609_add_song_genre.sql b/db/migrations/20241127165609_add_song_genre.sql new file mode 100644 index 0000000..5f214b4 --- /dev/null +++ b/db/migrations/20241127165609_add_song_genre.sql @@ -0,0 +1,45 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE song +DROP COLUMN artists; + +CREATE TABLE IF NOT EXISTS song_genre ( + id SERIAL PRIMARY KEY, + genre TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS song_artist ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + spotify_id TEXT NOT NULL, + followers INTEGER NOT NULL, + popularity INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS song_artist_song ( + id SERIAL PRIMARY KEY, + artist_id INTEGER NOT NULL, + song_id INTEGER NOT NULL, + FOREIGN KEY(artist_id) REFERENCES song_artist(id) ON DELETE CASCADE, + FOREIGN KEY(song_id) REFERENCES song(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS song_artist_genre ( + id SERIAL PRIMARY KEY, + artist_id INTEGER NOT NULL, + genre_id INTEGER NOT NULL, + FOREIGN KEY(artist_id) REFERENCES song_artist(id) ON DELETE CASCADE, + FOREIGN KEY(genre_id) REFERENCES song_genre(id) ON DELETE CASCADE +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS song_artist_genre; +DROP TABLE IF EXISTS song_artist_song; +DROP TABLE IF EXISTS song_artist; +DROP TABLE IF EXISTS song_genre; + +ALTER TABLE song +ADD COLUMN artists TEXT NOT NULL DEFAULT 'Unknown'; +-- +goose StatementEnd diff --git a/db/migrations/20241128115057_alter_song_table_add_lyrics.sql b/db/migrations/20241128115057_alter_song_table_add_lyrics.sql new file mode 100644 index 0000000..4189979 --- /dev/null +++ b/db/migrations/20241128115057_alter_song_table_add_lyrics.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE song +ADD COLUMN album TEXT NOT NULL; + +ALTER TABLE song +ADD COLUMN lyrics_type TEXT; + +ALTER TABLE song +ADD COLUMN lyrics TEXT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE song +DROP COLUMN lyrics; + +ALTER TABLE song +DROP COLUMN lyrics_type; + +ALTER TABLE song +DROP COLUMN album; +-- +goose StatementEnd diff --git a/db/migrations/20241203173952_event_add_poster.sql b/db/migrations/20241203173952_event_add_poster.sql new file mode 100644 index 0000000..3e195e5 --- /dev/null +++ b/db/migrations/20241203173952_event_add_poster.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE event +ADD COLUMN poster BYTEA; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE event +DROP COLUMN poster; +-- +goose StatementEnd diff --git a/db/migrations/20241203231431_gamification_add_avatar.sql b/db/migrations/20241203231431_gamification_add_avatar.sql new file mode 100644 index 0000000..0da53a0 --- /dev/null +++ b/db/migrations/20241203231431_gamification_add_avatar.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE gamification +DROP COLUMN avatar; + +ALTER TABLE gamification +ADD COLUMN avatar BYTEA; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE gamification +DROP COLUMN avatar; + +ALTER TABLE gamification +ADD COLUMN avatar VARCHAR(255) NOT NULL; +-- +goose StatementEnd diff --git a/db/migrations/20241209222600_song_created_at.sql b/db/migrations/20241209222600_song_created_at.sql new file mode 100644 index 0000000..8c9e3a9 --- /dev/null +++ b/db/migrations/20241209222600_song_created_at.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE song_history +ALTER COLUMN created_at +SET DEFAULT (NOW() - INTERVAL '1 second'); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE song_history +ALTER COLUMN created_at +SET DEFAULT CURRENT_TIMESTAMP; +-- +goose StatementEnd diff --git a/db/migrations/20241210002653_add_scan_id.sql b/db/migrations/20241210002653_add_scan_id.sql new file mode 100644 index 0000000..9a9adf2 --- /dev/null +++ b/db/migrations/20241210002653_add_scan_id.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE scan +ADD COLUMN scan_id INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE scan +ALTER COLUMN scan_id +DROP DEFAULT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE scan +DROP COLUMN scan_id; +-- +goose StatementEnd diff --git a/db/queries/event.sql b/db/queries/event.sql new file mode 100644 index 0000000..9646c2b --- /dev/null +++ b/db/queries/event.sql @@ -0,0 +1,37 @@ +-- 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 new file mode 100644 index 0000000..3113a19 --- /dev/null +++ b/db/queries/gamification.sql @@ -0,0 +1,32 @@ +-- 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 new file mode 100644 index 0000000..2b615ed --- /dev/null +++ b/db/queries/message.sql @@ -0,0 +1,41 @@ +-- CRUD + +-- name: GetAllMessages :many +SELECT * +FROM message; + +-- name: GetMessageByID :one +SELECT * +FROM message +WHERE id = $1; + +-- name: CreateMessage :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; diff --git a/db/queries/scan.sql b/db/queries/scan.sql new file mode 100644 index 0000000..706ffc5 --- /dev/null +++ b/db/queries/scan.sql @@ -0,0 +1,47 @@ +-- 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 +SELECT * +FROM scan +ORDER BY id DESC +LIMIT 1; + +-- name: GetAllScansSinceID :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); diff --git a/db/queries/season.sql b/db/queries/season.sql new file mode 100644 index 0000000..94287be --- /dev/null +++ b/db/queries/season.sql @@ -0,0 +1,38 @@ +-- CRUD + +-- name: GetAllSeasons :many +SELECT * +FROM season; + +-- name: GetSeasonByID :one +SELECT * +FROM season +WHERE id = $1; + +-- name: CreateSeason :one +INSERT INTO season (name, start, "end", current) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: UpdateSeason :one +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 +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 new file mode 100644 index 0000000..c81f777 --- /dev/null +++ b/db/queries/song.sql @@ -0,0 +1,108 @@ +-- 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 +FROM song_history sh +JOIN song s ON sh.song_id = s.id +ORDER BY created_at DESC +LIMIT 10; + +-- 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; + +-- 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; + +-- 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; diff --git a/db/queries/tap.sql b/db/queries/tap.sql new file mode 100644 index 0000000..d2a4929 --- /dev/null +++ b/db/queries/tap.sql @@ -0,0 +1,56 @@ +-- 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 +SELECT * +FROM tap +ORDER BY order_id DESC +LIMIT 1; + +-- name: GetOrderCount :many +SELECT category, COUNT(*) +FROM tap +GROUP BY category; + +-- 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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd4be99 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: scc + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - scc_data:/var/lib/postgresql/data + ports: + - "5432:5432" + +volumes: + scc_data: diff --git a/go.mod b/go.mod index adb0dd5..5c0c539 100644 --- a/go.mod +++ b/go.mod @@ -1,46 +1,85 @@ -module scc +module github.com/zeusWPI/scc -go 1.22.0 +go 1.23.1 require ( - github.com/gdamore/tcell/v2 v2.7.4 - github.com/gin-gonic/gin v1.9.1 - github.com/rivo/tview v0.0.0-20240616192244-23476fa0bab2 - gopkg.in/yaml.v3 v3.0.1 + github.com/NimbleMarkets/ntcharts v0.2.0 + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/lipgloss v1.0.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/gofiber/contrib/fiberzap v1.0.2 + github.com/gofiber/fiber/v2 v2.52.5 + github.com/jackc/pgx/v5 v5.7.1 + github.com/joho/godotenv v1.5.1 + github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/spf13/viper v1.19.0 + go.uber.org/zap v1.27.0 ) require ( - github.com/a-h/beeper v0.0.0-20190929170045-fc4b1a97b0b2 - github.com/bytedance/sonic v1.9.1 // indirect - github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect - github.com/gabriel-vasile/mimetype v1.4.2 // indirect - github.com/gdamore/encoding v1.0.0 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/PuerkitoBio/goquery v1.10.0 // indirect + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/antchfx/htmlquery v1.3.3 // indirect + github.com/antchfx/xmlquery v1.4.2 // indirect + github.com/antchfx/xpath v1.3.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.14.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect - github.com/leodido/go-urn v1.2.4 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/navidys/tvxwidgets v0.7.0 - github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/hashicorp/hcl v1.0.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.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // 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/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.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/stianeikeland/go-rpio v4.2.0+incompatible - github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.11 // indirect - golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.26.0 // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sys v0.23.0 // indirect - golang.org/x/term v0.23.0 // indirect - golang.org/x/text v0.17.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + github.com/sagikazarmark/locafero v0.4.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.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/temoto/robotstxt v1.1.2 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/navidys/tvxwidgets => github.com/topvennie/tvxwidgets v0.7.3 diff --git a/go.sum b/go.sum index 07e0a59..c64bca3 100644 --- a/go.sum +++ b/go.sum @@ -1,155 +1,235 @@ -github.com/a-h/beeper v0.0.0-20190929170045-fc4b1a97b0b2 h1:QXbKN2ADvvIPBw0RA5TT61Uk50vHxXflk2bDtWKD3zI= -github.com/a-h/beeper v0.0.0-20190929170045-fc4b1a97b0b2/go.mod h1:DxLcR2/OjZbhGzSvGfrtm6Kfij2L+mYFnw3YuxGcee4= -github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= -github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= -github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= -github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/NimbleMarkets/ntcharts v0.2.0 h1:uVpvUL9fZk/LGsc8E00kdBLHwh60llfvci+2JpJ6EDI= +github.com/NimbleMarkets/ntcharts v0.2.0/go.mod h1:BLzvdpQAv4NpGbOTsi3fCRzeDk276PGezkp75gD73kY= +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/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +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/xmlquery v1.4.2 h1:MZKd9+wblwxfQ1zd1AdrTsqVaMjMCwow3IqkCSe00KA= +github.com/antchfx/xmlquery v1.4.2/go.mod h1:QXhvf5ldTuGqhd1SHNvvtlhhdQLks4dD0awIVhXIDTA= +github.com/antchfx/xpath v1.3.2 h1:LNjzlsSjinu3bQpw9hWMY9ocB80oLOWuQqFvO6xt51U= +github.com/antchfx/xpath v1.3.2/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +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/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +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/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= -github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= -github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= -github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= -github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= -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/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/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/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/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/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.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= -github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +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/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/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/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/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/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/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/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/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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/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/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/onsi/ginkgo/v2 v2.20.0 h1:PE84V2mHqoT1sglvHc8ZdQtPcwmvvt29WLEEO3xmdZw= -github.com/onsi/ginkgo/v2 v2.20.0/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= -github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/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-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/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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/tview v0.0.0-20240616192244-23476fa0bab2 h1:LXMiBMxtuXw8e2paN61dI2LMp8JZYyH4UXDwssRI3ys= -github.com/rivo/tview v0.0.0-20240616192244-23476fa0bab2/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= +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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/stianeikeland/go-rpio v4.2.0+incompatible h1:CUOlIxdJdT+H1obJPsmg8byu7jMSECLfAN9zynm5QGo= -github.com/stianeikeland/go-rpio v4.2.0+incompatible/go.mod h1:Sh81rdJwD96E2wja2Gd7rrKM+XZ9LrwvN2w4IXrqLR8= +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/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/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/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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/topvennie/tvxwidgets v0.7.3 h1:kyRAIQcEtBkZGjzV3YrGLajtRlpkObiQ/WAZnMptm0c= -github.com/topvennie/tvxwidgets v0.7.3/go.mod h1:XzRWkNijoG9aLYk6XeOD5OIaJbcA8oisUIFXyb9dHdg= -github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= -github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +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/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/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/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= -golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +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/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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +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/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 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/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.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +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.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= 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.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-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-20220704084225-05e143d24a9e/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.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= -golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..2097678 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,16 @@ +// 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/db" + "github.com/zeusWPI/scc/internal/pkg/song" +) + +// New creates a new API instance +func New(router fiber.Router, db *db.DB, song *song.Song) { + message.New(router, db) + apiSong.New(router, db, song) +} diff --git a/internal/api/message/message.go b/internal/api/message/message.go new file mode 100644 index 0000000..9861569 --- /dev/null +++ b/internal/api/message/message.go @@ -0,0 +1,64 @@ +// Package message provides the API regarding the cammie chat messages +package message + +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/pkg/util" + "go.uber.org/zap" +) + +// Router is the message API router +type Router struct { + router fiber.Router + db *db.DB +} + +// New creates a new message API instance +func New(router fiber.Router, db *db.DB) *Router { + api := &Router{ + router: router.Group("/messages"), + db: db, + } + 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) + } + + return c.Status(fiber.StatusCreated).JSON(dto.MessageDTO(messageDB)) +} diff --git a/internal/api/song/song.go b/internal/api/song/song.go new file mode 100644 index 0000000..220663f --- /dev/null +++ b/internal/api/song/song.go @@ -0,0 +1,56 @@ +// 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/cmd/api.go b/internal/cmd/api.go new file mode 100644 index 0000000..1c70276 --- /dev/null +++ b/internal/cmd/api.go @@ -0,0 +1,38 @@ +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/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", + }), + ) + + apiGroup := app.Group("/api") + api.New(apiGroup, db, 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 new file mode 100644 index 0000000..6957ac2 --- /dev/null +++ b/internal/cmd/event.go @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..8561625 --- /dev/null +++ b/internal/cmd/gamification.go @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..198d84c --- /dev/null +++ b/internal/cmd/song.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/song" +) + +// Song starts the Song integration +func Song(db *db.DB) (*song.Song, error) { + song, err := song.New(db) + + return song, err +} diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go new file mode 100644 index 0000000..00e97f5 --- /dev/null +++ b/internal/cmd/tap.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/tap" + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +// Tap starts the tap instance +func Tap(db *db.DB) (*tap.Tap, chan bool) { + tap := tap.New(db) + done := make(chan bool) + interval := config.GetDefaultInt("backend.tap.interval_s", 60) + + go tapPeriodicUpdate(tap, done, interval) + + 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 new file mode 100644 index 0000000..ea097d5 --- /dev/null +++ b/internal/cmd/tui.go @@ -0,0 +1,92 @@ +// Package cmd provides all the commands to start parts of the application +package cmd + +import ( + "fmt" + "os" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/pkg/util" + "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{ + "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] + if !ok { + return fmt.Errorf("Screen %s not found. Options are %v", selectedScreen, util.Keys(screens)) + } + + screen := val(db) + 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) + } + + _, err := p.Run() + + for _, done := range dones { + done <- true + } + + 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") + + ticker := time.NewTicker(time.Duration(updateData.Interval) * time.Second) + defer ticker.Stop() + + // 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) + } + + 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) + } + + if msg != nil { + p.Send(msg) + } + } + } +} diff --git a/internal/cmd/zess.go b/internal/cmd/zess.go new file mode 100644 index 0000000..03e3888 --- /dev/null +++ b/internal/cmd/zess.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/zess" + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +// Zess starts the zess instance +func Zess(db *db.DB) (*zess.Zess, chan bool, chan bool) { + zess := zess.New(db) + + doneSeason := 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) + } + } + } +} diff --git a/internal/pkg/buzzer/buzzer.go b/internal/pkg/buzzer/buzzer.go new file mode 100644 index 0000000..2a20b6b --- /dev/null +++ b/internal/pkg/buzzer/buzzer.go @@ -0,0 +1,44 @@ +// Package buzzer provides all interactions with the buzzer +package buzzer + +import ( + "os/exec" + + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +// Buzzer represents a buzzer +type Buzzer struct { + Song []string +} + +var defaultSong = []string{ + "-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", +} + +// New returns a new buzzer instance +func New() *Buzzer { + return &Buzzer{ + Song: config.GetDefaultStringSlice("backend.buzzer.song", defaultSong), + } +} + +// Play plays the buzzer +func (b *Buzzer) Play() { + // See `man beep` for more information + cmd := exec.Command("beep", b.Song...) + err := cmd.Run() + + if err != nil { + zap.L().Error("Error running command 'beep'", zap.Error(err)) + } +} diff --git a/internal/pkg/db/db.go b/internal/pkg/db/db.go new file mode 100644 index 0000000..35b2223 --- /dev/null +++ b/internal/pkg/db/db.go @@ -0,0 +1,43 @@ +// Package db provides a database connection +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + "github.com/zeusWPI/scc/pkg/config" +) + +// DB represents a database connection +type DB struct { + Pool *pgxpool.Pool + Queries *sqlc.Queries +} + +// New creates a new database connection +func New() (*DB, error) { + pgConfig, err := pgxpool.ParseConfig("") + if err != nil { + return nil, err + } + + 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.User = config.GetDefaultString("db.user", "postgres") + pgConfig.ConnConfig.Password = config.GetDefaultString("db.password", "postgres") + + pool, err := pgxpool.NewWithConfig(context.Background(), pgConfig) + if err != nil { + return nil, err + } + + if err := pool.Ping(context.TODO()); err != nil { + return nil, err + } + + queries := sqlc.New(pool) + + return &DB{Pool: pool, Queries: queries}, nil +} diff --git a/internal/pkg/db/dto/dto.go b/internal/pkg/db/dto/dto.go new file mode 100644 index 0000000..08133bd --- /dev/null +++ b/internal/pkg/db/dto/dto.go @@ -0,0 +1,9 @@ +// 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 new file mode 100644 index 0000000..dabf0c5 --- /dev/null +++ b/internal/pkg/db/dto/event.go @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..bb2181e --- /dev/null +++ b/internal/pkg/db/dto/gamification.go @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..75672a8 --- /dev/null +++ b/internal/pkg/db/dto/message.go @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..5622e24 --- /dev/null +++ b/internal/pkg/db/dto/scan.go @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..ca75630 --- /dev/null +++ b/internal/pkg/db/dto/season.go @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000..b678297 --- /dev/null +++ b/internal/pkg/db/dto/song.go @@ -0,0 +1,163 @@ +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' or 'plain' + 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/db.go b/internal/pkg/db/sqlc/db.go new file mode 100644 index 0000000..b931bc5 --- /dev/null +++ b/internal/pkg/db/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/pkg/db/sqlc/event.sql.go b/internal/pkg/db/sqlc/event.sql.go new file mode 100644 index 0000000..02a56b8 --- /dev/null +++ b/internal/pkg/db/sqlc/event.sql.go @@ -0,0 +1,174 @@ +// 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 new file mode 100644 index 0000000..50d75df --- /dev/null +++ b/internal/pkg/db/sqlc/gamification.sql.go @@ -0,0 +1,149 @@ +// 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 new file mode 100644 index 0000000..194b970 --- /dev/null +++ b/internal/pkg/db/sqlc/message.sql.go @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 0000000..f50e025 --- /dev/null +++ b/internal/pkg/db/sqlc/models.go @@ -0,0 +1,97 @@ +// 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 new file mode 100644 index 0000000..38a6499 --- /dev/null +++ b/internal/pkg/db/sqlc/scan.sql.go @@ -0,0 +1,161 @@ +// 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 new file mode 100644 index 0000000..e40e0d8 --- /dev/null +++ b/internal/pkg/db/sqlc/season.sql.go @@ -0,0 +1,176 @@ +// 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 new file mode 100644 index 0000000..91d0493 --- /dev/null +++ b/internal/pkg/db/sqlc/song.sql.go @@ -0,0 +1,438 @@ +// 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 +FROM song_history sh +JOIN song s ON sh.song_id = s.id +ORDER BY created_at DESC +LIMIT 10 +` + +func (q *Queries) GetSongHistory(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, getSongHistory) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var title string + if err := rows.Scan(&title); err != nil { + return nil, err + } + items = append(items, title) + } + 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 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 new file mode 100644 index 0000000..56ac2eb --- /dev/null +++ b/internal/pkg/db/sqlc/tap.sql.go @@ -0,0 +1,287 @@ +// 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 new file mode 100644 index 0000000..7be8e1d --- /dev/null +++ b/internal/pkg/event/api.go @@ -0,0 +1,112 @@ +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 { + dateWithYear := fmt.Sprintf("%s 20%s", date, rangeParts[0]) + 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 new file mode 100644 index 0000000..9ccbb5f --- /dev/null +++ b/internal/pkg/event/event.go @@ -0,0 +1,105 @@ +// 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 new file mode 100644 index 0000000..6e3f1a3 --- /dev/null +++ b/internal/pkg/gamification/api.go @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..aeb2ed4 --- /dev/null +++ b/internal/pkg/gamification/gamification.go @@ -0,0 +1,45 @@ +// 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/lrc.go b/internal/pkg/lyrics/lrc.go new file mode 100644 index 0000000..a4ec841 --- /dev/null +++ b/internal/pkg/lyrics/lrc.go @@ -0,0 +1,123 @@ +package lyrics + +import ( + "regexp" + "strconv" + "strings" + "time" + + "github.com/zeusWPI/scc/internal/pkg/db/dto" +) + +var re = regexp.MustCompile(`^\[(\d{2}):(\d{2})\.(\d{2})\]`) + +// LRC represents synced lyrics +type LRC struct { + song dto.Song + lyrics []Lyric + i int +} + +func newLRC(song *dto.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 { + 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) + + for i := 1; i <= amount; i++ { + if l.i-i-1 < 0 { + break + } + + lyrics = append([]Lyric{l.lyrics[l.i-i-1]}, lyrics...) + } + + 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 + } + + 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 + } + + l.i++ + 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) + + for i := 0; i < amount; i++ { + if l.i+i >= len(l.lyrics) { + break + } + + lyrics = append(lyrics, l.lyrics[l.i+i]) + } + + 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)) +} + +func parseLRC(text string, totalDuration time.Duration) []Lyric { + lines := strings.Split(text, "\n") + + lyrics := make([]Lyric, 0, len(lines)+1) + var previousTimestamp time.Duration + + // Add first lyric (no text) + lyrics = append(lyrics, Lyric{Text: ""}) + previousTimestamp = time.Duration(0) + + for i, line := range lines { + parts := strings.SplitN(line, " ", 2) + if len(parts) != 2 { + continue + } + + // Duration part + timeParts := re.FindStringSubmatch(parts[0]) + minutes, _ := strconv.Atoi(timeParts[1]) + seconds, _ := strconv.Atoi(timeParts[2]) + hundredths, _ := strconv.Atoi(timeParts[3]) + timestamp := time.Duration(minutes)*time.Minute + + time.Duration(seconds)*time.Second + + time.Duration(hundredths)*10*time.Millisecond + + // Actual lyric + lyric := parts[1] + + lyrics = append(lyrics, Lyric{Text: lyric}) + lyrics[i].Duration = timestamp - previousTimestamp + previousTimestamp = timestamp + } + + // Set duration of last lyric + lyrics[len(lyrics)-1].Duration = totalDuration - previousTimestamp + + return lyrics +} diff --git a/internal/pkg/lyrics/lyrics.go b/internal/pkg/lyrics/lyrics.go new file mode 100644 index 0000000..69a6750 --- /dev/null +++ b/internal/pkg/lyrics/lyrics.go @@ -0,0 +1,33 @@ +// 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 { + if song.LyricsType == "synced" { + return newLRC(song) + } + + return newPlain(song) +} diff --git a/internal/pkg/lyrics/plain.go b/internal/pkg/lyrics/plain.go new file mode 100644 index 0000000..24cbd7f --- /dev/null +++ b/internal/pkg/lyrics/plain.go @@ -0,0 +1,68 @@ +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 + given bool +} + +func newPlain(song *dto.Song) Lyrics { + lyric := Lyric{ + Text: song.Lyrics, + Duration: time.Duration(song.DurationMS), + } + return &Plain{song: *song, lyrics: lyric, given: false} +} + +// 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 +func (p *Plain) Previous(_ int) []Lyric { + return []Lyric{} +} + +// Current provides the current lyric if any. +// If the song is finished the boolean is set to false +func (p *Plain) Current() (Lyric, bool) { + if p.given { + return Lyric{}, false + } + + return Lyric{}, true +} + +// Next provides the next lyric. +// If the lyrics are finished the boolean is set to false +func (p *Plain) Next() (Lyric, bool) { + if p.given { + return Lyric{}, false + } + + p.given = true + + return p.lyrics, true +} + +// Upcoming provides the next `amount` lyrics without affecting the current lyric +func (p *Plain) Upcoming(_ int) []Lyric { + return []Lyric{} +} + +// Progress shows the fraction of lyrics that have been used. +func (p *Plain) Progress() float64 { + if p.given { + return 1 + } + + return 0 +} diff --git a/internal/pkg/song/account.go b/internal/pkg/song/account.go new file mode 100644 index 0000000..774953a --- /dev/null +++ b/internal/pkg/song/account.go @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..1f651a7 --- /dev/null +++ b/internal/pkg/song/api.go @@ -0,0 +1,144 @@ +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 { + 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 { + return fmt.Errorf("Song: Lyrics request wrong status code %d", status) + } + if (res == &lyricsResponse{}) { + return errors.New("Song: Lyrics request returned empty struct") + } + + if res.SyncedLyrics != "" { + track.LyricsType = "synced" + track.Lyrics = res.SyncedLyrics + } else { + track.LyricsType = "plain" + track.Lyrics = res.PlainLyrics + } + + return nil +} diff --git a/internal/pkg/song/song.go b/internal/pkg/song/song.go new file mode 100644 index 0000000..5967d51 --- /dev/null +++ b/internal/pkg/song/song.go @@ -0,0 +1,181 @@ +// 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 new file mode 100644 index 0000000..522b16e --- /dev/null +++ b/internal/pkg/tap/api.go @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..808f8d0 --- /dev/null +++ b/internal/pkg/tap/tap.go @@ -0,0 +1,113 @@ +// 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") { + 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 new file mode 100644 index 0000000..3b26ff1 --- /dev/null +++ b/internal/pkg/zess/api.go @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000..f3800de --- /dev/null +++ b/internal/pkg/zess/zess.go @@ -0,0 +1,97 @@ +// 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" + "go.uber.org/zap" +) + +// 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 + } + + zap.S().Info(lastScan) + + 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/main.go b/main.go deleted file mode 100644 index 96f25a7..0000000 --- a/main.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "log" - "os" - "scc/api" - "scc/screen" -) - -func main() { - // Logging - logFile, err := os.OpenFile("scc.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) - if err != nil { - log.Fatalf("Error: Failed to open log file: %s", err) - } - log.SetOutput(logFile) - log.SetFlags(log.LstdFlags | log.Lshortfile) - - // Data holder for the screen - screenApp := screen.NewScreenApp() - - // Start the API - go api.Start(screenApp) - - // Start the screen - screen.Start(screenApp) -} diff --git a/makefile b/makefile new file mode 100644 index 0000000..cda5bb3 --- /dev/null +++ b/makefile @@ -0,0 +1,72 @@ +# Variables +BACKEND_BIN := backend_bin +TUI_BIN := tui_bin +BACKEND_SRC := cmd/backend/backend.go +TUI_SRC := cmd/tui/tui.go +DB_DIR := ./db/migrations +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 +build: clean build-backend build-tui + +build-backend: clean-backend + @echo "Building $(BACKEND_BIN)..." + @go build -o $(BACKEND_BIN) $(BACKEND_SRC) + +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: + @if [ -f "$(BACKEND_BIN)" ]; then \ + echo "Cleaning $(BACKEND_BIN)..."; \ + rm -f "$(BACKEND_BIN)"; \ + fi + +clean-tui: + @if [ -f "$(TUI_BIN)" ]; then \ + echo "Cleaning $(TUI_BIN)..."; \ + rm -f "$(TUI_BIN)"; \ + fi + +# SQL and migration targets +sqlc: + sqlc generate + +create-migration: + @read -p "Enter migration name: " name; \ + goose -dir $(DB_DIR) create $$name sql + +migrate: + @goose -dir $(DB_DIR) postgres "user=$(DB_USER) password=$(DB_PASSWORD) dbname=$(DB_NAME) host=localhost sslmode=disable" up + +goose: + @read -p "Action: " action; \ + goose -dir $(DB_DIR) postgres "user=$(DB_USER) password=$(DB_PASSWORD) dbname=$(DB_NAME) host=localhost sslmode=disable" $$action diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..b8ace59 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,92 @@ +// Package config provides all configuration related functions +package config + +import ( + "fmt" + "strings" + + "github.com/joho/godotenv" + "github.com/spf13/viper" +) + +func bindEnv(key string) { + envName := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) + + _ = viper.BindEnv(key, envName) +} + +// Init initializes the configuration +func Init() error { + if err := godotenv.Load(); err != nil { + return err + } + + viper.AutomaticEnv() + env := GetDefaultString("app.env", "development") + + viper.SetConfigName(fmt.Sprintf("%s.yaml", strings.ToLower(env))) + 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/date/date.go b/pkg/date/date.go new file mode 100644 index 0000000..af35c85 --- /dev/null +++ b/pkg/date/date.go @@ -0,0 +1,44 @@ +// Package date makes working with dates without timezones easier +package date + +import ( + "fmt" + "strings" + "time" + + "github.com/go-playground/validator/v10" +) + +// Date represents a date without a timezone +type Date time.Time + +const dateLayout = "2006-01-02" + +// UnmarshalJSON converts bytes to a Date +func (d *Date) UnmarshalJSON(b []byte) error { + str := strings.Trim(string(b), `"`) + if str == "" { + *d = Date(time.Time{}) + return nil + } + + // Parse the date + parsedTime, err := time.Parse(dateLayout, str) + if err != nil { + return fmt.Errorf("failed to parse date: %w", err) + } + *d = Date(parsedTime) + + return nil +} + +// ToTime converts a date to a time object +func (d Date) ToTime() time.Time { + return time.Time(d) +} + +// ValidateDate adds validation support for go-playground/validator for the Date type +func ValidateDate(f1 validator.FieldLevel) bool { + date, ok := f1.Field().Interface().(Date) + return ok && !date.ToTime().IsZero() +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..6dde1cf --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,45 @@ +// Package logger provides a logger instance +package logger + +import ( + "fmt" + "os" + + "github.com/zeusWPI/scc/pkg/config" + "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() + } + + outputPaths := []string{fmt.Sprintf("logs/%s.log", logFile)} + if console { + outputPaths = append(outputPaths, "stdout") + } + + errorOutputPaths := []string{fmt.Sprintf("logs/%s.log", logFile)} + if console { + errorOutputPaths = append(errorOutputPaths, "stderr") + } + + zapConfig.OutputPaths = outputPaths + zapConfig.ErrorOutputPaths = errorOutputPaths + + logger := zap.Must(zapConfig.Build()) + + return logger, nil +} diff --git a/pkg/util/map.go b/pkg/util/map.go new file mode 100644 index 0000000..5e45f39 --- /dev/null +++ b/pkg/util/map.go @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..1840f51 --- /dev/null +++ b/pkg/util/slice.go @@ -0,0 +1,31 @@ +// 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/screen/cammie.go b/screen/cammie.go deleted file mode 100644 index 2a1dad8..0000000 --- a/screen/cammie.go +++ /dev/null @@ -1,87 +0,0 @@ -package screen - -import ( - "fmt" - "hash/fnv" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -type CammieMessage struct { - Sender string - Message string -} - -// Component that displays messages received from the website aka cammie chat -type Cammie struct { - screenApp *ScreenApp - view *tview.TextView - - text string - buffer string -} - -// Initial value, gets adjusted once it's known how much space is available -var maxMessages = 20 - -// Available colors -var colors = [...]tcell.Color{ - tcell.ColorWhite, - tcell.ColorViolet, - tcell.ColorRed, - tcell.ColorOrange, - tcell.ColorGreen, - tcell.ColorAqua, - tcell.ColorCrimson, - tcell.ColorFuchsia, - tcell.ColorGoldenrod, - tcell.ColorYellow, - tcell.ColorSalmon, -} - -// Create a new cammie struct -func NewCammie(screenApp *ScreenApp) *Cammie { - cammie := Cammie{ - screenApp: screenApp, - view: tview.NewTextView(). - SetWordWrap(true). - SetScrollable(true). - SetDynamicColors(true), - } - - cammie.view.SetTitle(" Cammie "). - SetBorder(true). - SetBorderColor(tcell.ColorOrange). - SetTitleColor(tcell.ColorOrange) - - return &cammie -} - -// Run one-time setup -func (cammie *Cammie) Run() { - // Wait for the view to be properly set up - time.Sleep(1 * time.Second) -} - -// Updates the cammie chat -// Gets called when a new message is received from the website -func (cammie *Cammie) Update(message *CammieMessage) { - colorIndex := hashColor(message.Sender) - - color := colors[colorIndex].String() - - cammie.screenApp.execute(func() { - fmt.Fprintf(cammie.view, "\n[%s]%s %s[-:-:-:-]", color, message.Sender, message.Message) - - cammie.view.ScrollToEnd() - }) -} - -func hashColor(s string) int { - h := fnv.New32a() - h.Write([]byte(s)) - hashNumber := h.Sum32() - return int(hashNumber) % len(colors) -} diff --git a/screen/screen.go b/screen/screen.go deleted file mode 100644 index 83ec373..0000000 --- a/screen/screen.go +++ /dev/null @@ -1,64 +0,0 @@ -package screen - -import ( - "sync" - - "github.com/rivo/tview" -) - -// Main struct for the screen application -type ScreenApp struct { - mu sync.Mutex - app *tview.Application - - Spotify *Spotify - Cammie *Cammie - Tap *Tap - Zess *Zess -} - -// Execute a function with a lock -func (screenApp *ScreenApp) execute(f func()) { - screenApp.mu.Lock() - defer screenApp.mu.Unlock() - f() -} - -// Create a new screen application -func NewScreenApp() *ScreenApp { - screen := ScreenApp{ - app: tview.NewApplication(), - } - - screen.Spotify = NewSpotify(&screen) - screen.Cammie = NewCammie(&screen) - screen.Tap = NewTap(&screen) - screen.Zess = NewZess(&screen) - - // Build the screen layout - screen.app.SetRoot(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(screen.Spotify.view, 3, 2, false). - AddItem(tview.NewFlex(). - AddItem(screen.Cammie.view, 0, 5, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(screen.Tap.view, 0, 1, false). - AddItem(screen.Zess.view, 0, 1, false), 0, 4, false), 0, 13, false), true). - EnableMouse(false) - - return &screen -} - -// Start the screen application -func Start(screen *ScreenApp) { - - // Start each screen component - go screen.Spotify.Run() - go screen.Cammie.Run() - go screen.Tap.Run() - go screen.Zess.Run() - - // Start the screen application - if err := screen.app.Run(); err != nil { - panic(err) - } -} diff --git a/screen/spotify.go b/screen/spotify.go deleted file mode 100644 index 1eaa41f..0000000 --- a/screen/spotify.go +++ /dev/null @@ -1,72 +0,0 @@ -package screen - -import ( - "strings" - "sync" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -type Spotify struct { - screenApp *ScreenApp - view *tview.TextView - - mu sync.Mutex - text string - buffer string -} - -func NewSpotify(screenApp *ScreenApp) *Spotify { - spotify := Spotify{ - screenApp: screenApp, - view: tview.NewTextView(), - - text: "VERY COOL SONG - Le Artist", - buffer: "", - } - - spotify.view.SetTitle(" Spotify ") - spotify.view.SetBorder(true) - spotify.view.SetTextColor(tcell.ColorGreen) - spotify.view.SetBorderColor(tcell.ColorGreen) - spotify.view.SetTitleColor(tcell.ColorGreen) - - return &spotify -} - -func (spotify *Spotify) Run() { - time.Sleep(1 * time.Second) - - for { - _, _, w, _ := spotify.view.GetInnerRect() - - if w != 0 { - - spotify.screenApp.execute(func() { - if len(spotify.buffer) != w { - if len(spotify.text) > w { - spotify.text = spotify.text[0 : w-4] - spotify.text += "..." - } - spotify.buffer = spotify.text + strings.Repeat(" ", w-len(spotify.text)) - } - - spotify.buffer = spotify.buffer[1:] + string(spotify.buffer[0]) - }) - - spotify.screenApp.app.QueueUpdateDraw(func() { - spotify.view.SetText(spotify.buffer) - }) - } - time.Sleep(100 * time.Millisecond) - } -} - -func (spotify *Spotify) Update(text string) { - spotify.screenApp.execute(func() { - spotify.text = text - spotify.buffer = "" - }) -} diff --git a/screen/tap.go b/screen/tap.go deleted file mode 100644 index f576a2f..0000000 --- a/screen/tap.go +++ /dev/null @@ -1,119 +0,0 @@ -package screen - -import ( - "scc/config" - "sort" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/navidys/tvxwidgets" - "github.com/rivo/tview" -) - -type TapOrder struct { - OrderID int `json:"order_id"` - OrderCreatedAt time.Time `json:"order_created_at"` - ProductName string `json:"product_name"` - ProductCategory string `json:"product_category"` -} -type Tap struct { - ScreenApp *ScreenApp - view *tview.Flex - bar *tvxwidgets.BarChart -} -type tapItem struct { - value int - color tcell.Color -} - -const ( - soft = "Soft" - mate = "Mate" - beer = "Beer" - food = "Food" -) - -var tapItems = map[string]tapItem{ - soft: {0, tcell.ColorAqua}, - mate: {0, tcell.ColorOrange}, - beer: {0, tcell.ColorRed}, - food: {0, tcell.ColorGreen}, -} - -func NewTap(screenApp *ScreenApp) *Tap { - tap := Tap{ - ScreenApp: screenApp, - view: tview.NewFlex(), - bar: tvxwidgets.NewBarChart(), - } - - tap.view.SetBorder(true).SetTitle(" Tap ") - - tap.bar.SetAxesColor(tcell.ColorWhite) - tap.bar.SetAxesLabelColor(tcell.ColorWhite) - - tap.view.AddItem(tap.bar, 0, 1, false) - - return &tap -} - -func (tap *Tap) Run() { -} - -func (tap *Tap) Update(order *TapOrder) { - var key string - switch { - case order.ProductCategory == "food": - key = food - case order.ProductCategory != "beverages": - return - case strings.Contains(order.ProductName, "Mate"): - key = mate - case isBeer(order.ProductName): - key = beer - default: - key = soft - } - - entry := tapItems[key] - entry.value++ - tapItems[key] = entry - - // item.amount++ - tap.ScreenApp.execute(func() { - // Remove labels - for label := range tapItems { - tap.bar.RemoveBar(label) - } - - // Create slice of keys - keys := make([]string, 0, len(tapItems)) - for key := range tapItems { - keys = append(keys, key) - } - - // Sort slice - sort.Slice(keys, func(i, j int) bool { - return tapItems[keys[i]].value > tapItems[keys[j]].value - }) - - // Add labels back - for _, key := range keys { - tap.bar.AddBar(key, tapItems[key].value, tapItems[key].color) - } - - // Required so that the bars change relative height - tap.bar.SetMaxValue(tapItems[keys[0]].value) - }) -} - -func isBeer(productName string) bool { - for _, beer := range config.GetConfig().Tap.Beers { - if strings.Contains(productName, beer) { - return true - } - } - - return false -} diff --git a/screen/zess.go b/screen/zess.go deleted file mode 100644 index 8bd5a20..0000000 --- a/screen/zess.go +++ /dev/null @@ -1,83 +0,0 @@ -package screen - -import ( - "scc/config" - "scc/utils" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/navidys/tvxwidgets" - "github.com/rivo/tview" -) - -type ZessScan struct { - ID int `json:"id"` - ScanTime time.Time `json:"scan_time"` -} - -type Zess struct { - ScreenApp *ScreenApp - view *tview.Flex - chart *tvxwidgets.Plot -} - -var ( - scans = [][]float64{ - make([]float64, 0, config.GetConfig().Zess.DayAmount), - } - day = -1 -) - -func NewZess(screenApp *ScreenApp) *Zess { - zess := Zess{ - ScreenApp: screenApp, - view: tview.NewFlex(), - chart: tvxwidgets.NewPlot(), - } - - zess.view.SetBorder(true).SetTitle(" Zess ") - - zess.chart.SetBorder(false) - zess.chart.SetLineColor([]tcell.Color{tcell.ColorOrange}) - zess.chart.SetAxesLabelColor(tcell.ColorWhite) - zess.chart.SetAxesColor(tcell.ColorWhite) - zess.chart.SetMarker(tvxwidgets.PlotMarkerBraille) - zess.chart.SetDrawYAxisLabelFloat(false) - zess.chart.SetData(scans) - - zess.view.AddItem(zess.chart, 0, 1, false) - - return &zess -} - -func (zess *Zess) Run() { -} - -func (zess *Zess) Update(scan *ZessScan) { - if day == -1 { - scans[0] = append(scans[0], 0) - day = scan.ScanTime.YearDay() - } - - scanDay := scan.ScanTime.YearDay() - - if scanDay == day { - // Same day, increase the amount - scans[0][len(scans[0])-1]++ - } else { - // New day - - // Add the offset of days - dayDifference := utils.GetDayDifference(day, scan.ScanTime) - 1 - for i := 0; i < dayDifference; i++ { - scans[0] = utils.AddSliceElement(scans[0], 0) - } - - scans[0] = utils.AddSliceElement(scans[0], 1) - day = scanDay - } - - zess.ScreenApp.execute(func() { - zess.chart.SetData(scans) - }) -} diff --git a/sqlc.yml b/sqlc.yml new file mode 100644 index 0000000..a4821a6 --- /dev/null +++ b/sqlc.yml @@ -0,0 +1,11 @@ +version: "2" +sql: + - engine: "postgresql" + queries: + - "db/queries/*.sql" + schema: "db/migrations" + gen: + go: + package: "sqlc" + out: "internal/pkg/db/sqlc" + sql_package: "pgx/v5" diff --git a/tui/components/progress/progress.go b/tui/components/progress/progress.go new file mode 100644 index 0000000..3c123ea --- /dev/null +++ b/tui/components/progress/progress.go @@ -0,0 +1,125 @@ +// Package progress provides an animated progress bar +package progress + +import ( + "strings" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "go.uber.org/zap" +) + +var lastID int64 + +func nextID() int64 { + return atomic.AddInt64(&lastID, 1) +} + +// FrameMsg is a message that is sent on every progress frame tick +type FrameMsg struct { + id int64 +} + +// StartMsg is a message that starts the progress bar +type StartMsg struct { + width int + widthTarget int + interval time.Duration +} + +// Model for the progress component +type Model struct { + id int64 + width int + widthTarget int + interval time.Duration + styleFainted lipgloss.Style + styleGlow lipgloss.Style +} + +// New creates a new progress +func New(styleFainted, styleGlow lipgloss.Style) Model { + zap.S().Info(styleFainted) + return Model{id: nextID(), styleFainted: styleFainted, styleGlow: styleGlow} +} + +// Init initializes the progress component +func (m Model) Init() tea.Cmd { + zap.S().Info(m.styleFainted) + return nil +} + +// Start starts a progress bar until it reaches a given width in a given duration +func (m Model) Start(width int, runningTime time.Duration, duration time.Duration) tea.Cmd { + zap.S().Info(m.styleFainted) + return func() tea.Msg { + interval := (duration / 2) / time.Duration(width) + + return StartMsg{ + width: int(runningTime / interval), + widthTarget: width * 2, + interval: interval, + } + } +} + +// Update handles the progress frame tick +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + zap.S().Info(m.styleFainted) + switch msg := msg.(type) { + case FrameMsg: + if msg.id != m.id { + return m, nil + } + + m.width++ + + if m.width < m.widthTarget { + return m, tick(m.id, m.interval) + } + + return m, nil + + case StartMsg: + m.id = nextID() + m.width = msg.width + m.widthTarget = msg.widthTarget + m.interval = msg.interval + + return m, tick(m.id, m.interval) + } + + return m, nil +} + +// View of the progress bar component +func (m Model) View() string { + zap.S().Info(m.styleFainted) + glowCount := min(20, m.width) + // Make sure if m.width is uneven that the half block string is in the glow part + if m.width%2 == 1 && glowCount%2 == 0 { + glowCount-- + } + faintedCount := m.width - glowCount + + // Construct fainted + fainted := strings.Repeat("▄", faintedCount/2) + fainted = m.styleFainted.Render(fainted) + + // Construct glow + glow := strings.Repeat("▄", glowCount/2) + if glowCount%2 == 1 { + glow += "▖" + } + glow = m.styleGlow.Render(glow) + + return lipgloss.JoinHorizontal(lipgloss.Top, fainted, glow) +} + +func tick(id int64, interval time.Duration) tea.Cmd { + return tea.Tick(interval, func(_ time.Time) tea.Msg { + return FrameMsg{id: id} + }) +} diff --git a/tui/components/stopwatch/stopwatch.go b/tui/components/stopwatch/stopwatch.go new file mode 100644 index 0000000..6bf702b --- /dev/null +++ b/tui/components/stopwatch/stopwatch.go @@ -0,0 +1,128 @@ +// Package stopwatch provides a simple stopwatch component +package stopwatch + +import ( + "fmt" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// Slightly adjusted version of https://github.com/charmbracelet/bubbles/blob/master/stopwatch/stopwatch.go + +var lastID int64 + +func nextID() int64 { + return atomic.AddInt64(&lastID, 1) +} + +// TickMsg is a message that is sent on every stopwatch tick +type TickMsg struct { + id int64 +} + +// StartStopMsg is a message that controls if the stopwatch is running or not +type StartStopMsg struct { + running bool + startDuration time.Duration +} + +// ResetMsg is a message that resets the stopwatch +type ResetMsg struct { +} + +// Model for the stopwatch component +type Model struct { + id int64 + duration time.Duration + running bool +} + +// New creates a new stopwatch with a given interval +func New() Model { + return Model{ + id: nextID(), + duration: 0, + running: false, + } +} + +// Init initializes the stopwatch component +func (m Model) Init() tea.Cmd { + return nil +} + +// Start starts the stopwatch +func (m Model) Start(startDuration time.Duration) tea.Cmd { + return func() tea.Msg { + return StartStopMsg{running: true, startDuration: startDuration} + } +} + +// Stop stops the stopwatch +func (m Model) Stop() tea.Cmd { + return func() tea.Msg { + return StartStopMsg{running: false} + } +} + +// Reset resets the stopwatch +func (m Model) Reset() tea.Cmd { + return func() tea.Msg { + return ResetMsg{} + } +} + +// Update handles the stopwatch tick +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case TickMsg: + if msg.id != m.id || !m.running { + return m, nil + } + + m.duration += time.Second + return m, tick(m.id) + + case ResetMsg: + m.duration = 0 + m.running = false + + case StartStopMsg: + if msg.running { + // Start + if m.running { + // Already running + return m, nil + } + + m.id = nextID() + m.duration = msg.startDuration + m.running = true + + return m, tick(m.id) + } + + // Stop + m.running = false + return m, nil + } + 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) + + return fmt.Sprintf("%02d:%02d", min, sec) +} + +func tick(id int64) tea.Cmd { + return tea.Tick(time.Second, func(_ time.Time) tea.Msg { + return TickMsg{id: id} + }) +} diff --git a/tui/screen/cammie/cammie.go b/tui/screen/cammie/cammie.go new file mode 100644 index 0000000..bbb1ee3 --- /dev/null +++ b/tui/screen/cammie/cammie.go @@ -0,0 +1,171 @@ +// Package cammie returns the screen containing the cammie messages and other stats +package cammie + +import ( + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/tui/screen" + "github.com/zeusWPI/scc/tui/view" + "github.com/zeusWPI/scc/tui/view/event" + "github.com/zeusWPI/scc/tui/view/gamification" + "github.com/zeusWPI/scc/tui/view/message" + "github.com/zeusWPI/scc/tui/view/tap" + "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 +} + +// Message to update the bottomIndex +type msgIndex struct { + indexBottom 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} +} + +// Init initializes the cammie screen +func (c *Cammie) Init() tea.Cmd { + cmds := []tea.Cmd{updateBottomIndex(*c), c.messages.Init(), c.bottom.Init()} + for _, view := range c.top { + cmds = append(cmds, view.Init()) + } + + 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: + c.width = msg.Width + c.height = msg.Height + + sMsg = sMsg.Width(c.width/2 - view.GetOuterWidth(sMsg)).Height(c.height - sMsg.GetVerticalFrameSize() - sMsg.GetVerticalPadding()) + sTop = sTop.Width(c.width/2 - sTop.GetHorizontalFrameSize()).Height(c.height/2 - sTop.GetVerticalFrameSize()) + sBottom = sBottom.Width(c.width/2 - sBottom.GetHorizontalFrameSize()).Height(c.height/2 - sBottom.GetVerticalFrameSize()) + + return c, c.GetSizeMsg + case msgIndex: + c.indexTop = msg.indexBottom + + return c, updateBottomIndex(*c) + } + + cmds := make([]tea.Cmd, 0) + var cmd tea.Cmd + + c.messages, cmd = c.messages.Update(msg) + cmds = append(cmds, cmd) + + c.bottom, cmd = c.bottom.Update(msg) + cmds = append(cmds, cmd) + + for i, view := range c.top { + c.top[i], cmd = view.Update(msg) + cmds = append(cmds, 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..." + } + + // Render messages + messages := sMsg.Render(c.messages.View()) + + // Render top + // Render tabs + var topTabs []string + for i, view := range c.top { + if i == c.indexTop { + topTabs = append(topTabs, sActiveTab.Render(view.Name())) + } else { + topTabs = append(topTabs, sTabNormal.Render(view.Name())) + } + } + topTab := lipgloss.JoinHorizontal(lipgloss.Bottom, topTabs...) + topTabsLine := sTabNormal.Render(strings.Repeat(" ", max(0, (c.width/2)-lipgloss.Width(topTab)-10))) + topTab = lipgloss.JoinHorizontal(lipgloss.Bottom, topTab, topTabsLine) + + // Render top view + top := lipgloss.JoinVertical(lipgloss.Left, topTab, c.top[c.indexTop].View()) + top = sTop.Render(top) + + // Render bottom + bottom := sBottom.Render(c.bottom.View()) + + // Combine top and bottom + right := lipgloss.JoinVertical(lipgloss.Left, top, bottom) + + // Combine left and right + view := lipgloss.JoinHorizontal(lipgloss.Top, messages, right) + + return view +} + +// GetUpdateViews returns all the update functions for the cammie screen +func (c *Cammie) GetUpdateViews() []view.UpdateData { + updates := make([]view.UpdateData, 0) + + updates = append(updates, c.messages.GetUpdateDatas()...) + updates = append(updates, c.bottom.GetUpdateDatas()...) + + for _, view := range c.top { + updates = append(updates, view.GetUpdateDatas()...) + } + + return updates +} + +// GetSizeMsg returns a message for the views informing them about their width and height +func (c *Cammie) GetSizeMsg() tea.Msg { + sizes := make(map[string]view.Size) + + msgW := sMsg.GetWidth() + msgH := sMsg.GetHeight() + sizes[c.messages.Name()] = view.Size{Width: msgW, Height: msgH} + + bottomW := sBottom.GetWidth() + bottomH := sBottom.GetHeight() + sizes[c.bottom.Name()] = view.Size{Width: bottomW, Height: bottomH} + + for _, top := range c.top { + topW := sTop.GetWidth() + topH := sTop.GetHeight() + sizes[top.Name()] = view.Size{Width: topW, Height: topH} + } + + return view.MsgSize{Sizes: sizes} +} + +func updateBottomIndex(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) + + return msgIndex{indexBottom: newIndex} + }) +} diff --git a/tui/screen/cammie/style.go b/tui/screen/cammie/style.go new file mode 100644 index 0000000..832683c --- /dev/null +++ b/tui/screen/cammie/style.go @@ -0,0 +1,50 @@ +package cammie + +import "github.com/charmbracelet/lipgloss" + +// Base +var base = lipgloss.NewStyle() + +// Colors +var ( + cZeus = lipgloss.Color("#FF7F00") +) + +// Borders +var ( + bTabBorder = lipgloss.Border{ + Top: "─", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "┴", + BottomRight: "┴", + } + + bActiveTabBorder = lipgloss.Border{ + Top: "─", + Bottom: " ", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "┘", + BottomRight: "└", + } +) + +// Tabs +var ( + sTab = base.Border(bTabBorder, true).BorderForeground(cZeus).Padding(0, 1).MarginBottom(1) + sActiveTab = sTab.Border(bActiveTabBorder, true) + sTabNormal = sTab.BorderTop(false).BorderLeft(false).BorderRight(false) +) + +// Style +var ( + sMsg = base.Border(lipgloss.RoundedBorder(), true, true, true, true).BorderForeground(cZeus).MarginLeft(1).MarginRight(2) + sTop = base.Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(cZeus) + sBottom = base.AlignVertical(lipgloss.Center).AlignHorizontal(lipgloss.Center) +) diff --git a/tui/screen/screen.go b/tui/screen/screen.go new file mode 100644 index 0000000..b6c0a37 --- /dev/null +++ b/tui/screen/screen.go @@ -0,0 +1,16 @@ +// Package screen provides difference screens for the tui +package screen + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/tui/view" +) + +// Screen represents a screen +type Screen interface { + Init() tea.Cmd + Update(tea.Msg) (Screen, tea.Cmd) + View() string + GetUpdateViews() []view.UpdateData + GetSizeMsg() tea.Msg +} diff --git a/tui/screen/song/song.go b/tui/screen/song/song.go new file mode 100644 index 0000000..57c42ac --- /dev/null +++ b/tui/screen/song/song.go @@ -0,0 +1,77 @@ +// Package song contains the screen displaying the song view +package song + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/tui/screen" + "github.com/zeusWPI/scc/tui/view" + "github.com/zeusWPI/scc/tui/view/song" +) + +// Song represents the song screen +type Song struct { + db *db.DB + song view.View + width int + height int +} + +// 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} +} + +// Init initializes the song screen +func (s *Song) Init() tea.Cmd { + return s.song.Init() +} + +// Update updates the song screen and all its views +func (s *Song) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.width = msg.Width + s.height = msg.Height + + sSong = sSong.Width(s.width - view.GetOuterWidth(sSong)).Height(s.height - sSong.GetVerticalFrameSize() - sSong.GetVerticalPadding()) + + return s, s.GetSizeMsg + } + + cmds := make([]tea.Cmd, 0) + var cmd tea.Cmd + + s.song, cmd = s.song.Update(msg) + cmds = append(cmds, cmd) + + return s, tea.Batch(cmds...) +} + +// View returns the song screen view +func (s *Song) View() string { + if s.width == 0 || s.height == 0 { + return "Initializing..." + } + + view := s.song.View() + view = sSong.Render(view) + + return view +} + +// GetUpdateViews returns all the update functions for the song screen +func (s *Song) GetUpdateViews() []view.UpdateData { + return s.song.GetUpdateDatas() +} + +// GetSizeMsg returns a message for the views informing them about their width and height +func (s *Song) GetSizeMsg() tea.Msg { + sizes := make(map[string]view.Size) + + songW := sSong.GetWidth() + songH := sSong.GetHeight() + sizes[s.song.Name()] = view.Size{Width: songW, Height: songH} + + return view.MsgSize{Sizes: sizes} +} diff --git a/tui/screen/song/style.go b/tui/screen/song/style.go new file mode 100644 index 0000000..e8962d6 --- /dev/null +++ b/tui/screen/song/style.go @@ -0,0 +1,10 @@ +package song + +import "github.com/charmbracelet/lipgloss" + +var base = lipgloss.NewStyle() + +// Style +var ( + sSong = base.AlignVertical(lipgloss.Center).AlignHorizontal(lipgloss.Center) +) diff --git a/tui/tui.go b/tui/tui.go new file mode 100644 index 0000000..eb7008e --- /dev/null +++ b/tui/tui.go @@ -0,0 +1,52 @@ +// Package tui provides utilities for working with the terminal. +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/tui/screen" + "go.uber.org/zap" +) + +// TUI represent a terminal instance +type TUI struct { + screen screen.Screen +} + +// New creates a new tui instance +func New(screen screen.Screen) *TUI { + return &TUI{screen: screen} +} + +// Init initializes the tui +func (t *TUI) Init() tea.Cmd { + return t.screen.Init() +} + +// Update updates the tui +func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + screen, cmd := t.screen.Update(msg) + t.screen = screen + if cmd != nil { + cmds = append(cmds, cmd) + } + + // Handle global key events + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC: + zap.S().Info("Exiting") + // cmds = append(cmds, tea.ExitAltScreen) + cmds = append(cmds, tea.Quit) + } + } + + return t, tea.Batch(cmds...) +} + +// View returns the tui view +func (t *TUI) View() string { + return t.screen.View() +} diff --git a/tui/view/event/event.go b/tui/view/event/event.go new file mode 100644 index 0000000..755ad70 --- /dev/null +++ b/tui/view/event/event.go @@ -0,0 +1,121 @@ +// Package event provides the functions to draw all the upcoming zeus events on a TUI +package event + +import ( + "context" + "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/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 +} + +// Msg represents the message to update the event view +type Msg struct { + upcoming []dto.Event + passed []dto.Event + today *dto.Event +} + +// NewModel creates a new event view +func NewModel(db *db.DB) view.View { + return &Model{db: db} +} + +// 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 Msg: + m.passed = msg.passed + m.upcoming = msg.upcoming + m.today = msg.today + } + + return m, nil +} + +// View returns the view for the event model +func (m *Model) View() string { + if m.today != nil { + return m.viewToday() + } + + return m.viewNormal() +} + +// GetUpdateDatas returns all the update function for the event model +func (m *Model) GetUpdateDatas() []view.UpdateData { + return []view.UpdateData{ + { + Name: "event update", + View: m, + Update: updateEvents, + Interval: config.GetDefaultInt("tui.view.event.interval_s", 3600), + }, + } +} + +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 new file mode 100644 index 0000000..bfbfbc9 --- /dev/null +++ b/tui/view/event/style.go @@ -0,0 +1,57 @@ +package event + +import "github.com/charmbracelet/lipgloss" + +// Widths +var ( + widthToday = 45 + widthImage = 32 + + widthOverview = 45 + widthOverviewName = 35 + widthOverviewImage = 32 +) + +// Base +var ( + base = lipgloss.NewStyle() + baseToday = base.Width(widthToday).Align(lipgloss.Center) +) + +// Margins +var ( + mTodayWarning = 3 + mOverview = 5 +) + +// Color +var ( + cZeus = lipgloss.Color("#FF7F00") + cWarning = lipgloss.Color("#EE4B2B") + cBorder = lipgloss.Color("#383838") + cUpcoming = lipgloss.Color("#FFBF00") +) + +// Styles today +var ( + sTodayWarning = baseToday.Bold(true).Blink(true).Foreground(cWarning).Border(lipgloss.DoubleBorder(), true, false) + sTodayName = baseToday.Bold(true).Foreground(cZeus).BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder) + sTodayTime = baseToday + sTodayPlace = baseToday.Italic(true).Faint(true) + sToday = baseToday.MarginLeft(8).AlignVertical(lipgloss.Center) +) + +// Styles overview +var ( + sOverviewTotal = base.AlignVertical(lipgloss.Center) + sOverviewTitle = base.Bold(true).Foreground(cWarning).Width(widthOverview).Align(lipgloss.Center) + sOverview = base.Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(cBorder).Width(widthOverview).MarginRight(mOverview) + sPassedName = base.Foreground(cZeus).Faint(true).Width(widthOverviewName) + sPassedTime = base.Faint(true) + sNextName = base.Bold(true).Foreground(cZeus).Width(widthOverviewName) + sNextTime = base.Bold(true) + sNextPlace = base.Italic(true).Width(widthOverviewName) + sUpcomingName = base.Width(widthOverviewName).Foreground(cUpcoming) + sUpcomingTime = base.Faint(true) + sUpcomingPlace = base.Italic(true).Faint(true).Width(widthOverviewName) +) diff --git a/tui/view/event/view.go b/tui/view/event/view.go new file mode 100644 index 0000000..7d3a16c --- /dev/null +++ b/tui/view/event/view.go @@ -0,0 +1,116 @@ +package event + +import ( + "bytes" + "image" + + "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/tui/view" +) + +func (m *Model) viewToday() string { + // Render image + im := "" + if m.today.Poster != nil { + i, _, err := image.Decode(bytes.NewReader(m.today.Poster)) + if err == nil { + im = view.ImagetoString(widthImage, i) + } + } + + // Render text + warningTop := sTodayWarning.MarginBottom(mTodayWarning).Render("🥳 Event Today 🥳") + warningBottom := sTodayWarning.MarginTop(mTodayWarning).Render("🥳 Event Today 🥳") + + name := sTodayName.Render(m.today.Name) + time := sTodayTime.Render("🕙 " + m.today.Date.Format("15:04")) + location := sTodayPlace.Render("📍 " + m.today.Location) + + text := lipgloss.JoinVertical(lipgloss.Left, warningTop, name, time, location, warningBottom) + + // Resize so it's centered + if lipgloss.Height(im) > lipgloss.Height(text) { + sToday = sToday.Height(lipgloss.Height(im)) + } + text = sToday.Render(text) + + return lipgloss.JoinHorizontal(lipgloss.Top, im, text) +} + +func (m *Model) viewNormal() string { + // Poster if present + im := "" + if len(m.upcoming) > 0 && m.upcoming[0].Poster != nil { + i, _, err := image.Decode(bytes.NewReader(m.upcoming[0].Poster)) + if err == nil { + im = view.ImagetoString(widthOverviewImage, i) + } + } + + // Overview + events := m.viewGetEvents() + + // Filthy hack to avoid the last event being centered by the cammie screen + events = append(events, "\n") + + // Render events overview + overview := lipgloss.JoinVertical(lipgloss.Left, events...) + overview = sOverview.Render(overview) + + title := sOverviewTitle.Render("Events") + overview = lipgloss.JoinVertical(lipgloss.Left, title, overview) + + // Center the overview + if lipgloss.Height(im) > lipgloss.Height(overview) { + overview = sOverviewTotal.Height(lipgloss.Height(im)).Render(overview) + } + + // Combine image and overview + view := lipgloss.JoinHorizontal(lipgloss.Top, overview, im) + + return view +} + +func (m *Model) viewGetEvents() []string { + events := make([]string, 0, len(m.passed)+len(m.upcoming)) + + // Passed + for _, event := range m.passed { + time := sPassedTime.Render(event.Date.Format("02/01") + "\t") + name := sPassedName.Render(event.Name) + text := lipgloss.JoinHorizontal(lipgloss.Top, time, name) + + events = append(events, text) + } + + if len(m.upcoming) == 0 { + return events + } + + // Next + name := sNextName.Render(m.upcoming[0].Name) + time := sNextTime.Render(m.upcoming[0].Date.Format("02/01") + "\t") + location := sNextPlace.Render("📍 " + m.upcoming[0].Location) + + text := lipgloss.JoinVertical(lipgloss.Left, name, location) + text = lipgloss.JoinHorizontal(lipgloss.Top, time, text) + + events = append(events, text) + + // Upcoming + for i := 1; i < len(m.upcoming); i++ { + time := sUpcomingTime.Render(m.upcoming[i].Date.Format("02/01") + "\t") + name := sUpcomingName.Render(m.upcoming[i].Name) + text := name + if i < 3 { + location := sUpcomingPlace.Render("📍 " + m.upcoming[i].Location) + text = lipgloss.JoinVertical(lipgloss.Left, name, location) + } + + text = lipgloss.JoinHorizontal(lipgloss.Top, time, text) + + events = append(events, text) + } + + return events +} diff --git a/tui/view/gamification/gamification.go b/tui/view/gamification/gamification.go new file mode 100644 index 0000000..d47bec6 --- /dev/null +++ b/tui/view/gamification/gamification.go @@ -0,0 +1,132 @@ +// Package gamification provides the functions to draw an overview of gamification on a TUI +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 +} + +type gamificationItem struct { + image image.Image + item dto.Gamification +} + +// Msg contains the data to update the gamification model +type Msg struct { + leaderboard []gamificationItem +} + +// NewModel initializes a new gamification model +func NewModel(db *db.DB) view.View { + return &Model{db: db, leaderboard: []gamificationItem{}} +} + +// 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 Msg: + m.leaderboard = msg.leaderboard + } + + return m, nil +} + +// View draws the gamification view +func (m *Model) View() string { + columns := make([]string, 0, len(m.leaderboard)) + + positions := []lipgloss.Style{sFirst, sSecond, sThird, sFourth} + + for i, item := range m.leaderboard { + user := lipgloss.JoinVertical(lipgloss.Left, + positions[i].Render(fmt.Sprintf("%d. %s", i+1, item.item.Name)), + sScore.Render(strconv.Itoa(int(item.item.Score))), + ) + + column := lipgloss.JoinVertical(lipgloss.Left, view.ImagetoString(width, item.image), user) + columns = append(columns, sColumn.Render(column)) + } + + list := lipgloss.JoinHorizontal(lipgloss.Top, columns...) + + return list +} + +// GetUpdateDatas get all update functions for the gamification view +func (m *Model) GetUpdateDatas() []view.UpdateData { + return []view.UpdateData{ + { + Name: "gamification leaderboard", + View: m, + Update: updateLeaderboard, + Interval: config.GetDefaultInt("tui.view.gamification.interval_s", 3600), + }, + } +} + +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 new file mode 100644 index 0000000..9b5ce4e --- /dev/null +++ b/tui/view/gamification/styles.go @@ -0,0 +1,29 @@ +package gamification + +import "github.com/charmbracelet/lipgloss" + +var base = lipgloss.NewStyle() +var width = 20 + +// Colors +var ( + cGold = lipgloss.Color("#FFD700") + cZeus = lipgloss.Color("#FF7F00") + cBronze = lipgloss.Color("#CD7F32") + cBorder = lipgloss.Color("#383838") +) + +// Styles +var ( + sName = base.BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cBorder).Width(width).Align(lipgloss.Center) + sScore = base.Width(width).Align(lipgloss.Center) + sColumn = base.MarginRight(4) +) + +// Positions +var ( + sFirst = sName.Foreground(cGold) + sSecond = sName.Foreground(cZeus) + sThird = sName.Foreground(cBronze) + sFourth = sName +) diff --git a/tui/view/message/message.go b/tui/view/message/message.go new file mode 100644 index 0000000..abe6704 --- /dev/null +++ b/tui/view/message/message.go @@ -0,0 +1,134 @@ +// Package message provides the functions to draw all the cammie messages on a TUI +package message + +import ( + "context" + "hash/fnv" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/jackc/pgx/v5" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/tui/view" +) + +// Model represents the model for the message view +type Model struct { + width int + height int + db *db.DB + lastMessageID int32 + messages []message +} + +type message struct { + sender string + message string + color string + 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{}} +} + +// 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 { + m.width = entry.Width + m.height = entry.Height + } + + return m, nil + case Msg: + m.lastMessageID = msg.lastMessageID + m.messages = append(m.messages, msg.messages...) + + return m, nil + } + + return m, nil +} + +// View returns the view for the message model +func (m *Model) View() string { + if m.width == 0 || m.height == 0 { + return "Initializing..." + } + + return m.viewAll() +} + +// GetUpdateDatas returns all the update functions for the message model +func (m *Model) GetUpdateDatas() []view.UpdateData { + return []view.UpdateData{ + { + Name: "cammie messages", + View: m, + Update: updateMessages, + Interval: config.GetDefaultInt("tui.view.message.interval_s", 1), + }, + } +} + +func updateMessages(view view.View) (tea.Msg, error) { + m := view.(*Model) + lastMessageID := m.lastMessageID + + messagesDB, err := m.db.Queries.GetMessageSinceID(context.Background(), lastMessageID) + if err != nil { + if err == pgx.ErrNoRows { + err = nil + } + return nil, err + } + + if len(messagesDB) == 0 { + return nil, nil + } + + messages := make([]message, 0, len(messagesDB)) + lastID := m.lastMessageID + for _, m := range messagesDB { + if m.ID > lastID { + lastID = m.ID + } + + messages = append(messages, message{ + sender: m.Name, + message: m.Message, + color: hashColor(m.Name), + date: m.CreatedAt.Time, + }) + } + + return Msg{lastMessageID: lastID, messages: messages}, nil +} + +func hashColor(s string) string { + h := fnv.New32a() + h.Write([]byte(s)) + hash := h.Sum32() + return colors[hash%uint32(len(colors))] +} diff --git a/tui/view/message/style.go b/tui/view/message/style.go new file mode 100644 index 0000000..89ac4fc --- /dev/null +++ b/tui/view/message/style.go @@ -0,0 +1,38 @@ +package message + +import "github.com/charmbracelet/lipgloss" + +var base = lipgloss.NewStyle() + +// Message colors +var colors = []string{ + "#FAF500", // Yellow + "#3AFA00", // Green + "#FAD700", // Yellow Green + "#FAA600", // Orange + "#FAE200", // Yellow Orange + "#FA7200", // Orange Red + "#FA4600", // Red + "#FA0400", // Real Red + "#FA0079", // Pink Red + "#FA00FA", // Pink + "#EE00FA", // Purple + "#8300FA", // Purple Blue + "#3100FA", // Blue + "#00FAFA", // Light Blue + "#00FAA5", // Green Blue + "#00FA81", // IDK + "#F8FA91", // Weird Light Green + "#FAD392", // Light Orange + "#FA9E96", // Salmon + "#DEA2F9", // Fuchsia + "#B3D2F9", // Boring Blue +} + +// Style +var ( + sTime = base.Faint(true) + sSender = base.Bold(true) + sMessage = base + sDate = base.Faint(true) +) diff --git a/tui/view/message/view.go b/tui/view/message/view.go new file mode 100644 index 0000000..77d9812 --- /dev/null +++ b/tui/view/message/view.go @@ -0,0 +1,48 @@ +package message + +import ( + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +func (m *Model) viewAll() string { + rows := make([]string, 0, len(m.messages)) + var previousDate time.Time + + for _, msg := range m.messages { + time := sTime.Render(msg.date.Format("15:04") + " ") + sender := sSender.Foreground(lipgloss.Color(msg.color)).Render(msg.sender + " | ") + + width := m.width - lipgloss.Width(time) - lipgloss.Width(sender) + message := sMessage.Width(width).Foreground(lipgloss.Color(msg.color)).Render(msg.message) + + text := lipgloss.JoinHorizontal(lipgloss.Top, time, sender, message) + + // Add date if needed + if previousDate.IsZero() || previousDate.YearDay() != msg.date.YearDay() { + date := sDate.Render(" " + msg.date.Format("02/01") + " ") + + lineLength := (m.width - lipgloss.Width(date)) / 2 + left := sDate.Render(strings.Repeat("─", lineLength)) + right := sDate.Render(strings.Repeat("─", lineLength)) + + date = lipgloss.JoinHorizontal(lipgloss.Top, left, date, right) + text = lipgloss.JoinVertical(lipgloss.Left, date, text) + previousDate = msg.date + } + + rows = append(rows, text) + } + + view := lipgloss.JoinVertical(lipgloss.Left, rows...) + + lines := strings.Split(view, "\n") + height := min(m.height, len(lines)) + if len(lines) > 0 && len(lines) > height { + view = strings.Join(lines[len(lines)-height:], "\n") + } + + return view +} diff --git a/tui/view/song/song.go b/tui/view/song/song.go new file mode 100644 index 0000000..038c4e0 --- /dev/null +++ b/tui/view/song/song.go @@ -0,0 +1,359 @@ +// Package song provides the functions to draw an overview of the song integration +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/pkg/config" + "github.com/zeusWPI/scc/tui/components/progress" + "github.com/zeusWPI/scc/tui/components/stopwatch" + "github.com/zeusWPI/scc/tui/view" +) + +var ( + previousAmount = 5 // Amount of passed lyrics to show + upcomingAmount = 12 // Amount of upcoming lyrics to show +) + +type playing struct { + song *dto.Song + stopwatch stopwatch.Model + progress progress.Model + lyrics lyrics.Lyrics + previous []string // Lyrics already sang + current string // Current lyric + upcoming []string // Lyrics that are coming up +} + +// Model represents the view model for song +type Model struct { + db *db.DB + current playing + history []string + topSongs topStat + topGenres topStat + topArtists topStat + width int + height int +} + +// Msg triggers a song data update +// Required for the View interface +type Msg struct{} + +type msgPlaying struct { + song *dto.Song + lyrics lyrics.Lyrics +} + +type msgTop struct { + topSongs []topStatEntry + topGenres []topStatEntry + topArtists []topStatEntry +} + +type msgHistory struct { + history []string +} + +type msgLyrics struct { + song *dto.Song + previous []string + current string + upcoming []string + startNext time.Time + done bool +} + +type topStat struct { + title string + entries []topStatEntry +} + +type topStatEntry struct { + name string + amount int +} + +// New initializes a new song model +func New(db *db.DB) view.View { + return &Model{ + db: db, + current: playing{stopwatch: stopwatch.New(), progress: progress.New(sStatusProgressFainted, sStatusProgressGlow)}, + history: make([]string, 0, 5), + topSongs: topStat{title: "Top Tracks", entries: make([]topStatEntry, 0, 5)}, + topGenres: topStat{title: "Top Genres", entries: make([]topStatEntry, 0, 5)}, + topArtists: topStat{title: "Top Artists", entries: make([]topStatEntry, 0, 5)}, + width: 0, + height: 0, + } +} + +// Init starts the song view +func (m *Model) Init() tea.Cmd { + return tea.Batch( + m.current.stopwatch.Init(), + m.current.progress.Init(), + ) +} + +// Name returns the name of the view +func (m *Model) Name() string { + return "Songs" +} + +// Update updates the song 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 { + m.width = entry.Width + m.height = entry.Height + + sStatusSong = sStatusSong.Width(m.width - view.GetOuterWidth(sStatusSong)) + sStatusProgress = sStatusProgress.Width(m.width - view.GetOuterWidth(sStatusProgress)) + sLyric = sLyric.Width(m.width - view.GetOuterWidth(sLyric)) + sStatAll = sStatAll.Width(m.width - view.GetOuterWidth(sStatAll)) + sAll = sAll.Height(m.height - view.GetOuterHeight(sAll)).Width(m.width - view.GetOuterWidth(sAll)) + } + + return m, nil + + case msgPlaying: + m.current.song = msg.song + m.current.lyrics = msg.lyrics + // New song, start the commands to update the lyrics + lyric, ok := m.current.lyrics.Next() + if !ok { + // Song already done + m.current = playing{song: nil} + return m, m.current.stopwatch.Reset() + } + + // Go through the lyrics until we get to the current one + startTime := m.current.song.CreatedAt.Add(lyric.Duration) + for startTime.Before(time.Now()) { + lyric, ok := m.current.lyrics.Next() + if !ok { + // We're too late to display lyrics + m.current = playing{song: nil} + return m, m.current.stopwatch.Reset() + } + startTime = startTime.Add(lyric.Duration) + } + + m.current.previous = lyricsToString(m.current.lyrics.Previous(previousAmount)) + m.current.upcoming = lyricsToString(m.current.lyrics.Upcoming(upcomingAmount)) + return m, tea.Batch( + updateLyrics(m.current, startTime), + m.current.stopwatch.Start(time.Since(m.current.song.CreatedAt)), + m.current.progress.Start(view.GetWidth(sStatusProgress), time.Since(m.current.song.CreatedAt), time.Duration(m.current.song.DurationMS)*time.Millisecond), + ) + + case msgHistory: + m.history = msg.history + + return m, nil + + case msgTop: + if msg.topSongs != nil { + m.topSongs.entries = msg.topSongs + } + if msg.topGenres != nil { + m.topGenres.entries = msg.topGenres + } + if msg.topArtists != nil { + m.topArtists.entries = msg.topArtists + } + + return m, nil + + case msgLyrics: + // Check if it's still relevant + if msg.song.ID != m.current.song.ID { + // We already switched to a new song + return m, nil + } + + if msg.done { + // Song has finished. Reset variables + m.current = playing{song: nil} + return m, m.current.stopwatch.Reset() + } + + // Msg is relevant, update values + m.current.previous = msg.previous + m.current.current = msg.current + m.current.upcoming = msg.upcoming + + // Start the cmd to update the lyrics + return m, updateLyrics(m.current, msg.startNext) + } + + // Maybe a stopwatch message? + var cmd tea.Cmd + m.current.stopwatch, cmd = m.current.stopwatch.Update(msg) + if cmd != nil { + return m, cmd + } + + // Maybe a progress bar message? + m.current.progress, cmd = m.current.progress.Update(msg) + + return m, cmd +} + +// View draws the song view +func (m *Model) View() string { + if m.current.song != nil { + return m.viewPlaying() + } + + return m.viewNotPlaying() + +} + +// GetUpdateDatas gets all update functions for the song view +func (m *Model) GetUpdateDatas() []view.UpdateData { + return []view.UpdateData{ + { + Name: "update current song", + View: m, + Update: updateCurrentSong, + Interval: config.GetDefaultInt("tui.view.song.interval_current_s", 5), + }, + { + Name: "update history", + View: m, + Update: updateHistory, + Interval: config.GetDefaultInt("tui.view.song.interval_history_s", 5), + }, + { + Name: "top stats", + View: m, + Update: updateTopStats, + Interval: config.GetDefaultInt("tui.view.song.interval_top_s", 3600), + }, + } +} + +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.song != nil && songs[0].ID == m.current.song.ID { + // Song is already set to current + return nil, nil + } + + song := dto.SongDTOHistory(songs) + + return msgPlaying{song: song, lyrics: lyrics.New(song)}, nil +} + +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 + } + + return msgHistory{history: history}, nil +} + +func updateTopStats(view view.View) (tea.Msg, error) { + m := view.(*Model) + msg := msgTop{} + change := false + + songs, err := m.db.Queries.GetTopSongs(context.Background()) + if err != nil && err != pgx.ErrNoRows { + return nil, err + } + + if !equalTopSongs(m.topSongs.entries, songs) { + msg.topSongs = topStatSqlcSong(songs) + change = true + } + + genres, err := m.db.Queries.GetTopGenres(context.Background()) + if err != nil && err != pgx.ErrNoRows { + return nil, err + } + + if !equalTopGenres(m.topGenres.entries, genres) { + msg.topGenres = topStatSqlcGenre(genres) + change = true + } + + artists, err := m.db.Queries.GetTopArtists(context.Background()) + if err != nil && err != pgx.ErrNoRows { + return nil, err + } + + if !equalTopArtists(m.topArtists.entries, artists) { + msg.topArtists = topStatSqlcArtist(artists) + change = true + } + + if !change { + return nil, nil + } + + return msg, nil +} + +func updateLyrics(song playing, start time.Time) tea.Cmd { + 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, done: true} + } + + previous := song.lyrics.Previous(previousAmount) + upcoming := song.lyrics.Upcoming(upcomingAmount) + + end := start.Add(lyric.Duration) + + return msgLyrics{ + song: song.song, + previous: lyricsToString(previous), + current: lyric.Text, + upcoming: lyricsToString(upcoming), + startNext: end, + done: false, + } + }) +} diff --git a/tui/view/song/style.go b/tui/view/song/style.go new file mode 100644 index 0000000..81fb366 --- /dev/null +++ b/tui/view/song/style.go @@ -0,0 +1,53 @@ +package song + +import "github.com/charmbracelet/lipgloss" + +// Colors +var ( + cZeus = lipgloss.Color("#FF7F00") + cSpotify = lipgloss.Color("#1DB954") + cBorder = lipgloss.Color("#383838") +) + +// Base style +var base = lipgloss.NewStyle() + +// Styles for the stats +var ( + wStatTotal = 40 + wStatEnum = 3 + wStatAmount = 4 + wStatBody = wStatTotal - wStatEnum - wStatAmount + + sStatAll = base.Align(lipgloss.Center).BorderStyle(lipgloss.NormalBorder()).BorderTop(true).BorderForeground(cBorder).PaddingTop(3) + sStat = base.Width(wStatTotal).MarginRight(3).MarginBottom(2) + sStatTitle = base.Foreground(cZeus).Width(wStatTotal).Align(lipgloss.Center). + BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cSpotify) + sStatEnum = base.Foreground(cSpotify).Width(wStatEnum).Align(lipgloss.Left) + sStatBody = base.Width(wStatBody) + sStatAmount = base.Width(wStatAmount).Align(lipgloss.Right).Foreground(cZeus) +) + +// Styles for the status +var ( + sStatus = base.PaddingTop(1) + sStatusSong = base.Padding(0, 1).Align(lipgloss.Center) + sStatusStopwatch = base.Faint(true) + sStatusProgress = base.Padding(0, 2).PaddingBottom(3).Align(lipgloss.Left) + sStatusProgressFainted = base.Foreground(cZeus).Faint(true) + sStatusProgressGlow = base.Foreground(cZeus) +) + +// Styles for the lyrics +var ( + sLyricBase = base.Width(50).Align(lipgloss.Center).Bold(true) + sLyric = sLyricBase.AlignVertical(lipgloss.Center) + sLyricPrevious = sLyricBase.Foreground(cZeus).Faint(true) + sLyricCurrent = sLyricBase.Foreground(cZeus) + sLyricUpcoming = sLyricBase.Foreground(cSpotify) +) + +// Style for everything +var ( + sAll = base.Align(lipgloss.Center).AlignVertical(lipgloss.Center) +) diff --git a/tui/view/song/util.go b/tui/view/song/util.go new file mode 100644 index 0000000..8fb7b90 --- /dev/null +++ b/tui/view/song/util.go @@ -0,0 +1,80 @@ +package song + +import ( + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + "github.com/zeusWPI/scc/internal/pkg/lyrics" +) + +func equalTopSongs(s1 []topStatEntry, s2 []sqlc.GetTopSongsRow) bool { + if len(s1) != len(s2) { + return false + } + + for i, s := range s1 { + if s.name != s2[i].Title || s.amount != int(s2[i].PlayCount) { + return false + } + } + + return true +} + +func topStatSqlcSong(songs []sqlc.GetTopSongsRow) []topStatEntry { + topstats := make([]topStatEntry, 0, len(songs)) + for _, s := range songs { + topstats = append(topstats, topStatEntry{name: s.Title, amount: int(s.PlayCount)}) + } + return topstats +} + +func equalTopGenres(s1 []topStatEntry, s2 []sqlc.GetTopGenresRow) bool { + if len(s1) != len(s2) { + return false + } + + for i, s := range s1 { + if s.name != s2[i].GenreName || s.amount != int(s2[i].TotalPlays) { + return false + } + } + + return true +} + +func topStatSqlcGenre(songs []sqlc.GetTopGenresRow) []topStatEntry { + topstats := make([]topStatEntry, 0, len(songs)) + for _, s := range songs { + topstats = append(topstats, topStatEntry{name: s.GenreName, amount: int(s.TotalPlays)}) + } + return topstats +} + +func equalTopArtists(s1 []topStatEntry, s2 []sqlc.GetTopArtistsRow) bool { + if len(s1) != len(s2) { + return false + } + + for i, s := range s1 { + if s.name != s2[i].ArtistName || s.amount != int(s2[i].TotalPlays) { + return false + } + } + + return true +} + +func topStatSqlcArtist(songs []sqlc.GetTopArtistsRow) []topStatEntry { + topstats := make([]topStatEntry, 0, len(songs)) + for _, s := range songs { + topstats = append(topstats, topStatEntry{name: s.ArtistName, amount: int(s.TotalPlays)}) + } + return topstats +} + +func lyricsToString(lyrics []lyrics.Lyric) []string { + text := make([]string, 0, len(lyrics)) + for _, lyric := range lyrics { + text = append(text, lyric.Text) + } + return text +} diff --git a/tui/view/song/view.go b/tui/view/song/view.go new file mode 100644 index 0000000..97bf487 --- /dev/null +++ b/tui/view/song/view.go @@ -0,0 +1,134 @@ +package song + +import ( + "fmt" + "math" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func (m *Model) viewPlaying() string { + status := m.viewPlayingStatus() + status = sStatus.Render(status) + + stats := m.viewPlayingStats() + stats = sStatAll.Render(stats) + + lyrics := m.viewPlayingLyrics() + lyrics = sLyric.Height(sAll.GetHeight() - lipgloss.Height(status) - lipgloss.Height(stats)).Render(lyrics) + + view := lipgloss.JoinVertical(lipgloss.Left, status, lyrics, stats) + + return sAll.Render(view) +} + +func (m *Model) viewPlayingStatus() string { + // Stopwatch + durationS := int(math.Round(float64(m.current.song.DurationMS) / 1000)) + stopwatch := fmt.Sprintf("\t%s / %02d:%02d", m.current.stopwatch.View(), durationS/60, durationS%60) + stopwatch = sStatusStopwatch.Render(stopwatch) + + // Song name + var artists strings.Builder + for _, artist := range m.current.song.Artists { + artists.WriteString(artist.Name + " & ") + } + artist := artists.String() + if len(artist) > 0 { + artist = artist[:len(artist)-3] + } + + song := sStatusSong.Width(sStatusSong.GetWidth() - lipgloss.Width(stopwatch)).Render(fmt.Sprintf("%s | %s", m.current.song.Title, artist)) + + // Progress bar + progress := m.current.progress.View() + progress = sStatusProgress.Render(progress) + + view := lipgloss.JoinHorizontal(lipgloss.Top, song, stopwatch) + view = lipgloss.JoinVertical(lipgloss.Left, view, progress) + + return view +} + +func (m *Model) viewPlayingLyrics() string { + var previousB strings.Builder + for i, lyric := range m.current.previous { + previousB.WriteString(lyric) + if i != len(m.current.previous)-1 { + previousB.WriteString("\n") + } + } + previous := sLyricPrevious.Render(previousB.String()) + + current := sLyricCurrent.Render(m.current.current) + + var upcomingB strings.Builder + for _, lyric := range m.current.upcoming { + upcomingB.WriteString(lyric) + upcomingB.WriteString("\n") + } + upcoming := sLyricUpcoming.Render(upcomingB.String()) + + return sLyric.Render(lipgloss.JoinVertical(lipgloss.Left, previous, current, upcoming)) +} + +func (m *Model) viewPlayingStats() string { + columns := make([]string, 0, 4) + + columns = append(columns, m.viewRecent()) + columns = append(columns, m.viewTopStat(m.topSongs)) + columns = append(columns, m.viewTopStat(m.topArtists)) + columns = append(columns, m.viewTopStat(m.topGenres)) + + return lipgloss.JoinHorizontal(lipgloss.Top, columns...) +} + +func (m *Model) viewNotPlaying() string { + rows := make([][]string, 0, 2) + for i := 0; i < 2; i++ { + rows = append(rows, make([]string, 0, 2)) + } + + rows[0] = append(rows[0], m.viewRecent()) + rows[0] = append(rows[0], m.viewTopStat(m.topSongs)) + rows[1] = append(rows[1], m.viewTopStat(m.topArtists)) + rows[1] = append(rows[1], m.viewTopStat(m.topGenres)) + + renderedRows := make([]string, 0, 2) + for _, row := range rows { + renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, row...)) + } + + view := lipgloss.JoinVertical(lipgloss.Left, renderedRows...) + + return sAll.Render(view) +} + +func (m *Model) viewRecent() string { + items := make([]string, 0, len(m.history)) + for i, track := range m.history { + number := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) + body := sStatBody.Render(track) + items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, number, body)) + } + l := lipgloss.JoinVertical(lipgloss.Left, items...) + title := sStatTitle.Render("Recently Played") + + return sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l)) +} + +func (m *Model) viewTopStat(topStat topStat) string { + items := make([]string, 0, len(topStat.entries)) + for i, stat := range topStat.entries { + number := sStatEnum.Render(fmt.Sprintf("%d.", i+1)) + body := sStatBody.Render(stat.name) + amount := sStatAmount.Render(fmt.Sprintf("%d", stat.amount)) + items = append(items, lipgloss.JoinHorizontal(lipgloss.Top, number, body, amount)) + } + l := lipgloss.JoinVertical(lipgloss.Left, items...) + + title := sStatTitle.Render(topStat.title) + + return sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l)) +} diff --git a/tui/view/tap/style.go b/tui/view/tap/style.go new file mode 100644 index 0000000..d72b0e4 --- /dev/null +++ b/tui/view/tap/style.go @@ -0,0 +1,50 @@ +package tap + +import "github.com/charmbracelet/lipgloss" + +var base = lipgloss.NewStyle() + +// Width +var ( + widthAmount = 5 + widthCategory = 8 + widthLast = 13 +) + +// Margin +var mStats = 2 + +// Barchart +var ( + widthBar = 40 + heightBar = 20 +) + +// Colors +var ( + cMate = lipgloss.Color("#D27D2D") + cSoft = lipgloss.Color("#ADD8E6") + cBeer = lipgloss.Color("#F9B116") + cFood = lipgloss.Color("#00ff00") + + cBorder = lipgloss.Color("#383838") + cStatsTitle = lipgloss.Color("#EE4B2B") +) + +// Styles Chart +var ( + sMate = base.Foreground(cMate) + sSoft = base.Foreground(cSoft) + sBeer = base.Foreground(cBeer) + sFood = base.Foreground(cFood) + sUnknown = base +) + +// Styles stats +var ( + sStats = base.Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(cBorder).MarginLeft(mStats).PaddingLeft(mStats) + sStatsTitle = base.Foreground(cStatsTitle).Bold(true).Width(widthAmount+widthCategory+widthLast).Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(cBorder) + sStatsAmount = base.Width(widthAmount).Bold(true) + sStatsCategory = base.Width(widthCategory) + sStatsLast = base.Width(widthLast).Align(lipgloss.Right).Italic(true).Faint(true) +) diff --git a/tui/view/tap/tap.go b/tui/view/tap/tap.go new file mode 100644 index 0000000..499b030 --- /dev/null +++ b/tui/view/tap/tap.go @@ -0,0 +1,169 @@ +// Package tap provides the functions to draw an overview of the recent tap orders on a TUI +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/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, +} + +// Model represents the tap model +type Model struct { + db *db.DB + lastOrderID int32 + items []tapItem +} + +// Msg represents a tap message +type Msg struct { + lastOrderID int32 + items []tapItem +} + +type tapItem struct { + category category + amount int + last time.Time +} + +// NewModel creates a new tap model +func NewModel(db *db.DB) view.View { + return &Model{db: db, lastOrderID: -1} +} + +// 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 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 + } + + return m, nil +} + +// View returns the tap view +func (m *Model) View() string { + chart := m.viewChart() + stats := m.viewStats() + + // Give them same height + stats = sStats.Height(lipgloss.Height(chart)).Render(stats) + + // Join them together + view := lipgloss.JoinHorizontal(lipgloss.Top, chart, stats) + return view +} + +// GetUpdateDatas returns all the update functions for the tap model +func (m *Model) GetUpdateDatas() []view.UpdateData { + return []view.UpdateData{ + { + Name: "tap orders", + View: m, + Update: updateOrders, + Interval: config.GetDefaultInt("tui.view.tap.interval_s", 60), + }, + } +} + +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/view.go b/tui/view/tap/view.go new file mode 100644 index 0000000..65433ae --- /dev/null +++ b/tui/view/tap/view.go @@ -0,0 +1,55 @@ +package tap + +import ( + "strconv" + + "github.com/NimbleMarkets/ntcharts/barchart" + "github.com/charmbracelet/lipgloss" +) + +func (m *Model) viewChart() string { + chart := barchart.New(widthBar, heightBar) + bars := make([]barchart.BarData, 0, len(m.items)) + + for _, item := range m.items { + style, ok := categoryToStyle[item.category] + if !ok { + style = sUnknown + } + + bars = append(bars, barchart.BarData{ + Label: string(item.category), + Values: []barchart.BarValue{{ + Name: string(item.category), + Value: float64(item.amount), + Style: style, + }}, + }) + } + + chart.PushAll(bars) + chart.Draw() + + return chart.View() +} + +func (m *Model) viewStats() string { + rows := make([]string, 0, len(m.items)) + + for _, item := range m.items { + amount := sStatsAmount.Render(strconv.Itoa(item.amount)) + category := sStatsCategory.Inherit(categoryToStyle[item.category]).Render(string(item.category)) + last := sStatsLast.Render(item.last.Format("15:04 02/01")) + + text := lipgloss.JoinHorizontal(lipgloss.Top, amount, category, last) + rows = append(rows, text) + } + + view := lipgloss.JoinVertical(lipgloss.Left, rows...) + + // Add title + title := sStatsTitle.Render("Leaderboard") + view = lipgloss.JoinVertical(lipgloss.Left, title, view) + + return view +} diff --git a/tui/view/util.go b/tui/view/util.go new file mode 100644 index 0000000..d3fb57e --- /dev/null +++ b/tui/view/util.go @@ -0,0 +1,64 @@ +package view + +import ( + "image" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/disintegration/imaging" + "github.com/lucasb-eyer/go-colorful" +) + +// ImagetoString converts an image to a +// The height gets resized according to the aspect ratio +func ImagetoString(width int, img image.Image) string { + img = imaging.Resize(img, width, 0, imaging.Lanczos) + b := img.Bounds() + imageWidth := b.Max.X + h := b.Max.Y + str := strings.Builder{} + + for heightCounter := 0; heightCounter < h; heightCounter += 2 { + for x := imageWidth; x < width; x += 2 { + str.WriteString(" ") + } + + for x := 0; x < imageWidth; x++ { + c1, _ := colorful.MakeColor(img.At(x, heightCounter)) + color1 := lipgloss.Color(c1.Hex()) + c2, _ := colorful.MakeColor(img.At(x, heightCounter+1)) + color2 := lipgloss.Color(c2.Hex()) + + style := lipgloss.NewStyle().Foreground(color1) + // Prevent a dark line at the bottom for specific heights + if heightCounter != h-1 || heightCounter%2 != 0 { + style = style.Background(color2) + } + str.WriteString(style.Render("▀")) + } + + str.WriteString("\n") + } + + return str.String() +} + +// GetOuterWidth returns the outer border size of a lipgloss Style +func GetOuterWidth(style lipgloss.Style) int { + return style.GetHorizontalFrameSize() + style.GetHorizontalPadding() +} + +// GetWidth returns the inner width of a lipgloss Style +func GetWidth(style lipgloss.Style) int { + return style.GetWidth() - GetOuterWidth(style) +} + +// GetOuterHeight returns the outer border size of a lipgloss Style +func GetOuterHeight(style lipgloss.Style) int { + return style.GetVerticalFrameSize() + style.GetVerticalPadding() +} + +// GetHeight returns the inner width of a lipgloss Style +func GetHeight(style lipgloss.Style) int { + return style.GetHeight() - GetOuterHeight(style) +} diff --git a/tui/view/view.go b/tui/view/view.go new file mode 100644 index 0000000..fa57bcc --- /dev/null +++ b/tui/view/view.go @@ -0,0 +1,35 @@ +// Package view contains all the different views for the tui +package view + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// UpdateData represents the data needed to update a view +type UpdateData struct { + Name string + View View + Update func(view View) (tea.Msg, error) + Interval int +} + +// View represents a view +type View interface { + Init() tea.Cmd + Name() string + Update(tea.Msg) (View, tea.Cmd) + View() string + GetUpdateDatas() []UpdateData +} + +// MsgSize is a message to let a view know its size +// The key of Sizes is the name of a view +type MsgSize struct { + Sizes map[string]Size +} + +// Size contains the size data +type Size struct { + Width int + Height int +} diff --git a/tui/view/zess/style.go b/tui/view/zess/style.go new file mode 100644 index 0000000..fe6f3ad --- /dev/null +++ b/tui/view/zess/style.go @@ -0,0 +1,69 @@ +package zess + +import "github.com/charmbracelet/lipgloss" + +var base = lipgloss.NewStyle() + +// Width +var ( + widthAmount = 5 + widthWeek = 8 +) + +// Margin +var mOverview = 2 + +// Barchart +var ( + widthBar = 60 + heightBar = 20 +) + +// Colors +var ( + cBorder = lipgloss.Color("#383838") + cZeus = lipgloss.Color("#FF7F00") + cStatsTitle = lipgloss.Color("#EE4B2B") +) + +// Message colors +var colors = []string{ + "#FAF500", // Yellow + "#3AFA00", // Green + "#FAD700", // Yellow Green + "#FAA600", // Orange + "#FAE200", // Yellow Orange + "#FA7200", // Orange Red + "#FA4600", // Red + "#FA0400", // Real Red + "#FA0079", // Pink Red + "#FA00FA", // Pink + "#EE00FA", // Purple + "#8300FA", // Purple Blue + "#3100FA", // Blue + "#00FAFA", // Light Blue + "#00FAA5", // Green Blue + "#00FA81", // IDK + "#F8FA91", // Weird Light Green + "#FAD392", // Light Orange + "#FA9E96", // Salmon + "#DEA2F9", // Fuchsia + "#B3D2F9", // Boring Blue +} + +// Styles chart +var ( + sBar = base +) + +// Styles stats +var ( + sStats = base.Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(cBorder).MarginLeft(mOverview).PaddingLeft(mOverview) + sStatsTitle = base.Foreground(cStatsTitle).Bold(true).Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(cBorder).Width(widthAmount + widthWeek).Align(lipgloss.Center) + sStatsWeek = base.Width(widthWeek) + sStatsAmount = base.Bold(true).Width(widthAmount).Align(lipgloss.Right) + sStatsAmountMax = sStatsAmount.Foreground(cZeus) + sStatsTotal = base.Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(cBorder).MarginTop(1) + sStatsTotalTitle = sStatsWeek + sStatsTotalAmount = sStatsAmount +) diff --git a/tui/view/zess/view.go b/tui/view/zess/view.go new file mode 100644 index 0000000..6828146 --- /dev/null +++ b/tui/view/zess/view.go @@ -0,0 +1,63 @@ +package zess + +import ( + "strconv" + + "github.com/NimbleMarkets/ntcharts/barchart" + "github.com/charmbracelet/lipgloss" +) + +func (m *Model) viewChart() string { + chart := barchart.New(widthBar, heightBar) + + for _, scan := range m.scans { + bar := barchart.BarData{ + Label: scan.label, + Values: []barchart.BarValue{{ + Name: scan.label, + Value: float64(scan.amount), + Style: sBar.Foreground(lipgloss.Color(scan.color)), + }}, + } + + chart.Push(bar) + } + + chart.Draw() + + return chart.View() +} + +func (m *Model) viewStats() string { + // Overview of each week + rows := make([]string, 0, len(m.scans)) + + for _, scan := range m.scans { + week := sStatsWeek.Render(scan.label) + + var amount string + if scan.amount == m.maxWeekScans { + amount = sStatsAmountMax.Render(strconv.Itoa(int(scan.amount))) + } else { + amount = sStatsAmount.Render(strconv.Itoa(int(scan.amount))) + } + + text := lipgloss.JoinHorizontal(lipgloss.Top, week, amount) + rows = append(rows, text) + } + + view := lipgloss.JoinVertical(lipgloss.Left, rows...) + + // Title + title := sStatsTitle.Render("Overview") + + // Total scans + total := sStatsTotalTitle.Render("Total") + amount := sStatsTotalAmount.Render(strconv.Itoa(int(m.seasonScans))) + total = lipgloss.JoinHorizontal(lipgloss.Top, total, amount) + total = sStatsTotal.Render(total) + + view = lipgloss.JoinVertical(lipgloss.Left, title, view, total) + + return view +} diff --git a/tui/view/zess/zess.go b/tui/view/zess/zess.go new file mode 100644 index 0000000..a8d8c9d --- /dev/null +++ b/tui/view/zess/zess.go @@ -0,0 +1,289 @@ +// Package zess provides the functions to draw an overview of the zess scans on a TUI +package zess + +import ( + "context" + "math/rand/v2" + + 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/pkg/config" + "github.com/zeusWPI/scc/tui/view" + "go.uber.org/zap" +) + +// yearWeek represents a yearWeek object by keeping the year and week number +type yearWeek struct { + year int + week int +} + +type weekScan struct { + time yearWeek + amount int64 + label string + 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 +} + +// Msg is the base message to indicate that something changed in the zess view +type Msg struct{} + +// scanMsg is used to indicate that the zess view should be updated with new scans +type scanMsg struct { + Msg + lastScanID int32 + scans []weekScan +} + +// seasonMsg is used to indicate that the current season changed. +type seasonMsg struct { + Msg + start yearWeek +} + +// NewModel creates a new zess model view +func NewModel(db *db.DB) 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 + } + _, _ = 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) { + // 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 + } + + return m, nil +} + +// View returns the view for the zess model +func (m *Model) View() string { + chart := m.viewChart() + overview := m.viewStats() + + // Give them the same height + overview = sStats.Height(lipgloss.Height(chart)).Render(overview) + + // Join them together + view := lipgloss.JoinHorizontal(lipgloss.Top, chart, overview) + return view +} + +// GetUpdateDatas returns all the update functions for the zess model +func (m *Model) GetUpdateDatas() []view.UpdateData { + return []view.UpdateData{ + { + Name: "zess scans", + 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), + }, + } +} + +// 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, + label: 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))] +} diff --git a/utils/utils.go b/utils/utils.go deleted file mode 100644 index 4ce3637..0000000 --- a/utils/utils.go +++ /dev/null @@ -1,48 +0,0 @@ -package utils - -import ( - "math/rand/v2" - "time" -) - -func RandRange(min, max int) int { - return rand.IntN(max-min) + min -} - -func TimeAndDateFormat() string { - currentTime := time.Now() - formattedTime := currentTime.Format("15:04 02/01") - - return formattedTime -} - -func isLeapYear(year int) bool { - return (year%4 == 0 && year%100 != 0) || (year%400 == 0) -} - -func GetDayDifference(day1 int, date2 time.Time) int { - daysInYear := 365 - if isLeapYear(date2.Year()) { - daysInYear = 366 - } - - return (date2.YearDay() - day1 + daysInYear) % daysInYear -} - -func ShiftSliceBackward[T any](slice []T) []T { - newSlice := make([]T, len(slice)-1, cap(slice)) - copy(newSlice, slice[1:]) - - return newSlice -} - -func AddSliceElement[T any](slice []T, element T) []T { - if len(slice) >= cap(slice) { - // Array is max size, shift everything - slice = ShiftSliceBackward(slice) - } - - slice = append(slice, element) - - return slice -}