From 18f5a9ee2d1748e23eb0b551bf53f397dc56f4bf Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 13 Nov 2024 16:25:46 +0100 Subject: [PATCH 01/46] chore: template --- .air.toml | 46 +++++++++ .githooks/pre-commit | 8 ++ .github/workflows/go.yml | 28 ----- .github/workflows/golangci-lint.yml | 30 ++++++ .github/workflows/sqlc-diff.yml | 12 +++ .gitignore | 22 ++-- .golangci.yml | 35 +++++++ .tool-versions | 2 +- README.md | 19 +++- api/api.go | 41 -------- api/cammie.go | 84 --------------- api/common.go | 40 ------- api/spotify.go | 119 --------------------- api/tap.go | 42 -------- api/zess.go | 42 -------- buzzer/buzzer.go | 41 -------- config.example.yaml | 30 ------ config.template.yaml | 16 --- config/config.go | 62 ----------- go.mod | 47 +-------- go.sum | 155 ---------------------------- main.go | 27 ----- makefile | 39 +++++++ restart.sh | 8 -- run-forever.sh | 10 -- screen/cammie.go | 87 ---------------- screen/screen.go | 64 ------------ screen/spotify.go | 72 ------------- screen/tap.go | 119 --------------------- screen/zess.go | 83 --------------- sqlc.yml | 11 ++ utils/utils.go | 48 --------- 32 files changed, 213 insertions(+), 1276 deletions(-) create mode 100644 .air.toml create mode 100644 .githooks/pre-commit delete mode 100644 .github/workflows/go.yml create mode 100644 .github/workflows/golangci-lint.yml create mode 100644 .github/workflows/sqlc-diff.yml create mode 100644 .golangci.yml delete mode 100644 api/api.go delete mode 100644 api/cammie.go delete mode 100644 api/common.go delete mode 100644 api/spotify.go delete mode 100644 api/tap.go delete mode 100644 api/zess.go delete mode 100644 buzzer/buzzer.go delete mode 100644 config.example.yaml delete mode 100644 config.template.yaml delete mode 100644 config/config.go delete mode 100644 go.sum delete mode 100644 main.go create mode 100644 makefile delete mode 100755 restart.sh delete mode 100755 run-forever.sh delete mode 100644 screen/cammie.go delete mode 100644 screen/screen.go delete mode 100644 screen/spotify.go delete mode 100644 screen/tap.go delete mode 100644 screen/zess.go create mode 100644 sqlc.yml delete mode 100644 utils/utils.go diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..7775d4b --- /dev/null +++ b/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "scc" + cmd = "make build" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..8f175aa --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,8 @@ +#!/bin/bash + +echo "Backend 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..636b3c8 --- /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@v3 + - uses: sqlc-dev/setup-sqlc@v3 + with: + sqlc-version: '1.26.0' + - run: sqlc diff diff --git a/.gitignore b/.gitignore index 446b662..829a05a 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,19 @@ # Go workspace file go.work +tmp/ -tmp.go +# IDE specific files +.vscode +.idea -config.yaml +# .env file +.env +.env.* +!.env.example -# Binary +# Project build scc -# Log file -scc.log + +.data/ +/public/ 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..96d5b4f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,20 @@ # Screen Cammie Chat -Go 1.22.0 +Displays the cammie chat along with some other statistics. -- `go mod tidy` +## Development -- `go run .` of `go build .` \ No newline at end of file +Check [.tool-versions](.tool-versions) for the current used version of golang + +- Install pre-commit hooks `git config --local core.hooksPath .githooks/`. +- Install goose `go install github.com/pressly/goose/v3/cmd/goose@latest`. +- Install sqlc `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest` +- Install air `go install github.com/air-verse/air@latest` + +## Build + +- `make` + +## Run + +- `make run` 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/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/go.mod b/go.mod index adb0dd5..b1c9008 100644 --- a/go.mod +++ b/go.mod @@ -1,46 +1,3 @@ -module scc +module github.com/zeusWPI/scc -go 1.22.0 - -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 -) - -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/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/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 -) - -replace github.com/navidys/tvxwidgets => github.com/topvennie/tvxwidgets v0.7.3 +go 1.23.1 diff --git a/go.sum b/go.sum deleted file mode 100644 index 07e0a59..0000000 --- a/go.sum +++ /dev/null @@ -1,155 +0,0 @@ -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/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/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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -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/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/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/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/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/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/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= -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/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/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/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-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.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/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/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.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/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/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= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -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/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..e870d7c --- /dev/null +++ b/makefile @@ -0,0 +1,39 @@ +all: build + +build: clean + @go build -o scc cmd/tty/scc.go + +run: + @go run cmd/tty/scc.go + +clean: + @rm -f scc + +sqlc: + sqlc generate + +create-migration: + @read -p "Enter migration name: " name; \ + goose -dir ./db/migrations create $$name sql + +goose: + @read -p "Action: " action; \ + goose -dir ./db/migrations postgres "user=postgres password=postgres dbname=adoca host=localhost sslmode=disable" $$action + +migrate: + @goose -dir ./db/migrations postgres "user=postgres password=postgres dbname=adoca host=localhost sslmode=disable" up + +watch: + @if command -v air > /dev/null; then \ + air; \ + else \ + read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ + if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ + go install github.com/air-verse/air@latest; \ + air; \ + echo "Watching...";\ + else \ + echo "You chose not to install air. Exiting..."; \ + exit 1; \ + fi; \ + fi diff --git a/restart.sh b/restart.sh deleted file mode 100755 index d5f268d..0000000 --- a/restart.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -cd "$(dirname "${BASH_SOURCE[0]}")" - -go mod tidy -go build - -pkill scc diff --git a/run-forever.sh b/run-forever.sh deleted file mode 100755 index 593fbf1..0000000 --- a/run-forever.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -cd "$(dirname "${BASH_SOURCE[0]}")" - -while true -do - PORT=8888 ./scc - echo 'scc has quit! restarting in 1 second' - sleep 1 -done \ No newline at end of file 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/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 -} From f7a89a12359f5ddad4a911fe82f26a2f4ff5a909 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Nov 2024 12:25:03 +0100 Subject: [PATCH 02/46] chore: template --- .env.example | 1 + .gitignore | 3 ++ README.md | 9 ++-- cmd/tty/scc.go | 7 ++++ config/development.toml | 12 ++++++ config/production.toml | 0 go.mod | 28 +++++++++++++ go.sum | 60 +++++++++++++++++++++++++++ internal/cmd/root.go | 18 ++++++++ pkg/config/config.go | 91 +++++++++++++++++++++++++++++++++++++++++ pkg/logger/logger.go | 31 ++++++++++++++ 11 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 cmd/tty/scc.go create mode 100644 config/development.toml create mode 100644 config/production.toml create mode 100644 go.sum create mode 100644 internal/cmd/root.go create mode 100644 pkg/config/config.go create mode 100644 pkg/logger/logger.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8b9e46e --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +app.env = development diff --git a/.gitignore b/.gitignore index 829a05a..79f32e2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ scc .data/ /public/ + +# Log files +logs/ diff --git a/README.md b/README.md index 96d5b4f..96852b3 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,13 @@ Check [.tool-versions](.tool-versions) for the current used version of golang - Install sqlc `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest` - Install air `go install github.com/air-verse/air@latest` -## Build +Create a `.env` (Look at [.env.example](.env.example])). -- `make` +Start developing with `make watch`. + +Logs will be logged to the `./logs` directory (will be made at the start of the first run). -## Run +## Build & Run +- `make` - `make run` diff --git a/cmd/tty/scc.go b/cmd/tty/scc.go new file mode 100644 index 0000000..3c51caa --- /dev/null +++ b/cmd/tty/scc.go @@ -0,0 +1,7 @@ +package main + +import "github.com/zeusWPI/scc/internal/cmd" + +func main() { + cmd.Execute() +} diff --git a/config/development.toml b/config/development.toml new file mode 100644 index 0000000..c7ab8c8 --- /dev/null +++ b/config/development.toml @@ -0,0 +1,12 @@ +[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", +] diff --git a/config/production.toml b/config/production.toml new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod index b1c9008..3ea709b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,31 @@ module github.com/zeusWPI/scc go 1.23.1 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/spf13/viper v1.19.0 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // 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 + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..04400ba --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +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/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/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/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.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/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= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +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/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/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= diff --git a/internal/cmd/root.go b/internal/cmd/root.go new file mode 100644 index 0000000..cccc574 --- /dev/null +++ b/internal/cmd/root.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/logger" + "go.uber.org/zap" +) + +// Execute starts the entire application +func Execute() { + err := config.Init() + if err != nil { + panic(err) + } + + zapLogger := logger.New() + zap.ReplaceGlobals(zapLogger) +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..1d172d7 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,91 @@ +// 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, ".", "_")) + // nolint:errcheck // we do not care if it can get binded + 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.toml", env)) + viper.SetConfigType("toml") + 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/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..fb66222 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,31 @@ +// Package logger provides a logger instance +package logger + +import ( + "os" + + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +// New returns a new logger instance +func New() *zap.Logger { + // Create logs directory + os.Mkdir("logs", os.ModePerm) + + // Create logger + var zapConfig zap.Config + env := config.GetDefaultString("app.env", "development") + if env == "development" { + zapConfig = zap.NewDevelopmentConfig() + } else { + zapConfig = zap.NewProductionConfig() + } + zapConfig.OutputPaths = []string{"logs/scc.log"} + zapConfig.ErrorOutputPaths = []string{"logs/scc.log"} + + logger := zap.Must(zapConfig.Build()) + defer logger.Sync() + + return logger +} From 91cbc615a79178b4cbd29c6ce3f0f13fbf4a8d1d Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Nov 2024 12:29:42 +0100 Subject: [PATCH 03/46] feat: buzzer --- .githooks/pre-commit | 0 cmd/tty/scc.go | 1 + internal/cmd/root.go | 6 ++++- internal/pkg/buzzer/buzzer.go | 45 +++++++++++++++++++++++++++++++++++ pkg/logger/logger.go | 10 ++++---- 5 files changed, 57 insertions(+), 5 deletions(-) mode change 100644 => 100755 .githooks/pre-commit create mode 100644 internal/pkg/buzzer/buzzer.go diff --git a/.githooks/pre-commit b/.githooks/pre-commit old mode 100644 new mode 100755 diff --git a/cmd/tty/scc.go b/cmd/tty/scc.go index 3c51caa..38b1424 100644 --- a/cmd/tty/scc.go +++ b/cmd/tty/scc.go @@ -1,3 +1,4 @@ +// Main entry point for the scc application package main import "github.com/zeusWPI/scc/internal/cmd" diff --git a/internal/cmd/root.go b/internal/cmd/root.go index cccc574..30aa0bb 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -1,3 +1,4 @@ +// Package cmd package contains all the commands to start the application package cmd import ( @@ -13,6 +14,9 @@ func Execute() { panic(err) } - zapLogger := logger.New() + zapLogger, err := logger.New() + if err != nil { + panic(err) + } zap.ReplaceGlobals(zapLogger) } diff --git a/internal/pkg/buzzer/buzzer.go b/internal/pkg/buzzer/buzzer.go new file mode 100644 index 0000000..ab19a3b --- /dev/null +++ b/internal/pkg/buzzer/buzzer.go @@ -0,0 +1,45 @@ +// 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 { + song := config.GetDefaultStringSlice("buzzer.song", defaultSong) + return &Buzzer{ + Song: song, + } +} + +// 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/pkg/logger/logger.go b/pkg/logger/logger.go index fb66222..b38ea9a 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -9,9 +9,12 @@ import ( ) // New returns a new logger instance -func New() *zap.Logger { +func New() (*zap.Logger, error) { // Create logs directory - os.Mkdir("logs", os.ModePerm) + err := os.Mkdir("logs", os.ModePerm) + if err != nil && !os.IsExist(err) { + return nil, err + } // Create logger var zapConfig zap.Config @@ -25,7 +28,6 @@ func New() *zap.Logger { zapConfig.ErrorOutputPaths = []string{"logs/scc.log"} logger := zap.Must(zapConfig.Build()) - defer logger.Sync() - return logger + return logger, nil } From e416983e540c1d28196785b9ab93b7712aa3d2c8 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Nov 2024 12:35:12 +0100 Subject: [PATCH 04/46] feat: add bash run scripts --- restart.sh | 8 ++++++++ run-forever.sh | 10 ++++++++++ 2 files changed, 18 insertions(+) create mode 100755 restart.sh create mode 100755 run-forever.sh diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000..d5f268d --- /dev/null +++ b/restart.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +cd "$(dirname "${BASH_SOURCE[0]}")" + +go mod tidy +go build + +pkill scc diff --git a/run-forever.sh b/run-forever.sh new file mode 100755 index 0000000..593fbf1 --- /dev/null +++ b/run-forever.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +cd "$(dirname "${BASH_SOURCE[0]}")" + +while true +do + PORT=8888 ./scc + echo 'scc has quit! restarting in 1 second' + sleep 1 +done \ No newline at end of file From 1111346a6d93a4e466ed56cbcd5a97ae4ad67c5b Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Nov 2024 14:40:54 +0100 Subject: [PATCH 05/46] chore: add db template --- internal/pkg/db/db.go | 34 ++++++++++++++++++++++++++++++++++ internal/pkg/db/dto/dto.go | 7 +++++++ internal/pkg/db/sqlc/db.go | 31 +++++++++++++++++++++++++++++++ sqlc.yml | 3 +-- 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 internal/pkg/db/db.go create mode 100644 internal/pkg/db/dto/dto.go create mode 100644 internal/pkg/db/sqlc/db.go diff --git a/internal/pkg/db/db.go b/internal/pkg/db/db.go new file mode 100644 index 0000000..36263b7 --- /dev/null +++ b/internal/pkg/db/db.go @@ -0,0 +1,34 @@ +// Package db provides a database connection +package db + +import ( + "database/sql" + + _ "github.com/mattn/go-sqlite3" // SQLite driver + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + "github.com/zeusWPI/scc/pkg/config" +) + +// DB represents a database connection +type DB struct { + DB *sql.DB + Queries *sqlc.Queries +} + +// New creates a new database connection +func New() (*DB, error) { + dbPath := config.GetDefaultString("db.path", "./sqlite.db") + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, err + } + + if err := db.Ping(); err != nil { + return nil, err + } + + queries := sqlc.New(db) + + return &DB{DB: db, 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..9382061 --- /dev/null +++ b/internal/pkg/db/dto/dto.go @@ -0,0 +1,7 @@ +// 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/sqlc/db.go b/internal/pkg/db/sqlc/db.go new file mode 100644 index 0000000..2248616 --- /dev/null +++ b/internal/pkg/db/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sqlc + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/sqlc.yml b/sqlc.yml index a4821a6..2fe8046 100644 --- a/sqlc.yml +++ b/sqlc.yml @@ -1,6 +1,6 @@ version: "2" sql: - - engine: "postgresql" + - engine: "sqlite" queries: - "db/queries/*.sql" schema: "db/migrations" @@ -8,4 +8,3 @@ sql: go: package: "sqlc" out: "internal/pkg/db/sqlc" - sql_package: "pgx/v5" From 910a88038440d064470ed8076bb3b872a5abb4a7 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Nov 2024 14:41:02 +0100 Subject: [PATCH 06/46] feat: add messages api --- .../20241114125504_add_messages_table.sql | 15 ++ db/queries/message.sql | 25 ++++ go.mod | 29 +++- go.sum | 81 +++++++++-- internal/api/api.go | 13 ++ internal/api/message/message.go | 64 +++++++++ internal/cmd/api.go | 36 +++++ internal/cmd/root.go | 8 ++ internal/pkg/db/dto/message.go | 36 +++++ internal/pkg/db/sqlc/message.sql.go | 135 ++++++++++++++++++ internal/pkg/db/sqlc/models.go | 17 +++ pkg/util/slice.go | 11 ++ 12 files changed, 458 insertions(+), 12 deletions(-) create mode 100644 db/migrations/20241114125504_add_messages_table.sql create mode 100644 db/queries/message.sql create mode 100644 internal/api/api.go create mode 100644 internal/api/message/message.go create mode 100644 internal/cmd/api.go create mode 100644 internal/pkg/db/dto/message.go create mode 100644 internal/pkg/db/sqlc/message.sql.go create mode 100644 internal/pkg/db/sqlc/models.go create mode 100644 pkg/util/slice.go diff --git a/db/migrations/20241114125504_add_messages_table.sql b/db/migrations/20241114125504_add_messages_table.sql new file mode 100644 index 0000000..2777928 --- /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 INTEGER PRIMARY KEY, + name TEXT NOT NULL, + ip TEXT NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS message; +-- +goose StatementEnd diff --git a/db/queries/message.sql b/db/queries/message.sql new file mode 100644 index 0000000..727583e --- /dev/null +++ b/db/queries/message.sql @@ -0,0 +1,25 @@ +-- CRUD + +-- name: GetAllMessages :many +SELECT * +FROM message; + +-- name: GetMessageByID :one +SELECT * +FROM message +WHERE id = ?; + +-- name: CreateMessage :one +INSERT INTO message (name, ip, message) +VALUES (?, ?, ?) +RETURNING *; + +-- name: UpdateMessage :one +UPDATE message +SET name = ?, ip = ?, message = ? +WHERE id = ? +RETURNING *; + +-- name: DeleteMessage :execrows +DELETE FROM message +WHERE id = ?; diff --git a/go.mod b/go.mod index 3ea709b..738cd96 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,34 @@ require ( github.com/spf13/viper v1.19.0 ) +require ( + github.com/andybalholm/brotli v1.0.5 // 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/google/uuid v1.5.0 // indirect + github.com/klauspost/compress v1.17.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.4.3 // 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 + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/net v0.23.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) + require ( github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-playground/validator/v10 v10.22.1 + github.com/gofiber/contrib/fiberzap v1.0.2 + github.com/gofiber/fiber/v2 v2.52.5 github.com/hashicorp/hcl v1.0.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-sqlite3 v1.14.24 github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect @@ -20,12 +44,11 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.27.0 golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 04400ba..6863115 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,69 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/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.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= +github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +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/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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/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/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-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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/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/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 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -31,28 +82,40 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ 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.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +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= +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.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/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 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= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..e51fdf3 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,13 @@ +// Package api provides all the API endpoints +package api + +import ( + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/api/message" + "github.com/zeusWPI/scc/internal/pkg/db" +) + +// New creates a new API instance +func New(router fiber.Router, db *db.DB) { + message.New(router, db) +} diff --git a/internal/api/message/message.go b/internal/api/message/message.go new file mode 100644 index 0000000..cd57b47 --- /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", 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("Body parser", err) + return c.SendStatus(fiber.StatusBadRequest) + } + + if err := dto.Validate.Struct(message); err != nil { + zap.S().Error("Validation", 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", err) + return c.SendStatus(fiber.StatusInternalServerError) + } + + return c.Status(fiber.StatusOK).JSON(dto.MessageDTO(messageDB)) +} diff --git a/internal/cmd/api.go b/internal/cmd/api.go new file mode 100644 index 0000000..bf3165d --- /dev/null +++ b/internal/cmd/api.go @@ -0,0 +1,36 @@ +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/pkg/config" + "go.uber.org/zap" +) + +func apiCmd(db *db.DB) { + 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) + + host := config.GetDefaultString("server.host", "127.0.0.1") + port := config.GetDefaultInt("server.port", 8080) + + zap.S().Fatal("API: Fatal server error", app.Listen(fmt.Sprintf("%s:%d", host, port))) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 30aa0bb..9c42cda 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( + "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/logger" "go.uber.org/zap" @@ -19,4 +20,11 @@ func Execute() { panic(err) } zap.ReplaceGlobals(zapLogger) + + db, err := db.New() + if err != nil { + zap.S().Fatal("DB: Fatal error", err) + } + + apiCmd(db) } diff --git a/internal/pkg/db/dto/message.go b/internal/pkg/db/dto/message.go new file mode 100644 index 0000000..fa20de9 --- /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 int64 `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, + } +} + +// 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/sqlc/message.sql.go b/internal/pkg/db/sqlc/message.sql.go new file mode 100644 index 0000000..24b2306 --- /dev/null +++ b/internal/pkg/db/sqlc/message.sql.go @@ -0,0 +1,135 @@ +// 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 (?, ?, ?) +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.QueryRowContext(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 = ? +` + +func (q *Queries) DeleteMessage(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteMessage, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +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.QueryContext(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.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMessageByID = `-- name: GetMessageByID :one +SELECT id, name, ip, message, created_at +FROM message +WHERE id = ? +` + +func (q *Queries) GetMessageByID(ctx context.Context, id int64) (Message, error) { + row := q.db.QueryRowContext(ctx, getMessageByID, id) + var i Message + err := row.Scan( + &i.ID, + &i.Name, + &i.Ip, + &i.Message, + &i.CreatedAt, + ) + return i, err +} + +const updateMessage = `-- name: UpdateMessage :one +UPDATE message +SET name = ?, ip = ?, message = ? +WHERE id = ? +RETURNING id, name, ip, message, created_at +` + +type UpdateMessageParams struct { + Name string + Ip string + Message string + ID int64 +} + +func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) (Message, error) { + row := q.db.QueryRowContext(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..01fdc23 --- /dev/null +++ b/internal/pkg/db/sqlc/models.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sqlc + +import ( + "time" +) + +type Message struct { + ID int64 + Name string + Ip string + Message string + CreatedAt time.Time +} diff --git a/pkg/util/slice.go b/pkg/util/slice.go new file mode 100644 index 0000000..2c50209 --- /dev/null +++ b/pkg/util/slice.go @@ -0,0 +1,11 @@ +// 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 +} From 715ae2a695f30c6ae55071dfe82702754bef25b6 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Nov 2024 14:53:39 +0100 Subject: [PATCH 07/46] chore: add & remove config options --- .gitignore | 3 +++ config/development.toml | 4 ++++ internal/cmd/api.go | 2 +- internal/pkg/db/db.go | 5 +---- makefile | 4 ++-- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 79f32e2..0c6ad57 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ scc # Log files logs/ + +# DB +*.db diff --git a/config/development.toml b/config/development.toml index c7ab8c8..33b3547 100644 --- a/config/development.toml +++ b/config/development.toml @@ -1,3 +1,7 @@ +[server] +host = "localhost" +port = 3000 + [buzzer] song = [ "-n", "-f880", "-l100", "-d0", diff --git a/internal/cmd/api.go b/internal/cmd/api.go index bf3165d..1e04757 100644 --- a/internal/cmd/api.go +++ b/internal/cmd/api.go @@ -30,7 +30,7 @@ func apiCmd(db *db.DB) { api.New(apiGroup, db) host := config.GetDefaultString("server.host", "127.0.0.1") - port := config.GetDefaultInt("server.port", 8080) + 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/pkg/db/db.go b/internal/pkg/db/db.go index 36263b7..6de1d04 100644 --- a/internal/pkg/db/db.go +++ b/internal/pkg/db/db.go @@ -6,7 +6,6 @@ import ( _ "github.com/mattn/go-sqlite3" // SQLite driver "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/config" ) // DB represents a database connection @@ -17,9 +16,7 @@ type DB struct { // New creates a new database connection func New() (*DB, error) { - dbPath := config.GetDefaultString("db.path", "./sqlite.db") - - db, err := sql.Open("sqlite3", dbPath) + db, err := sql.Open("sqlite3", "./sqlite.db") if err != nil { return nil, err } diff --git a/makefile b/makefile index e870d7c..1f404c9 100644 --- a/makefile +++ b/makefile @@ -18,10 +18,10 @@ create-migration: goose: @read -p "Action: " action; \ - goose -dir ./db/migrations postgres "user=postgres password=postgres dbname=adoca host=localhost sslmode=disable" $$action + goose -dir ./db/migrations sqlite3 ./sqlite.db $$action migrate: - @goose -dir ./db/migrations postgres "user=postgres password=postgres dbname=adoca host=localhost sslmode=disable" up + @goose -dir ./db/migrations sqlite3 ./sqlite.db up watch: @if command -v air > /dev/null; then \ From cc1cbe14b576d93820c69e9516b059dca42bd95d Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Nov 2024 16:16:35 +0100 Subject: [PATCH 08/46] feat: add spotify integration --- config/development.toml | 4 + .../20241114135818_add_spotify_table.sql | 16 ++ db/queries/spotify.sql | 33 ++++ internal/api/api.go | 5 +- internal/api/message/message.go | 10 +- internal/api/spotify/spotify.go | 56 ++++++ internal/cmd/api.go | 5 +- internal/cmd/root.go | 10 +- internal/pkg/db/dto/spotify.go | 39 ++++ internal/pkg/db/sqlc/models.go | 9 + internal/pkg/db/sqlc/spotify.sql.go | 169 ++++++++++++++++++ internal/pkg/spotify/account.go | 47 +++++ internal/pkg/spotify/api.go | 45 +++++ internal/pkg/spotify/spotify.go | 84 +++++++++ pkg/util/slice.go | 11 ++ 15 files changed, 533 insertions(+), 10 deletions(-) create mode 100644 db/migrations/20241114135818_add_spotify_table.sql create mode 100644 db/queries/spotify.sql create mode 100644 internal/api/spotify/spotify.go create mode 100644 internal/pkg/db/dto/spotify.go create mode 100644 internal/pkg/db/sqlc/spotify.sql.go create mode 100644 internal/pkg/spotify/account.go create mode 100644 internal/pkg/spotify/api.go create mode 100644 internal/pkg/spotify/spotify.go diff --git a/config/development.toml b/config/development.toml index 33b3547..ebb26a0 100644 --- a/config/development.toml +++ b/config/development.toml @@ -2,6 +2,10 @@ host = "localhost" port = 3000 +[spotify] +client_id = "your_client_id" +client_secret = "your_client_secret" + [buzzer] song = [ "-n", "-f880", "-l100", "-d0", diff --git a/db/migrations/20241114135818_add_spotify_table.sql b/db/migrations/20241114135818_add_spotify_table.sql new file mode 100644 index 0000000..acbee16 --- /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 INTEGER PRIMARY KEY, + title TEXT NOT NULL, + artists TEXT NOT NULL, + spotify_id TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS spotify; +-- +goose StatementEnd diff --git a/db/queries/spotify.sql b/db/queries/spotify.sql new file mode 100644 index 0000000..2074db9 --- /dev/null +++ b/db/queries/spotify.sql @@ -0,0 +1,33 @@ +-- CRUD + +-- name: GetAllSpotify :many +SELECT * +FROM spotify; + +-- name: GetSpotifyByID :one +SELECT * +FROM spotify +WHERE id = ?; + +-- name: CreateSpotify :one +INSERT INTO spotify (title, artists, spotify_id, duration_ms) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: UpdateSpotify :one +UPDATE spotify +SET title = ?, artists = ?, spotify_id = ?, duration_ms = ? +WHERE id = ? +RETURNING *; + +-- name: DeleteSpotify :execrows +DELETE FROM spotify +WHERE id = ?; + + +-- Other + +-- name: GetSpotifyBySpotifyID :one +SELECT * +FROM spotify +WHERE spotify_id = ?; diff --git a/internal/api/api.go b/internal/api/api.go index e51fdf3..e78b6cd 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -4,10 +4,13 @@ package api import ( "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/api/message" + apiSpotify "github.com/zeusWPI/scc/internal/api/spotify" "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/spotify" ) // New creates a new API instance -func New(router fiber.Router, db *db.DB) { +func New(router fiber.Router, db *db.DB, spotify *spotify.Spotify) { message.New(router, db) + apiSpotify.New(router, db, spotify) } diff --git a/internal/api/message/message.go b/internal/api/message/message.go index cd57b47..9861569 100644 --- a/internal/api/message/message.go +++ b/internal/api/message/message.go @@ -34,7 +34,7 @@ func (r *Router) createRoutes() { 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", err) + zap.S().Error("DB: Get all messages\n", err) return c.SendStatus(fiber.StatusInternalServerError) } @@ -45,20 +45,20 @@ func (r *Router) create(c *fiber.Ctx) error { message := new(dto.Message) if err := c.BodyParser(message); err != nil { - zap.S().Error("Body parser", err) + 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("Validation", err) + 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", err) + zap.S().Error("DB: Create message\n", err) return c.SendStatus(fiber.StatusInternalServerError) } - return c.Status(fiber.StatusOK).JSON(dto.MessageDTO(messageDB)) + return c.Status(fiber.StatusCreated).JSON(dto.MessageDTO(messageDB)) } diff --git a/internal/api/spotify/spotify.go b/internal/api/spotify/spotify.go new file mode 100644 index 0000000..c9a0bd4 --- /dev/null +++ b/internal/api/spotify/spotify.go @@ -0,0 +1,56 @@ +// Package spotify provides the API regarding spotify integration +package spotify + +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/spotify" + "go.uber.org/zap" +) + +// Router is the spotify API router +type Router struct { + router fiber.Router + db *db.DB + spotify *spotify.Spotify +} + +// New creates a new spotify API instance +func New(router fiber.Router, db *db.DB, spotify *spotify.Spotify) *Router { + api := &Router{ + router: router.Group("/spotify"), + db: db, + spotify: spotify, + } + api.createRoutes() + + return api +} + +func (r *Router) createRoutes() { + r.router.Post("/", r.new) +} + +func (r *Router) new(c *fiber.Ctx) error { + spotify := new(dto.Spotify) + + if err := c.BodyParser(spotify); err != nil { + zap.S().Error("API: Spotify body parser\n", err) + return c.SendStatus(fiber.StatusBadRequest) + } + + if err := dto.Validate.Struct(spotify); err != nil { + zap.S().Error("API: Spotify validation\n", err) + return c.SendStatus(fiber.StatusBadRequest) + } + + go func() { + err := r.spotify.Track(spotify) + if err != nil { + zap.S().Error("Spotify: Get Track\n", err) + } + }() + + return c.SendStatus(fiber.StatusOK) +} diff --git a/internal/cmd/api.go b/internal/cmd/api.go index 1e04757..0e76d3d 100644 --- a/internal/cmd/api.go +++ b/internal/cmd/api.go @@ -8,11 +8,12 @@ import ( "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/spotify" "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) -func apiCmd(db *db.DB) { +func apiCmd(db *db.DB, spotify *spotify.Spotify) { app := fiber.New(fiber.Config{ BodyLimit: 1024 * 1024 * 1024, }) @@ -27,7 +28,7 @@ func apiCmd(db *db.DB) { ) apiGroup := app.Group("/api") - api.New(apiGroup, db) + api.New(apiGroup, db, spotify) host := config.GetDefaultString("server.host", "127.0.0.1") port := config.GetDefaultInt("server.port", 3000) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 9c42cda..70b8030 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/spotify" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/logger" "go.uber.org/zap" @@ -23,8 +24,13 @@ func Execute() { db, err := db.New() if err != nil { - zap.S().Fatal("DB: Fatal error", err) + zap.S().Fatal("DB: Fatal error\n", err) } - apiCmd(db) + spotify, err := spotify.New(db) + if err != nil { + zap.S().Error("Spotify: Initiating error, integration will not work.\n", err) + } + + apiCmd(db, spotify) } diff --git a/internal/pkg/db/dto/spotify.go b/internal/pkg/db/dto/spotify.go new file mode 100644 index 0000000..be4cbb5 --- /dev/null +++ b/internal/pkg/db/dto/spotify.go @@ -0,0 +1,39 @@ +package dto + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" +) + +// Spotify is the DTO for the spotify +type Spotify struct { + ID int64 `json:"id"` + Title string `json:"title"` + Artists string `json:"artists"` + SpotifyID string `json:"spotify_id" validate:"required"` + DurationMS int64 `json:"duration_ms"` + CreatedAt time.Time `json:"created_at"` +} + +// SpotifyDTO converts a sqlc.Spotify to a Spotify +func SpotifyDTO(spotify sqlc.Spotify) *Spotify { + return &Spotify{ + ID: spotify.ID, + Title: spotify.Title, + Artists: spotify.Artists, + SpotifyID: spotify.SpotifyID, + DurationMS: spotify.DurationMs, + CreatedAt: spotify.CreatedAt, + } +} + +// CreateParams converts a Spotify to sqlc.CreateSpotifyParams +func (s *Spotify) CreateParams() sqlc.CreateSpotifyParams { + return sqlc.CreateSpotifyParams{ + Title: s.Title, + Artists: s.Artists, + SpotifyID: s.SpotifyID, + DurationMs: s.DurationMS, + } +} diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index 01fdc23..5097850 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -15,3 +15,12 @@ type Message struct { Message string CreatedAt time.Time } + +type Spotify struct { + ID int64 + Title string + Artists string + SpotifyID string + DurationMs int64 + CreatedAt time.Time +} diff --git a/internal/pkg/db/sqlc/spotify.sql.go b/internal/pkg/db/sqlc/spotify.sql.go new file mode 100644 index 0000000..c79eeb6 --- /dev/null +++ b/internal/pkg/db/sqlc/spotify.sql.go @@ -0,0 +1,169 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: spotify.sql + +package sqlc + +import ( + "context" +) + +const createSpotify = `-- name: CreateSpotify :one +INSERT INTO spotify (title, artists, spotify_id, duration_ms) +VALUES (?, ?, ?, ?) +RETURNING id, title, artists, spotify_id, duration_ms, created_at +` + +type CreateSpotifyParams struct { + Title string + Artists string + SpotifyID string + DurationMs int64 +} + +func (q *Queries) CreateSpotify(ctx context.Context, arg CreateSpotifyParams) (Spotify, error) { + row := q.db.QueryRowContext(ctx, createSpotify, + arg.Title, + arg.Artists, + arg.SpotifyID, + arg.DurationMs, + ) + var i Spotify + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + &i.CreatedAt, + ) + return i, err +} + +const deleteSpotify = `-- name: DeleteSpotify :execrows +DELETE FROM spotify +WHERE id = ? +` + +func (q *Queries) DeleteSpotify(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteSpotify, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getAllSpotify = `-- name: GetAllSpotify :many + +SELECT id, title, artists, spotify_id, duration_ms, created_at +FROM spotify +` + +// CRUD +func (q *Queries) GetAllSpotify(ctx context.Context) ([]Spotify, error) { + rows, err := q.db.QueryContext(ctx, getAllSpotify) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Spotify + for rows.Next() { + var i Spotify + if err := rows.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSpotifyByID = `-- name: GetSpotifyByID :one +SELECT id, title, artists, spotify_id, duration_ms, created_at +FROM spotify +WHERE id = ? +` + +func (q *Queries) GetSpotifyByID(ctx context.Context, id int64) (Spotify, error) { + row := q.db.QueryRowContext(ctx, getSpotifyByID, id) + var i Spotify + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + &i.CreatedAt, + ) + return i, err +} + +const getSpotifyBySpotifyID = `-- name: GetSpotifyBySpotifyID :one + +SELECT id, title, artists, spotify_id, duration_ms, created_at +FROM spotify +WHERE spotify_id = ? +` + +// Other +func (q *Queries) GetSpotifyBySpotifyID(ctx context.Context, spotifyID string) (Spotify, error) { + row := q.db.QueryRowContext(ctx, getSpotifyBySpotifyID, spotifyID) + var i Spotify + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + &i.CreatedAt, + ) + return i, err +} + +const updateSpotify = `-- name: UpdateSpotify :one +UPDATE spotify +SET title = ?, artists = ?, spotify_id = ?, duration_ms = ? +WHERE id = ? +RETURNING id, title, artists, spotify_id, duration_ms, created_at +` + +type UpdateSpotifyParams struct { + Title string + Artists string + SpotifyID string + DurationMs int64 + ID int64 +} + +func (q *Queries) UpdateSpotify(ctx context.Context, arg UpdateSpotifyParams) (Spotify, error) { + row := q.db.QueryRowContext(ctx, updateSpotify, + arg.Title, + arg.Artists, + arg.SpotifyID, + arg.DurationMs, + arg.ID, + ) + var i Spotify + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/pkg/spotify/account.go b/internal/pkg/spotify/account.go new file mode 100644 index 0000000..816c869 --- /dev/null +++ b/internal/pkg/spotify/account.go @@ -0,0 +1,47 @@ +package spotify + +import ( + "encoding/json" + "errors" + "time" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +const accountURL = "https://accounts.spotify.com/api/token" + +type accountResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +func (s *Spotify) refreshToken() error { + zap.S().Info("Spotify: Refreshing access token") + + body, err := json.Marshal(fiber.Map{ + "grant_type": "client_credentials", + "client_id": s.ClientID, + "client_secret": s.ClientSecret, + }) + if err != nil { + return err + } + + req := fiber.Post(accountURL).Body(body).ContentType("application/json") + + res := new(accountResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(errs...) + } + if status != fiber.StatusOK { + return errors.New("error getting access token") + } + + s.AccessToken = res.AccessToken + s.ExpiresTime = time.Now().Unix() + res.ExpiresIn + + return nil +} diff --git a/internal/pkg/spotify/api.go b/internal/pkg/spotify/api.go new file mode 100644 index 0000000..2715587 --- /dev/null +++ b/internal/pkg/spotify/api.go @@ -0,0 +1,45 @@ +package spotify + +import ( + "errors" + "fmt" + + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/pkg/util" + "go.uber.org/zap" +) + +const apiURL = "https://api.spotify.com/v1" + +type trackArtist struct { + Name string `json:"name"` +} + +type trackResponse struct { + Name string `json:"name"` + Artists []trackArtist `json:"artists"` + DurationMS int64 `json:"duration_ms"` +} + +func (s *Spotify) setTrack(track *dto.Spotify) error { + zap.S().Info("Spotify: Getting track info for id: ", track.SpotifyID) + + req := fiber.Get(fmt.Sprintf("%s/%s/%s", apiURL, "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(errs...) + } + if status != fiber.StatusOK { + return errors.New("error getting track") + } + + track.Title = res.Name + track.Artists = util.SliceStringJoin(res.Artists, ", ", func(a trackArtist) string { return a.Name }) + track.DurationMS = res.DurationMS + + return nil +} diff --git a/internal/pkg/spotify/spotify.go b/internal/pkg/spotify/spotify.go new file mode 100644 index 0000000..4cc4b33 --- /dev/null +++ b/internal/pkg/spotify/spotify.go @@ -0,0 +1,84 @@ +// Package spotify provides all spotify related logic +package spotify + +import ( + "context" + "database/sql" + "errors" + "time" + + "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" +) + +// Spotify represents a spotify instance +type Spotify struct { + db *db.DB + ClientID string + ClientSecret string + AccessToken string + ExpiresTime int64 +} + +// New creates a new spotify instance +func New(db *db.DB) (*Spotify, error) { + clientID := config.GetDefaultString("spotify.client_id", "") + clientSecret := config.GetDefaultString("spotify.client_secret", "") + + if clientID == "" || clientSecret == "" { + return &Spotify{}, errors.New("Spotify client id or secret not set") + } + + return &Spotify{db: db, ClientID: clientID, ClientSecret: clientSecret, ExpiresTime: 0}, nil +} + +// Track gets information about the current track and stores it in the database +func (s *Spotify) Track(track *dto.Spotify) error { + if s.ClientID == "" || s.ClientSecret == "" { + return errors.New("spotify client id or secret not set") + } + + // Check if song is already in DB + trackDB, err := s.db.Queries.GetSpotifyBySpotifyID(context.Background(), track.SpotifyID) + if err != nil { + if err != sql.ErrNoRows { + return err + } + } + + if (trackDB != sqlc.Spotify{}) { + // Already in DB + // No need to refetch data + track.Title = trackDB.Title + track.Artists = trackDB.Artists + track.DurationMS = trackDB.DurationMs + _, err := s.db.Queries.CreateSpotify(context.Background(), track.CreateParams()) + + return err + } + + // Refresh token if needed + if s.ExpiresTime <= time.Now().Unix() { + err := s.refreshToken() + if err != nil { + return err + } + } + + // Set track info + err = s.setTrack(track) + if err != nil { + return err + } + + // Store track in DB + _, err = s.db.Queries.CreateSpotify(context.Background(), track.CreateParams()) + if err != nil { + return err + } + + return nil + +} diff --git a/pkg/util/slice.go b/pkg/util/slice.go index 2c50209..5e10700 100644 --- a/pkg/util/slice.go +++ b/pkg/util/slice.go @@ -1,6 +1,8 @@ // Package util provides utility functions package util +import "strings" + // 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)) @@ -9,3 +11,12 @@ func SliceMap[T any, U any](input []T, mapFunc func(T) U) []U { } 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 { + v := make([]string, len(input)) + for i, item := range input { + v[i] = mapFunc(item) + } + return strings.Join(v, sep) +} From 893f6171ded7ea4d9247f16e16f3a89a1b187583 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Nov 2024 17:41:16 +0100 Subject: [PATCH 09/46] feat: tap integration --- config/development.toml | 16 ++ .../20241114152122_add_tap_table.sql | 16 ++ db/queries/tap.sql | 43 ++++ internal/cmd/root.go | 16 ++ internal/cmd/tap.go | 41 ++++ internal/pkg/db/sqlc/models.go | 9 + internal/pkg/db/sqlc/tap.sql.go | 227 ++++++++++++++++++ internal/pkg/tap/api.go | 39 +++ internal/pkg/tap/tap.go | 109 +++++++++ pkg/util/slice.go | 21 +- 10 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 db/migrations/20241114152122_add_tap_table.sql create mode 100644 db/queries/tap.sql create mode 100644 internal/cmd/tap.go create mode 100644 internal/pkg/db/sqlc/tap.sql.go create mode 100644 internal/pkg/tap/api.go create mode 100644 internal/pkg/tap/tap.go diff --git a/config/development.toml b/config/development.toml index ebb26a0..8da9187 100644 --- a/config/development.toml +++ b/config/development.toml @@ -6,6 +6,22 @@ port = 3000 client_id = "your_client_id" client_secret = "your_client_secret" +[tap] +interval_s = 60 +beers = [ + "Schelfaut", + "Duvel", + "Fourchette", + "Jupiler", + "Karmeliet", + "Kriek", + "Chouffe", + "Maes", + "Somersby", + "Sportzot", + "Stella", +] + [buzzer] song = [ "-n", "-f880", "-l100", "-d0", diff --git a/db/migrations/20241114152122_add_tap_table.sql b/db/migrations/20241114152122_add_tap_table.sql new file mode 100644 index 0000000..0492937 --- /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 INTEGER PRIMARY KEY, + order_id INTEGER NOT NULL, + order_created_at TIMESTAMP NOT NULL, + name TEXT NOT NULL, + category TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS tap; +-- +goose StatementEnd diff --git a/db/queries/tap.sql b/db/queries/tap.sql new file mode 100644 index 0000000..3d2e123 --- /dev/null +++ b/db/queries/tap.sql @@ -0,0 +1,43 @@ +-- CRUD + +-- name: GetAllTaps :many +SELECT * +FROM tap; + +-- name: GetTapByID :one +SELECT * +FROM tap +WHERE id = ?; + +-- name: CreateTap :one +INSERT INTO tap (order_id, order_created_at, name, category) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: UpdateTap :one +UPDATE tap +SET order_id = ?, order_created_at = ?, name = ?, category = ? +WHERE id = ? +RETURNING *; + +-- name: DeleteTap :execrows +DELETE FROM tap +WHERE id = ?; + +-- Other + +-- name: GetTapByOrderID :one +SELECT * +FROM tap +WHERE order_id = ?; + +-- name: GetTapByCategory :many +SELECT * +FROM tap +WHERE category = ?; + +-- name: GetLastOrder :one +SELECT * +FROM tap +ORDER BY id DESC +LIMIT 1; diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 70b8030..f7c0368 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -4,6 +4,7 @@ package cmd import ( "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/internal/pkg/spotify" + "github.com/zeusWPI/scc/internal/pkg/tap" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/logger" "go.uber.org/zap" @@ -11,26 +12,41 @@ import ( // Execute starts the entire application func Execute() { + zap.S().Info("Initializing application") + + // Config err := config.Init() if err != nil { panic(err) } + // Logger zapLogger, err := logger.New() if err != nil { panic(err) } zap.ReplaceGlobals(zapLogger) + // Database db, err := db.New() if err != nil { zap.S().Fatal("DB: Fatal error\n", err) } + // Spotify spotify, err := spotify.New(db) if err != nil { zap.S().Error("Spotify: Initiating error, integration will not work.\n", err) } + // Tap + tap := tap.New(db) + + // Everything that needs to be initialized is done + // Time to start all parts of the application + zap.S().Info("Starting application") + + _ = tapCmd(tap) + apiCmd(db, spotify) } diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go new file mode 100644 index 0000000..87057a0 --- /dev/null +++ b/internal/cmd/tap.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/tap" + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" +) + +func tapCmd(tap *tap.Tap) chan bool { + done := make(chan bool) + + go tapPeriodicUpdate(tap, done) + + return done +} + +func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { + interval := config.GetDefaultInt("tap.interval_s", 60) + zap.S().Info("Tap: Starting periodic update with an interval of ", interval, " seconds") + + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-done: + zap.S().Info("Tap: Stopping periodic update") + return + case <-ticker.C: + // Update tap + zap.S().Info("Tap: Updating tap") + err := tap.Update() + if err != nil { + zap.S().Error("Tap: Error updating tap\n", err) + } + } + } + +} diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index 5097850..e50215c 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -24,3 +24,12 @@ type Spotify struct { DurationMs int64 CreatedAt time.Time } + +type Tap struct { + ID int64 + OrderID int64 + OrderCreatedAt time.Time + Name string + Category string + CreatedAt time.Time +} diff --git a/internal/pkg/db/sqlc/tap.sql.go b/internal/pkg/db/sqlc/tap.sql.go new file mode 100644 index 0000000..209c882 --- /dev/null +++ b/internal/pkg/db/sqlc/tap.sql.go @@ -0,0 +1,227 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: tap.sql + +package sqlc + +import ( + "context" + "time" +) + +const createTap = `-- name: CreateTap :one +INSERT INTO tap (order_id, order_created_at, name, category) +VALUES (?, ?, ?, ?) +RETURNING id, order_id, order_created_at, name, category, created_at +` + +type CreateTapParams struct { + OrderID int64 + OrderCreatedAt time.Time + Name string + Category string +} + +func (q *Queries) CreateTap(ctx context.Context, arg CreateTapParams) (Tap, error) { + row := q.db.QueryRowContext(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 = ? +` + +func (q *Queries) DeleteTap(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteTap, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +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.QueryContext(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.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getLastOrder = `-- name: GetLastOrder :one +SELECT id, order_id, order_created_at, name, category, created_at +FROM tap +ORDER BY id DESC +LIMIT 1 +` + +func (q *Queries) GetLastOrder(ctx context.Context) (Tap, error) { + row := q.db.QueryRowContext(ctx, getLastOrder) + var i Tap + err := row.Scan( + &i.ID, + &i.OrderID, + &i.OrderCreatedAt, + &i.Name, + &i.Category, + &i.CreatedAt, + ) + return i, err +} + +const getTapByCategory = `-- name: GetTapByCategory :many +SELECT id, order_id, order_created_at, name, category, created_at +FROM tap +WHERE category = ? +` + +func (q *Queries) GetTapByCategory(ctx context.Context, category string) ([]Tap, error) { + rows, err := q.db.QueryContext(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.Close(); err != nil { + return nil, err + } + 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 = ? +` + +func (q *Queries) GetTapByID(ctx context.Context, id int64) (Tap, error) { + row := q.db.QueryRowContext(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 = ? +` + +// Other +func (q *Queries) GetTapByOrderID(ctx context.Context, orderID int64) (Tap, error) { + row := q.db.QueryRowContext(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 = ?, order_created_at = ?, name = ?, category = ? +WHERE id = ? +RETURNING id, order_id, order_created_at, name, category, created_at +` + +type UpdateTapParams struct { + OrderID int64 + OrderCreatedAt time.Time + Name string + Category string + ID int64 +} + +func (q *Queries) UpdateTap(ctx context.Context, arg UpdateTapParams) (Tap, error) { + row := q.db.QueryRowContext(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/tap/api.go b/internal/pkg/tap/api.go new file mode 100644 index 0000000..6613e19 --- /dev/null +++ b/internal/pkg/tap/api.go @@ -0,0 +1,39 @@ +package tap + +import ( + "errors" + "time" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +const apiURL = "https://tap.zeus.gent/recent" + +type orderResponseItem struct { + OrderID int64 `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(apiURL) + + res := new(orderResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + 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..a4d50f8 --- /dev/null +++ b/internal/pkg/tap/tap.go @@ -0,0 +1,109 @@ +// Package tap provides all tap related logic +package tap + +import ( + "context" + "database/sql" + "errors" + "slices" + "strings" + + "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 +} + +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 { + beers := config.GetDefaultStringSlice("tap.beers", defaultBeers) + + return &Tap{db: db, beers: beers} +} + +// Update gets all new orders from tap +func (t *Tap) Update() error { + // Get latest order + lastOrder, err := t.db.Queries.GetLastOrder(context.Background()) + if err != nil { + if err != sql.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: order.OrderCreatedAt, + 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/pkg/util/slice.go b/pkg/util/slice.go index 5e10700..1840f51 100644 --- a/pkg/util/slice.go +++ b/pkg/util/slice.go @@ -1,8 +1,6 @@ // Package util provides utility functions package util -import "strings" - // 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)) @@ -14,9 +12,20 @@ func SliceMap[T any, U any](input []T, mapFunc func(T) U) []U { // 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 { - v := make([]string, len(input)) - for i, item := range input { - v[i] = mapFunc(item) + str := "" + for _, item := range input { + str += mapFunc(item) + sep } - return strings.Join(v, 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 } From e7ed1f0a856d2481c0abe0dc242653a599321f5a Mon Sep 17 00:00:00 2001 From: Topvennie Date: Fri, 15 Nov 2024 12:55:24 +0100 Subject: [PATCH 10/46] chore: replace air with bash script --- .air.toml | 46 ---------------------------------------------- README.md | 4 +++- makefile | 17 +++-------------- watch.sh | 45 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 61 deletions(-) delete mode 100644 .air.toml create mode 100755 watch.sh diff --git a/.air.toml b/.air.toml deleted file mode 100644 index 7775d4b..0000000 --- a/.air.toml +++ /dev/null @@ -1,46 +0,0 @@ -root = "." -testdata_dir = "testdata" -tmp_dir = "tmp" - -[build] - args_bin = [] - bin = "scc" - cmd = "make build" - delay = 1000 - exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"] - exclude_file = [] - exclude_regex = ["_test.go"] - exclude_unchanged = false - follow_symlink = false - full_bin = "" - include_dir = [] - include_ext = ["go", "tpl", "tmpl", "html"] - include_file = [] - kill_delay = "0s" - log = "build-errors.log" - poll = false - poll_interval = 0 - post_cmd = [] - pre_cmd = [] - rerun = false - rerun_delay = 500 - send_interrupt = false - stop_on_error = false - -[color] - app = "" - build = "yellow" - main = "magenta" - runner = "green" - watcher = "cyan" - -[log] - main_only = false - time = false - -[misc] - clean_on_exit = false - -[screen] - clear_on_rebuild = false - keep_scroll = true diff --git a/README.md b/README.md index 96852b3..354b86b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ Check [.tool-versions](.tool-versions) for the current used version of golang Create a `.env` (Look at [.env.example](.env.example])). -Start developing with `make watch`. +Start developing with `make build` and `make run`. +For live reloading `inotify` is required `sudo apt install inotify-tools`. +`make watch` will start the hot reloading. Logs will be logged to the `./logs` directory (will be made at the start of the first run). diff --git a/makefile b/makefile index 1f404c9..42ba382 100644 --- a/makefile +++ b/makefile @@ -4,7 +4,7 @@ build: clean @go build -o scc cmd/tty/scc.go run: - @go run cmd/tty/scc.go + @./scc clean: @rm -f scc @@ -24,16 +24,5 @@ migrate: @goose -dir ./db/migrations sqlite3 ./sqlite.db up watch: - @if command -v air > /dev/null; then \ - air; \ - else \ - read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ - if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ - go install github.com/air-verse/air@latest; \ - air; \ - echo "Watching...";\ - else \ - echo "You chose not to install air. Exiting..."; \ - exit 1; \ - fi; \ - fi + @echo "Starting the watch script..." + ./watch.sh diff --git a/watch.sh b/watch.sh new file mode 100755 index 0000000..71f9720 --- /dev/null +++ b/watch.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + + +# Directories to exclude +EXCLUDE_DIRS=(".githooks" ".github" "logs") + +# Build the exclude patterns for inotifywait +EXCLUDE_PATTERN=$(printf "|%s" "${EXCLUDE_DIRS[@]}") +EXCLUDE_PATTERN=${EXCLUDE_PATTERN:1} # Remove leading | + +# Kill background jobs on exit +cleanup() { + if [[ -n $RUN_PID ]]; then + kill $RUN_PID + wait $RUN_PID 2>/dev/null + fi + exit +} +trap cleanup SIGINT SIGTERM + +# Function to restart the program +restart_program() { + echo "Change detected. Restarting program..." + # Stop the running program + if [[ -n $RUN_PID ]]; then + kill $RUN_PID + wait $RUN_PID 2>/dev/null + fi + + # Rebuild and restart + make build || { echo "Build failed"; return; } + make run & + RUN_PID=$! +} + +# Start the program initially +restart_program + +# Watch for file changes, ignoring excluded directories +while true; do + CHANGED_FILE=$(inotifywait -re modify --exclude "(${EXCLUDE_PATTERN})" . 2>/dev/null) + if [[ $? -eq 0 ]]; then + restart_program + fi +done From 25031a6825265c4430069998d222d5f8647a99a9 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 18 Nov 2024 17:00:29 +0100 Subject: [PATCH 11/46] feat: add tui template --- .gitignore | 3 +- cmd/tty/scc.go | 8 ----- db/queries/tap.sql | 5 +++ go.mod | 18 ++++++++++- go.sum | 39 +++++++++++++++++++++++ internal/cmd/api.go | 3 +- internal/cmd/root.go | 52 ------------------------------ internal/cmd/spotify.go | 13 ++++++++ internal/cmd/tap.go | 7 +++-- internal/cmd/tui.go | 17 ++++++++++ internal/pkg/db/sqlc/tap.sql.go | 34 ++++++++++++++++++++ makefile | 15 ++++++--- ui/tui.go | 56 +++++++++++++++++++++++++++++++++ 13 files changed, 200 insertions(+), 70 deletions(-) delete mode 100644 cmd/tty/scc.go delete mode 100644 internal/cmd/root.go create mode 100644 internal/cmd/spotify.go create mode 100644 internal/cmd/tui.go create mode 100644 ui/tui.go diff --git a/.gitignore b/.gitignore index 0c6ad57..52db340 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,8 @@ tmp/ !.env.example # Project build -scc +backend +tui .data/ /public/ diff --git a/cmd/tty/scc.go b/cmd/tty/scc.go deleted file mode 100644 index 38b1424..0000000 --- a/cmd/tty/scc.go +++ /dev/null @@ -1,8 +0,0 @@ -// Main entry point for the scc application -package main - -import "github.com/zeusWPI/scc/internal/cmd" - -func main() { - cmd.Execute() -} diff --git a/db/queries/tap.sql b/db/queries/tap.sql index 3d2e123..6d78deb 100644 --- a/db/queries/tap.sql +++ b/db/queries/tap.sql @@ -41,3 +41,8 @@ SELECT * FROM tap ORDER BY id DESC LIMIT 1; + +-- name: GetOrderCount :many +SELECT category, COUNT(*) +FROM tap +GROUP BY category; diff --git a/go.mod b/go.mod index 738cd96..96f85c1 100644 --- a/go.mod +++ b/go.mod @@ -9,25 +9,41 @@ require ( require ( github.com/andybalholm/brotli v1.0.5 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.18.0 // indirect + github.com/charmbracelet/bubbletea v0.25.0 // indirect + github.com/charmbracelet/lipgloss v1.0.0 // indirect + github.com/charmbracelet/x/ansi v0.4.2 // indirect + github.com/containerd/console v1.0.4 // 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/google/uuid v1.5.0 // 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-20240125042004-b7bafc493195 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // 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.15 // indirect - github.com/rivo/uniseg v0.4.3 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // 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 golang.org/x/crypto v0.27.0 // indirect golang.org/x/net v0.23.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/term v0.24.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) require ( + github.com/NimbleMarkets/ntcharts v0.1.2 github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-playground/validator/v10 v10.22.1 github.com/gofiber/contrib/fiberzap v1.0.2 diff --git a/go.sum b/go.sum index 6863115..a8270f5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,21 @@ +github.com/NimbleMarkets/ntcharts v0.1.2 h1:iW1aiOif/Dm74sQd18opi10RMED5589cVhy9SGp98Tw= +github.com/NimbleMarkets/ntcharts v0.1.2/go.mod h1:WcHS7kc8oQctN1543DeV9a+gOrS4DDVfKp1N9RZFUqc= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +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.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= +github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= +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.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= +github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= +github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -41,6 +57,10 @@ 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-20240125042004-b7bafc493195 h1:zcxmFnwisGZSaEzgvkOrs4belfcRlKyIUfa3sOQSttQ= +github.com/lrstanley/bubblezone v0.0.0-20240125042004-b7bafc493195/go.mod h1:v5lEwWaguF1o2MW/ucO0ZIA/IZymdBYJJ+2cMRLE7LU= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -48,20 +68,34 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk 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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 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/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +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/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.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= 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/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= @@ -107,10 +141,15 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= +golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cmd/api.go b/internal/cmd/api.go index 0e76d3d..9a9e508 100644 --- a/internal/cmd/api.go +++ b/internal/cmd/api.go @@ -13,7 +13,8 @@ import ( "go.uber.org/zap" ) -func apiCmd(db *db.DB, spotify *spotify.Spotify) { +// API starts the API server +func API(db *db.DB, spotify *spotify.Spotify) { app := fiber.New(fiber.Config{ BodyLimit: 1024 * 1024 * 1024, }) diff --git a/internal/cmd/root.go b/internal/cmd/root.go deleted file mode 100644 index f7c0368..0000000 --- a/internal/cmd/root.go +++ /dev/null @@ -1,52 +0,0 @@ -// Package cmd package contains all the commands to start the application -package cmd - -import ( - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/spotify" - "github.com/zeusWPI/scc/internal/pkg/tap" - "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/pkg/logger" - "go.uber.org/zap" -) - -// Execute starts the entire application -func Execute() { - zap.S().Info("Initializing application") - - // Config - err := config.Init() - if err != nil { - panic(err) - } - - // Logger - zapLogger, err := logger.New() - if err != nil { - panic(err) - } - zap.ReplaceGlobals(zapLogger) - - // Database - db, err := db.New() - if err != nil { - zap.S().Fatal("DB: Fatal error\n", err) - } - - // Spotify - spotify, err := spotify.New(db) - if err != nil { - zap.S().Error("Spotify: Initiating error, integration will not work.\n", err) - } - - // Tap - tap := tap.New(db) - - // Everything that needs to be initialized is done - // Time to start all parts of the application - zap.S().Info("Starting application") - - _ = tapCmd(tap) - - apiCmd(db, spotify) -} diff --git a/internal/cmd/spotify.go b/internal/cmd/spotify.go new file mode 100644 index 0000000..64e4072 --- /dev/null +++ b/internal/cmd/spotify.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/spotify" +) + +// Spotify starts the Spotify integration +func Spotify(db *db.DB) (*spotify.Spotify, error) { + spotify, err := spotify.New(db) + + return spotify, err +} diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go index 87057a0..867434f 100644 --- a/internal/cmd/tap.go +++ b/internal/cmd/tap.go @@ -3,17 +3,20 @@ 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" ) -func tapCmd(tap *tap.Tap) chan bool { +// Tap starts the tap +func Tap(db *db.DB) (*tap.Tap, chan bool) { + tap := tap.New(db) done := make(chan bool) go tapPeriodicUpdate(tap, done) - return done + return tap, done } func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go new file mode 100644 index 0000000..73400e0 --- /dev/null +++ b/internal/cmd/tui.go @@ -0,0 +1,17 @@ +// Package cmd provides all the commands to start parts of the application +package cmd + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/pkg/db" + tui "github.com/zeusWPI/scc/ui" +) + +// TUI starts the terminal user interface +func TUI(db *db.DB) *tea.Program { + tui := tui.New(db) + + program := tea.NewProgram(tui) + + return program +} diff --git a/internal/pkg/db/sqlc/tap.sql.go b/internal/pkg/db/sqlc/tap.sql.go index 209c882..5096e03 100644 --- a/internal/pkg/db/sqlc/tap.sql.go +++ b/internal/pkg/db/sqlc/tap.sql.go @@ -113,6 +113,40 @@ func (q *Queries) GetLastOrder(ctx context.Context) (Tap, error) { 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.QueryContext(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.Close(); err != nil { + return nil, err + } + 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 diff --git a/makefile b/makefile index 42ba382..a70118f 100644 --- a/makefile +++ b/makefile @@ -1,13 +1,18 @@ all: build -build: clean - @go build -o scc cmd/tty/scc.go +build: clean backend tui -run: - @./scc +run: backend tui + @./backend & ./tui + +backend: + @[ -f backend ] || (echo "Building backend..." && go build -o backend cmd/backend/backend.go) + +tui: + @[ -f tui ] || (echo "Building tui..." && go build -o tui cmd/tui/tui.go) clean: - @rm -f scc + @rm -f backend tui sqlc: sqlc generate diff --git a/ui/tui.go b/ui/tui.go new file mode 100644 index 0000000..664794a --- /dev/null +++ b/ui/tui.go @@ -0,0 +1,56 @@ +// Package tui provides utilities for working with the terminal. +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/ui/views" + "go.uber.org/zap" +) + +// TUI represent a terminal instance +type TUI struct { + db *db.DB + tap tea.Model +} + +// New creates a new tty instance +func New(db *db.DB) *TUI { + return &TUI{ + db: db, + tap: views.NewTapModel(db), + } +} + +// Init initializes the tty +func (t *TUI) Init() tea.Cmd { + return tea.Batch(tea.EnterAltScreen, t.tap.Init()) +} + +// Update updates the tty +func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + tapModel, tapCmd := t.tap.Update(msg) + if tapCmd != nil { + cmds = append(cmds, tapCmd) + } + t.tap = tapModel + + 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 tty view +func (t *TUI) View() string { + return t.tap.View() +} From 086b13b41c242a1217ba0623f0bfa76b07145df6 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 18 Nov 2024 21:20:19 +0100 Subject: [PATCH 12/46] feat: tap view --- db/queries/tap.sql | 10 +- internal/cmd/tui.go | 27 +++++- internal/pkg/db/sqlc/tap.sql.go | 43 ++++++++- internal/pkg/tap/tap.go | 2 +- makefile | 7 ++ pkg/logger/logger.go | 7 +- pkg/util/map.go | 10 ++ ui/screen/cammie.go | 32 +++++++ ui/tui.go | 32 +++---- ui/view/tap.go | 164 ++++++++++++++++++++++++++++++++ 10 files changed, 302 insertions(+), 32 deletions(-) create mode 100644 pkg/util/map.go create mode 100644 ui/screen/cammie.go create mode 100644 ui/view/tap.go diff --git a/db/queries/tap.sql b/db/queries/tap.sql index 6d78deb..2ffb924 100644 --- a/db/queries/tap.sql +++ b/db/queries/tap.sql @@ -36,13 +36,19 @@ SELECT * FROM tap WHERE category = ?; --- name: GetLastOrder :one +-- name: GetLastOrderByOrderID :one SELECT * FROM tap -ORDER BY id DESC +ORDER BY order_id DESC LIMIT 1; -- name: GetOrderCount :many SELECT category, COUNT(*) FROM tap GROUP BY category; + +-- name: GetOrderCountByCategorySinceOrderID :many +SELECT category, COUNT(*) +FROM tap +WHERE order_id >= ? +GROUP BY category; diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index 73400e0..cdcdf21 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -2,16 +2,37 @@ package cmd import ( + "fmt" + "os" + tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/pkg/util" tui "github.com/zeusWPI/scc/ui" + "github.com/zeusWPI/scc/ui/screen" ) +var screens = map[string]func(*db.DB) tea.Model{ + "cammie": screen.NewCammie, +} + // TUI starts the terminal user interface -func TUI(db *db.DB) *tea.Program { - tui := tui.New(db) +func TUI(db *db.DB) (*tea.Program, error) { + args := os.Args + if len(args) < 2 { + return nil, fmt.Errorf("No screen specified. Options are %v", util.Keys(screens)) + } + + screen := args[1] + + val, ok := screens[screen] + if !ok { + return nil, fmt.Errorf("Screen %s not found. Options are %v", screen, util.Keys(screens)) + } + + tui := tui.New(val(db)) program := tea.NewProgram(tui) - return program + return program, nil } diff --git a/internal/pkg/db/sqlc/tap.sql.go b/internal/pkg/db/sqlc/tap.sql.go index 5096e03..775c7ab 100644 --- a/internal/pkg/db/sqlc/tap.sql.go +++ b/internal/pkg/db/sqlc/tap.sql.go @@ -92,15 +92,15 @@ func (q *Queries) GetAllTaps(ctx context.Context) ([]Tap, error) { return items, nil } -const getLastOrder = `-- name: GetLastOrder :one +const getLastOrderByOrderID = `-- name: GetLastOrderByOrderID :one SELECT id, order_id, order_created_at, name, category, created_at FROM tap -ORDER BY id DESC +ORDER BY order_id DESC LIMIT 1 ` -func (q *Queries) GetLastOrder(ctx context.Context) (Tap, error) { - row := q.db.QueryRowContext(ctx, getLastOrder) +func (q *Queries) GetLastOrderByOrderID(ctx context.Context) (Tap, error) { + row := q.db.QueryRowContext(ctx, getLastOrderByOrderID) var i Tap err := row.Scan( &i.ID, @@ -147,6 +147,41 @@ func (q *Queries) GetOrderCount(ctx context.Context) ([]GetOrderCountRow, error) return items, nil } +const getOrderCountByCategorySinceOrderID = `-- name: GetOrderCountByCategorySinceOrderID :many +SELECT category, COUNT(*) +FROM tap +WHERE order_id >= ? +GROUP BY category +` + +type GetOrderCountByCategorySinceOrderIDRow struct { + Category string + Count int64 +} + +func (q *Queries) GetOrderCountByCategorySinceOrderID(ctx context.Context, orderID int64) ([]GetOrderCountByCategorySinceOrderIDRow, error) { + rows, err := q.db.QueryContext(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); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + 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 diff --git a/internal/pkg/tap/tap.go b/internal/pkg/tap/tap.go index a4d50f8..59aecad 100644 --- a/internal/pkg/tap/tap.go +++ b/internal/pkg/tap/tap.go @@ -44,7 +44,7 @@ func New(db *db.DB) *Tap { // Update gets all new orders from tap func (t *Tap) Update() error { // Get latest order - lastOrder, err := t.db.Queries.GetLastOrder(context.Background()) + lastOrder, err := t.db.Queries.GetLastOrderByOrderID(context.Background()) if err != nil { if err != sql.ErrNoRows { return err diff --git a/makefile b/makefile index a70118f..671e929 100644 --- a/makefile +++ b/makefile @@ -5,6 +5,13 @@ build: clean backend tui run: backend tui @./backend & ./tui +run-backend: backend + @./backend + +run-tui: tui + @read -p "Enter screen name: " screen; \ + ./tui $$screen + backend: @[ -f backend ] || (echo "Building backend..." && go build -o backend cmd/backend/backend.go) diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index b38ea9a..22f8fca 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -2,6 +2,7 @@ package logger import ( + "fmt" "os" "github.com/zeusWPI/scc/pkg/config" @@ -9,7 +10,7 @@ import ( ) // New returns a new logger instance -func New() (*zap.Logger, error) { +func New(logFile string) (*zap.Logger, error) { // Create logs directory err := os.Mkdir("logs", os.ModePerm) if err != nil && !os.IsExist(err) { @@ -24,8 +25,8 @@ func New() (*zap.Logger, error) { } else { zapConfig = zap.NewProductionConfig() } - zapConfig.OutputPaths = []string{"logs/scc.log"} - zapConfig.ErrorOutputPaths = []string{"logs/scc.log"} + zapConfig.OutputPaths = []string{fmt.Sprintf("logs/%s.log", logFile)} + zapConfig.ErrorOutputPaths = []string{fmt.Sprintf("logs/%s.log", logFile)} logger := zap.Must(zapConfig.Build()) 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/ui/screen/cammie.go b/ui/screen/cammie.go new file mode 100644 index 0000000..8c96472 --- /dev/null +++ b/ui/screen/cammie.go @@ -0,0 +1,32 @@ +// Package screen provides difference screens for the tui +package screen + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/pkg/db" +) + +// Cammie represents the cammie screen +type Cammie struct { + db *db.DB +} + +// NewCammie creates a new cammie screen +func NewCammie(db *db.DB) tea.Model { + return &Cammie{db: db} +} + +// Init initializes the cammie screen +func (c *Cammie) Init() tea.Cmd { + return nil +} + +// Update updates the cammie screen +func (c *Cammie) Update(_ tea.Msg) (tea.Model, tea.Cmd) { + return c, nil +} + +// View returns the cammie screen view +func (c *Cammie) View() string { + return "" +} diff --git a/ui/tui.go b/ui/tui.go index 664794a..146dc90 100644 --- a/ui/tui.go +++ b/ui/tui.go @@ -3,39 +3,33 @@ package tui import ( tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/ui/views" "go.uber.org/zap" ) // TUI represent a terminal instance type TUI struct { - db *db.DB - tap tea.Model + screen tea.Model } -// New creates a new tty instance -func New(db *db.DB) *TUI { - return &TUI{ - db: db, - tap: views.NewTapModel(db), - } +// New creates a new tui instance +func New(screen tea.Model) *TUI { + return &TUI{screen: screen} } -// Init initializes the tty +// Init initializes the tui func (t *TUI) Init() tea.Cmd { - return tea.Batch(tea.EnterAltScreen, t.tap.Init()) + return tea.Batch(tea.EnterAltScreen, t.screen.Init()) } -// Update updates the tty +// Update updates the tui func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - tapModel, tapCmd := t.tap.Update(msg) - if tapCmd != nil { - cmds = append(cmds, tapCmd) + model, cmd := t.screen.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) } - t.tap = tapModel + t.screen = model switch msg := msg.(type) { case tea.KeyMsg: @@ -50,7 +44,7 @@ func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return t, tea.Batch(cmds...) } -// View returns the tty view +// View returns the ttuity view func (t *TUI) View() string { - return t.tap.View() + return t.screen.View() } diff --git a/ui/view/tap.go b/ui/view/tap.go new file mode 100644 index 0000000..f5aac2f --- /dev/null +++ b/ui/view/tap.go @@ -0,0 +1,164 @@ +// Package view contains all the different views for the tui +package view + +import ( + "context" + "time" + + "github.com/NimbleMarkets/ntcharts/barchart" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/internal/pkg/db" + "go.uber.org/zap" +) + +// TapModel represents the tap model +type TapModel struct { + db *db.DB + lastOrderID int64 + mate float64 + soft float64 + beer float64 + food float64 +} + +// TapMessage represents a tap message +type TapMessage struct { + lastOrderID int64 + items []tapItem +} + +type tapItem struct { + category string + amount float64 +} + +var tapCategoryColor = map[string]lipgloss.Color{ + "Mate": lipgloss.Color("208"), + "Soft": lipgloss.Color("86"), + "Beer": lipgloss.Color("160"), + "Food": lipgloss.Color("40"), +} + +// NewTapModel creates a new tap model +func NewTapModel(db *db.DB) *TapModel { + return &TapModel{db: db, lastOrderID: -1} +} + +// Init initializes the tap model +func (t *TapModel) Init() tea.Cmd { + return updateOrders(t.db, t.lastOrderID) +} + +// Update updates the tap model +func (t *TapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + t.lastOrderID = msg.(TapMessage).lastOrderID + + for _, msg := range msg.(TapMessage).items { + switch msg.category { + case "Mate": + t.mate += msg.amount + case "Soft": + t.soft += msg.amount + case "Beer": + t.beer += msg.amount + case "Food": + t.food += msg.amount + } + } + + return t, updateOrders(t.db, t.lastOrderID) +} + +// View returns the tap view +func (t *TapModel) View() string { + chart := barchart.New(20, 20) + + barMate := barchart.BarData{ + Label: "Mate", + Values: []barchart.BarValue{{ + Name: "Item1", + Value: t.mate, + Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Mate"]), + }}, + } + barSoft := barchart.BarData{ + Label: "Soft", + Values: []barchart.BarValue{{ + Name: "Item1", + Value: t.soft, + Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Soft"]), + }}, + } + barBeer := barchart.BarData{ + Label: "Beer", + Values: []barchart.BarValue{{ + Name: "Item1", + Value: t.beer, + Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Beer"]), + }}, + } + barFood := barchart.BarData{ + Label: "Food", + Values: []barchart.BarValue{{ + Name: "Item1", + Value: t.food, + Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Food"]), + }}, + } + + chart.PushAll([]barchart.BarData{barMate, barSoft, barBeer, barFood}) + chart.Draw() + + return chart.View() +} + +func updateOrders(db *db.DB, lastOrderID int64) tea.Cmd { + return tea.Tick(time.Second, func(_ time.Time) tea.Msg { + order, err := db.Queries.GetLastOrderByOrderID(context.Background()) + if err != nil { + zap.S().Error("DB: Failed to get last order", err) + return nil + } + + if order.OrderID <= lastOrderID { + return nil + } + + orders, err := db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) + if err != nil { + zap.S().Error("DB: Failed to get tap orders", err) + return nil + } + + mate, soft, beer, food := 0.0, 0.0, 0.0, 0.0 + for _, order := range orders { + switch order.Category { + case "Mate": + mate += float64(order.Count) + case "Soft": + soft += float64(order.Count) + case "Beer": + beer += float64(order.Count) + case "Food": + food += float64(order.Count) + } + } + + messages := make([]tapItem, 0, 4) + if mate > 0 { + messages = append(messages, tapItem{"Mate", mate}) + } + if soft > 0 { + messages = append(messages, tapItem{"Soft", soft}) + } + if beer > 0 { + messages = append(messages, tapItem{"Beer", beer}) + } + if food > 0 { + messages = append(messages, tapItem{"Food", food}) + } + + return TapMessage{lastOrderID: order.OrderID, items: messages} + }) +} From e649034030b3f45343150223dc1be5596e7e0bc5 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 20 Nov 2024 20:09:42 +0100 Subject: [PATCH 13/46] chore: finalisation of tap --- makefile | 64 ++++++++++++++++++++++++++++++++++---------------- ui/view/tap.go | 45 +++++++++++++++++++---------------- watch.sh | 45 ----------------------------------- 3 files changed, 69 insertions(+), 85 deletions(-) delete mode 100755 watch.sh diff --git a/makefile b/makefile index 671e929..bb70fdb 100644 --- a/makefile +++ b/makefile @@ -1,40 +1,64 @@ +# Variables +BACKEND_BIN := backend +TUI_BIN := tui +BACKEND_SRC := cmd/backend/backend.go +TUI_SRC := cmd/tui/tui.go +DB_DIR := ./db/migrations +DB_FILE := ./sqlite.db + +# Phony targets +.PHONY: all build clean run run-backend run-tui sqlc create-migration goose migrate watch + +# Default target: build everything all: build -build: clean backend tui +# Build targets +build: clean build-backend build-tui + +build-backend: + @echo "Building $(BACKEND_BIN)..." + @rm -f $(BACKEND_BIN) + @go build -o $(BACKEND_BIN) $(BACKEND_SRC) -run: backend tui - @./backend & ./tui +build-tui: + @echo "Building $(TUI_BIN)..." + @rm -f $(TUI_BIN) + @go build -o $(TUI_BIN) $(TUI_SRC) -run-backend: backend - @./backend +# Run targets +run: run-backend run-tui -run-tui: 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 $$screen + ./$(TUI_BIN) $$screen -backend: - @[ -f backend ] || (echo "Building backend..." && go build -o backend cmd/backend/backend.go) +# Clean targets +clean: clean-backend clean-tui -tui: - @[ -f tui ] || (echo "Building tui..." && go build -o tui cmd/tui/tui.go) +clean-backend: + @echo "Cleaning $(BACKEND_BIN)..." + @rm -f $(BACKEND_BIN) -clean: - @rm -f backend tui +clean-tui: + @echo "Cleaning $(TUI_BIN)..." + @rm -f $(TUI_BIN) +# SQL and migration targets sqlc: sqlc generate create-migration: @read -p "Enter migration name: " name; \ - goose -dir ./db/migrations create $$name sql + goose -dir $(DB_DIR) create $$name sql goose: @read -p "Action: " action; \ - goose -dir ./db/migrations sqlite3 ./sqlite.db $$action + goose -dir $(DB_DIR) sqlite3 $(DB_FILE) $$action migrate: - @goose -dir ./db/migrations sqlite3 ./sqlite.db up - -watch: - @echo "Starting the watch script..." - ./watch.sh + @goose -dir $(DB_DIR) sqlite3 $(DB_FILE) up diff --git a/ui/view/tap.go b/ui/view/tap.go index f5aac2f..e654030 100644 --- a/ui/view/tap.go +++ b/ui/view/tap.go @@ -52,22 +52,27 @@ func (t *TapModel) Init() tea.Cmd { // Update updates the tap model func (t *TapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - t.lastOrderID = msg.(TapMessage).lastOrderID - - for _, msg := range msg.(TapMessage).items { - switch msg.category { - case "Mate": - t.mate += msg.amount - case "Soft": - t.soft += msg.amount - case "Beer": - t.beer += msg.amount - case "Food": - t.food += msg.amount + switch msg := msg.(type) { + case TapMessage: + t.lastOrderID = msg.lastOrderID + + for _, msg := range msg.items { + switch msg.category { + case "Mate": + t.mate += msg.amount + case "Soft": + t.soft += msg.amount + case "Beer": + t.beer += msg.amount + case "Food": + t.food += msg.amount + } } + + return t, updateOrders(t.db, t.lastOrderID) } - return t, updateOrders(t.db, t.lastOrderID) + return t, nil } // View returns the tap view @@ -85,7 +90,7 @@ func (t *TapModel) View() string { barSoft := barchart.BarData{ Label: "Soft", Values: []barchart.BarValue{{ - Name: "Item1", + Name: "Soft", Value: t.soft, Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Soft"]), }}, @@ -93,7 +98,7 @@ func (t *TapModel) View() string { barBeer := barchart.BarData{ Label: "Beer", Values: []barchart.BarValue{{ - Name: "Item1", + Name: "Beer", Value: t.beer, Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Beer"]), }}, @@ -101,7 +106,7 @@ func (t *TapModel) View() string { barFood := barchart.BarData{ Label: "Food", Values: []barchart.BarValue{{ - Name: "Item1", + Name: "Food", Value: t.food, Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Food"]), }}, @@ -114,21 +119,21 @@ func (t *TapModel) View() string { } func updateOrders(db *db.DB, lastOrderID int64) tea.Cmd { - return tea.Tick(time.Second, func(_ time.Time) tea.Msg { + return tea.Tick(60*time.Second, func(_ time.Time) tea.Msg { order, err := db.Queries.GetLastOrderByOrderID(context.Background()) if err != nil { zap.S().Error("DB: Failed to get last order", err) - return nil + return TapMessage{lastOrderID: lastOrderID, items: nil} } if order.OrderID <= lastOrderID { - return nil + return TapMessage{lastOrderID: lastOrderID, items: nil} } orders, err := db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) if err != nil { zap.S().Error("DB: Failed to get tap orders", err) - return nil + return TapMessage{lastOrderID: lastOrderID, items: nil} } mate, soft, beer, food := 0.0, 0.0, 0.0, 0.0 diff --git a/watch.sh b/watch.sh deleted file mode 100755 index 71f9720..0000000 --- a/watch.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash - - -# Directories to exclude -EXCLUDE_DIRS=(".githooks" ".github" "logs") - -# Build the exclude patterns for inotifywait -EXCLUDE_PATTERN=$(printf "|%s" "${EXCLUDE_DIRS[@]}") -EXCLUDE_PATTERN=${EXCLUDE_PATTERN:1} # Remove leading | - -# Kill background jobs on exit -cleanup() { - if [[ -n $RUN_PID ]]; then - kill $RUN_PID - wait $RUN_PID 2>/dev/null - fi - exit -} -trap cleanup SIGINT SIGTERM - -# Function to restart the program -restart_program() { - echo "Change detected. Restarting program..." - # Stop the running program - if [[ -n $RUN_PID ]]; then - kill $RUN_PID - wait $RUN_PID 2>/dev/null - fi - - # Rebuild and restart - make build || { echo "Build failed"; return; } - make run & - RUN_PID=$! -} - -# Start the program initially -restart_program - -# Watch for file changes, ignoring excluded directories -while true; do - CHANGED_FILE=$(inotifywait -re modify --exclude "(${EXCLUDE_PATTERN})" . 2>/dev/null) - if [[ $? -eq 0 ]]; then - restart_program - fi -done From 9aad0cf73e4b016159341c930e946b56ada0a3aa Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 21 Nov 2024 14:25:46 +0100 Subject: [PATCH 14/46] feat: add cammie chat --- .githooks/pre-commit | 2 +- db/queries/message.sql | 16 ++++ db/queries/tap.sql | 2 + internal/cmd/tui.go | 2 +- internal/pkg/db/sqlc/message.sql.go | 59 +++++++++++++++ internal/pkg/db/sqlc/tap.sql.go | 1 + ui/screen/cammie.go | 17 +++-- ui/tui.go | 2 +- ui/view/message.go | 109 ++++++++++++++++++++++++++++ ui/view/tap.go | 14 ++-- 10 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 ui/view/message.go diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8f175aa..dfd1704 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,6 +1,6 @@ #!/bin/bash -echo "Backend linting" +echo "Linting" golangci-lint run if [ $? -ne 0 ]; then echo "golangci-lint failed. Please fix the errors before committing." diff --git a/db/queries/message.sql b/db/queries/message.sql index 727583e..5617dd1 100644 --- a/db/queries/message.sql +++ b/db/queries/message.sql @@ -23,3 +23,19 @@ RETURNING *; -- name: DeleteMessage :execrows DELETE FROM message WHERE id = ?; + + +-- Other + + +-- name: GetLastMessage :one +SELECT * +FROM message +ORDER BY id DESC +LIMIT 1; + +-- name: GetMessageSinceID :many +SELECT * +FROM message +WHERE id > ? +ORDER BY created_at ASC; diff --git a/db/queries/tap.sql b/db/queries/tap.sql index 2ffb924..36072da 100644 --- a/db/queries/tap.sql +++ b/db/queries/tap.sql @@ -24,8 +24,10 @@ RETURNING *; DELETE FROM tap WHERE id = ?; + -- Other + -- name: GetTapByOrderID :one SELECT * FROM tap diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index cdcdf21..2ad8ad6 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -32,7 +32,7 @@ func TUI(db *db.DB) (*tea.Program, error) { tui := tui.New(val(db)) - program := tea.NewProgram(tui) + program := tea.NewProgram(tui, tea.WithAltScreen()) return program, nil } diff --git a/internal/pkg/db/sqlc/message.sql.go b/internal/pkg/db/sqlc/message.sql.go index 24b2306..14626f9 100644 --- a/internal/pkg/db/sqlc/message.sql.go +++ b/internal/pkg/db/sqlc/message.sql.go @@ -83,6 +83,29 @@ func (q *Queries) GetAllMessages(ctx context.Context) ([]Message, error) { 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.QueryRowContext(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 @@ -102,6 +125,42 @@ func (q *Queries) GetMessageByID(ctx context.Context, id int64) (Message, error) return i, err } +const getMessageSinceID = `-- name: GetMessageSinceID :many +SELECT id, name, ip, message, created_at +FROM message +WHERE id > ? +ORDER BY created_at ASC +` + +func (q *Queries) GetMessageSinceID(ctx context.Context, id int64) ([]Message, error) { + rows, err := q.db.QueryContext(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.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateMessage = `-- name: UpdateMessage :one UPDATE message SET name = ?, ip = ?, message = ? diff --git a/internal/pkg/db/sqlc/tap.sql.go b/internal/pkg/db/sqlc/tap.sql.go index 775c7ab..66dfd70 100644 --- a/internal/pkg/db/sqlc/tap.sql.go +++ b/internal/pkg/db/sqlc/tap.sql.go @@ -240,6 +240,7 @@ func (q *Queries) GetTapByID(ctx context.Context, id int64) (Tap, error) { const getTapByOrderID = `-- name: GetTapByOrderID :one + SELECT id, order_id, order_created_at, name, category, created_at FROM tap WHERE order_id = ? diff --git a/ui/screen/cammie.go b/ui/screen/cammie.go index 8c96472..0a2e97b 100644 --- a/ui/screen/cammie.go +++ b/ui/screen/cammie.go @@ -4,29 +4,34 @@ package screen import ( tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/ui/view" ) // Cammie represents the cammie screen type Cammie struct { - db *db.DB + db *db.DB + cammie *view.MessageModel } // NewCammie creates a new cammie screen func NewCammie(db *db.DB) tea.Model { - return &Cammie{db: db} + return &Cammie{db: db, cammie: view.NewMessageModel(db)} } // Init initializes the cammie screen func (c *Cammie) Init() tea.Cmd { - return nil + return c.cammie.Init() } // Update updates the cammie screen -func (c *Cammie) Update(_ tea.Msg) (tea.Model, tea.Cmd) { - return c, nil +func (c *Cammie) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cammie, cmd := c.cammie.Update(msg) + c.cammie = cammie.(*view.MessageModel) + + return c, cmd } // View returns the cammie screen view func (c *Cammie) View() string { - return "" + return c.cammie.View() } diff --git a/ui/tui.go b/ui/tui.go index 146dc90..ae39bdf 100644 --- a/ui/tui.go +++ b/ui/tui.go @@ -18,7 +18,7 @@ func New(screen tea.Model) *TUI { // Init initializes the tui func (t *TUI) Init() tea.Cmd { - return tea.Batch(tea.EnterAltScreen, t.screen.Init()) + return tea.Batch(t.screen.Init()) } // Update updates the tui diff --git a/ui/view/message.go b/ui/view/message.go new file mode 100644 index 0000000..80e1762 --- /dev/null +++ b/ui/view/message.go @@ -0,0 +1,109 @@ +package view + +import ( + "context" + "fmt" + "hash/fnv" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/list" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + "go.uber.org/zap" +) + +// MessageModel represents the model for the message view +type MessageModel struct { + db *db.DB + lastMessageID int64 + messages []string +} + +// MessageMsg represents the message to update the message view +type MessageMsg struct { + lastMessageID int64 + messages []string +} + +var messageColor = []string{ + "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0", + "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff", +} + +// NewMessageModel creates a new message model view +func NewMessageModel(db *db.DB) *MessageModel { + return &MessageModel{db: db, lastMessageID: -1, messages: []string{}} +} + +// Init initializes the message model view +func (c *MessageModel) Init() tea.Cmd { + return updateMessages(c.db, c.lastMessageID) +} + +// Update updates the message model view +func (c *MessageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case MessageMsg: + c.lastMessageID = msg.lastMessageID + c.messages = append(c.messages, msg.messages...) + + return c, updateMessages(c.db, c.lastMessageID) + } + + return c, nil +} + +// View returns the view for the message model +func (c *MessageModel) View() string { + l := list.New(c.messages).Enumerator(func(_ list.Items, _ int) string { return "" }) + return l.String() +} + +func updateMessages(db *db.DB, lastMessageID int64) tea.Cmd { + return tea.Tick(1*time.Second, func(_ time.Time) tea.Msg { + message, err := db.Queries.GetLastMessage(context.Background()) + if err != nil { + zap.S().Error("DB: Failed to get last message", err) + return MessageMsg{lastMessageID: lastMessageID, messages: []string{}} + } + + if message.ID <= lastMessageID { + return MessageMsg{lastMessageID: lastMessageID, messages: []string{}} + } + + messages, err := db.Queries.GetMessageSinceID(context.Background(), lastMessageID) + if err != nil { + zap.S().Error("DB: Failed to get messages", err) + return MessageMsg{lastMessageID: lastMessageID, messages: []string{}} + } + + formattedMessages := make([]string, 0, len(messages)) + for _, message := range messages { + formattedMessages = append(formattedMessages, formatMessage(message)) + } + + return MessageMsg{lastMessageID: message.ID, messages: formattedMessages} + }) +} + +func hashColor(s string) string { + h := fnv.New32a() + h.Write([]byte(s)) + hash := h.Sum32() + return messageColor[hash%uint32(len(messageColor))] +} + +func formatMessage(msg sqlc.Message) string { + dateStyle := lipgloss.NewStyle().Faint(true) + date := dateStyle.Render(fmt.Sprintf("%s | ", msg.CreatedAt.Format("02/01"))) + + color := hashColor(msg.Name) + colorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) + + sender := fmt.Sprintf("%s %s %s ", colorStyle.Render("["), colorStyle.Bold(true).Render(msg.Name), colorStyle.Render("]")) + message := colorStyle.Render(msg.Message) + + return fmt.Sprintf("%s%s%s", date, sender, message) +} diff --git a/ui/view/tap.go b/ui/view/tap.go index e654030..486fc50 100644 --- a/ui/view/tap.go +++ b/ui/view/tap.go @@ -22,8 +22,8 @@ type TapModel struct { food float64 } -// TapMessage represents a tap message -type TapMessage struct { +// TapMsg represents a tap message +type TapMsg struct { lastOrderID int64 items []tapItem } @@ -53,7 +53,7 @@ func (t *TapModel) Init() tea.Cmd { // Update updates the tap model func (t *TapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case TapMessage: + case TapMsg: t.lastOrderID = msg.lastOrderID for _, msg := range msg.items { @@ -123,17 +123,17 @@ func updateOrders(db *db.DB, lastOrderID int64) tea.Cmd { order, err := db.Queries.GetLastOrderByOrderID(context.Background()) if err != nil { zap.S().Error("DB: Failed to get last order", err) - return TapMessage{lastOrderID: lastOrderID, items: nil} + return TapMsg{lastOrderID: lastOrderID, items: nil} } if order.OrderID <= lastOrderID { - return TapMessage{lastOrderID: lastOrderID, items: nil} + return TapMsg{lastOrderID: lastOrderID, items: nil} } orders, err := db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) if err != nil { zap.S().Error("DB: Failed to get tap orders", err) - return TapMessage{lastOrderID: lastOrderID, items: nil} + return TapMsg{lastOrderID: lastOrderID, items: nil} } mate, soft, beer, food := 0.0, 0.0, 0.0, 0.0 @@ -164,6 +164,6 @@ func updateOrders(db *db.DB, lastOrderID int64) tea.Cmd { messages = append(messages, tapItem{"Food", food}) } - return TapMessage{lastOrderID: order.OrderID, items: messages} + return TapMsg{lastOrderID: order.OrderID, items: messages} }) } From 9f81f6572e04762e0416a89aed8cd6c0dff69978 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 21 Nov 2024 17:16:06 +0100 Subject: [PATCH 15/46] feat: add zess package --- config/development.toml | 6 + .../20241121141143_add_zess_table.sql | 21 +++ db/queries/scan.sql | 35 +++++ db/queries/season.sql | 25 ++++ internal/cmd/tap.go | 2 +- internal/cmd/zess.go | 68 +++++++++ internal/pkg/db/dto/scan.go | 34 +++++ internal/pkg/db/dto/season.go | 54 +++++++ internal/pkg/db/sqlc/models.go | 12 ++ internal/pkg/db/sqlc/scan.sql.go | 116 +++++++++++++++ internal/pkg/db/sqlc/season.sql.go | 132 ++++++++++++++++++ internal/pkg/spotify/account.go | 2 +- internal/pkg/spotify/api.go | 2 +- internal/pkg/tap/api.go | 6 +- internal/pkg/tap/tap.go | 4 +- internal/pkg/zess/api.go | 57 ++++++++ internal/pkg/zess/zess.go | 116 +++++++++++++++ pkg/logger/logger.go | 17 ++- ui/view/message.go | 4 +- 19 files changed, 700 insertions(+), 13 deletions(-) create mode 100644 db/migrations/20241121141143_add_zess_table.sql create mode 100644 db/queries/scan.sql create mode 100644 db/queries/season.sql create mode 100644 internal/cmd/zess.go create mode 100644 internal/pkg/db/dto/scan.go create mode 100644 internal/pkg/db/dto/season.go create mode 100644 internal/pkg/db/sqlc/scan.sql.go create mode 100644 internal/pkg/db/sqlc/season.sql.go create mode 100644 internal/pkg/zess/api.go create mode 100644 internal/pkg/zess/zess.go diff --git a/config/development.toml b/config/development.toml index 8da9187..775786e 100644 --- a/config/development.toml +++ b/config/development.toml @@ -7,6 +7,7 @@ client_id = "your_client_id" client_secret = "your_client_secret" [tap] +api = "https://tap.zeus.gent" interval_s = 60 beers = [ "Schelfaut", @@ -22,6 +23,11 @@ beers = [ "Stella", ] +[zess] +api = "http://localhost:4000/api" +interval_season_s = 300 +interval_scan_s = 60 + [buzzer] song = [ "-n", "-f880", "-l100", "-d0", diff --git a/db/migrations/20241121141143_add_zess_table.sql b/db/migrations/20241121141143_add_zess_table.sql new file mode 100644 index 0000000..d679be8 --- /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 INTEGER PRIMARY KEY, + name VARCHAR(255) NOT NULL, + start TIMESTAMP NOT NULL, + end TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS scan ( + id INTEGER PRIMARY KEY, + scan_time TIMESTAMP NOT NULL +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS scan; + +DROP TABLE IF EXISTS season; +-- +goose StatementEnd diff --git a/db/queries/scan.sql b/db/queries/scan.sql new file mode 100644 index 0000000..4586e4b --- /dev/null +++ b/db/queries/scan.sql @@ -0,0 +1,35 @@ +-- CRUD + +-- name: GetAllScans :many +SELECT * +FROM scan; + +-- name: GetScanByID :one +SELECT * +FROM scan +WHERE id = ?; + +-- name: CreateScan :one +INSERT INTO scan (scan_time) +VALUES (?) +RETURNING *; + +-- name: UpdateScan :one +UPDATE scan +SET scan_time = ? +WHERE id = ? +RETURNING *; + +-- name: DeleteScan :execrows +DELETE FROM scan +WHERE id = ?; + + +-- Other + + +-- name: GetLatestScan :one +SELECT * +FROM scan +ORDER BY id DESC +LIMIT 1; diff --git a/db/queries/season.sql b/db/queries/season.sql new file mode 100644 index 0000000..df14e54 --- /dev/null +++ b/db/queries/season.sql @@ -0,0 +1,25 @@ +-- CRUD + +-- name: GetAllSeasons :many +SELECT * +FROM season; + +-- name: GetSeasonByID :one +SELECT * +FROM season +WHERE id = ?; + +-- name: CreateSeason :one +INSERT INTO season (name, start, end) +VALUES (?, ?, ?) +RETURNING *; + +-- name: UpdateSeason :one +UPDATE season +SET name = ?, start = ?, end = ? +WHERE id = ? +RETURNING *; + +-- name: DeleteSeason :execrows +DELETE FROM season +WHERE id = ?; diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go index 867434f..015ffa8 100644 --- a/internal/cmd/tap.go +++ b/internal/cmd/tap.go @@ -9,7 +9,7 @@ import ( "go.uber.org/zap" ) -// Tap starts the tap +// Tap starts the tap instance func Tap(db *db.DB) (*tap.Tap, chan bool) { tap := tap.New(db) done := make(chan bool) diff --git a/internal/cmd/zess.go b/internal/cmd/zess.go new file mode 100644 index 0000000..55c5247 --- /dev/null +++ b/internal/cmd/zess.go @@ -0,0 +1,68 @@ +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) + doneScan := make(chan bool) + + go zessPeriodicSeasonUpdate(zess, doneSeason) + go zessPeriodicScanUpdate(zess, doneScan) + + return zess, doneSeason, doneScan +} + +func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool) { + interval := config.GetDefaultInt("zess.interval_season_s", 300) + 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() + + for { + select { + case <-done: + zap.S().Info("Zess: Stopping periodic season update") + return + case <-ticker.C: + // Update seasons + zap.S().Info("Zess: Updating seasons") + err := zess.UpdateSeasons() + if err != nil { + zap.S().Error("Zess: Error updating seasons\n", err) + } + } + } +} + +func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool) { + interval := config.GetDefaultInt("zess.interval_scan_s", 60) + 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() + + for { + select { + case <-done: + zap.S().Info("Zess: Stopping periodic scan update") + return + case <-ticker.C: + // Update scans + zap.S().Info("Zess: Updating scans") + err := zess.UpdateScans() + if err != nil { + zap.S().Error("Zess: Error updating scans\n", err) + } + } + } +} diff --git a/internal/pkg/db/dto/scan.go b/internal/pkg/db/dto/scan.go new file mode 100644 index 0000000..63628fc --- /dev/null +++ b/internal/pkg/db/dto/scan.go @@ -0,0 +1,34 @@ +package dto + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" +) + +// Scan is the DTO for the scan +type Scan struct { + ID int64 `json:"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, + ScanTime: scan.ScanTime, + } +} + +// CreateParams converts a Scan to sqlc.CreateScanParams +func (s *Scan) CreateParams() time.Time { + return s.ScanTime +} + +// UpdateParams converts a Scan to sqlc.UpdateScanParams +func (s *Scan) UpdateParams() sqlc.UpdateScanParams { + return sqlc.UpdateScanParams{ + ID: s.ID, + ScanTime: s.ScanTime, + } +} diff --git a/internal/pkg/db/dto/season.go b/internal/pkg/db/dto/season.go new file mode 100644 index 0000000..46c4196 --- /dev/null +++ b/internal/pkg/db/dto/season.go @@ -0,0 +1,54 @@ +package dto + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" +) + +// Season is the DTO for the season +type Season struct { + ID int64 `json:"id"` + Name string `json:"name" validate:"required"` + Start time.Time `json:"start" validate:"required"` + End time.Time `json:"end" validate:"required"` +} + +// SeasonDTO converts a sqlc.Season to a Season +func SeasonDTO(season sqlc.Season) *Season { + return &Season{ + ID: season.ID, + Name: season.Name, + Start: season.Start, + End: season.End, + } +} + +// 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 { + return 0 + } + + return 1 +} + +// CreateParams converts a Season to sqlc.CreateSeasonParams +func (s *Season) CreateParams() sqlc.CreateSeasonParams { + return sqlc.CreateSeasonParams{ + Name: s.Name, + Start: s.Start, + End: s.End, + } +} + +// UpdateParams converts a Season to sqlc.UpdateSeasonParams +func (s *Season) UpdateParams() sqlc.UpdateSeasonParams { + return sqlc.UpdateSeasonParams{ + ID: s.ID, + Name: s.Name, + Start: s.Start, + End: s.End, + } +} diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index e50215c..49b30c7 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -16,6 +16,18 @@ type Message struct { CreatedAt time.Time } +type Scan struct { + ID int64 + ScanTime time.Time +} + +type Season struct { + ID int64 + Name string + Start time.Time + End time.Time +} + type Spotify struct { ID int64 Title string diff --git a/internal/pkg/db/sqlc/scan.sql.go b/internal/pkg/db/sqlc/scan.sql.go new file mode 100644 index 0000000..a13c05a --- /dev/null +++ b/internal/pkg/db/sqlc/scan.sql.go @@ -0,0 +1,116 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: scan.sql + +package sqlc + +import ( + "context" + "time" +) + +const createScan = `-- name: CreateScan :one +INSERT INTO scan (scan_time) +VALUES (?) +RETURNING id, scan_time +` + +func (q *Queries) CreateScan(ctx context.Context, scanTime time.Time) (Scan, error) { + row := q.db.QueryRowContext(ctx, createScan, scanTime) + var i Scan + err := row.Scan(&i.ID, &i.ScanTime) + return i, err +} + +const deleteScan = `-- name: DeleteScan :execrows +DELETE FROM scan +WHERE id = ? +` + +func (q *Queries) DeleteScan(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteScan, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getAllScans = `-- name: GetAllScans :many + +SELECT id, scan_time +FROM scan +` + +// CRUD +func (q *Queries) GetAllScans(ctx context.Context) ([]Scan, error) { + rows, err := q.db.QueryContext(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); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getLatestScan = `-- name: GetLatestScan :one + + +SELECT id, scan_time +FROM scan +ORDER BY id DESC +LIMIT 1 +` + +// Other +func (q *Queries) GetLatestScan(ctx context.Context) (Scan, error) { + row := q.db.QueryRowContext(ctx, getLatestScan) + var i Scan + err := row.Scan(&i.ID, &i.ScanTime) + return i, err +} + +const getScanByID = `-- name: GetScanByID :one +SELECT id, scan_time +FROM scan +WHERE id = ? +` + +func (q *Queries) GetScanByID(ctx context.Context, id int64) (Scan, error) { + row := q.db.QueryRowContext(ctx, getScanByID, id) + var i Scan + err := row.Scan(&i.ID, &i.ScanTime) + return i, err +} + +const updateScan = `-- name: UpdateScan :one +UPDATE scan +SET scan_time = ? +WHERE id = ? +RETURNING id, scan_time +` + +type UpdateScanParams struct { + ScanTime time.Time + ID int64 +} + +func (q *Queries) UpdateScan(ctx context.Context, arg UpdateScanParams) (Scan, error) { + row := q.db.QueryRowContext(ctx, updateScan, arg.ScanTime, arg.ID) + var i Scan + err := row.Scan(&i.ID, &i.ScanTime) + 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..6cbd8af --- /dev/null +++ b/internal/pkg/db/sqlc/season.sql.go @@ -0,0 +1,132 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: season.sql + +package sqlc + +import ( + "context" + "time" +) + +const createSeason = `-- name: CreateSeason :one +INSERT INTO season (name, start, end) +VALUES (?, ?, ?) +RETURNING id, name, start, "end" +` + +type CreateSeasonParams struct { + Name string + Start time.Time + End time.Time +} + +func (q *Queries) CreateSeason(ctx context.Context, arg CreateSeasonParams) (Season, error) { + row := q.db.QueryRowContext(ctx, createSeason, arg.Name, arg.Start, arg.End) + var i Season + err := row.Scan( + &i.ID, + &i.Name, + &i.Start, + &i.End, + ) + return i, err +} + +const deleteSeason = `-- name: DeleteSeason :execrows +DELETE FROM season +WHERE id = ? +` + +func (q *Queries) DeleteSeason(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteSeason, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getAllSeasons = `-- name: GetAllSeasons :many + +SELECT id, name, start, "end" +FROM season +` + +// CRUD +func (q *Queries) GetAllSeasons(ctx context.Context) ([]Season, error) { + rows, err := q.db.QueryContext(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, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSeasonByID = `-- name: GetSeasonByID :one +SELECT id, name, start, "end" +FROM season +WHERE id = ? +` + +func (q *Queries) GetSeasonByID(ctx context.Context, id int64) (Season, error) { + row := q.db.QueryRowContext(ctx, getSeasonByID, id) + var i Season + err := row.Scan( + &i.ID, + &i.Name, + &i.Start, + &i.End, + ) + return i, err +} + +const updateSeason = `-- name: UpdateSeason :one +UPDATE season +SET name = ?, start = ?, end = ? +WHERE id = ? +RETURNING id, name, start, "end" +` + +type UpdateSeasonParams struct { + Name string + Start time.Time + End time.Time + ID int64 +} + +func (q *Queries) UpdateSeason(ctx context.Context, arg UpdateSeasonParams) (Season, error) { + row := q.db.QueryRowContext(ctx, updateSeason, + arg.Name, + arg.Start, + arg.End, + arg.ID, + ) + var i Season + err := row.Scan( + &i.ID, + &i.Name, + &i.Start, + &i.End, + ) + return i, err +} diff --git a/internal/pkg/spotify/account.go b/internal/pkg/spotify/account.go index 816c869..7b95575 100644 --- a/internal/pkg/spotify/account.go +++ b/internal/pkg/spotify/account.go @@ -34,7 +34,7 @@ func (s *Spotify) refreshToken() error { res := new(accountResponse) status, _, errs := req.Struct(res) if len(errs) > 0 { - return errors.Join(errs...) + return errors.Join(append([]error{errors.New("Spotify: Token refresh request failed")}, errs...)...) } if status != fiber.StatusOK { return errors.New("error getting access token") diff --git a/internal/pkg/spotify/api.go b/internal/pkg/spotify/api.go index 2715587..d57a969 100644 --- a/internal/pkg/spotify/api.go +++ b/internal/pkg/spotify/api.go @@ -31,7 +31,7 @@ func (s *Spotify) setTrack(track *dto.Spotify) error { res := new(trackResponse) status, _, errs := req.Struct(res) if len(errs) > 0 { - return errors.Join(errs...) + return errors.Join(append([]error{errors.New("Spotify: Track request failed")}, errs...)...) } if status != fiber.StatusOK { return errors.New("error getting track") diff --git a/internal/pkg/tap/api.go b/internal/pkg/tap/api.go index 6613e19..7bdc80d 100644 --- a/internal/pkg/tap/api.go +++ b/internal/pkg/tap/api.go @@ -8,8 +8,6 @@ import ( "go.uber.org/zap" ) -const apiURL = "https://tap.zeus.gent/recent" - type orderResponseItem struct { OrderID int64 `json:"order_id"` OrderCreatedAt time.Time `json:"order_created_at"` @@ -24,12 +22,12 @@ type orderResponse struct { func (t *Tap) getOrders() ([]orderResponseItem, error) { zap.S().Info("Tap: Getting orders") - req := fiber.Get(apiURL) + req := fiber.Get(t.api + "/recent") res := new(orderResponse) status, _, errs := req.Struct(res) if len(errs) > 0 { - return nil, errors.Join(errs...) + return nil, errors.Join(append([]error{errors.New("Tap: Order API request failed")}, errs...)...) } if status != fiber.StatusOK { return nil, errors.New("error getting orders") diff --git a/internal/pkg/tap/tap.go b/internal/pkg/tap/tap.go index 59aecad..2dd2cd2 100644 --- a/internal/pkg/tap/tap.go +++ b/internal/pkg/tap/tap.go @@ -17,6 +17,7 @@ import ( // Tap represents a tap instance type Tap struct { db *db.DB + api string beers []string } @@ -36,9 +37,10 @@ var defaultBeers = []string{ // New creates a new tap instance func New(db *db.DB) *Tap { + api := config.GetDefaultString("tap.api", "https://tap.zeus.gent") beers := config.GetDefaultStringSlice("tap.beers", defaultBeers) - return &Tap{db: db, beers: beers} + return &Tap{db: db, api: api, beers: beers} } // Update gets all new orders from tap diff --git a/internal/pkg/zess/api.go b/internal/pkg/zess/api.go new file mode 100644 index 0000000..770e0dd --- /dev/null +++ b/internal/pkg/zess/api.go @@ -0,0 +1,57 @@ +package zess + +import ( + "errors" + + "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([]error{errors.New("Zess: Scan API request failed")}, errs...)...) + } + if status != fiber.StatusOK { + return nil, errors.New("error getting scans") + } + + 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..bf251c4 --- /dev/null +++ b/internal/pkg/zess/zess.go @@ -0,0 +1,116 @@ +// Package zess provides all zess related logic +package zess + +import ( + "context" + "database/sql" + "errors" + "slices" + + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/pkg/util" +) + +// Zess represents a zess instance +type Zess struct { + db *db.DB + api string +} + +// New creates a new zess instance +func New(db *db.DB) *Zess { + api := config.GetDefaultString("zess.api", "https://zess.zeus.gent") + return &Zess{db: db, api: api} +} + +// UpdateSeasons updates the seasons +func (z *Zess) UpdateSeasons() error { + seasons, err := z.db.Queries.GetAllSeasons(context.Background()) + if err != nil { + if err != sql.ErrNoRows { + return err + } + } + + // Get all seasons from zess + zessSeasons, err := z.getSeasons() + if err != nil { + return err + } + + equal := slices.CompareFunc(util.SliceMap(seasons, dto.SeasonDTO), *zessSeasons, dto.SeasonCmp) + + // Same seasons + if equal == 0 { + return nil + } + + // Update seasons + errs := make([]error, 0) + + for i, season := range *zessSeasons { + if i < len(seasons) { + // Update seasons + seasons[i].ID = season.ID + seasons[i].Name = season.Name + seasons[i].Start = season.Start + seasons[i].End = season.End + + _, err := z.db.Queries.UpdateSeason(context.Background(), dto.SeasonDTO(seasons[i]).UpdateParams()) + if err != nil { + errs = append(errs, err) + } + } else { + // Create seasons + _, err := z.db.Queries.CreateSeason(context.Background(), season.CreateParams()) + if err != nil { + errs = append(errs, err) + } + } + } + + // Delete seasons + for i := len(*zessSeasons); i < len(seasons); i++ { + _, err := z.db.Queries.DeleteSeason(context.Background(), seasons[i].ID) + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// UpdateScans updates the scans +func (z *Zess) UpdateScans() error { + lastScan, err := z.db.Queries.GetLatestScan(context.Background()) + if err != nil { + if err != sql.ErrNoRows { + return err + } + + lastScan = sqlc.Scan{ID: -1} + } + + // Get all scans + zessScans, err := z.getScans() + if err != nil { + return err + } + + errs := make([]error, 0) + for _, scan := range *zessScans { + if lastScan.ID >= scan.ID { + continue + } + + _, err := z.db.Queries.CreateScan(context.Background(), scan.CreateParams()) + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 22f8fca..6dde1cf 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -10,7 +10,7 @@ import ( ) // New returns a new logger instance -func New(logFile string) (*zap.Logger, error) { +func New(logFile string, console bool) (*zap.Logger, error) { // Create logs directory err := os.Mkdir("logs", os.ModePerm) if err != nil && !os.IsExist(err) { @@ -25,8 +25,19 @@ func New(logFile string) (*zap.Logger, error) { } else { zapConfig = zap.NewProductionConfig() } - zapConfig.OutputPaths = []string{fmt.Sprintf("logs/%s.log", logFile)} - zapConfig.ErrorOutputPaths = []string{fmt.Sprintf("logs/%s.log", logFile)} + + 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()) diff --git a/ui/view/message.go b/ui/view/message.go index 80e1762..ad21d52 100644 --- a/ui/view/message.go +++ b/ui/view/message.go @@ -97,12 +97,12 @@ func hashColor(s string) string { func formatMessage(msg sqlc.Message) string { dateStyle := lipgloss.NewStyle().Faint(true) - date := dateStyle.Render(fmt.Sprintf("%s | ", msg.CreatedAt.Format("02/01"))) + date := dateStyle.Render(fmt.Sprintf("%s ", msg.CreatedAt.Format("02/01"))) color := hashColor(msg.Name) colorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) - sender := fmt.Sprintf("%s %s %s ", colorStyle.Render("["), colorStyle.Bold(true).Render(msg.Name), colorStyle.Render("]")) + sender := fmt.Sprintf("%s %s ", colorStyle.Bold(true).Render(msg.Name), colorStyle.Render("|")) message := colorStyle.Render(msg.Message) return fmt.Sprintf("%s%s%s", date, sender, message) From d92422a9e027241a4eb356461c4d146277c12ea8 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 21 Nov 2024 17:55:44 +0100 Subject: [PATCH 16/46] chore: update readme --- README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++----------- makefile | 6 ++--- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 354b86b..99c2169 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,65 @@ Displays the cammie chat along with some other statistics. -## Development +## Development Setup -Check [.tool-versions](.tool-versions) for the current used version of golang +### Prerequisites -- Install pre-commit hooks `git config --local core.hooksPath .githooks/`. -- Install goose `go install github.com/pressly/goose/v3/cmd/goose@latest`. -- Install sqlc `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest` -- Install air `go install github.com/air-verse/air@latest` +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) -Create a `.env` (Look at [.env.example](.env.example])). +### Configuration -Start developing with `make build` and `make run`. -For live reloading `inotify` is required `sudo apt install inotify-tools`. -`make watch` will start the hot reloading. +1. Create a `.env` file specifying the environment. Available options are: + - `development` + - `production` +2. Configure the appropriate settings in the corresponding configuration file located in the [config directory](./config) -Logs will be logged to the `./logs` directory (will be made at the start of the first run). +## DB -## Build & Run +This project uses an SQLite database. +SQLC is used to generate statically typed queries and goose is responsible for the database migrations. -- `make` -- `make run` +### Usefull commands + +- `make migrate`: Run database migrations to update your database schema. +- `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](./ui/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](./ui/view/). + +### Logs + +TUI logs are written to `./logs/tui.log` and _not_ to `stdout`. diff --git a/makefile b/makefile index bb70fdb..6900102 100644 --- a/makefile +++ b/makefile @@ -56,9 +56,9 @@ create-migration: @read -p "Enter migration name: " name; \ goose -dir $(DB_DIR) create $$name sql +migrate: + @goose -dir $(DB_DIR) sqlite3 $(DB_FILE) up + goose: @read -p "Action: " action; \ goose -dir $(DB_DIR) sqlite3 $(DB_FILE) $$action - -migrate: - @goose -dir $(DB_DIR) sqlite3 $(DB_FILE) up From ca5015a743b4571fcd70eabd2c8ead6b6214f218 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 24 Nov 2024 14:21:39 +0100 Subject: [PATCH 17/46] feat: add zess stats --- ...20241121175601_add_column_zess_current.sql | 11 ++ db/queries/scan.sql | 14 +- db/queries/season.sql | 6 +- internal/pkg/db/dto/season.go | 36 +++-- internal/pkg/db/sqlc/models.go | 9 +- internal/pkg/db/sqlc/scan.sql.go | 50 +++++- internal/pkg/db/sqlc/season.sql.go | 42 +++-- internal/pkg/zess/zess.go | 2 +- ui/screen/cammie.go | 22 ++- ui/view/message.go | 8 +- ui/view/tap.go | 13 +- ui/view/zess.go | 153 ++++++++++++++++++ 12 files changed, 309 insertions(+), 57 deletions(-) create mode 100644 db/migrations/20241121175601_add_column_zess_current.sql create mode 100644 ui/view/zess.go 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/queries/scan.sql b/db/queries/scan.sql index 4586e4b..f30cecf 100644 --- a/db/queries/scan.sql +++ b/db/queries/scan.sql @@ -28,8 +28,20 @@ WHERE id = ?; -- Other --- name: GetLatestScan :one +-- name: GetLastScan :one SELECT * FROM scan ORDER BY id DESC LIMIT 1; + +-- name: GetAllScansSinceID :many +SELECT * +FROM scan +WHERE id > ? +ORDER BY 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 index df14e54..98eaeae 100644 --- a/db/queries/season.sql +++ b/db/queries/season.sql @@ -10,13 +10,13 @@ FROM season WHERE id = ?; -- name: CreateSeason :one -INSERT INTO season (name, start, end) -VALUES (?, ?, ?) +INSERT INTO season (name, start, end, current) +VALUES (?, ?, ?, ?) RETURNING *; -- name: UpdateSeason :one UPDATE season -SET name = ?, start = ?, end = ? +SET name = ?, start = ?, end = ?, current = ? WHERE id = ? RETURNING *; diff --git a/internal/pkg/db/dto/season.go b/internal/pkg/db/dto/season.go index 46c4196..9dbab42 100644 --- a/internal/pkg/db/dto/season.go +++ b/internal/pkg/db/dto/season.go @@ -8,26 +8,28 @@ import ( // Season is the DTO for the season type Season struct { - ID int64 `json:"id"` - Name string `json:"name" validate:"required"` - Start time.Time `json:"start" validate:"required"` - End time.Time `json:"end" validate:"required"` + ID int64 `json:"id"` + Name string `json:"name" validate:"required"` + Start time.Time `json:"start" validate:"required"` + End time.Time `json:"end" validate:"required"` + Current bool `json:"is_current" validate:"required"` } // SeasonDTO converts a sqlc.Season to a Season func SeasonDTO(season sqlc.Season) *Season { return &Season{ - ID: season.ID, - Name: season.Name, - Start: season.Start, - End: season.End, + ID: season.ID, + Name: season.Name, + Start: season.Start, + End: season.End, + 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 { + if s1.ID == s2.ID && s1.Name == s2.Name && s1.Start == s2.Start && s1.End == s2.End && s1.Current == s2.Current { return 0 } @@ -37,18 +39,20 @@ func SeasonCmp(s1, s2 *Season) int { // CreateParams converts a Season to sqlc.CreateSeasonParams func (s *Season) CreateParams() sqlc.CreateSeasonParams { return sqlc.CreateSeasonParams{ - Name: s.Name, - Start: s.Start, - End: s.End, + Name: s.Name, + Start: s.Start, + End: s.End, + 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, - Start: s.Start, - End: s.End, + ID: s.ID, + Name: s.Name, + Start: s.Start, + End: s.End, + Current: s.Current, } } diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index 49b30c7..3a7e15f 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -22,10 +22,11 @@ type Scan struct { } type Season struct { - ID int64 - Name string - Start time.Time - End time.Time + ID int64 + Name string + Start time.Time + End time.Time + Current bool } type Spotify struct { diff --git a/internal/pkg/db/sqlc/scan.sql.go b/internal/pkg/db/sqlc/scan.sql.go index a13c05a..58f294e 100644 --- a/internal/pkg/db/sqlc/scan.sql.go +++ b/internal/pkg/db/sqlc/scan.sql.go @@ -66,7 +66,37 @@ func (q *Queries) GetAllScans(ctx context.Context) ([]Scan, error) { return items, nil } -const getLatestScan = `-- name: GetLatestScan :one +const getAllScansSinceID = `-- name: GetAllScansSinceID :many +SELECT id, scan_time +FROM scan +WHERE id > ? +ORDER BY scan_time ASC +` + +func (q *Queries) GetAllScansSinceID(ctx context.Context, id int64) ([]Scan, error) { + rows, err := q.db.QueryContext(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); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getLastScan = `-- name: GetLastScan :one SELECT id, scan_time @@ -76,8 +106,8 @@ LIMIT 1 ` // Other -func (q *Queries) GetLatestScan(ctx context.Context) (Scan, error) { - row := q.db.QueryRowContext(ctx, getLatestScan) +func (q *Queries) GetLastScan(ctx context.Context) (Scan, error) { + row := q.db.QueryRowContext(ctx, getLastScan) var i Scan err := row.Scan(&i.ID, &i.ScanTime) return i, err @@ -96,6 +126,20 @@ func (q *Queries) GetScanByID(ctx context.Context, id int64) (Scan, error) { 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.QueryRowContext(ctx, getScansInCurrentSeason) + var amount int64 + err := row.Scan(&amount) + return amount, err +} + const updateScan = `-- name: UpdateScan :one UPDATE scan SET scan_time = ? diff --git a/internal/pkg/db/sqlc/season.sql.go b/internal/pkg/db/sqlc/season.sql.go index 6cbd8af..40663ab 100644 --- a/internal/pkg/db/sqlc/season.sql.go +++ b/internal/pkg/db/sqlc/season.sql.go @@ -11,25 +11,32 @@ import ( ) const createSeason = `-- name: CreateSeason :one -INSERT INTO season (name, start, end) -VALUES (?, ?, ?) -RETURNING id, name, start, "end" +INSERT INTO season (name, start, end, current) +VALUES (?, ?, ?, ?) +RETURNING id, name, start, "end", "current" ` type CreateSeasonParams struct { - Name string - Start time.Time - End time.Time + Name string + Start time.Time + End time.Time + Current bool } func (q *Queries) CreateSeason(ctx context.Context, arg CreateSeasonParams) (Season, error) { - row := q.db.QueryRowContext(ctx, createSeason, arg.Name, arg.Start, arg.End) + row := q.db.QueryRowContext(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 } @@ -49,7 +56,7 @@ func (q *Queries) DeleteSeason(ctx context.Context, id int64) (int64, error) { const getAllSeasons = `-- name: GetAllSeasons :many -SELECT id, name, start, "end" +SELECT id, name, start, "end", "current" FROM season ` @@ -68,6 +75,7 @@ func (q *Queries) GetAllSeasons(ctx context.Context) ([]Season, error) { &i.Name, &i.Start, &i.End, + &i.Current, ); err != nil { return nil, err } @@ -83,7 +91,7 @@ func (q *Queries) GetAllSeasons(ctx context.Context) ([]Season, error) { } const getSeasonByID = `-- name: GetSeasonByID :one -SELECT id, name, start, "end" +SELECT id, name, start, "end", "current" FROM season WHERE id = ? ` @@ -96,22 +104,24 @@ func (q *Queries) GetSeasonByID(ctx context.Context, id int64) (Season, error) { &i.Name, &i.Start, &i.End, + &i.Current, ) return i, err } const updateSeason = `-- name: UpdateSeason :one UPDATE season -SET name = ?, start = ?, end = ? +SET name = ?, start = ?, end = ?, current = ? WHERE id = ? -RETURNING id, name, start, "end" +RETURNING id, name, start, "end", "current" ` type UpdateSeasonParams struct { - Name string - Start time.Time - End time.Time - ID int64 + Name string + Start time.Time + End time.Time + Current bool + ID int64 } func (q *Queries) UpdateSeason(ctx context.Context, arg UpdateSeasonParams) (Season, error) { @@ -119,6 +129,7 @@ func (q *Queries) UpdateSeason(ctx context.Context, arg UpdateSeasonParams) (Sea arg.Name, arg.Start, arg.End, + arg.Current, arg.ID, ) var i Season @@ -127,6 +138,7 @@ func (q *Queries) UpdateSeason(ctx context.Context, arg UpdateSeasonParams) (Sea &i.Name, &i.Start, &i.End, + &i.Current, ) return i, err } diff --git a/internal/pkg/zess/zess.go b/internal/pkg/zess/zess.go index bf251c4..47a8d72 100644 --- a/internal/pkg/zess/zess.go +++ b/internal/pkg/zess/zess.go @@ -85,7 +85,7 @@ func (z *Zess) UpdateSeasons() error { // UpdateScans updates the scans func (z *Zess) UpdateScans() error { - lastScan, err := z.db.Queries.GetLatestScan(context.Background()) + lastScan, err := z.db.Queries.GetLastScan(context.Background()) if err != nil { if err != sql.ErrNoRows { return err diff --git a/ui/screen/cammie.go b/ui/screen/cammie.go index 0a2e97b..9f884a9 100644 --- a/ui/screen/cammie.go +++ b/ui/screen/cammie.go @@ -2,6 +2,8 @@ package screen import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/ui/view" @@ -9,29 +11,33 @@ import ( // Cammie represents the cammie screen type Cammie struct { - db *db.DB - cammie *view.MessageModel + db *db.DB + zess *view.ZessModel + tap *view.TapModel } // NewCammie creates a new cammie screen func NewCammie(db *db.DB) tea.Model { - return &Cammie{db: db, cammie: view.NewMessageModel(db)} + return &Cammie{db: db, zess: view.NewZessModel(db), tap: view.NewTapModel(db)} } // Init initializes the cammie screen func (c *Cammie) Init() tea.Cmd { - return c.cammie.Init() + return tea.Batch(c.zess.Init(), c.tap.Init()) } // Update updates the cammie screen func (c *Cammie) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cammie, cmd := c.cammie.Update(msg) - c.cammie = cammie.(*view.MessageModel) + cammie, cmd := c.zess.Update(msg) + c.zess = cammie.(*view.ZessModel) + + tap, cmd2 := c.tap.Update(msg) + c.tap = tap.(*view.TapModel) - return c, cmd + return c, tea.Batch(cmd, cmd2) } // View returns the cammie screen view func (c *Cammie) View() string { - return c.cammie.View() + return fmt.Sprintf("%s\n%s", c.tap.View(), c.zess.View()) } diff --git a/ui/view/message.go b/ui/view/message.go index ad21d52..6a2c588 100644 --- a/ui/view/message.go +++ b/ui/view/message.go @@ -2,6 +2,7 @@ package view import ( "context" + "database/sql" "fmt" "hash/fnv" "time" @@ -57,6 +58,9 @@ func (c *MessageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View returns the view for the message model func (c *MessageModel) View() string { + // TODO: Limit the amount of messages shown + // TODO: Wrap messages + zap.S().Info("Viewing messages") l := list.New(c.messages).Enumerator(func(_ list.Items, _ int) string { return "" }) return l.String() } @@ -65,7 +69,9 @@ func updateMessages(db *db.DB, lastMessageID int64) tea.Cmd { return tea.Tick(1*time.Second, func(_ time.Time) tea.Msg { message, err := db.Queries.GetLastMessage(context.Background()) if err != nil { - zap.S().Error("DB: Failed to get last message", err) + if err != sql.ErrNoRows { + zap.S().Error("DB: Failed to get last message", err) + } return MessageMsg{lastMessageID: lastMessageID, messages: []string{}} } diff --git a/ui/view/tap.go b/ui/view/tap.go index 486fc50..414bc0a 100644 --- a/ui/view/tap.go +++ b/ui/view/tap.go @@ -3,6 +3,7 @@ package view import ( "context" + "database/sql" "time" "github.com/NimbleMarkets/ntcharts/barchart" @@ -82,7 +83,7 @@ func (t *TapModel) View() string { barMate := barchart.BarData{ Label: "Mate", Values: []barchart.BarValue{{ - Name: "Item1", + Name: "Mate", Value: t.mate, Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Mate"]), }}, @@ -122,18 +123,20 @@ func updateOrders(db *db.DB, lastOrderID int64) tea.Cmd { return tea.Tick(60*time.Second, func(_ time.Time) tea.Msg { order, err := db.Queries.GetLastOrderByOrderID(context.Background()) if err != nil { - zap.S().Error("DB: Failed to get last order", err) - return TapMsg{lastOrderID: lastOrderID, items: nil} + if err != sql.ErrNoRows { + zap.S().Error("DB: Failed to get last order", err) + } + return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}} } if order.OrderID <= lastOrderID { - return TapMsg{lastOrderID: lastOrderID, items: nil} + return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}} } orders, err := db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) if err != nil { zap.S().Error("DB: Failed to get tap orders", err) - return TapMsg{lastOrderID: lastOrderID, items: nil} + return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}} } mate, soft, beer, food := 0.0, 0.0, 0.0, 0.0 diff --git a/ui/view/zess.go b/ui/view/zess.go new file mode 100644 index 0000000..0a1d1fa --- /dev/null +++ b/ui/view/zess.go @@ -0,0 +1,153 @@ +package view + +import ( + "context" + "database/sql" + "time" + + "github.com/NimbleMarkets/ntcharts/canvas" + "github.com/NimbleMarkets/ntcharts/linechart/wavelinechart" + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/pkg/db" + "go.uber.org/zap" +) + +// ZessModel represents the model for the zess view +type ZessModel struct { + db *db.DB + lastScanID int64 + scans []zessDayScan + totalScans int64 +} + +type zessDayScan struct { + date time.Time + amount int64 +} + +// ZessScanMsg represents the message to update the zess view +type ZessScanMsg struct { + lastScanID int64 + scans []zessDayScan +} + +// ZessSeasonMsg represents the message to update the zess view +type ZessSeasonMsg struct { + valid bool + amount int64 +} + +// NewZessModel creates a new zess model view +func NewZessModel(db *db.DB) *ZessModel { + return &ZessModel{db: db, lastScanID: -1, scans: make([]zessDayScan, 0), totalScans: 0} +} + +// Init created a new zess model +func (z *ZessModel) Init() tea.Cmd { + return tea.Batch(updateScans(z.db, z.lastScanID), updateSeason(z.db)) +} + +// Update updates the zess model +func (z *ZessModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case ZessScanMsg: + z.lastScanID = msg.lastScanID + for _, scan := range msg.scans { + if len(z.scans) == 0 || scan.date.After(z.scans[len(z.scans)-1].date) { + z.scans = append(z.scans, scan) + // TODO: Potentially remove first element (scans = scans[1:]) + continue + } + + for i := len(z.scans) - 1; i >= 0; i-- { + if scan.date.Equal(z.scans[i].date) { + z.scans[i].amount += scan.amount + break + } + } + } + + return z, updateScans(z.db, z.lastScanID) + + case ZessSeasonMsg: + if msg.valid { + z.totalScans = msg.amount + } + + return z, updateSeason(z.db) + } + + return z, nil +} + +// View returns the view for the zess model +func (z *ZessModel) View() string { + chart := wavelinechart.New(40, 20, wavelinechart.WithYRange(-2, 30)) + chart.XLabelFormatter = func(_ int, v float64) string { + return time.Now().Add(-time.Duration(v*24) * time.Hour).Format("02") + } + + now := time.Now().Truncate(24 * time.Hour) + for _, scan := range z.scans { + chart.Plot(canvas.Float64Point{X: now.Sub(scan.date).Hours() / 24, Y: float64(scan.amount)}) + } + chart.Draw() + + return chart.View() +} + +func updateScans(db *db.DB, lastScanID int64) tea.Cmd { + return tea.Tick(60*time.Second, func(_ time.Time) tea.Msg { + scan, err := db.Queries.GetLastScan(context.Background()) + if err != nil { + if err != sql.ErrNoRows { + zap.S().Error("DB: Failed to get last scan", err) + } + return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}} + } + + if scan.ID <= lastScanID { + return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}} + } + + scans, err := db.Queries.GetAllScansSinceID(context.Background(), lastScanID) + if err != nil { + if err != sql.ErrNoRows { + zap.S().Error("DB: Failed to get scan count by day", err) + } + return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}} + } + + zessMsg := ZessScanMsg{lastScanID: scan.ID, scans: []zessDayScan{}} + for _, scan := range scans { + date := scan.ScanTime.Truncate(24 * time.Hour) + + if len(zessMsg.scans) > 0 && zessMsg.scans[len(zessMsg.scans)-1].date.Equal(date) { + // Already entry for that day + zessMsg.scans[len(zessMsg.scans)-1].amount++ + } else { + // New day entry + zessMsg.scans = append(zessMsg.scans, zessDayScan{ + date: date, + amount: 1, + }) + } + } + + return zessMsg + }) +} + +func updateSeason(db *db.DB) tea.Cmd { + return tea.Tick(3600*time.Second, func(_ time.Time) tea.Msg { + amount, err := db.Queries.GetScansInCurrentSeason(context.Background()) + if err != nil { + if err != sql.ErrNoRows { + zap.S().Error("DB: Failed to get scans in current season", err) + } + return ZessSeasonMsg{valid: false, amount: 0} + } + + return ZessSeasonMsg{valid: true, amount: amount} + }) +} From 469a8014ccf40dd726ddae3ca74af3a2f8bb9875 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 24 Nov 2024 16:27:13 +0100 Subject: [PATCH 18/46] chore: move data update out of tui loop --- internal/cmd/tui.go | 59 +++++++++++++++++++---- ui/screen/cammie.go | 29 +++++------- ui/screen/screen.go | 15 ++++++ ui/tui.go | 14 +++--- ui/view/message.go | 77 +++++++++++++++++------------- ui/view/tap.go | 108 +++++++++++++++++++++++------------------- ui/view/view.go | 23 +++++++++ ui/view/zess.go | 111 +++++++++++++++++++++++++------------------- 8 files changed, 276 insertions(+), 160 deletions(-) create mode 100644 ui/screen/screen.go create mode 100644 ui/view/view.go diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index 2ad8ad6..c5a936c 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -4,35 +4,76 @@ package cmd import ( "fmt" "os" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/pkg/util" tui "github.com/zeusWPI/scc/ui" "github.com/zeusWPI/scc/ui/screen" + "github.com/zeusWPI/scc/ui/view" + "go.uber.org/zap" ) -var screens = map[string]func(*db.DB) tea.Model{ +var screens = map[string]func(*db.DB) screen.Screen{ "cammie": screen.NewCammie, } // TUI starts the terminal user interface -func TUI(db *db.DB) (*tea.Program, error) { +func TUI(db *db.DB) error { args := os.Args if len(args) < 2 { - return nil, fmt.Errorf("No screen specified. Options are %v", util.Keys(screens)) + return fmt.Errorf("No screen specified. Options are %v", util.Keys(screens)) } - screen := args[1] + selectedScreen := args[1] - val, ok := screens[screen] + val, ok := screens[selectedScreen] if !ok { - return nil, fmt.Errorf("Screen %s not found. Options are %v", screen, util.Keys(screens)) + return fmt.Errorf("Screen %s not found. Options are %v", selectedScreen, util.Keys(screens)) } - tui := tui.New(val(db)) + screen := val(db) + tui := tui.New(screen) + p := tea.NewProgram(tui, tea.WithAltScreen()) - program := 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(db, p, updateData, done) + } + + _, err := p.Run() + + for _, done := range dones { + done <- true + } + + return err +} + +func tuiPeriodicUpdates(db *db.DB, 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() - return program, nil + for { + select { + case <-done: + zap.S().Info("TUI: Stopping periodic update for ", updateData.Name) + return + case <-ticker.C: + // Update tap + msg, err := updateData.Update(db, updateData.View) + if err != nil { + zap.S().Error("TUI: Error updating ", updateData.Name, "\n", err) + } + + if msg != nil { + p.Send(msg) + } + } + } } diff --git a/ui/screen/cammie.go b/ui/screen/cammie.go index 9f884a9..07f7322 100644 --- a/ui/screen/cammie.go +++ b/ui/screen/cammie.go @@ -2,8 +2,6 @@ package screen import ( - "fmt" - tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/ui/view" @@ -11,33 +9,30 @@ import ( // Cammie represents the cammie screen type Cammie struct { - db *db.DB - zess *view.ZessModel - tap *view.TapModel + db *db.DB } // NewCammie creates a new cammie screen -func NewCammie(db *db.DB) tea.Model { - return &Cammie{db: db, zess: view.NewZessModel(db), tap: view.NewTapModel(db)} +func NewCammie(db *db.DB) Screen { + return &Cammie{db: db} } // Init initializes the cammie screen func (c *Cammie) Init() tea.Cmd { - return tea.Batch(c.zess.Init(), c.tap.Init()) + return nil } // Update updates the cammie screen -func (c *Cammie) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cammie, cmd := c.zess.Update(msg) - c.zess = cammie.(*view.ZessModel) - - tap, cmd2 := c.tap.Update(msg) - c.tap = tap.(*view.TapModel) - - return c, tea.Batch(cmd, cmd2) +func (c *Cammie) Update(_ tea.Msg) (Screen, tea.Cmd) { + return c, nil } // View returns the cammie screen view func (c *Cammie) View() string { - return fmt.Sprintf("%s\n%s", c.tap.View(), c.zess.View()) + return "" +} + +// GetUpdateViews returns all the update functions for the cammie screen +func (c *Cammie) GetUpdateViews() []view.UpdateData { + return []view.UpdateData{} } diff --git a/ui/screen/screen.go b/ui/screen/screen.go new file mode 100644 index 0000000..6b2a4d9 --- /dev/null +++ b/ui/screen/screen.go @@ -0,0 +1,15 @@ +// Package screen provides difference screens for the tui +package screen + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/ui/view" +) + +// Screen represents a screen +type Screen interface { + Init() tea.Cmd + Update(tea.Msg) (Screen, tea.Cmd) + View() string + GetUpdateViews() []view.UpdateData +} diff --git a/ui/tui.go b/ui/tui.go index ae39bdf..5ff68f8 100644 --- a/ui/tui.go +++ b/ui/tui.go @@ -3,34 +3,36 @@ package tui import ( tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/ui/screen" "go.uber.org/zap" ) // TUI represent a terminal instance type TUI struct { - screen tea.Model + screen screen.Screen } // New creates a new tui instance -func New(screen tea.Model) *TUI { +func New(screen screen.Screen) *TUI { return &TUI{screen: screen} } // Init initializes the tui func (t *TUI) Init() tea.Cmd { - return tea.Batch(t.screen.Init()) + return t.screen.Init() } // Update updates the tui func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - model, cmd := t.screen.Update(msg) + screen, cmd := t.screen.Update(msg) + t.screen = screen if cmd != nil { cmds = append(cmds, cmd) } - t.screen = model + // Handle global key events switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { @@ -44,7 +46,7 @@ func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return t, tea.Batch(cmds...) } -// View returns the ttuity view +// View returns the tui view func (t *TUI) View() string { return t.screen.View() } diff --git a/ui/view/message.go b/ui/view/message.go index 6a2c588..f9e4521 100644 --- a/ui/view/message.go +++ b/ui/view/message.go @@ -5,13 +5,13 @@ import ( "database/sql" "fmt" "hash/fnv" - "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/list" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) @@ -39,59 +39,72 @@ func NewMessageModel(db *db.DB) *MessageModel { } // Init initializes the message model view -func (c *MessageModel) Init() tea.Cmd { - return updateMessages(c.db, c.lastMessageID) +func (m *MessageModel) Init() tea.Cmd { + return nil } // Update updates the message model view -func (c *MessageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *MessageModel) Update(msg tea.Msg) (View, tea.Cmd) { switch msg := msg.(type) { case MessageMsg: - c.lastMessageID = msg.lastMessageID - c.messages = append(c.messages, msg.messages...) + m.lastMessageID = msg.lastMessageID + m.messages = append(m.messages, msg.messages...) - return c, updateMessages(c.db, c.lastMessageID) + return m, nil } - return c, nil + return m, nil } // View returns the view for the message model -func (c *MessageModel) View() string { +func (m *MessageModel) View() string { // TODO: Limit the amount of messages shown // TODO: Wrap messages zap.S().Info("Viewing messages") - l := list.New(c.messages).Enumerator(func(_ list.Items, _ int) string { return "" }) + l := list.New(m.messages).Enumerator(func(_ list.Items, _ int) string { return "" }) return l.String() } -func updateMessages(db *db.DB, lastMessageID int64) tea.Cmd { - return tea.Tick(1*time.Second, func(_ time.Time) tea.Msg { - message, err := db.Queries.GetLastMessage(context.Background()) - if err != nil { - if err != sql.ErrNoRows { - zap.S().Error("DB: Failed to get last message", err) - } - return MessageMsg{lastMessageID: lastMessageID, messages: []string{}} - } +// GetUpdateDatas returns all the update functions for the message model +func (m *MessageModel) GetUpdateDatas() []UpdateData { + return []UpdateData{ + { + Name: "cammie messages", + View: m, + Update: updateMessages, + Interval: config.GetDefaultInt("tui.interval.message_s", 1), + }, + } +} - if message.ID <= lastMessageID { - return MessageMsg{lastMessageID: lastMessageID, messages: []string{}} - } +func updateMessages(db *db.DB, view View) (tea.Msg, error) { + m := view.(*MessageModel) + lastMessageID := m.lastMessageID - messages, err := db.Queries.GetMessageSinceID(context.Background(), lastMessageID) - if err != nil { - zap.S().Error("DB: Failed to get messages", err) - return MessageMsg{lastMessageID: lastMessageID, messages: []string{}} + message, err := db.Queries.GetLastMessage(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + err = nil } + return MessageMsg{lastMessageID: lastMessageID, messages: []string{}}, err + } - formattedMessages := make([]string, 0, len(messages)) - for _, message := range messages { - formattedMessages = append(formattedMessages, formatMessage(message)) - } + if message.ID <= lastMessageID { + return MessageMsg{lastMessageID: lastMessageID, messages: []string{}}, nil + } + + messages, err := db.Queries.GetMessageSinceID(context.Background(), lastMessageID) + if err != nil { + zap.S().Error("DB: Failed to get messages", err) + return MessageMsg{lastMessageID: lastMessageID, messages: []string{}}, err + } + + formattedMessages := make([]string, 0, len(messages)) + for _, message := range messages { + formattedMessages = append(formattedMessages, formatMessage(message)) + } - return MessageMsg{lastMessageID: message.ID, messages: formattedMessages} - }) + return MessageMsg{lastMessageID: message.ID, messages: formattedMessages}, nil } func hashColor(s string) string { diff --git a/ui/view/tap.go b/ui/view/tap.go index 414bc0a..d811938 100644 --- a/ui/view/tap.go +++ b/ui/view/tap.go @@ -1,16 +1,14 @@ -// Package view contains all the different views for the tui package view import ( "context" "database/sql" - "time" "github.com/NimbleMarkets/ntcharts/barchart" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/zeusWPI/scc/internal/pkg/db" - "go.uber.org/zap" + "github.com/zeusWPI/scc/pkg/config" ) // TapModel represents the tap model @@ -48,11 +46,11 @@ func NewTapModel(db *db.DB) *TapModel { // Init initializes the tap model func (t *TapModel) Init() tea.Cmd { - return updateOrders(t.db, t.lastOrderID) + return nil } // Update updates the tap model -func (t *TapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (t *TapModel) Update(msg tea.Msg) (View, tea.Cmd) { switch msg := msg.(type) { case TapMsg: t.lastOrderID = msg.lastOrderID @@ -70,7 +68,7 @@ func (t *TapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - return t, updateOrders(t.db, t.lastOrderID) + return t, nil } return t, nil @@ -119,54 +117,66 @@ func (t *TapModel) View() string { return chart.View() } -func updateOrders(db *db.DB, lastOrderID int64) tea.Cmd { - return tea.Tick(60*time.Second, func(_ time.Time) tea.Msg { - order, err := db.Queries.GetLastOrderByOrderID(context.Background()) - if err != nil { - if err != sql.ErrNoRows { - zap.S().Error("DB: Failed to get last order", err) - } - return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}} - } +// GetUpdateDatas returns all the update functions for the tap model +func (t *TapModel) GetUpdateDatas() []UpdateData { + return []UpdateData{ + { + Name: "tap orders", + View: t, + Update: updateOrders, + Interval: config.GetDefaultInt("view.interval.tap_s", 60), + }, + } +} - if order.OrderID <= lastOrderID { - return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}} - } +func updateOrders(db *db.DB, view View) (tea.Msg, error) { + t := view.(*TapModel) + lastOrderID := t.lastOrderID - orders, err := db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) - if err != nil { - zap.S().Error("DB: Failed to get tap orders", err) - return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}} + order, err := db.Queries.GetLastOrderByOrderID(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + err = nil } + return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}}, err + } - mate, soft, beer, food := 0.0, 0.0, 0.0, 0.0 - for _, order := range orders { - switch order.Category { - case "Mate": - mate += float64(order.Count) - case "Soft": - soft += float64(order.Count) - case "Beer": - beer += float64(order.Count) - case "Food": - food += float64(order.Count) - } - } + if order.OrderID <= lastOrderID { + return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}}, nil + } - messages := make([]tapItem, 0, 4) - if mate > 0 { - messages = append(messages, tapItem{"Mate", mate}) - } - if soft > 0 { - messages = append(messages, tapItem{"Soft", soft}) - } - if beer > 0 { - messages = append(messages, tapItem{"Beer", beer}) - } - if food > 0 { - messages = append(messages, tapItem{"Food", food}) + orders, err := db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) + if err != nil { + return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}}, err + } + + mate, soft, beer, food := 0.0, 0.0, 0.0, 0.0 + for _, order := range orders { + switch order.Category { + case "Mate": + mate += float64(order.Count) + case "Soft": + soft += float64(order.Count) + case "Beer": + beer += float64(order.Count) + case "Food": + food += float64(order.Count) } + } + + messages := make([]tapItem, 0, 4) + if mate > 0 { + messages = append(messages, tapItem{"Mate", mate}) + } + if soft > 0 { + messages = append(messages, tapItem{"Soft", soft}) + } + if beer > 0 { + messages = append(messages, tapItem{"Beer", beer}) + } + if food > 0 { + messages = append(messages, tapItem{"Food", food}) + } - return TapMsg{lastOrderID: order.OrderID, items: messages} - }) + return TapMsg{lastOrderID: order.OrderID, items: messages}, err } diff --git a/ui/view/view.go b/ui/view/view.go new file mode 100644 index 0000000..8168d26 --- /dev/null +++ b/ui/view/view.go @@ -0,0 +1,23 @@ +// Package view contains all the different views for the tui +package view + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/pkg/db" +) + +// UpdateData represents the data needed to update a view +type UpdateData struct { + Name string + View View + Update func(db *db.DB, view View) (tea.Msg, error) + Interval int +} + +// View represents a view +type View interface { + Init() tea.Cmd + Update(tea.Msg) (View, tea.Cmd) + View() string + GetUpdateDatas() []UpdateData +} diff --git a/ui/view/zess.go b/ui/view/zess.go index 0a1d1fa..549eb9f 100644 --- a/ui/view/zess.go +++ b/ui/view/zess.go @@ -44,11 +44,11 @@ func NewZessModel(db *db.DB) *ZessModel { // Init created a new zess model func (z *ZessModel) Init() tea.Cmd { - return tea.Batch(updateScans(z.db, z.lastScanID), updateSeason(z.db)) + return nil } // Update updates the zess model -func (z *ZessModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (z *ZessModel) Update(msg tea.Msg) (View, tea.Cmd) { switch msg := msg.(type) { case ZessScanMsg: z.lastScanID = msg.lastScanID @@ -67,14 +67,14 @@ func (z *ZessModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - return z, updateScans(z.db, z.lastScanID) + return z, nil case ZessSeasonMsg: if msg.valid { z.totalScans = msg.amount } - return z, updateSeason(z.db) + return z, nil } return z, nil @@ -96,58 +96,75 @@ func (z *ZessModel) View() string { return chart.View() } -func updateScans(db *db.DB, lastScanID int64) tea.Cmd { - return tea.Tick(60*time.Second, func(_ time.Time) tea.Msg { - scan, err := db.Queries.GetLastScan(context.Background()) - if err != nil { - if err != sql.ErrNoRows { - zap.S().Error("DB: Failed to get last scan", err) - } - return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}} - } +// GetUpdateDatas returns all the update functions for the zess model +func (z *ZessModel) GetUpdateDatas() []UpdateData { + return []UpdateData{ + { + Name: "zess scans", + View: z, + Update: updateScans, + Interval: 1, + }, + { + Name: "zess season", + View: z, + Update: updateSeason, + Interval: 1, + }, + } +} - if scan.ID <= lastScanID { - return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}} +func updateScans(db *db.DB, view View) (tea.Msg, error) { + z := view.(*ZessModel) + lastScanID := z.lastScanID + + scan, err := db.Queries.GetLastScan(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + err = nil } + return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}}, err + } - scans, err := db.Queries.GetAllScansSinceID(context.Background(), lastScanID) - if err != nil { - if err != sql.ErrNoRows { - zap.S().Error("DB: Failed to get scan count by day", err) - } - return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}} + if scan.ID <= lastScanID { + return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}}, nil + } + + scans, err := db.Queries.GetAllScansSinceID(context.Background(), lastScanID) + if err != nil { + if err != sql.ErrNoRows { + zap.S().Error("DB: Failed to get scan count by day", err) } + return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}}, err + } - zessMsg := ZessScanMsg{lastScanID: scan.ID, scans: []zessDayScan{}} - for _, scan := range scans { - date := scan.ScanTime.Truncate(24 * time.Hour) - - if len(zessMsg.scans) > 0 && zessMsg.scans[len(zessMsg.scans)-1].date.Equal(date) { - // Already entry for that day - zessMsg.scans[len(zessMsg.scans)-1].amount++ - } else { - // New day entry - zessMsg.scans = append(zessMsg.scans, zessDayScan{ - date: date, - amount: 1, - }) - } + zessMsg := ZessScanMsg{lastScanID: scan.ID, scans: []zessDayScan{}} + for _, scan := range scans { + date := scan.ScanTime.Truncate(24 * time.Hour) + + if len(zessMsg.scans) > 0 && zessMsg.scans[len(zessMsg.scans)-1].date.Equal(date) { + // Already entry for that day + zessMsg.scans[len(zessMsg.scans)-1].amount++ + } else { + // New day entry + zessMsg.scans = append(zessMsg.scans, zessDayScan{ + date: date, + amount: 1, + }) } + } - return zessMsg - }) + return zessMsg, nil } -func updateSeason(db *db.DB) tea.Cmd { - return tea.Tick(3600*time.Second, func(_ time.Time) tea.Msg { - amount, err := db.Queries.GetScansInCurrentSeason(context.Background()) - if err != nil { - if err != sql.ErrNoRows { - zap.S().Error("DB: Failed to get scans in current season", err) - } - return ZessSeasonMsg{valid: false, amount: 0} +func updateSeason(db *db.DB, _ View) (tea.Msg, error) { + amount, err := db.Queries.GetScansInCurrentSeason(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + err = nil } + return ZessSeasonMsg{valid: false, amount: 0}, err + } - return ZessSeasonMsg{valid: true, amount: amount} - }) + return ZessSeasonMsg{valid: true, amount: amount}, nil } From 73258bfd14d806c8908ee8b7a1947ba339786a48 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 24 Nov 2024 19:30:52 +0100 Subject: [PATCH 19/46] chore: finish zess view --- config/development.toml | 13 ++ db/queries/season.sql | 9 + internal/pkg/db/sqlc/season.sql.go | 22 +++ ui/screen/cammie.go | 31 +++- ui/view/message.go | 4 +- ui/view/tap.go | 4 +- ui/view/zess.go | 262 +++++++++++++++++++++-------- 7 files changed, 262 insertions(+), 83 deletions(-) diff --git a/config/development.toml b/config/development.toml index 775786e..a0416f7 100644 --- a/config/development.toml +++ b/config/development.toml @@ -40,3 +40,16 @@ song = [ "-n", "-f555", "-l100", "-d0", "-n", "-f495", "-l100", "-d0", ] + +[tui] + +[tui.zess] +weeks = 10 +interval_scan_s = 60 +interval_season_s = 3600 + +[tui.message] +interval_s = 1 + +[tui.tap] +interval_s = 60 diff --git a/db/queries/season.sql b/db/queries/season.sql index 98eaeae..a39f2fc 100644 --- a/db/queries/season.sql +++ b/db/queries/season.sql @@ -23,3 +23,12 @@ RETURNING *; -- name: DeleteSeason :execrows DELETE FROM season WHERE id = ?; + + +-- Other + + +-- name: GetSeasonCurrent :one +SELECT * +FROM season +WHERE current = true; diff --git a/internal/pkg/db/sqlc/season.sql.go b/internal/pkg/db/sqlc/season.sql.go index 40663ab..7da8781 100644 --- a/internal/pkg/db/sqlc/season.sql.go +++ b/internal/pkg/db/sqlc/season.sql.go @@ -109,6 +109,28 @@ func (q *Queries) GetSeasonByID(ctx context.Context, id int64) (Season, error) { 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.QueryRowContext(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 = ?, start = ?, end = ?, current = ? diff --git a/ui/screen/cammie.go b/ui/screen/cammie.go index 07f7322..dc65771 100644 --- a/ui/screen/cammie.go +++ b/ui/screen/cammie.go @@ -9,30 +9,47 @@ import ( // Cammie represents the cammie screen type Cammie struct { - db *db.DB + db *db.DB + zess view.View } // NewCammie creates a new cammie screen func NewCammie(db *db.DB) Screen { - return &Cammie{db: db} + return &Cammie{db: db, zess: view.NewZessModel(db)} } // Init initializes the cammie screen func (c *Cammie) Init() tea.Cmd { - return nil + return c.zess.Init() } // Update updates the cammie screen -func (c *Cammie) Update(_ tea.Msg) (Screen, tea.Cmd) { - return c, nil +func (c *Cammie) Update(msg tea.Msg) (Screen, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case view.ZessMsg: + zess, cmd := c.zess.Update(msg) + c.zess = zess + + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + var cmd tea.Cmd + if cmds != nil { + cmd = tea.Batch(cmds...) + } + + return c, cmd } // View returns the cammie screen view func (c *Cammie) View() string { - return "" + return c.zess.View() } // GetUpdateViews returns all the update functions for the cammie screen func (c *Cammie) GetUpdateViews() []view.UpdateData { - return []view.UpdateData{} + return c.zess.GetUpdateDatas() } diff --git a/ui/view/message.go b/ui/view/message.go index f9e4521..f502345 100644 --- a/ui/view/message.go +++ b/ui/view/message.go @@ -34,7 +34,7 @@ var messageColor = []string{ } // NewMessageModel creates a new message model view -func NewMessageModel(db *db.DB) *MessageModel { +func NewMessageModel(db *db.DB) View { return &MessageModel{db: db, lastMessageID: -1, messages: []string{}} } @@ -72,7 +72,7 @@ func (m *MessageModel) GetUpdateDatas() []UpdateData { Name: "cammie messages", View: m, Update: updateMessages, - Interval: config.GetDefaultInt("tui.interval.message_s", 1), + Interval: config.GetDefaultInt("tui.message.interval_s", 1), }, } } diff --git a/ui/view/tap.go b/ui/view/tap.go index d811938..04137a0 100644 --- a/ui/view/tap.go +++ b/ui/view/tap.go @@ -40,7 +40,7 @@ var tapCategoryColor = map[string]lipgloss.Color{ } // NewTapModel creates a new tap model -func NewTapModel(db *db.DB) *TapModel { +func NewTapModel(db *db.DB) View { return &TapModel{db: db, lastOrderID: -1} } @@ -124,7 +124,7 @@ func (t *TapModel) GetUpdateDatas() []UpdateData { Name: "tap orders", View: t, Update: updateOrders, - Interval: config.GetDefaultInt("view.interval.tap_s", 60), + Interval: config.GetDefaultInt("tui.tap.interval_s", 60), }, } } diff --git a/ui/view/zess.go b/ui/view/zess.go index 549eb9f..eff0044 100644 --- a/ui/view/zess.go +++ b/ui/view/zess.go @@ -3,43 +3,82 @@ package view import ( "context" "database/sql" - "time" + "fmt" - "github.com/NimbleMarkets/ntcharts/canvas" - "github.com/NimbleMarkets/ntcharts/linechart/wavelinechart" + "github.com/NimbleMarkets/ntcharts/barchart" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) -// ZessModel represents the model for the zess view -type ZessModel struct { - db *db.DB - lastScanID int64 - scans []zessDayScan - totalScans int64 +// zessTime represents a time object by keeping the year and week number +type zessTime struct { + year int + week int } -type zessDayScan struct { - date time.Time +type zessWeekScan struct { + time zessTime amount int64 + label string +} + +// ZessModel represents the model for the zess view +type ZessModel struct { + db *db.DB + lastScanID int64 + scans []zessWeekScan // Queue of scans per week + maxWeekScans int64 + currentSeason zessTime // Start week of the season + seasonScans int64 } -// ZessScanMsg represents the message to update the zess view -type ZessScanMsg struct { +// ZessMsg is the base message to indicate that something changed in the zess view +type ZessMsg struct{} + +// zessScanMsg is used to indicate that the zess view should be updated with new scans +type zessScanMsg struct { + ZessMsg lastScanID int64 - scans []zessDayScan + scans []zessWeekScan } -// ZessSeasonMsg represents the message to update the zess view -type ZessSeasonMsg struct { - valid bool - amount int64 +// zessSeasonMsg is used to indicate that the current season changed. +type zessSeasonMsg struct { + ZessMsg + start zessTime } // NewZessModel creates a new zess model view -func NewZessModel(db *db.DB) *ZessModel { - return &ZessModel{db: db, lastScanID: -1, scans: make([]zessDayScan, 0), totalScans: 0} +func NewZessModel(db *db.DB) View { + z := &ZessModel{ + db: db, + lastScanID: -1, + scans: make([]zessWeekScan, 0), + maxWeekScans: -1, + currentSeason: zessTime{year: -1, week: -1}, + seasonScans: 0, + } + + // Populate with data + // The order in which this is called is important! + msgScans, err := updateScans(db, z) + if err != nil { + zap.S().Error("TUI: Unable to update zess scans\n", err) + return z + } + _, _ = z.Update(msgScans) + + msgSeason, err := updateSeason(db, z) + if err != nil { + zap.S().Error("TUI: Unable to update zess seasons\n", err) + return z + } + _, _ = z.Update(msgSeason) + + return z } // Init created a new zess model @@ -50,31 +89,64 @@ func (z *ZessModel) Init() tea.Cmd { // Update updates the zess model func (z *ZessModel) Update(msg tea.Msg) (View, tea.Cmd) { switch msg := msg.(type) { - case ZessScanMsg: + // New scan(s) + case zessScanMsg: z.lastScanID = msg.lastScanID - for _, scan := range msg.scans { - if len(z.scans) == 0 || scan.date.After(z.scans[len(z.scans)-1].date) { - z.scans = append(z.scans, scan) - // TODO: Potentially remove first element (scans = scans[1:]) - continue - } + // Add new scans + for _, newScan := range msg.scans { + found := false + for i, modelScan := range z.scans { + if newScan.time.equal(modelScan.time) { + z.scans[i].amount++ + // Check for maxWeekScans + if z.scans[i].amount > z.maxWeekScans { + z.maxWeekScans = modelScan.amount + } - for i := len(z.scans) - 1; i >= 0; i-- { - if scan.date.Equal(z.scans[i].date) { - z.scans[i].amount += scan.amount + found = true break } } + + if !found { + z.scans = append(z.scans, newScan) + // Check for maxWeekScans + if newScan.amount > z.maxWeekScans { + z.maxWeekScans = newScan.amount + } + // Make sure the array doesn't get too big + if len(z.scans) > config.GetDefaultInt("tui.zess.weeks", 10) { + z.scans = z.scans[:1] + } + } + + // Update seasonScans + z.seasonScans += newScan.amount } - return z, nil + // New season! + // Update variables accordinly + case zessSeasonMsg: + z.currentSeason = msg.start + z.seasonScans = 0 + z.maxWeekScans = 0 - case ZessSeasonMsg: - if msg.valid { - z.totalScans = msg.amount + validScans := make([]zessWeekScan, 0, len(z.scans)) + + for _, scan := range z.scans { + // Add scans if they happend after (or in the same week of) the season start + if scan.time.equal(z.currentSeason) || scan.time.after(z.currentSeason) { + validScans = append(validScans, scan) + + if scan.amount > z.maxWeekScans { + z.maxWeekScans = scan.amount + } + + z.seasonScans += scan.amount + } } - return z, nil + z.scans = validScans } return z, nil @@ -82,18 +154,32 @@ func (z *ZessModel) Update(msg tea.Msg) (View, tea.Cmd) { // View returns the view for the zess model func (z *ZessModel) View() string { - chart := wavelinechart.New(40, 20, wavelinechart.WithYRange(-2, 30)) - chart.XLabelFormatter = func(_ int, v float64) string { - return time.Now().Add(-time.Duration(v*24) * time.Hour).Format("02") - } + chart := barchart.New(20, 20) - now := time.Now().Truncate(24 * time.Hour) for _, scan := range z.scans { - chart.Plot(canvas.Float64Point{X: now.Sub(scan.date).Hours() / 24, Y: float64(scan.amount)}) + bar := barchart.BarData{ + Label: scan.label, + Values: []barchart.BarValue{{ + Name: scan.label, + Value: float64(scan.amount), + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("21")), + }}, + } + + chart.Push(bar) } + chart.Draw() - return chart.View() + style := lipgloss.NewStyle().Height(20).Align(lipgloss.Bottom).Render(lipgloss.JoinVertical(lipgloss.Left, + fmt.Sprintf("Season scans\n%d", z.seasonScans), + fmt.Sprintf("Max scans in a week\n%d", z.maxWeekScans), + )) + + return lipgloss.JoinHorizontal(lipgloss.Top, + chart.View(), + style, + ) } // GetUpdateDatas returns all the update functions for the zess model @@ -103,68 +189,100 @@ func (z *ZessModel) GetUpdateDatas() []UpdateData { Name: "zess scans", View: z, Update: updateScans, - Interval: 1, + Interval: config.GetDefaultInt("tui.zess.interval_scan_s", 60), }, { Name: "zess season", View: z, Update: updateSeason, - Interval: 1, + Interval: config.GetDefaultInt("tui.zess.interval_season_s", 3600), }, } } +// Check for any new scans func updateScans(db *db.DB, view View) (tea.Msg, error) { z := view.(*ZessModel) lastScanID := z.lastScanID - scan, err := db.Queries.GetLastScan(context.Background()) + // Get new scans + scans, err := db.Queries.GetAllScansSinceID(context.Background(), lastScanID) if err != nil { if err == sql.ErrNoRows { + // No rows shouldn't be considered an error err = nil } - return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}}, err + return nil, err } - if scan.ID <= lastScanID { - return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}}, nil + // No new scans + if len(scans) == 0 { + return nil, nil } - scans, err := db.Queries.GetAllScansSinceID(context.Background(), lastScanID) - if err != nil { - if err != sql.ErrNoRows { - zap.S().Error("DB: Failed to get scan count by day", err) + zessScanMsg := zessScanMsg{lastScanID: lastScanID, scans: make([]zessWeekScan, 0)} + + // Add new scans to scan msg + for _, newScan := range scans { + yearNumber, weekNumber := newScan.ScanTime.ISOWeek() + newTime := zessTime{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, zessWeekScan{time: newTime, amount: 1, label: newScan.ScanTime.Format("02/01")}) } - return ZessScanMsg{lastScanID: lastScanID, scans: []zessDayScan{}}, err - } - zessMsg := ZessScanMsg{lastScanID: scan.ID, scans: []zessDayScan{}} - for _, scan := range scans { - date := scan.ScanTime.Truncate(24 * time.Hour) - - if len(zessMsg.scans) > 0 && zessMsg.scans[len(zessMsg.scans)-1].date.Equal(date) { - // Already entry for that day - zessMsg.scans[len(zessMsg.scans)-1].amount++ - } else { - // New day entry - zessMsg.scans = append(zessMsg.scans, zessDayScan{ - date: date, - amount: 1, - }) + // Update scan ID + // Not necessarly the first or last entry in the scans slice + if newScan.ID > zessScanMsg.lastScanID { + zessScanMsg.lastScanID = newScan.ID } } - return zessMsg, nil + return zessScanMsg, nil } -func updateSeason(db *db.DB, _ View) (tea.Msg, error) { - amount, err := db.Queries.GetScansInCurrentSeason(context.Background()) +// Check if a new season started +func updateSeason(db *db.DB, view View) (tea.Msg, error) { + z := view.(*ZessModel) + + season, err := db.Queries.GetSeasonCurrent(context.Background()) if err != nil { if err == sql.ErrNoRows { + // No rows shouldn't be considered an error err = nil } - return ZessSeasonMsg{valid: false, amount: 0}, err + return nil, err + } + + // Check if we have a new season + yearNumber, weekNumber := season.Start.ISOWeek() + seasonStart := zessTime{year: yearNumber, week: weekNumber} + if z.currentSeason.equal(seasonStart) { + // Same season + return nil, nil + } + + return zessSeasonMsg{start: seasonStart}, nil +} + +func (z *zessTime) equal(z2 zessTime) bool { + return z.week == z2.week && z.year == z2.year +} +func (z *zessTime) after(z2 zessTime) bool { + if z.year > z2.year { + return true + } else if z.year < z2.year { + return false } - return ZessSeasonMsg{valid: true, amount: amount}, nil + return z.week > z2.week } From d707b6ec73e2502c7487b4d05447fc5abecdf9e1 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 25 Nov 2024 14:51:42 +0100 Subject: [PATCH 20/46] feat: add gamification integration --- config/development.toml | 4 + .../20241125113707_add_gamification_table.sql | 14 +++ db/queries/gamification.sql | 24 ++++ internal/cmd/gamification.go | 50 ++++++++ internal/cmd/tap.go | 8 +- internal/cmd/tui.go | 1 + internal/cmd/zess.go | 14 +++ internal/pkg/db/dto/gamification.go | 38 ++++++ internal/pkg/db/sqlc/gamification.sql.go | 109 ++++++++++++++++++ internal/pkg/db/sqlc/models.go | 7 ++ internal/pkg/gamification/api.go | 61 ++++++++++ internal/pkg/gamification/gamification.go | 75 ++++++++++++ internal/pkg/tap/api.go | 2 +- internal/pkg/zess/api.go | 5 +- 14 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 db/migrations/20241125113707_add_gamification_table.sql create mode 100644 db/queries/gamification.sql create mode 100644 internal/cmd/gamification.go create mode 100644 internal/pkg/db/dto/gamification.go create mode 100644 internal/pkg/db/sqlc/gamification.sql.go create mode 100644 internal/pkg/gamification/api.go create mode 100644 internal/pkg/gamification/gamification.go diff --git a/config/development.toml b/config/development.toml index a0416f7..86d60d1 100644 --- a/config/development.toml +++ b/config/development.toml @@ -28,6 +28,10 @@ api = "http://localhost:4000/api" interval_season_s = 300 interval_scan_s = 60 +[gamification] +api = "https://gamification.zeus.gent" +interval_s = 3600 + [buzzer] song = [ "-n", "-f880", "-l100", "-d0", diff --git a/db/migrations/20241125113707_add_gamification_table.sql b/db/migrations/20241125113707_add_gamification_table.sql new file mode 100644 index 0000000..7cef662 --- /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 INTEGER PRIMARY KEY, + name VARCHAR(255) NOT NULL, + score INTEGER NOT NULL, + avatar VARCHAR(255) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS gamification; +-- +goose StatementEnd diff --git a/db/queries/gamification.sql b/db/queries/gamification.sql new file mode 100644 index 0000000..18e76b6 --- /dev/null +++ b/db/queries/gamification.sql @@ -0,0 +1,24 @@ +-- CRUD + +-- name: GetAllGamification :many +SELECT * +FROM gamification; + +-- name: CreateGamification :one +INSERT INTO gamification (name, score, avatar) +VALUES (?, ?, ?) +RETURNING *; + +-- name: DeleteGamification :execrows +DELETE FROM gamification +WHERE id = ?; + + +-- Other + + +-- name: UpdateGamificationScore :one +UPDATE gamification +SET score = ? +WHERE id = ? +RETURNING *; diff --git a/internal/cmd/gamification.go b/internal/cmd/gamification.go new file mode 100644 index 0000000..550f857 --- /dev/null +++ b/internal/cmd/gamification.go @@ -0,0 +1,50 @@ +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) + + go gamificationPeriodicUpdate(gam, done) + + return gam, done +} + +func gamificationPeriodicUpdate(gam *gamification.Gamification, done chan bool) { + interval := config.GetDefaultInt("gamification.interval_s", 3600) + 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") + err := gam.Update() + if 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") + err := gam.Update() + if err != nil { + zap.S().Error("gamification: Error updating leaderboard\n", err) + } + } + } +} diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go index 015ffa8..39a18d6 100644 --- a/internal/cmd/tap.go +++ b/internal/cmd/tap.go @@ -26,6 +26,13 @@ func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() + // Run immediatly once + zap.S().Info("Tap: Updating tap") + err := tap.Update() + if err != nil { + zap.S().Error("Tap: Error updating tap\n", err) + } + for { select { case <-done: @@ -40,5 +47,4 @@ func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { } } } - } diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index c5a936c..850af6f 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -17,6 +17,7 @@ import ( var screens = map[string]func(*db.DB) screen.Screen{ "cammie": screen.NewCammie, + "test": screen.NewTest, } // TUI starts the terminal user interface diff --git a/internal/cmd/zess.go b/internal/cmd/zess.go index 55c5247..f80a529 100644 --- a/internal/cmd/zess.go +++ b/internal/cmd/zess.go @@ -28,6 +28,13 @@ func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool) { ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() + // Run immediatly once + zap.S().Info("Zess: Updating seasons") + err := zess.UpdateSeasons() + if err != nil { + zap.S().Error("Zess: Error updating seasons\n", err) + } + for { select { case <-done: @@ -51,6 +58,13 @@ func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool) { ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() + // Run immediatly once + zap.S().Info("Zess: Updating scans") + err := zess.UpdateScans() + if err != nil { + zap.S().Error("Zess: Error updating scans\n", err) + } + for { select { case <-done: diff --git a/internal/pkg/db/dto/gamification.go b/internal/pkg/db/dto/gamification.go new file mode 100644 index 0000000..8aa6484 --- /dev/null +++ b/internal/pkg/db/dto/gamification.go @@ -0,0 +1,38 @@ +package dto + +import "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + +// Gamification represents the DTO object for gamification +type Gamification struct { + ID int64 `json:"id"` + Name string `json:"github_name"` + Score int64 `json:"score"` + Avatar string `json:"avatar_url"` +} + +// 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, + } +} + +// 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/sqlc/gamification.sql.go b/internal/pkg/db/sqlc/gamification.sql.go new file mode 100644 index 0000000..c83ef13 --- /dev/null +++ b/internal/pkg/db/sqlc/gamification.sql.go @@ -0,0 +1,109 @@ +// 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 (?, ?, ?) +RETURNING id, name, score, avatar +` + +type CreateGamificationParams struct { + Name string + Score int64 + Avatar string +} + +func (q *Queries) CreateGamification(ctx context.Context, arg CreateGamificationParams) (Gamification, error) { + row := q.db.QueryRowContext(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 = ? +` + +func (q *Queries) DeleteGamification(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteGamification, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +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.QueryContext(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.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateGamificationScore = `-- name: UpdateGamificationScore :one + + +UPDATE gamification +SET score = ? +WHERE id = ? +RETURNING id, name, score, avatar +` + +type UpdateGamificationScoreParams struct { + Score int64 + ID int64 +} + +// Other +func (q *Queries) UpdateGamificationScore(ctx context.Context, arg UpdateGamificationScoreParams) (Gamification, error) { + row := q.db.QueryRowContext(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/models.go b/internal/pkg/db/sqlc/models.go index 3a7e15f..a2d2dfc 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -8,6 +8,13 @@ import ( "time" ) +type Gamification struct { + ID int64 + Name string + Score int64 + Avatar string +} + type Message struct { ID int64 Name string diff --git a/internal/pkg/gamification/api.go b/internal/pkg/gamification/api.go new file mode 100644 index 0000000..f1f7272 --- /dev/null +++ b/internal/pkg/gamification/api.go @@ -0,0 +1,61 @@ +package gamification + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/pkg/db/dto" + "go.uber.org/zap" +) + +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([]*dto.Gamification) + 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) + } + } + + return res, errors.Join(errs...) +} + +func downloadAvatar(gam dto.Gamification) (string, error) { + req := fiber.Get(gam.Avatar) + status, body, errs := req.Bytes() + if errs != nil { + return "", errors.Join(append(errs, errors.New("Gamification: Download avatar request failed"))...) + } + if status != fiber.StatusOK { + return "", fmt.Errorf("Gamification: Download avatar returned bad status code %d", status) + } + + location := fmt.Sprintf("public/%s.png", gam.Name) + out, err := os.Create(location) + if err != nil && err != os.ErrExist { + return "", err + } + defer func() { + _ = out.Close() + }() + + _, err = io.Copy(out, bytes.NewReader(body)) + + return location, err +} diff --git a/internal/pkg/gamification/gamification.go b/internal/pkg/gamification/gamification.go new file mode 100644 index 0000000..c8d95a7 --- /dev/null +++ b/internal/pkg/gamification/gamification.go @@ -0,0 +1,75 @@ +// Package gamification provides all gamification related logic +package gamification + +import ( + "context" + "database/sql" + "errors" + "os" + + "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 { + api := config.GetDefaultString("gamification.api", "https://gamification.zeus.gent") + + return &Gamification{db: db, api: api} +} + +// Update gets the current leaderboard from gamification +func (g *Gamification) Update() error { + leaderboard, err := g.db.Queries.GetAllGamification(context.Background()) + if err != nil { + if err != sql.ErrNoRows { + return err + } + } + + gamLeaderboard, err := g.getLeaderboard() + if err != nil { + return err + } + + // Delete old + var errs []error + var errsOS []error // OS specific errors. + for _, l := range leaderboard { + // Remove picture + if err := os.Remove(l.Avatar); err != nil && err != os.ErrNotExist { + errsOS = append(errsOS, err) + } + + // Remove DB entry + if _, err = g.db.Queries.DeleteGamification(context.Background(), l.ID); err != nil { + errs = append(errs, err) + } + } + + // Don't quit if the only error(s) are os related + if errs != nil { + return errors.Join(append(errs, errsOS...)...) + } + + // Insert new ones + for _, gamL := range *gamLeaderboard { + location, err := downloadAvatar(*gamL) + if err != nil { + errs = append(errs, err) + } + gamL.Avatar = location + + if _, err = g.db.Queries.CreateGamification(context.Background(), gamL.CreateParams()); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} diff --git a/internal/pkg/tap/api.go b/internal/pkg/tap/api.go index 7bdc80d..8dd1cca 100644 --- a/internal/pkg/tap/api.go +++ b/internal/pkg/tap/api.go @@ -27,7 +27,7 @@ func (t *Tap) getOrders() ([]orderResponseItem, error) { res := new(orderResponse) status, _, errs := req.Struct(res) if len(errs) > 0 { - return nil, errors.Join(append([]error{errors.New("Tap: Order API request failed")}, errs...)...) + return nil, errors.Join(append(errs, errors.New("Tap: Order API request failed"))...) } if status != fiber.StatusOK { return nil, errors.New("error getting orders") diff --git a/internal/pkg/zess/api.go b/internal/pkg/zess/api.go index 770e0dd..3b26ff1 100644 --- a/internal/pkg/zess/api.go +++ b/internal/pkg/zess/api.go @@ -2,6 +2,7 @@ package zess import ( "errors" + "fmt" "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" @@ -40,10 +41,10 @@ func (z *Zess) getScans() (*[]*dto.Scan, error) { res := new([]*dto.Scan) status, _, errs := req.Struct(res) if len(errs) > 0 { - return nil, errors.Join(append([]error{errors.New("Zess: Scan API request failed")}, errs...)...) + return nil, errors.Join(append(errs, errors.New("Zess: Scan API request failed"))...) } if status != fiber.StatusOK { - return nil, errors.New("error getting scans") + return nil, fmt.Errorf("Zess: Scan API returned bad status code %d", status) } errs = make([]error, 0) From 6561e34a0d6c2a4fb222eb14eb4fe580999ad033 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 25 Nov 2024 16:38:39 +0100 Subject: [PATCH 21/46] feat: added gamication view --- config/development.toml | 3 + .../20241125113707_add_gamification_table.sql | 2 +- db/queries/gamification.sql | 5 + go.mod | 8 +- go.sum | 44 ++++- internal/cmd/tui.go | 12 +- internal/pkg/db/dto/gamification.go | 5 + internal/pkg/db/sqlc/gamification.sql.go | 34 ++++ ui/view/gamification.go | 183 ++++++++++++++++++ 9 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 ui/view/gamification.go diff --git a/config/development.toml b/config/development.toml index 86d60d1..ca54d00 100644 --- a/config/development.toml +++ b/config/development.toml @@ -57,3 +57,6 @@ interval_s = 1 [tui.tap] interval_s = 60 + +[tui.gamification] +interval_s = 3600 diff --git a/db/migrations/20241125113707_add_gamification_table.sql b/db/migrations/20241125113707_add_gamification_table.sql index 7cef662..46ba33f 100644 --- a/db/migrations/20241125113707_add_gamification_table.sql +++ b/db/migrations/20241125113707_add_gamification_table.sql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS gamification ( id INTEGER PRIMARY KEY, name VARCHAR(255) NOT NULL, score INTEGER NOT NULL, - avatar VARCHAR(255) + avatar VARCHAR(255) NOT NULL ); -- +goose StatementEnd diff --git a/db/queries/gamification.sql b/db/queries/gamification.sql index 18e76b6..7081864 100644 --- a/db/queries/gamification.sql +++ b/db/queries/gamification.sql @@ -22,3 +22,8 @@ UPDATE gamification SET score = ? WHERE id = ? RETURNING *; + +-- name: GetAllGamificationByScore :many +SELECT * +FROM gamification +ORDER BY score DESC; diff --git a/go.mod b/go.mod index 96f85c1..20eda78 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,11 @@ module github.com/zeusWPI/scc go 1.23.1 require ( + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v1.0.0 + github.com/disintegration/imaging v1.6.2 github.com/joho/godotenv v1.5.1 + github.com/lucasb-eyer/go-colorful v1.2.0 github.com/spf13/viper v1.19.0 ) @@ -11,8 +15,6 @@ require ( github.com/andybalholm/brotli v1.0.5 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbles v0.18.0 // indirect - github.com/charmbracelet/bubbletea v0.25.0 // indirect - github.com/charmbracelet/lipgloss v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.4.2 // indirect github.com/containerd/console v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect @@ -22,7 +24,6 @@ require ( github.com/klauspost/compress v1.17.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lrstanley/bubblezone v0.0.0-20240125042004-b7bafc493195 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // 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 @@ -36,6 +37,7 @@ require ( github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect golang.org/x/crypto v0.27.0 // indirect + golang.org/x/image v0.11.0 // indirect golang.org/x/net v0.23.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/term v0.24.0 // indirect diff --git a/go.sum b/go.sum index a8270f5..9a589d4 100644 --- a/go.sum +++ b/go.sum @@ -4,22 +4,26 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/ github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= -github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= -github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= 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.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/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= @@ -92,8 +96,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= -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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -129,29 +131,63 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S 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= 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.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/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo= +golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8= +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.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.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.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index 850af6f..dccdedf 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -60,13 +60,23 @@ func tuiPeriodicUpdates(db *db.DB, p *tea.Program, updateData view.UpdateData, d ticker := time.NewTicker(time.Duration(updateData.Interval) * time.Second) defer ticker.Stop() + // Immediatly update once + msg, err := updateData.Update(db, 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 tap + // Update msg, err := updateData.Update(db, updateData.View) if err != nil { zap.S().Error("TUI: Error updating ", updateData.Name, "\n", err) diff --git a/internal/pkg/db/dto/gamification.go b/internal/pkg/db/dto/gamification.go index 8aa6484..e00a6e4 100644 --- a/internal/pkg/db/dto/gamification.go +++ b/internal/pkg/db/dto/gamification.go @@ -20,6 +20,11 @@ func GamificationDTO(gam sqlc.Gamification) *Gamification { } } +// Equal compares 2 Gamification objects for equality +func (g *Gamification) Equal(g2 Gamification) bool { + return g.Name == g2.Name && g.Score == g2.Score && g.Avatar == g2.Avatar +} + // CreateParams converts a Gamification DTO to a sqlc CreateGamificationParams object func (g *Gamification) CreateParams() sqlc.CreateGamificationParams { return sqlc.CreateGamificationParams{ diff --git a/internal/pkg/db/sqlc/gamification.sql.go b/internal/pkg/db/sqlc/gamification.sql.go index c83ef13..7da2289 100644 --- a/internal/pkg/db/sqlc/gamification.sql.go +++ b/internal/pkg/db/sqlc/gamification.sql.go @@ -81,6 +81,40 @@ func (q *Queries) GetAllGamification(ctx context.Context) ([]Gamification, error 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.QueryContext(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.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateGamificationScore = `-- name: UpdateGamificationScore :one diff --git a/ui/view/gamification.go b/ui/view/gamification.go new file mode 100644 index 0000000..7608f1d --- /dev/null +++ b/ui/view/gamification.go @@ -0,0 +1,183 @@ +package view + +import ( + "context" + "database/sql" + "fmt" + "image" + "os" + "path/filepath" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/disintegration/imaging" + "github.com/lucasb-eyer/go-colorful" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/pkg/config" +) + +var width = 20 + +var ( + base = lipgloss.NewStyle() + columnStyle = base.MarginLeft(1) + nameBase = base.BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(lipgloss.Color("#383838")).Width(width).Align(lipgloss.Center) + nameStyles = []lipgloss.Style{ + nameBase.Foreground(lipgloss.Color("#FFD700")), + nameBase.Foreground(lipgloss.Color("#FF7F00")), + nameBase.Foreground(lipgloss.Color("#CD7F32")), + nameBase, + } + scoreStyle = base.Width(width).Align(lipgloss.Center) +) + +// GamificationModel represents the view model for gamification +type GamificationModel struct { + db *db.DB + leaderboard []gamificationItem +} + +type gamificationItem struct { + image image.Image + item dto.Gamification +} + +// GamificationMsg contains the data to update the gamification model +type GamificationMsg struct { + leaderboard []gamificationItem +} + +// NewGamificationModel initializes a new gamification model +func NewGamificationModel(db *db.DB) View { + return &GamificationModel{db: db, leaderboard: []gamificationItem{}} +} + +// Init starts the gamification view +func (g *GamificationModel) Init() tea.Cmd { + return nil +} + +// Update updates the gamification view +func (g *GamificationModel) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case GamificationMsg: + g.leaderboard = msg.leaderboard + } + + return g, nil +} + +// View draws the gamification view +func (g *GamificationModel) View() string { + columns := make([]string, 0, len(g.leaderboard)) + + for i, item := range g.leaderboard { + user := lipgloss.JoinVertical(lipgloss.Left, + nameStyles[i%len(nameStyles)].Render(fmt.Sprintf("%d. %s", i+1, item.item.Name)), + scoreStyle.Render(strconv.Itoa(int(item.item.Score))), + ) + + column := lipgloss.JoinVertical(lipgloss.Left, gamificationToString(width, item.image), user) + columns = append(columns, columnStyle.Render(column)) + } + + list := lipgloss.JoinHorizontal(lipgloss.Top, columns...) + + return list +} + +// GetUpdateDatas get all update functions for the gamification view +func (g *GamificationModel) GetUpdateDatas() []UpdateData { + return []UpdateData{ + { + Name: "gamification leaderboard", + View: g, + Update: updateLeaderboard, + Interval: config.GetDefaultInt("tui.gamification.interval_s", 3600), + }, + } +} + +func updateLeaderboard(db *db.DB, view View) (tea.Msg, error) { + g := view.(*GamificationModel) + + gams, err := db.Queries.GetAllGamificationByScore(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + err = nil + } + return nil, err + } + + // Check if both leaderboards are equal + equal := false + if len(g.leaderboard) == len(gams) { + equal = true + for i, l := range g.leaderboard { + if !l.item.Equal(*dto.GamificationDTO(gams[i])) { + equal = false + break + } + } + } + + if equal { + return nil, nil + } + + msg := GamificationMsg{leaderboard: []gamificationItem{}} + for _, gam := range gams { + if gam.Avatar == "" { + // No avatar downloaded + msg.leaderboard = append(msg.leaderboard, gamificationItem{image: nil, item: *dto.GamificationDTO(gam)}) + continue + } + + file, err := os.Open(filepath.Clean(gam.Avatar)) + if err != nil { + return nil, err + } + defer func() { + _ = file.Close() + }() + + img, _, err := image.Decode(file) + if err != nil { + return nil, err + } + + msg.leaderboard = append(msg.leaderboard, gamificationItem{image: img, item: *dto.GamificationDTO(gam)}) + } + + return msg, nil +} + +func gamificationToString(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()) + str.WriteString(lipgloss.NewStyle().Foreground(color1). + Background(color2).Render("▀")) + } + + str.WriteString("\n") + } + + return str.String() +} From 5bd1a1ab72ca26e7579cf76c687ffbc511260e4c Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 25 Nov 2024 16:56:46 +0100 Subject: [PATCH 22/46] chore: small view refactor --- ui/screen/cammie.go | 5 +- ui/view/{ => gamification}/gamification.go | 36 ++++----- ui/view/{ => message}/message.go | 42 ++++++----- ui/view/{ => tap}/tap.go | 42 ++++++----- ui/view/{ => zess}/zess.go | 88 +++++++++++----------- 5 files changed, 111 insertions(+), 102 deletions(-) rename ui/view/{ => gamification}/gamification.go (82%) rename ui/view/{ => message}/message.go (71%) rename ui/view/{ => tap}/tap.go (77%) rename ui/view/{ => zess}/zess.go (72%) diff --git a/ui/screen/cammie.go b/ui/screen/cammie.go index dc65771..3c1aed2 100644 --- a/ui/screen/cammie.go +++ b/ui/screen/cammie.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/ui/view" + "github.com/zeusWPI/scc/ui/view/zess" ) // Cammie represents the cammie screen @@ -15,7 +16,7 @@ type Cammie struct { // NewCammie creates a new cammie screen func NewCammie(db *db.DB) Screen { - return &Cammie{db: db, zess: view.NewZessModel(db)} + return &Cammie{db: db, zess: zess.NewModel(db)} } // Init initializes the cammie screen @@ -27,7 +28,7 @@ func (c *Cammie) Init() tea.Cmd { func (c *Cammie) Update(msg tea.Msg) (Screen, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { - case view.ZessMsg: + case zess.Msg: zess, cmd := c.zess.Update(msg) c.zess = zess diff --git a/ui/view/gamification.go b/ui/view/gamification/gamification.go similarity index 82% rename from ui/view/gamification.go rename to ui/view/gamification/gamification.go index 7608f1d..ac25c35 100644 --- a/ui/view/gamification.go +++ b/ui/view/gamification/gamification.go @@ -1,4 +1,5 @@ -package view +// Package gamification provides the functions to draw an overview of gamification on a TUI +package gamification import ( "context" @@ -17,6 +18,7 @@ import ( "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/ui/view" ) var width = 20 @@ -34,8 +36,8 @@ var ( scoreStyle = base.Width(width).Align(lipgloss.Center) ) -// GamificationModel represents the view model for gamification -type GamificationModel struct { +// Model represents the view model for gamification +type Model struct { db *db.DB leaderboard []gamificationItem } @@ -45,25 +47,25 @@ type gamificationItem struct { item dto.Gamification } -// GamificationMsg contains the data to update the gamification model -type GamificationMsg struct { +// Msg contains the data to update the gamification model +type Msg struct { leaderboard []gamificationItem } -// NewGamificationModel initializes a new gamification model -func NewGamificationModel(db *db.DB) View { - return &GamificationModel{db: db, 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 (g *GamificationModel) Init() tea.Cmd { +func (g *Model) Init() tea.Cmd { return nil } // Update updates the gamification view -func (g *GamificationModel) Update(msg tea.Msg) (View, tea.Cmd) { +func (g *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { - case GamificationMsg: + case Msg: g.leaderboard = msg.leaderboard } @@ -71,7 +73,7 @@ func (g *GamificationModel) Update(msg tea.Msg) (View, tea.Cmd) { } // View draws the gamification view -func (g *GamificationModel) View() string { +func (g *Model) View() string { columns := make([]string, 0, len(g.leaderboard)) for i, item := range g.leaderboard { @@ -90,8 +92,8 @@ func (g *GamificationModel) View() string { } // GetUpdateDatas get all update functions for the gamification view -func (g *GamificationModel) GetUpdateDatas() []UpdateData { - return []UpdateData{ +func (g *Model) GetUpdateDatas() []view.UpdateData { + return []view.UpdateData{ { Name: "gamification leaderboard", View: g, @@ -101,8 +103,8 @@ func (g *GamificationModel) GetUpdateDatas() []UpdateData { } } -func updateLeaderboard(db *db.DB, view View) (tea.Msg, error) { - g := view.(*GamificationModel) +func updateLeaderboard(db *db.DB, view view.View) (tea.Msg, error) { + g := view.(*Model) gams, err := db.Queries.GetAllGamificationByScore(context.Background()) if err != nil { @@ -128,7 +130,7 @@ func updateLeaderboard(db *db.DB, view View) (tea.Msg, error) { return nil, nil } - msg := GamificationMsg{leaderboard: []gamificationItem{}} + msg := Msg{leaderboard: []gamificationItem{}} for _, gam := range gams { if gam.Avatar == "" { // No avatar downloaded diff --git a/ui/view/message.go b/ui/view/message/message.go similarity index 71% rename from ui/view/message.go rename to ui/view/message/message.go index f502345..4e5d33a 100644 --- a/ui/view/message.go +++ b/ui/view/message/message.go @@ -1,4 +1,5 @@ -package view +// Package message provides the functions to draw all the cammie messages on a TUI +package message import ( "context" @@ -12,18 +13,19 @@ import ( "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/ui/view" "go.uber.org/zap" ) -// MessageModel represents the model for the message view -type MessageModel struct { +// Model represents the model for the message view +type Model struct { db *db.DB lastMessageID int64 messages []string } -// MessageMsg represents the message to update the message view -type MessageMsg struct { +// Msg represents the message to update the message view +type Msg struct { lastMessageID int64 messages []string } @@ -33,20 +35,20 @@ var messageColor = []string{ "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff", } -// NewMessageModel creates a new message model view -func NewMessageModel(db *db.DB) View { - return &MessageModel{db: db, lastMessageID: -1, messages: []string{}} +// NewModel creates a new message model view +func NewModel(db *db.DB) view.View { + return &Model{db: db, lastMessageID: -1, messages: []string{}} } // Init initializes the message model view -func (m *MessageModel) Init() tea.Cmd { +func (m *Model) Init() tea.Cmd { return nil } // Update updates the message model view -func (m *MessageModel) Update(msg tea.Msg) (View, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { - case MessageMsg: + case Msg: m.lastMessageID = msg.lastMessageID m.messages = append(m.messages, msg.messages...) @@ -57,7 +59,7 @@ func (m *MessageModel) Update(msg tea.Msg) (View, tea.Cmd) { } // View returns the view for the message model -func (m *MessageModel) View() string { +func (m *Model) View() string { // TODO: Limit the amount of messages shown // TODO: Wrap messages zap.S().Info("Viewing messages") @@ -66,8 +68,8 @@ func (m *MessageModel) View() string { } // GetUpdateDatas returns all the update functions for the message model -func (m *MessageModel) GetUpdateDatas() []UpdateData { - return []UpdateData{ +func (m *Model) GetUpdateDatas() []view.UpdateData { + return []view.UpdateData{ { Name: "cammie messages", View: m, @@ -77,8 +79,8 @@ func (m *MessageModel) GetUpdateDatas() []UpdateData { } } -func updateMessages(db *db.DB, view View) (tea.Msg, error) { - m := view.(*MessageModel) +func updateMessages(db *db.DB, view view.View) (tea.Msg, error) { + m := view.(*Model) lastMessageID := m.lastMessageID message, err := db.Queries.GetLastMessage(context.Background()) @@ -86,17 +88,17 @@ func updateMessages(db *db.DB, view View) (tea.Msg, error) { if err == sql.ErrNoRows { err = nil } - return MessageMsg{lastMessageID: lastMessageID, messages: []string{}}, err + return Msg{lastMessageID: lastMessageID, messages: []string{}}, err } if message.ID <= lastMessageID { - return MessageMsg{lastMessageID: lastMessageID, messages: []string{}}, nil + return Msg{lastMessageID: lastMessageID, messages: []string{}}, nil } messages, err := db.Queries.GetMessageSinceID(context.Background(), lastMessageID) if err != nil { zap.S().Error("DB: Failed to get messages", err) - return MessageMsg{lastMessageID: lastMessageID, messages: []string{}}, err + return Msg{lastMessageID: lastMessageID, messages: []string{}}, err } formattedMessages := make([]string, 0, len(messages)) @@ -104,7 +106,7 @@ func updateMessages(db *db.DB, view View) (tea.Msg, error) { formattedMessages = append(formattedMessages, formatMessage(message)) } - return MessageMsg{lastMessageID: message.ID, messages: formattedMessages}, nil + return Msg{lastMessageID: message.ID, messages: formattedMessages}, nil } func hashColor(s string) string { diff --git a/ui/view/tap.go b/ui/view/tap/tap.go similarity index 77% rename from ui/view/tap.go rename to ui/view/tap/tap.go index 04137a0..5c39477 100644 --- a/ui/view/tap.go +++ b/ui/view/tap/tap.go @@ -1,4 +1,5 @@ -package view +// Package tap provides the functions to draw an overview of the recent tap orders on a TUI +package tap import ( "context" @@ -9,10 +10,11 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/ui/view" ) -// TapModel represents the tap model -type TapModel struct { +// Model represents the tap model +type Model struct { db *db.DB lastOrderID int64 mate float64 @@ -21,8 +23,8 @@ type TapModel struct { food float64 } -// TapMsg represents a tap message -type TapMsg struct { +// Msg represents a tap message +type Msg struct { lastOrderID int64 items []tapItem } @@ -39,20 +41,20 @@ var tapCategoryColor = map[string]lipgloss.Color{ "Food": lipgloss.Color("40"), } -// NewTapModel creates a new tap model -func NewTapModel(db *db.DB) View { - return &TapModel{db: db, lastOrderID: -1} +// 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 (t *TapModel) Init() tea.Cmd { +func (t *Model) Init() tea.Cmd { return nil } // Update updates the tap model -func (t *TapModel) Update(msg tea.Msg) (View, tea.Cmd) { +func (t *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { - case TapMsg: + case Msg: t.lastOrderID = msg.lastOrderID for _, msg := range msg.items { @@ -75,7 +77,7 @@ func (t *TapModel) Update(msg tea.Msg) (View, tea.Cmd) { } // View returns the tap view -func (t *TapModel) View() string { +func (t *Model) View() string { chart := barchart.New(20, 20) barMate := barchart.BarData{ @@ -118,8 +120,8 @@ func (t *TapModel) View() string { } // GetUpdateDatas returns all the update functions for the tap model -func (t *TapModel) GetUpdateDatas() []UpdateData { - return []UpdateData{ +func (t *Model) GetUpdateDatas() []view.UpdateData { + return []view.UpdateData{ { Name: "tap orders", View: t, @@ -129,8 +131,8 @@ func (t *TapModel) GetUpdateDatas() []UpdateData { } } -func updateOrders(db *db.DB, view View) (tea.Msg, error) { - t := view.(*TapModel) +func updateOrders(db *db.DB, view view.View) (tea.Msg, error) { + t := view.(*Model) lastOrderID := t.lastOrderID order, err := db.Queries.GetLastOrderByOrderID(context.Background()) @@ -138,16 +140,16 @@ func updateOrders(db *db.DB, view View) (tea.Msg, error) { if err == sql.ErrNoRows { err = nil } - return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}}, err + return Msg{lastOrderID: lastOrderID, items: []tapItem{}}, err } if order.OrderID <= lastOrderID { - return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}}, nil + return Msg{lastOrderID: lastOrderID, items: []tapItem{}}, nil } orders, err := db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) if err != nil { - return TapMsg{lastOrderID: lastOrderID, items: []tapItem{}}, err + return Msg{lastOrderID: lastOrderID, items: []tapItem{}}, err } mate, soft, beer, food := 0.0, 0.0, 0.0, 0.0 @@ -178,5 +180,5 @@ func updateOrders(db *db.DB, view View) (tea.Msg, error) { messages = append(messages, tapItem{"Food", food}) } - return TapMsg{lastOrderID: order.OrderID, items: messages}, err + return Msg{lastOrderID: order.OrderID, items: messages}, err } diff --git a/ui/view/zess.go b/ui/view/zess/zess.go similarity index 72% rename from ui/view/zess.go rename to ui/view/zess/zess.go index eff0044..07681e4 100644 --- a/ui/view/zess.go +++ b/ui/view/zess/zess.go @@ -1,4 +1,5 @@ -package view +// Package zess provides the functions to draw an overview of the zess scans on a TUI +package zess import ( "context" @@ -10,55 +11,56 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/ui/view" "go.uber.org/zap" ) -// zessTime represents a time object by keeping the year and week number -type zessTime struct { +// time represents a time object by keeping the year and week number +type time struct { year int week int } -type zessWeekScan struct { - time zessTime +type weekScan struct { + time time amount int64 label string } -// ZessModel represents the model for the zess view -type ZessModel struct { +// Model represents the Model for the zess view +type Model struct { db *db.DB lastScanID int64 - scans []zessWeekScan // Queue of scans per week + scans []weekScan // Queue of scans per week maxWeekScans int64 - currentSeason zessTime // Start week of the season + currentSeason time // Start week of the season seasonScans int64 } -// ZessMsg is the base message to indicate that something changed in the zess view -type ZessMsg struct{} +// Msg is the base message to indicate that something changed in the zess view +type Msg struct{} -// zessScanMsg is used to indicate that the zess view should be updated with new scans -type zessScanMsg struct { - ZessMsg +// scanMsg is used to indicate that the zess view should be updated with new scans +type scanMsg struct { + Msg lastScanID int64 - scans []zessWeekScan + scans []weekScan } -// zessSeasonMsg is used to indicate that the current season changed. -type zessSeasonMsg struct { - ZessMsg - start zessTime +// seasonMsg is used to indicate that the current season changed. +type seasonMsg struct { + Msg + start time } -// NewZessModel creates a new zess model view -func NewZessModel(db *db.DB) View { - z := &ZessModel{ +// NewModel creates a new zess model view +func NewModel(db *db.DB) view.View { + z := &Model{ db: db, lastScanID: -1, - scans: make([]zessWeekScan, 0), + scans: make([]weekScan, 0), maxWeekScans: -1, - currentSeason: zessTime{year: -1, week: -1}, + currentSeason: time{year: -1, week: -1}, seasonScans: 0, } @@ -82,15 +84,15 @@ func NewZessModel(db *db.DB) View { } // Init created a new zess model -func (z *ZessModel) Init() tea.Cmd { +func (z *Model) Init() tea.Cmd { return nil } // Update updates the zess model -func (z *ZessModel) Update(msg tea.Msg) (View, tea.Cmd) { +func (z *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { // New scan(s) - case zessScanMsg: + case scanMsg: z.lastScanID = msg.lastScanID // Add new scans for _, newScan := range msg.scans { @@ -126,12 +128,12 @@ func (z *ZessModel) Update(msg tea.Msg) (View, tea.Cmd) { // New season! // Update variables accordinly - case zessSeasonMsg: + case seasonMsg: z.currentSeason = msg.start z.seasonScans = 0 z.maxWeekScans = 0 - validScans := make([]zessWeekScan, 0, len(z.scans)) + validScans := make([]weekScan, 0, len(z.scans)) for _, scan := range z.scans { // Add scans if they happend after (or in the same week of) the season start @@ -153,7 +155,7 @@ func (z *ZessModel) Update(msg tea.Msg) (View, tea.Cmd) { } // View returns the view for the zess model -func (z *ZessModel) View() string { +func (z *Model) View() string { chart := barchart.New(20, 20) for _, scan := range z.scans { @@ -183,8 +185,8 @@ func (z *ZessModel) View() string { } // GetUpdateDatas returns all the update functions for the zess model -func (z *ZessModel) GetUpdateDatas() []UpdateData { - return []UpdateData{ +func (z *Model) GetUpdateDatas() []view.UpdateData { + return []view.UpdateData{ { Name: "zess scans", View: z, @@ -201,8 +203,8 @@ func (z *ZessModel) GetUpdateDatas() []UpdateData { } // Check for any new scans -func updateScans(db *db.DB, view View) (tea.Msg, error) { - z := view.(*ZessModel) +func updateScans(db *db.DB, view view.View) (tea.Msg, error) { + z := view.(*Model) lastScanID := z.lastScanID // Get new scans @@ -220,12 +222,12 @@ func updateScans(db *db.DB, view View) (tea.Msg, error) { return nil, nil } - zessScanMsg := zessScanMsg{lastScanID: lastScanID, scans: make([]zessWeekScan, 0)} + zessScanMsg := scanMsg{lastScanID: lastScanID, scans: make([]weekScan, 0)} // Add new scans to scan msg for _, newScan := range scans { yearNumber, weekNumber := newScan.ScanTime.ISOWeek() - newTime := zessTime{year: yearNumber, week: weekNumber} + newTime := time{year: yearNumber, week: weekNumber} found := false for i, scan := range zessScanMsg.scans { @@ -237,7 +239,7 @@ func updateScans(db *db.DB, view View) (tea.Msg, error) { } if !found { - zessScanMsg.scans = append(zessScanMsg.scans, zessWeekScan{time: newTime, amount: 1, label: newScan.ScanTime.Format("02/01")}) + zessScanMsg.scans = append(zessScanMsg.scans, weekScan{time: newTime, amount: 1, label: newScan.ScanTime.Format("02/01")}) } // Update scan ID @@ -251,8 +253,8 @@ func updateScans(db *db.DB, view View) (tea.Msg, error) { } // Check if a new season started -func updateSeason(db *db.DB, view View) (tea.Msg, error) { - z := view.(*ZessModel) +func updateSeason(db *db.DB, view view.View) (tea.Msg, error) { + z := view.(*Model) season, err := db.Queries.GetSeasonCurrent(context.Background()) if err != nil { @@ -265,19 +267,19 @@ func updateSeason(db *db.DB, view View) (tea.Msg, error) { // Check if we have a new season yearNumber, weekNumber := season.Start.ISOWeek() - seasonStart := zessTime{year: yearNumber, week: weekNumber} + seasonStart := time{year: yearNumber, week: weekNumber} if z.currentSeason.equal(seasonStart) { // Same season return nil, nil } - return zessSeasonMsg{start: seasonStart}, nil + return seasonMsg{start: seasonStart}, nil } -func (z *zessTime) equal(z2 zessTime) bool { +func (z *time) equal(z2 time) bool { return z.week == z2.week && z.year == z2.year } -func (z *zessTime) after(z2 zessTime) bool { +func (z *time) after(z2 time) bool { if z.year > z2.year { return true } else if z.year < z2.year { From 119420fddf870e70d1a5d7877623b74173c4e4c8 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 27 Nov 2024 16:41:59 +0100 Subject: [PATCH 23/46] feat: add website events integration --- .gitignore | 3 + config/development.toml | 4 + .../20241127133125_add_events_table.sql | 15 ++ db/queries/event.sql | 28 ++++ go.mod | 17 ++- go.sum | 45 +++++- internal/cmd/event.go | 48 ++++++ internal/cmd/gamification.go | 6 +- internal/cmd/tap.go | 6 +- internal/cmd/zess.go | 12 +- internal/pkg/db/dto/event.go | 42 ++++++ internal/pkg/db/sqlc/event.sql.go | 137 ++++++++++++++++++ internal/pkg/db/sqlc/models.go | 8 + internal/pkg/event/api.go | 71 +++++++++ internal/pkg/event/event.go | 72 +++++++++ 15 files changed, 495 insertions(+), 19 deletions(-) create mode 100644 db/migrations/20241127133125_add_events_table.sql create mode 100644 db/queries/event.sql create mode 100644 internal/cmd/event.go create mode 100644 internal/pkg/db/dto/event.go create mode 100644 internal/pkg/db/sqlc/event.sql.go create mode 100644 internal/pkg/event/api.go create mode 100644 internal/pkg/event/event.go diff --git a/.gitignore b/.gitignore index 52db340..00e13bd 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ logs/ # DB *.db + +# UI Test file +ui/screen/test.go diff --git a/config/development.toml b/config/development.toml index ca54d00..6a0b36e 100644 --- a/config/development.toml +++ b/config/development.toml @@ -32,6 +32,10 @@ interval_scan_s = 60 api = "https://gamification.zeus.gent" interval_s = 3600 +[event] +api = "https://zeus.gent/events" +interval_s = 86400 + [buzzer] song = [ "-n", "-f880", "-l100", "-d0", diff --git a/db/migrations/20241127133125_add_events_table.sql b/db/migrations/20241127133125_add_events_table.sql new file mode 100644 index 0000000..a083569 --- /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 INTEGER PRIMARY KEY, + name TEXT NOT NULL, + date TIMESTAMP 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/queries/event.sql b/db/queries/event.sql new file mode 100644 index 0000000..e421a05 --- /dev/null +++ b/db/queries/event.sql @@ -0,0 +1,28 @@ +-- CRUD + + +-- name: GetAllEvents :many +SELECT * +FROM event; + +-- name: CreateEvent :one +INSERT INTO event (name, date, academic_year, location) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: DeleteEvent :exec +DELETE FROM event +WHERE id = ?; + + +-- Other + + +-- name: GetEventByAcademicYear :many +SELECT * +FROM event +WHERE academic_year = ?; + +-- name: DeleteEventByAcademicYear :exec +DELETE FROM event +WHERE academic_year = ?; diff --git a/go.mod b/go.mod index 20eda78..0a6f16f 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,16 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/spf13/viper v1.19.0 + ) require ( + 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/bubbles v0.18.0 // indirect github.com/charmbracelet/x/ansi v0.4.2 // indirect @@ -20,7 +26,11 @@ require ( 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/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/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-20240125042004-b7bafc493195 // indirect @@ -33,14 +43,18 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // 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 golang.org/x/crypto v0.27.0 // indirect golang.org/x/image v0.11.0 // indirect - golang.org/x/net v0.23.0 // indirect + golang.org/x/net v0.29.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/term v0.24.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) @@ -48,6 +62,7 @@ require ( github.com/NimbleMarkets/ntcharts v0.1.2 github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-playground/validator/v10 v10.22.1 + 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/hashicorp/hcl v1.0.0 // indirect diff --git a/go.sum b/go.sum index 9a589d4..9499d22 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,17 @@ github.com/NimbleMarkets/ntcharts v0.1.2 h1:iW1aiOif/Dm74sQd18opi10RMED5589cVhy9SGp98Tw= github.com/NimbleMarkets/ntcharts v0.1.2/go.mod h1:WcHS7kc8oQctN1543DeV9a+gOrS4DDVfKp1N9RZFUqc= +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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -38,10 +48,21 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= github.com/go-playground/validator/v10 v10.22.1/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.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= @@ -50,6 +71,8 @@ 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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -104,6 +127,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke 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= @@ -118,6 +143,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ 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.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -125,6 +151,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT 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= @@ -153,8 +181,10 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL 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.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +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= @@ -169,17 +199,21 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.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.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= 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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= @@ -188,6 +222,13 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn 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/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.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= diff --git a/internal/cmd/event.go b/internal/cmd/event.go new file mode 100644 index 0000000..ff392a6 --- /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) + + go eventPeriodicUpdate(ev, done) + + return ev, done +} + +func eventPeriodicUpdate(ev *event.Event, done chan bool) { + interval := config.GetDefaultInt("event.interval_s", 3600) + zap.S().Info("EventL 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 index 550f857..aae1918 100644 --- a/internal/cmd/gamification.go +++ b/internal/cmd/gamification.go @@ -28,8 +28,7 @@ func gamificationPeriodicUpdate(gam *gamification.Gamification, done chan bool) // Run immediatly once zap.S().Info("Gamification: Updating leaderboard") - err := gam.Update() - if err != nil { + if err := gam.Update(); err != nil { zap.S().Error("gamification: Error updating leaderboard\n", err) } @@ -41,8 +40,7 @@ func gamificationPeriodicUpdate(gam *gamification.Gamification, done chan bool) case <-ticker.C: // Update leaderboard zap.S().Info("Gamification: Updating leaderboard") - err := gam.Update() - if err != nil { + if err := gam.Update(); err != nil { zap.S().Error("gamification: Error updating leaderboard\n", err) } } diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go index 39a18d6..a2fcc8f 100644 --- a/internal/cmd/tap.go +++ b/internal/cmd/tap.go @@ -28,8 +28,7 @@ func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { // Run immediatly once zap.S().Info("Tap: Updating tap") - err := tap.Update() - if err != nil { + if err := tap.Update(); err != nil { zap.S().Error("Tap: Error updating tap\n", err) } @@ -41,8 +40,7 @@ func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { case <-ticker.C: // Update tap zap.S().Info("Tap: Updating tap") - err := tap.Update() - if err != nil { + if err := tap.Update(); err != nil { zap.S().Error("Tap: Error updating tap\n", err) } } diff --git a/internal/cmd/zess.go b/internal/cmd/zess.go index f80a529..c40ab34 100644 --- a/internal/cmd/zess.go +++ b/internal/cmd/zess.go @@ -30,8 +30,7 @@ func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool) { // Run immediatly once zap.S().Info("Zess: Updating seasons") - err := zess.UpdateSeasons() - if err != nil { + if err := zess.UpdateSeasons(); err != nil { zap.S().Error("Zess: Error updating seasons\n", err) } @@ -43,8 +42,7 @@ func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool) { case <-ticker.C: // Update seasons zap.S().Info("Zess: Updating seasons") - err := zess.UpdateSeasons() - if err != nil { + if err := zess.UpdateSeasons(); err != nil { zap.S().Error("Zess: Error updating seasons\n", err) } } @@ -60,8 +58,7 @@ func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool) { // Run immediatly once zap.S().Info("Zess: Updating scans") - err := zess.UpdateScans() - if err != nil { + if err := zess.UpdateScans(); err != nil { zap.S().Error("Zess: Error updating scans\n", err) } @@ -73,8 +70,7 @@ func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool) { case <-ticker.C: // Update scans zap.S().Info("Zess: Updating scans") - err := zess.UpdateScans() - if err != nil { + if err := zess.UpdateScans(); err != nil { zap.S().Error("Zess: Error updating scans\n", err) } } diff --git a/internal/pkg/db/dto/event.go b/internal/pkg/db/dto/event.go new file mode 100644 index 0000000..838d833 --- /dev/null +++ b/internal/pkg/db/dto/event.go @@ -0,0 +1,42 @@ +package dto + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" +) + +// Event represents the DTO object for event +type Event struct { + ID int64 + Name string + Date time.Time + AcademicYear string + Location string +} + +// 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, + AcademicYear: e.AcademicYear, + Location: e.Location, + } +} + +// 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 +} + +// CreateParams converts a Event DTO to a sqlc CreateEventParams object +func (e *Event) CreateParams() sqlc.CreateEventParams { + return sqlc.CreateEventParams{ + Name: e.Name, + Date: e.Date, + AcademicYear: e.AcademicYear, + Location: e.Location, + } +} diff --git a/internal/pkg/db/sqlc/event.sql.go b/internal/pkg/db/sqlc/event.sql.go new file mode 100644 index 0000000..4759542 --- /dev/null +++ b/internal/pkg/db/sqlc/event.sql.go @@ -0,0 +1,137 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: event.sql + +package sqlc + +import ( + "context" + "time" +) + +const createEvent = `-- name: CreateEvent :one +INSERT INTO event (name, date, academic_year, location) +VALUES (?, ?, ?, ?) +RETURNING id, name, date, academic_year, location +` + +type CreateEventParams struct { + Name string + Date time.Time + AcademicYear string + Location string +} + +func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { + row := q.db.QueryRowContext(ctx, createEvent, + arg.Name, + arg.Date, + arg.AcademicYear, + arg.Location, + ) + var i Event + err := row.Scan( + &i.ID, + &i.Name, + &i.Date, + &i.AcademicYear, + &i.Location, + ) + return i, err +} + +const deleteEvent = `-- name: DeleteEvent :exec +DELETE FROM event +WHERE id = ? +` + +func (q *Queries) DeleteEvent(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteEvent, id) + return err +} + +const deleteEventByAcademicYear = `-- name: DeleteEventByAcademicYear :exec +DELETE FROM event +WHERE academic_year = ? +` + +func (q *Queries) DeleteEventByAcademicYear(ctx context.Context, academicYear string) error { + _, err := q.db.ExecContext(ctx, deleteEventByAcademicYear, academicYear) + return err +} + +const getAllEvents = `-- name: GetAllEvents :many + + +SELECT id, name, date, academic_year, location +FROM event +` + +// CRUD +func (q *Queries) GetAllEvents(ctx context.Context) ([]Event, error) { + rows, err := q.db.QueryContext(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, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getEventByAcademicYear = `-- name: GetEventByAcademicYear :many + + +SELECT id, name, date, academic_year, location +FROM event +WHERE academic_year = ? +` + +// Other +func (q *Queries) GetEventByAcademicYear(ctx context.Context, academicYear string) ([]Event, error) { + rows, err := q.db.QueryContext(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, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index a2d2dfc..ed03606 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -8,6 +8,14 @@ import ( "time" ) +type Event struct { + ID int64 + Name string + Date time.Time + AcademicYear string + Location string +} + type Gamification struct { ID int64 Name string diff --git a/internal/pkg/event/api.go b/internal/pkg/event/api.go new file mode 100644 index 0000000..37a5358 --- /dev/null +++ b/internal/pkg/event/api.go @@ -0,0 +1,71 @@ +package event + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/gocolly/colly" + "github.com/zeusWPI/scc/internal/pkg/db/dto" +) + +var layout = "Monday 02 January, 15:04 2006" + +func (e *Event) getEvents() ([]dto.Event, error) { + 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.api) + if err != nil { + return nil, err + } + + c.Wait() + + return events, errors.Join(errs...) +} diff --git a/internal/pkg/event/event.go b/internal/pkg/event/event.go new file mode 100644 index 0000000..18140f3 --- /dev/null +++ b/internal/pkg/event/event.go @@ -0,0 +1,72 @@ +// Package event provides all logic regarding the events of the website +package event + +import ( + "context" + "errors" + "slices" + + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/pkg/config" +) + +// Event represents a event instance +type Event struct { + db *db.DB + api string +} + +// New creates a new event instance +func New(db *db.DB) *Event { + api := config.GetDefaultString("event.api", "https://zeus.gent/events") + + return &Event{db: db, api: api} +} + +// 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 + } + + eventsDB, err := e.db.Queries.GetEventByAcademicYear(context.Background(), events[0].AcademicYear) + if err != nil { + return err + } + + equal := false + if len(events) == len(eventsDB) { + for _, event := range eventsDB { + found := slices.ContainsFunc(events, func(ev dto.Event) bool { return ev.Equal(*dto.EventDTO(event)) }) + if !found { + break + } + } + equal = true + } + + // 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 + } + var errs []error + for _, event := range events { + _, err = e.db.Queries.CreateEvent(context.Background(), event.CreateParams()) + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} From f54fa513c6ec70c38dabea427b22348de7305ae5 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 27 Nov 2024 17:55:06 +0100 Subject: [PATCH 24/46] feat(song): history --- config/development.toml | 8 +- .../20241127162048_add_song_history_table.sql | 24 +++ db/queries/song.sql | 33 ++++ db/queries/song_history.sql | 16 ++ db/queries/spotify.sql | 33 ---- internal/api/api.go | 8 +- internal/api/song/song.go | 56 ++++++ internal/api/spotify/spotify.go | 56 ------ internal/cmd/api.go | 6 +- internal/cmd/song.go | 13 ++ internal/cmd/spotify.go | 13 -- internal/pkg/db/dto/song.go | 35 ++++ internal/pkg/db/dto/spotify.go | 39 ---- internal/pkg/db/sqlc/models.go | 9 +- internal/pkg/db/sqlc/song.sql.go | 164 +++++++++++++++++ internal/pkg/db/sqlc/song_history.sql.go | 42 +++++ internal/pkg/db/sqlc/spotify.sql.go | 169 ------------------ internal/pkg/{spotify => song}/account.go | 16 +- internal/pkg/{spotify => song}/api.go | 16 +- internal/pkg/song/song.go | 93 ++++++++++ internal/pkg/spotify/spotify.go | 84 --------- 21 files changed, 511 insertions(+), 422 deletions(-) create mode 100644 db/migrations/20241127162048_add_song_history_table.sql create mode 100644 db/queries/song.sql create mode 100644 db/queries/song_history.sql delete mode 100644 db/queries/spotify.sql create mode 100644 internal/api/song/song.go delete mode 100644 internal/api/spotify/spotify.go create mode 100644 internal/cmd/song.go delete mode 100644 internal/cmd/spotify.go create mode 100644 internal/pkg/db/dto/song.go delete mode 100644 internal/pkg/db/dto/spotify.go create mode 100644 internal/pkg/db/sqlc/song.sql.go create mode 100644 internal/pkg/db/sqlc/song_history.sql.go delete mode 100644 internal/pkg/db/sqlc/spotify.sql.go rename internal/pkg/{spotify => song}/account.go (58%) rename internal/pkg/{spotify => song}/api.go (61%) create mode 100644 internal/pkg/song/song.go delete mode 100644 internal/pkg/spotify/spotify.go diff --git a/config/development.toml b/config/development.toml index 6a0b36e..00a97df 100644 --- a/config/development.toml +++ b/config/development.toml @@ -2,9 +2,11 @@ host = "localhost" port = 3000 -[spotify] -client_id = "your_client_id" -client_secret = "your_client_secret" +[song] +spotify_client_id = "your_client_id" +spotify_client_secret = "your_client_secret" +spotify_api = "https://api.spotify.com/v1" +spotify_account = "https://accounts.spotify.com/api/token" [tap] api = "https://tap.zeus.gent" 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..bd71340 --- /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 song_history ( + id INTEGER PRIMARY KEY, + song_id INTEGER NOT NULL, + created_at TIMESTAMP 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 NOT NULL DEFAULT CURRENT_TIMESTAMP; +-- +goose StatementEnd diff --git a/db/queries/song.sql b/db/queries/song.sql new file mode 100644 index 0000000..42829ac --- /dev/null +++ b/db/queries/song.sql @@ -0,0 +1,33 @@ +-- CRUD + +-- name: GetAllSongs :many +SELECT * +FROM song; + +-- name: GetSongByID :one +SELECT * +FROM song +WHERE id = ?; + +-- name: CreateSong :one +INSERT INTO song (title, artists, spotify_id, duration_ms) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: UpdateSong :one +UPDATE song +SET title = ?, artists = ?, spotify_id = ?, duration_ms = ? +WHERE id = ? +RETURNING *; + +-- name: DeleteSong :execrows +DELETE FROM song +WHERE id = ?; + + +-- Other + +-- name: GetSongBySpotifyID :one +SELECT * +FROM song +WHERE spotify_id = ?; diff --git a/db/queries/song_history.sql b/db/queries/song_history.sql new file mode 100644 index 0000000..cc9fcf8 --- /dev/null +++ b/db/queries/song_history.sql @@ -0,0 +1,16 @@ +-- CRUD + +-- name: CreateSongHistory :one +INSERT INTO song_history (song_id) +VALUES (?) +RETURNING *; + + +-- Other + + +-- name: GetLastSongHistory :one +SELECT * +FROM song_history +ORDER BY created_at DESC +LIMIT 1; diff --git a/db/queries/spotify.sql b/db/queries/spotify.sql deleted file mode 100644 index 2074db9..0000000 --- a/db/queries/spotify.sql +++ /dev/null @@ -1,33 +0,0 @@ --- CRUD - --- name: GetAllSpotify :many -SELECT * -FROM spotify; - --- name: GetSpotifyByID :one -SELECT * -FROM spotify -WHERE id = ?; - --- name: CreateSpotify :one -INSERT INTO spotify (title, artists, spotify_id, duration_ms) -VALUES (?, ?, ?, ?) -RETURNING *; - --- name: UpdateSpotify :one -UPDATE spotify -SET title = ?, artists = ?, spotify_id = ?, duration_ms = ? -WHERE id = ? -RETURNING *; - --- name: DeleteSpotify :execrows -DELETE FROM spotify -WHERE id = ?; - - --- Other - --- name: GetSpotifyBySpotifyID :one -SELECT * -FROM spotify -WHERE spotify_id = ?; diff --git a/internal/api/api.go b/internal/api/api.go index e78b6cd..2097678 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -4,13 +4,13 @@ package api import ( "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/api/message" - apiSpotify "github.com/zeusWPI/scc/internal/api/spotify" + apiSong "github.com/zeusWPI/scc/internal/api/song" "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/spotify" + "github.com/zeusWPI/scc/internal/pkg/song" ) // New creates a new API instance -func New(router fiber.Router, db *db.DB, spotify *spotify.Spotify) { +func New(router fiber.Router, db *db.DB, song *song.Song) { message.New(router, db) - apiSpotify.New(router, db, spotify) + apiSong.New(router, db, song) } 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/api/spotify/spotify.go b/internal/api/spotify/spotify.go deleted file mode 100644 index c9a0bd4..0000000 --- a/internal/api/spotify/spotify.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package spotify provides the API regarding spotify integration -package spotify - -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/spotify" - "go.uber.org/zap" -) - -// Router is the spotify API router -type Router struct { - router fiber.Router - db *db.DB - spotify *spotify.Spotify -} - -// New creates a new spotify API instance -func New(router fiber.Router, db *db.DB, spotify *spotify.Spotify) *Router { - api := &Router{ - router: router.Group("/spotify"), - db: db, - spotify: spotify, - } - api.createRoutes() - - return api -} - -func (r *Router) createRoutes() { - r.router.Post("/", r.new) -} - -func (r *Router) new(c *fiber.Ctx) error { - spotify := new(dto.Spotify) - - if err := c.BodyParser(spotify); err != nil { - zap.S().Error("API: Spotify body parser\n", err) - return c.SendStatus(fiber.StatusBadRequest) - } - - if err := dto.Validate.Struct(spotify); err != nil { - zap.S().Error("API: Spotify validation\n", err) - return c.SendStatus(fiber.StatusBadRequest) - } - - go func() { - err := r.spotify.Track(spotify) - if err != nil { - zap.S().Error("Spotify: Get Track\n", err) - } - }() - - return c.SendStatus(fiber.StatusOK) -} diff --git a/internal/cmd/api.go b/internal/cmd/api.go index 9a9e508..c021fd7 100644 --- a/internal/cmd/api.go +++ b/internal/cmd/api.go @@ -8,13 +8,13 @@ import ( "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/spotify" + "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, spotify *spotify.Spotify) { +func API(db *db.DB, song *song.Song) { app := fiber.New(fiber.Config{ BodyLimit: 1024 * 1024 * 1024, }) @@ -29,7 +29,7 @@ func API(db *db.DB, spotify *spotify.Spotify) { ) apiGroup := app.Group("/api") - api.New(apiGroup, db, spotify) + api.New(apiGroup, db, song) host := config.GetDefaultString("server.host", "127.0.0.1") port := config.GetDefaultInt("server.port", 3000) 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/spotify.go b/internal/cmd/spotify.go deleted file mode 100644 index 64e4072..0000000 --- a/internal/cmd/spotify.go +++ /dev/null @@ -1,13 +0,0 @@ -package cmd - -import ( - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/internal/pkg/spotify" -) - -// Spotify starts the Spotify integration -func Spotify(db *db.DB) (*spotify.Spotify, error) { - spotify, err := spotify.New(db) - - return spotify, err -} diff --git a/internal/pkg/db/dto/song.go b/internal/pkg/db/dto/song.go new file mode 100644 index 0000000..268b8d4 --- /dev/null +++ b/internal/pkg/db/dto/song.go @@ -0,0 +1,35 @@ +package dto + +import ( + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" +) + +// Song is the DTO for the song +type Song struct { + ID int64 `json:"id"` + Title string `json:"title"` + Artists string `json:"artists"` + SpotifyID string `json:"spotify_id" validate:"required"` + DurationMS int64 `json:"duration_ms"` +} + +// SongDTO converts a sqlc.Song to a Song +func SongDTO(song sqlc.Song) *Song { + return &Song{ + ID: song.ID, + Title: song.Title, + Artists: song.Artists, + SpotifyID: song.SpotifyID, + DurationMS: song.DurationMs, + } +} + +// CreateParams converts a Song to sqlc.CreateSongParams +func (s *Song) CreateParams() sqlc.CreateSongParams { + return sqlc.CreateSongParams{ + Title: s.Title, + Artists: s.Artists, + SpotifyID: s.SpotifyID, + DurationMs: s.DurationMS, + } +} diff --git a/internal/pkg/db/dto/spotify.go b/internal/pkg/db/dto/spotify.go deleted file mode 100644 index be4cbb5..0000000 --- a/internal/pkg/db/dto/spotify.go +++ /dev/null @@ -1,39 +0,0 @@ -package dto - -import ( - "time" - - "github.com/zeusWPI/scc/internal/pkg/db/sqlc" -) - -// Spotify is the DTO for the spotify -type Spotify struct { - ID int64 `json:"id"` - Title string `json:"title"` - Artists string `json:"artists"` - SpotifyID string `json:"spotify_id" validate:"required"` - DurationMS int64 `json:"duration_ms"` - CreatedAt time.Time `json:"created_at"` -} - -// SpotifyDTO converts a sqlc.Spotify to a Spotify -func SpotifyDTO(spotify sqlc.Spotify) *Spotify { - return &Spotify{ - ID: spotify.ID, - Title: spotify.Title, - Artists: spotify.Artists, - SpotifyID: spotify.SpotifyID, - DurationMS: spotify.DurationMs, - CreatedAt: spotify.CreatedAt, - } -} - -// CreateParams converts a Spotify to sqlc.CreateSpotifyParams -func (s *Spotify) CreateParams() sqlc.CreateSpotifyParams { - return sqlc.CreateSpotifyParams{ - Title: s.Title, - Artists: s.Artists, - SpotifyID: s.SpotifyID, - DurationMs: s.DurationMS, - } -} diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index ed03606..84b77f7 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -44,13 +44,18 @@ type Season struct { Current bool } -type Spotify struct { +type Song struct { ID int64 Title string Artists string SpotifyID string DurationMs int64 - CreatedAt time.Time +} + +type SongHistory struct { + ID int64 + SongID int64 + CreatedAt time.Time } type Tap struct { diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go new file mode 100644 index 0000000..e72a626 --- /dev/null +++ b/internal/pkg/db/sqlc/song.sql.go @@ -0,0 +1,164 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: song.sql + +package sqlc + +import ( + "context" +) + +const createSong = `-- name: CreateSong :one +INSERT INTO song (title, artists, spotify_id, duration_ms) +VALUES (?, ?, ?, ?) +RETURNING id, title, artists, spotify_id, duration_ms +` + +type CreateSongParams struct { + Title string + Artists string + SpotifyID string + DurationMs int64 +} + +func (q *Queries) CreateSong(ctx context.Context, arg CreateSongParams) (Song, error) { + row := q.db.QueryRowContext(ctx, createSong, + arg.Title, + arg.Artists, + arg.SpotifyID, + arg.DurationMs, + ) + var i Song + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + ) + return i, err +} + +const deleteSong = `-- name: DeleteSong :execrows +DELETE FROM song +WHERE id = ? +` + +func (q *Queries) DeleteSong(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteSong, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getAllSongs = `-- name: GetAllSongs :many + +SELECT id, title, artists, spotify_id, duration_ms +FROM song +` + +// CRUD +func (q *Queries) GetAllSongs(ctx context.Context) ([]Song, error) { + rows, err := q.db.QueryContext(ctx, getAllSongs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Song + for rows.Next() { + var i Song + if err := rows.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSongByID = `-- name: GetSongByID :one +SELECT id, title, artists, spotify_id, duration_ms +FROM song +WHERE id = ? +` + +func (q *Queries) GetSongByID(ctx context.Context, id int64) (Song, error) { + row := q.db.QueryRowContext(ctx, getSongByID, id) + var i Song + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + ) + return i, err +} + +const getSongBySpotifyID = `-- name: GetSongBySpotifyID :one + +SELECT id, title, artists, spotify_id, duration_ms +FROM song +WHERE spotify_id = ? +` + +// Other +func (q *Queries) GetSongBySpotifyID(ctx context.Context, spotifyID string) (Song, error) { + row := q.db.QueryRowContext(ctx, getSongBySpotifyID, spotifyID) + var i Song + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + ) + return i, err +} + +const updateSong = `-- name: UpdateSong :one +UPDATE song +SET title = ?, artists = ?, spotify_id = ?, duration_ms = ? +WHERE id = ? +RETURNING id, title, artists, spotify_id, duration_ms +` + +type UpdateSongParams struct { + Title string + Artists string + SpotifyID string + DurationMs int64 + ID int64 +} + +func (q *Queries) UpdateSong(ctx context.Context, arg UpdateSongParams) (Song, error) { + row := q.db.QueryRowContext(ctx, updateSong, + arg.Title, + arg.Artists, + arg.SpotifyID, + arg.DurationMs, + arg.ID, + ) + var i Song + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + ) + return i, err +} diff --git a/internal/pkg/db/sqlc/song_history.sql.go b/internal/pkg/db/sqlc/song_history.sql.go new file mode 100644 index 0000000..d1e8f95 --- /dev/null +++ b/internal/pkg/db/sqlc/song_history.sql.go @@ -0,0 +1,42 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: song_history.sql + +package sqlc + +import ( + "context" +) + +const createSongHistory = `-- name: CreateSongHistory :one + +INSERT INTO song_history (song_id) +VALUES (?) +RETURNING id, song_id, created_at +` + +// CRUD +func (q *Queries) CreateSongHistory(ctx context.Context, songID int64) (SongHistory, error) { + row := q.db.QueryRowContext(ctx, createSongHistory, songID) + var i SongHistory + err := row.Scan(&i.ID, &i.SongID, &i.CreatedAt) + return i, err +} + +const getLastSongHistory = `-- name: GetLastSongHistory :one + + +SELECT id, song_id, created_at +FROM song_history +ORDER BY created_at DESC +LIMIT 1 +` + +// Other +func (q *Queries) GetLastSongHistory(ctx context.Context) (SongHistory, error) { + row := q.db.QueryRowContext(ctx, getLastSongHistory) + var i SongHistory + err := row.Scan(&i.ID, &i.SongID, &i.CreatedAt) + return i, err +} diff --git a/internal/pkg/db/sqlc/spotify.sql.go b/internal/pkg/db/sqlc/spotify.sql.go deleted file mode 100644 index c79eeb6..0000000 --- a/internal/pkg/db/sqlc/spotify.sql.go +++ /dev/null @@ -1,169 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: spotify.sql - -package sqlc - -import ( - "context" -) - -const createSpotify = `-- name: CreateSpotify :one -INSERT INTO spotify (title, artists, spotify_id, duration_ms) -VALUES (?, ?, ?, ?) -RETURNING id, title, artists, spotify_id, duration_ms, created_at -` - -type CreateSpotifyParams struct { - Title string - Artists string - SpotifyID string - DurationMs int64 -} - -func (q *Queries) CreateSpotify(ctx context.Context, arg CreateSpotifyParams) (Spotify, error) { - row := q.db.QueryRowContext(ctx, createSpotify, - arg.Title, - arg.Artists, - arg.SpotifyID, - arg.DurationMs, - ) - var i Spotify - err := row.Scan( - &i.ID, - &i.Title, - &i.Artists, - &i.SpotifyID, - &i.DurationMs, - &i.CreatedAt, - ) - return i, err -} - -const deleteSpotify = `-- name: DeleteSpotify :execrows -DELETE FROM spotify -WHERE id = ? -` - -func (q *Queries) DeleteSpotify(ctx context.Context, id int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteSpotify, id) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -const getAllSpotify = `-- name: GetAllSpotify :many - -SELECT id, title, artists, spotify_id, duration_ms, created_at -FROM spotify -` - -// CRUD -func (q *Queries) GetAllSpotify(ctx context.Context) ([]Spotify, error) { - rows, err := q.db.QueryContext(ctx, getAllSpotify) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Spotify - for rows.Next() { - var i Spotify - if err := rows.Scan( - &i.ID, - &i.Title, - &i.Artists, - &i.SpotifyID, - &i.DurationMs, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getSpotifyByID = `-- name: GetSpotifyByID :one -SELECT id, title, artists, spotify_id, duration_ms, created_at -FROM spotify -WHERE id = ? -` - -func (q *Queries) GetSpotifyByID(ctx context.Context, id int64) (Spotify, error) { - row := q.db.QueryRowContext(ctx, getSpotifyByID, id) - var i Spotify - err := row.Scan( - &i.ID, - &i.Title, - &i.Artists, - &i.SpotifyID, - &i.DurationMs, - &i.CreatedAt, - ) - return i, err -} - -const getSpotifyBySpotifyID = `-- name: GetSpotifyBySpotifyID :one - -SELECT id, title, artists, spotify_id, duration_ms, created_at -FROM spotify -WHERE spotify_id = ? -` - -// Other -func (q *Queries) GetSpotifyBySpotifyID(ctx context.Context, spotifyID string) (Spotify, error) { - row := q.db.QueryRowContext(ctx, getSpotifyBySpotifyID, spotifyID) - var i Spotify - err := row.Scan( - &i.ID, - &i.Title, - &i.Artists, - &i.SpotifyID, - &i.DurationMs, - &i.CreatedAt, - ) - return i, err -} - -const updateSpotify = `-- name: UpdateSpotify :one -UPDATE spotify -SET title = ?, artists = ?, spotify_id = ?, duration_ms = ? -WHERE id = ? -RETURNING id, title, artists, spotify_id, duration_ms, created_at -` - -type UpdateSpotifyParams struct { - Title string - Artists string - SpotifyID string - DurationMs int64 - ID int64 -} - -func (q *Queries) UpdateSpotify(ctx context.Context, arg UpdateSpotifyParams) (Spotify, error) { - row := q.db.QueryRowContext(ctx, updateSpotify, - arg.Title, - arg.Artists, - arg.SpotifyID, - arg.DurationMs, - arg.ID, - ) - var i Spotify - err := row.Scan( - &i.ID, - &i.Title, - &i.Artists, - &i.SpotifyID, - &i.DurationMs, - &i.CreatedAt, - ) - return i, err -} diff --git a/internal/pkg/spotify/account.go b/internal/pkg/song/account.go similarity index 58% rename from internal/pkg/spotify/account.go rename to internal/pkg/song/account.go index 7b95575..7a445b0 100644 --- a/internal/pkg/spotify/account.go +++ b/internal/pkg/song/account.go @@ -1,4 +1,4 @@ -package spotify +package song import ( "encoding/json" @@ -6,19 +6,18 @@ import ( "time" "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) -const accountURL = "https://accounts.spotify.com/api/token" - type accountResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int64 `json:"expires_in"` } -func (s *Spotify) refreshToken() error { - zap.S().Info("Spotify: Refreshing access token") +func (s *Song) refreshToken() error { + zap.S().Info("Song: Refreshing spotify access token") body, err := json.Marshal(fiber.Map{ "grant_type": "client_credentials", @@ -29,15 +28,16 @@ func (s *Spotify) refreshToken() error { return err } - req := fiber.Post(accountURL).Body(body).ContentType("application/json") + api := config.GetDefaultString("song.spotify_account", "https://accounts.spotify.com/api/token") + req := fiber.Post(api).Body(body).ContentType("application/json") res := new(accountResponse) status, _, errs := req.Struct(res) if len(errs) > 0 { - return errors.Join(append([]error{errors.New("Spotify: Token refresh request failed")}, errs...)...) + return errors.Join(append([]error{errors.New("Song: Spotify token refresh request failed")}, errs...)...) } if status != fiber.StatusOK { - return errors.New("error getting access token") + return errors.New("Song: Error getting access token") } s.AccessToken = res.AccessToken diff --git a/internal/pkg/spotify/api.go b/internal/pkg/song/api.go similarity index 61% rename from internal/pkg/spotify/api.go rename to internal/pkg/song/api.go index d57a969..9ae37bc 100644 --- a/internal/pkg/spotify/api.go +++ b/internal/pkg/song/api.go @@ -1,4 +1,4 @@ -package spotify +package song import ( "errors" @@ -6,12 +6,11 @@ import ( "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/util" "go.uber.org/zap" ) -const apiURL = "https://api.spotify.com/v1" - type trackArtist struct { Name string `json:"name"` } @@ -22,19 +21,20 @@ type trackResponse struct { DurationMS int64 `json:"duration_ms"` } -func (s *Spotify) setTrack(track *dto.Spotify) error { - zap.S().Info("Spotify: Getting track info for id: ", track.SpotifyID) +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", apiURL, "tracks", track.SpotifyID)). + api := config.GetDefaultString("song.spotify_api", "https://api.spotify.com/v1") + req := fiber.Get(fmt.Sprintf("%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("Spotify: Track request failed")}, errs...)...) + return errors.Join(append([]error{errors.New("Song: Track request failed")}, errs...)...) } if status != fiber.StatusOK { - return errors.New("error getting track") + return errors.New("Song: Error getting track") } track.Title = res.Name diff --git a/internal/pkg/song/song.go b/internal/pkg/song/song.go new file mode 100644 index 0000000..3d4cd5f --- /dev/null +++ b/internal/pkg/song/song.go @@ -0,0 +1,93 @@ +// Package song provides all song related logic +package song + +import ( + "context" + "database/sql" + "errors" + "time" + + "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 +} + +// New creates a new song instance +func New(db *db.DB) (*Song, error) { + clientID := config.GetDefaultString("song.spotify_client_id", "") + clientSecret := config.GetDefaultString("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}, nil +} + +// Track gets information about the current track and stores it in the database +func (s *Song) Track(track *dto.Song) 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 != sql.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 != sql.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 + } + } + + // Set track info + err = s.getTrack(track) + if err != nil { + return err + } + + // Store track in DB + trackDB, err = s.db.Queries.CreateSong(context.Background(), track.CreateParams()) + if err != nil { + return err + } + + // Add to song history + _, err = s.db.Queries.CreateSongHistory(context.Background(), trackDB.ID) + + return err + +} diff --git a/internal/pkg/spotify/spotify.go b/internal/pkg/spotify/spotify.go deleted file mode 100644 index 4cc4b33..0000000 --- a/internal/pkg/spotify/spotify.go +++ /dev/null @@ -1,84 +0,0 @@ -// Package spotify provides all spotify related logic -package spotify - -import ( - "context" - "database/sql" - "errors" - "time" - - "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" -) - -// Spotify represents a spotify instance -type Spotify struct { - db *db.DB - ClientID string - ClientSecret string - AccessToken string - ExpiresTime int64 -} - -// New creates a new spotify instance -func New(db *db.DB) (*Spotify, error) { - clientID := config.GetDefaultString("spotify.client_id", "") - clientSecret := config.GetDefaultString("spotify.client_secret", "") - - if clientID == "" || clientSecret == "" { - return &Spotify{}, errors.New("Spotify client id or secret not set") - } - - return &Spotify{db: db, ClientID: clientID, ClientSecret: clientSecret, ExpiresTime: 0}, nil -} - -// Track gets information about the current track and stores it in the database -func (s *Spotify) Track(track *dto.Spotify) error { - if s.ClientID == "" || s.ClientSecret == "" { - return errors.New("spotify client id or secret not set") - } - - // Check if song is already in DB - trackDB, err := s.db.Queries.GetSpotifyBySpotifyID(context.Background(), track.SpotifyID) - if err != nil { - if err != sql.ErrNoRows { - return err - } - } - - if (trackDB != sqlc.Spotify{}) { - // Already in DB - // No need to refetch data - track.Title = trackDB.Title - track.Artists = trackDB.Artists - track.DurationMS = trackDB.DurationMs - _, err := s.db.Queries.CreateSpotify(context.Background(), track.CreateParams()) - - return err - } - - // Refresh token if needed - if s.ExpiresTime <= time.Now().Unix() { - err := s.refreshToken() - if err != nil { - return err - } - } - - // Set track info - err = s.setTrack(track) - if err != nil { - return err - } - - // Store track in DB - _, err = s.db.Queries.CreateSpotify(context.Background(), track.CreateParams()) - if err != nil { - return err - } - - return nil - -} From 6415ebac5dd9648c5c8e40bfbf3a45e6bffc0215 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 27 Nov 2024 22:11:05 +0100 Subject: [PATCH 25/46] feat(song): genre --- .env.example | 4 +- README.md | 11 +- config/development.toml | 2 - .../20241127162048_add_song_history_table.sql | 2 +- .../20241127165609_add_song_genre.sql | 45 ++++ db/queries/song.sql | 61 ++++- db/queries/song_history.sql | 16 -- internal/pkg/db/dto/song.go | 67 ++++- internal/pkg/db/sqlc/models.go | 26 +- internal/pkg/db/sqlc/song.sql.go | 239 ++++++++++++++++-- internal/pkg/db/sqlc/song_history.sql.go | 42 --- internal/pkg/song/account.go | 13 +- internal/pkg/song/api.go | 50 +++- internal/pkg/song/song.go | 80 +++++- 14 files changed, 536 insertions(+), 122 deletions(-) create mode 100644 db/migrations/20241127165609_add_song_genre.sql delete mode 100644 db/queries/song_history.sql delete mode 100644 internal/pkg/db/sqlc/song_history.sql.go diff --git a/.env.example b/.env.example index 8b9e46e..580ea18 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ -app.env = development +APP_ENV = development +SONG_SPOTIFY_CLIENT_ID = your_client_id +SONG_SPOTIFY_CLIENT_SECRET = your_client_secret diff --git a/README.md b/README.md index 99c2169..2dacb28 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,12 @@ Displays the cammie chat along with some other statistics. ### Configuration -1. Create a `.env` file specifying the environment. Available options are: - - `development` - - `production` +1. Create a `.env` file specifying + - `APP_ENV`. Available options are: + - `development` + - `production` + - `SONG_SPOTIFY_CLIENT_ID` + - `SONG_SPOTIFY_CLIENT_SECRET` 2. Configure the appropriate settings in the corresponding configuration file located in the [config directory](./config) ## DB @@ -26,7 +29,7 @@ SQLC is used to generate statically typed queries and goose is responsible for t ### Usefull commands -- `make migrate`: Run database migrations to update your database schema. +- `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. diff --git a/config/development.toml b/config/development.toml index 00a97df..055ad23 100644 --- a/config/development.toml +++ b/config/development.toml @@ -3,8 +3,6 @@ host = "localhost" port = 3000 [song] -spotify_client_id = "your_client_id" -spotify_client_secret = "your_client_secret" spotify_api = "https://api.spotify.com/v1" spotify_account = "https://accounts.spotify.com/api/token" diff --git a/db/migrations/20241127162048_add_song_history_table.sql b/db/migrations/20241127162048_add_song_history_table.sql index bd71340..03cc0a6 100644 --- a/db/migrations/20241127162048_add_song_history_table.sql +++ b/db/migrations/20241127162048_add_song_history_table.sql @@ -5,7 +5,7 @@ DROP COLUMN created_at; ALTER TABLE spotify RENAME TO song; -CREATE TABLE song_history ( +CREATE TABLE IF NOT EXISTS song_history ( id INTEGER PRIMARY KEY, song_id INTEGER NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/db/migrations/20241127165609_add_song_genre.sql b/db/migrations/20241127165609_add_song_genre.sql new file mode 100644 index 0000000..75b1b12 --- /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 INTEGER PRIMARY KEY, + genre TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS song_artist ( + id INTEGER 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 INTEGER PRIMARY KEY, + artist_id INTEGER NOT NULL, + song_id INTEGER NOT NULL, + FOREIGN KEY(artist_id) REFERENCES artist(id), + FOREIGN KEY(song_id) REFERENCES song(id) +); + +CREATE TABLE IF NOT EXISTS song_artist_genre ( + id INTEGER PRIMARY KEY, + artist_id INTEGER NOT NULL, + genre_id INTEGER NOT NULL, + FOREIGN KEY(artist_id) REFERENCES artist(id), + FOREIGN KEY(genre_id) REFERENCES genre(id) +); +-- +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/queries/song.sql b/db/queries/song.sql index 42829ac..595d7fd 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -10,13 +10,38 @@ FROM song WHERE id = ?; -- name: CreateSong :one -INSERT INTO song (title, artists, spotify_id, duration_ms) +INSERT INTO song (title, spotify_id, duration_ms) +VALUES (?, ?, ?) +RETURNING *; + +-- name: CreateSongHistory :one +INSERT INTO song_history (song_id) +VALUES (?) +RETURNING *; + +-- name: CreateSongGenre :one +INSERT INTO song_genre (genre) +VALUES (?) +RETURNING *; + +-- name: CreateSongArtist :one +INSERT INTO song_artist (name, spotify_id, followers, popularity) VALUES (?, ?, ?, ?) RETURNING *; +-- name: CreateSongArtistSong :one +INSERT INTO song_artist_song (artist_id, song_id) +VALUES (?, ?) +RETURNING *; + +-- name: CreateSongArtistGenre :one +INSERT INTO song_artist_genre (artist_id, genre_id) +VALUES (?, ?) +RETURNING *; + -- name: UpdateSong :one UPDATE song -SET title = ?, artists = ?, spotify_id = ?, duration_ms = ? +SET title = ?, spotify_id = ?, duration_ms = ? WHERE id = ? RETURNING *; @@ -31,3 +56,35 @@ WHERE id = ?; SELECT * FROM song WHERE spotify_id = ?; + +-- name: GetSongArtistBySpotifyID :one +SELECT * +FROM song_artist +WHERE spotify_id = ?; + +-- name: GetLastSongHistory :one +SELECT * +FROM song_history +ORDER BY created_at DESC +LIMIT 1; + +-- name: GetSongGenreByName :one +SELECT * +FROM song_genre +WHERE genre = ?; + +-- name: GetSongArtistByName :one +SELECT * +FROM song_artist +WHERE name = ?; + +-- name: GetLastSongFull :many +SELECT s.title AS song_title, s.spotify_id, s.duration_ms, a.name AS artist_name, g.genre AS genre +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; diff --git a/db/queries/song_history.sql b/db/queries/song_history.sql deleted file mode 100644 index cc9fcf8..0000000 --- a/db/queries/song_history.sql +++ /dev/null @@ -1,16 +0,0 @@ --- CRUD - --- name: CreateSongHistory :one -INSERT INTO song_history (song_id) -VALUES (?) -RETURNING *; - - --- Other - - --- name: GetLastSongHistory :one -SELECT * -FROM song_history -ORDER BY created_at DESC -LIMIT 1; diff --git a/internal/pkg/db/dto/song.go b/internal/pkg/db/dto/song.go index 268b8d4..4476d3c 100644 --- a/internal/pkg/db/dto/song.go +++ b/internal/pkg/db/dto/song.go @@ -4,13 +4,29 @@ import ( "github.com/zeusWPI/scc/internal/pkg/db/sqlc" ) -// Song is the DTO for the song +// Song is the DTO for a song type Song struct { - ID int64 `json:"id"` - Title string `json:"title"` - Artists string `json:"artists"` - SpotifyID string `json:"spotify_id" validate:"required"` - DurationMS int64 `json:"duration_ms"` + ID int64 `json:"id"` + Title string `json:"title"` + SpotifyID string `json:"spotify_id" validate:"required"` + DurationMS int64 `json:"duration_ms"` + Artists []SongArtist `json:"artists"` +} + +// SongArtist is the DTO for a song artist +type SongArtist struct { + ID int64 `json:"id"` + Name string `json:"name"` + SpotifyID string `json:"spotify_id"` + Followers int64 `json:"followers"` + Popularity int64 `json:"popularity"` + Genres []SongGenre `json:"genres"` +} + +// SongGenre is the DTO for a song genre +type SongGenre struct { + ID int64 `json:"id"` + Genre string `json:"genre"` } // SongDTO converts a sqlc.Song to a Song @@ -18,18 +34,47 @@ func SongDTO(song sqlc.Song) *Song { return &Song{ ID: song.ID, Title: song.Title, - Artists: song.Artists, SpotifyID: song.SpotifyID, DurationMS: song.DurationMs, } } -// CreateParams converts a Song to sqlc.CreateSongParams -func (s *Song) CreateParams() sqlc.CreateSongParams { - return sqlc.CreateSongParams{ +// CreateSongParams converts a Song DTO to a sqlc CreateSongParams object +func (s *Song) CreateSongParams() *sqlc.CreateSongParams { + return &sqlc.CreateSongParams{ Title: s.Title, - Artists: s.Artists, SpotifyID: s.SpotifyID, DurationMs: s.DurationMS, } } + +// 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/models.go b/internal/pkg/db/sqlc/models.go index 84b77f7..5bff769 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -47,11 +47,35 @@ type Season struct { type Song struct { ID int64 Title string - Artists string SpotifyID string DurationMs int64 } +type SongArtist struct { + ID int64 + Name string + SpotifyID string + Followers int64 + Popularity int64 +} + +type SongArtistGenre struct { + ID int64 + ArtistID int64 + GenreID int64 +} + +type SongArtistSong struct { + ID int64 + ArtistID int64 + SongID int64 +} + +type SongGenre struct { + ID int64 + Genre string +} + type SongHistory struct { ID int64 SongID int64 diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go index e72a626..8ba42fe 100644 --- a/internal/pkg/db/sqlc/song.sql.go +++ b/internal/pkg/db/sqlc/song.sql.go @@ -7,39 +7,126 @@ package sqlc import ( "context" + "database/sql" ) const createSong = `-- name: CreateSong :one -INSERT INTO song (title, artists, spotify_id, duration_ms) -VALUES (?, ?, ?, ?) -RETURNING id, title, artists, spotify_id, duration_ms +INSERT INTO song (title, spotify_id, duration_ms) +VALUES (?, ?, ?) +RETURNING id, title, spotify_id, duration_ms ` type CreateSongParams struct { Title string - Artists string SpotifyID string DurationMs int64 } func (q *Queries) CreateSong(ctx context.Context, arg CreateSongParams) (Song, error) { - row := q.db.QueryRowContext(ctx, createSong, - arg.Title, - arg.Artists, - arg.SpotifyID, - arg.DurationMs, - ) + row := q.db.QueryRowContext(ctx, createSong, arg.Title, arg.SpotifyID, arg.DurationMs) var i Song err := row.Scan( &i.ID, &i.Title, - &i.Artists, &i.SpotifyID, &i.DurationMs, ) return i, err } +const createSongArtist = `-- name: CreateSongArtist :one +INSERT INTO song_artist (name, spotify_id, followers, popularity) +VALUES (?, ?, ?, ?) +RETURNING id, name, spotify_id, followers, popularity +` + +type CreateSongArtistParams struct { + Name string + SpotifyID string + Followers int64 + Popularity int64 +} + +func (q *Queries) CreateSongArtist(ctx context.Context, arg CreateSongArtistParams) (SongArtist, error) { + row := q.db.QueryRowContext(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 (?, ?) +RETURNING id, artist_id, genre_id +` + +type CreateSongArtistGenreParams struct { + ArtistID int64 + GenreID int64 +} + +func (q *Queries) CreateSongArtistGenre(ctx context.Context, arg CreateSongArtistGenreParams) (SongArtistGenre, error) { + row := q.db.QueryRowContext(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 (?, ?) +RETURNING id, artist_id, song_id +` + +type CreateSongArtistSongParams struct { + ArtistID int64 + SongID int64 +} + +func (q *Queries) CreateSongArtistSong(ctx context.Context, arg CreateSongArtistSongParams) (SongArtistSong, error) { + row := q.db.QueryRowContext(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 (?) +RETURNING id, genre +` + +func (q *Queries) CreateSongGenre(ctx context.Context, genre string) (SongGenre, error) { + row := q.db.QueryRowContext(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 (?) +RETURNING id, song_id, created_at +` + +func (q *Queries) CreateSongHistory(ctx context.Context, songID int64) (SongHistory, error) { + row := q.db.QueryRowContext(ctx, createSongHistory, songID) + var i SongHistory + err := row.Scan(&i.ID, &i.SongID, &i.CreatedAt) + return i, err +} + const deleteSong = `-- name: DeleteSong :execrows DELETE FROM song WHERE id = ? @@ -55,7 +142,7 @@ func (q *Queries) DeleteSong(ctx context.Context, id int64) (int64, error) { const getAllSongs = `-- name: GetAllSongs :many -SELECT id, title, artists, spotify_id, duration_ms +SELECT id, title, spotify_id, duration_ms FROM song ` @@ -72,7 +159,6 @@ func (q *Queries) GetAllSongs(ctx context.Context) ([]Song, error) { if err := rows.Scan( &i.ID, &i.Title, - &i.Artists, &i.SpotifyID, &i.DurationMs, ); err != nil { @@ -89,8 +175,109 @@ func (q *Queries) GetAllSongs(ctx context.Context) ([]Song, error) { return items, nil } +const getLastSongFull = `-- name: GetLastSongFull :many +SELECT s.title AS song_title, s.spotify_id, s.duration_ms, a.name AS artist_name, g.genre AS genre +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 { + SongTitle string + SpotifyID string + DurationMs int64 + ArtistName sql.NullString + Genre sql.NullString +} + +func (q *Queries) GetLastSongFull(ctx context.Context) ([]GetLastSongFullRow, error) { + rows, err := q.db.QueryContext(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.SongTitle, + &i.SpotifyID, + &i.DurationMs, + &i.ArtistName, + &i.Genre, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + 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.QueryRowContext(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 = ? +` + +func (q *Queries) GetSongArtistByName(ctx context.Context, name string) (SongArtist, error) { + row := q.db.QueryRowContext(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 = ? +` + +func (q *Queries) GetSongArtistBySpotifyID(ctx context.Context, spotifyID string) (SongArtist, error) { + row := q.db.QueryRowContext(ctx, getSongArtistBySpotifyID, spotifyID) + var i SongArtist + err := row.Scan( + &i.ID, + &i.Name, + &i.SpotifyID, + &i.Followers, + &i.Popularity, + ) + return i, err +} + const getSongByID = `-- name: GetSongByID :one -SELECT id, title, artists, spotify_id, duration_ms +SELECT id, title, spotify_id, duration_ms FROM song WHERE id = ? ` @@ -101,7 +288,6 @@ func (q *Queries) GetSongByID(ctx context.Context, id int64) (Song, error) { err := row.Scan( &i.ID, &i.Title, - &i.Artists, &i.SpotifyID, &i.DurationMs, ) @@ -110,7 +296,7 @@ func (q *Queries) GetSongByID(ctx context.Context, id int64) (Song, error) { const getSongBySpotifyID = `-- name: GetSongBySpotifyID :one -SELECT id, title, artists, spotify_id, duration_ms +SELECT id, title, spotify_id, duration_ms FROM song WHERE spotify_id = ? ` @@ -122,23 +308,34 @@ func (q *Queries) GetSongBySpotifyID(ctx context.Context, spotifyID string) (Son err := row.Scan( &i.ID, &i.Title, - &i.Artists, &i.SpotifyID, &i.DurationMs, ) return i, err } +const getSongGenreByName = `-- name: GetSongGenreByName :one +SELECT id, genre +FROM song_genre +WHERE genre = ? +` + +func (q *Queries) GetSongGenreByName(ctx context.Context, genre string) (SongGenre, error) { + row := q.db.QueryRowContext(ctx, getSongGenreByName, genre) + var i SongGenre + err := row.Scan(&i.ID, &i.Genre) + return i, err +} + const updateSong = `-- name: UpdateSong :one UPDATE song -SET title = ?, artists = ?, spotify_id = ?, duration_ms = ? +SET title = ?, spotify_id = ?, duration_ms = ? WHERE id = ? -RETURNING id, title, artists, spotify_id, duration_ms +RETURNING id, title, spotify_id, duration_ms ` type UpdateSongParams struct { Title string - Artists string SpotifyID string DurationMs int64 ID int64 @@ -147,7 +344,6 @@ type UpdateSongParams struct { func (q *Queries) UpdateSong(ctx context.Context, arg UpdateSongParams) (Song, error) { row := q.db.QueryRowContext(ctx, updateSong, arg.Title, - arg.Artists, arg.SpotifyID, arg.DurationMs, arg.ID, @@ -156,7 +352,6 @@ func (q *Queries) UpdateSong(ctx context.Context, arg UpdateSongParams) (Song, e err := row.Scan( &i.ID, &i.Title, - &i.Artists, &i.SpotifyID, &i.DurationMs, ) diff --git a/internal/pkg/db/sqlc/song_history.sql.go b/internal/pkg/db/sqlc/song_history.sql.go deleted file mode 100644 index d1e8f95..0000000 --- a/internal/pkg/db/sqlc/song_history.sql.go +++ /dev/null @@ -1,42 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: song_history.sql - -package sqlc - -import ( - "context" -) - -const createSongHistory = `-- name: CreateSongHistory :one - -INSERT INTO song_history (song_id) -VALUES (?) -RETURNING id, song_id, created_at -` - -// CRUD -func (q *Queries) CreateSongHistory(ctx context.Context, songID int64) (SongHistory, error) { - row := q.db.QueryRowContext(ctx, createSongHistory, songID) - var i SongHistory - err := row.Scan(&i.ID, &i.SongID, &i.CreatedAt) - return i, err -} - -const getLastSongHistory = `-- name: GetLastSongHistory :one - - -SELECT id, song_id, created_at -FROM song_history -ORDER BY created_at DESC -LIMIT 1 -` - -// Other -func (q *Queries) GetLastSongHistory(ctx context.Context) (SongHistory, error) { - row := q.db.QueryRowContext(ctx, getLastSongHistory) - var i SongHistory - err := row.Scan(&i.ID, &i.SongID, &i.CreatedAt) - return i, err -} diff --git a/internal/pkg/song/account.go b/internal/pkg/song/account.go index 7a445b0..be29ffb 100644 --- a/internal/pkg/song/account.go +++ b/internal/pkg/song/account.go @@ -1,7 +1,6 @@ package song import ( - "encoding/json" "errors" "time" @@ -19,17 +18,11 @@ type accountResponse struct { func (s *Song) refreshToken() error { zap.S().Info("Song: Refreshing spotify access token") - body, err := json.Marshal(fiber.Map{ - "grant_type": "client_credentials", - "client_id": s.ClientID, - "client_secret": s.ClientSecret, - }) - if err != nil { - return err - } + form := &fiber.Args{} + form.Add("grant_type", "client_credentials") api := config.GetDefaultString("song.spotify_account", "https://accounts.spotify.com/api/token") - req := fiber.Post(api).Body(body).ContentType("application/json") + req := fiber.Post(api).Form(form).BasicAuth(s.ClientID, s.ClientSecret) res := new(accountResponse) status, _, errs := req.Struct(res) diff --git a/internal/pkg/song/api.go b/internal/pkg/song/api.go index 9ae37bc..b58d31c 100644 --- a/internal/pkg/song/api.go +++ b/internal/pkg/song/api.go @@ -7,11 +7,13 @@ import ( "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/pkg/util" "go.uber.org/zap" ) +var api = config.GetDefaultString("song.spotify_api", "https://api.spotify.com/v1") + type trackArtist struct { + ID string `json:"id"` Name string `json:"name"` } @@ -24,7 +26,6 @@ type trackResponse struct { func (s *Song) getTrack(track *dto.Song) error { zap.S().Info("Song: Getting track info for id: ", track.SpotifyID) - api := config.GetDefaultString("song.spotify_api", "https://api.spotify.com/v1") req := fiber.Get(fmt.Sprintf("%s/%s/%s", api, "tracks", track.SpotifyID)). Set("Authorization", fmt.Sprintf("Bearer %s", s.AccessToken)) @@ -34,12 +35,53 @@ func (s *Song) getTrack(track *dto.Song) error { return errors.Join(append([]error{errors.New("Song: Track request failed")}, errs...)...) } if status != fiber.StatusOK { - return errors.New("Song: Error getting track") + return fmt.Errorf("Song: Track request wrong status code %d", status) } track.Title = res.Name - track.Artists = util.SliceStringJoin(res.Artists, ", ", func(a trackArtist) string { return a.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 { + req := fiber.Get(fmt.Sprintf("%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 = int64(res.Popularity) + artist.Followers = int64(res.Followers.Total) + + for _, genre := range res.Genres { + artist.Genres = append(artist.Genres, dto.SongGenre{Genre: genre}) + } + return nil } diff --git a/internal/pkg/song/song.go b/internal/pkg/song/song.go index 3d4cd5f..34eec0d 100644 --- a/internal/pkg/song/song.go +++ b/internal/pkg/song/song.go @@ -73,21 +73,89 @@ func (s *Song) Track(track *dto.Song) error { } } - // Set track info - err = s.getTrack(track) - if err != nil { + // Get track info + if err = s.getTrack(track); err != nil { return err } // Store track in DB - trackDB, err = s.db.Queries.CreateSong(context.Background(), track.CreateParams()) + trackDB, err = s.db.Queries.CreateSong(context.Background(), *track.CreateSongParams()) if err != nil { return err } + track.ID = trackDB.ID + + // Handle artists + var errs []error + for i, artist := range track.Artists { + a, err := s.db.Queries.GetSongArtistBySpotifyID(context.Background(), artist.SpotifyID) + if err != nil && err != sql.ErrNoRows { + errs = append(errs, err) + continue + } + + if (a != sqlc.SongArtist{}) { + // Artist already exists + // Add it as an artist for this track + 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 != sql.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 - _, err = s.db.Queries.CreateSongHistory(context.Background(), trackDB.ID) + if _, err = s.db.Queries.CreateSongHistory(context.Background(), trackDB.ID); err != nil { + errs = append(errs, err) + } - return err + return errors.Join(errs...) } From 25e8579fa0f6e39be1b2855a9a08660eb5f205e0 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 28 Nov 2024 16:27:46 +0100 Subject: [PATCH 26/46] feat(song): lyrics --- config/development.toml | 1 + ...1128115057_alter_song_table_add_lyrics.sql | 23 +++ db/queries/song.sql | 25 +--- go.mod | 52 ++++--- go.sum | 11 +- internal/pkg/db/dto/song.go | 28 ++++ internal/pkg/db/sqlc/models.go | 4 + internal/pkg/db/sqlc/song.sql.go | 133 ++++-------------- internal/pkg/song/api.go | 56 ++++++++ internal/pkg/song/song.go | 11 +- 10 files changed, 181 insertions(+), 163 deletions(-) create mode 100644 db/migrations/20241128115057_alter_song_table_add_lyrics.sql diff --git a/config/development.toml b/config/development.toml index 055ad23..e8ddddd 100644 --- a/config/development.toml +++ b/config/development.toml @@ -5,6 +5,7 @@ port = 3000 [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" 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..7219ea7 --- /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 isrc_id; + +ALTER TABLE song +DROP COLUMN lyrics; + +ALTER TABLE song +DROP COLUMN common_id; +-- +goose StatementEnd diff --git a/db/queries/song.sql b/db/queries/song.sql index 595d7fd..66707ba 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -1,17 +1,8 @@ -- CRUD --- name: GetAllSongs :many -SELECT * -FROM song; - --- name: GetSongByID :one -SELECT * -FROM song -WHERE id = ?; - -- name: CreateSong :one -INSERT INTO song (title, spotify_id, duration_ms) -VALUES (?, ?, ?) +INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) +VALUES (?, ?, ?, ?, ?, ?) RETURNING *; -- name: CreateSongHistory :one @@ -39,16 +30,6 @@ INSERT INTO song_artist_genre (artist_id, genre_id) VALUES (?, ?) RETURNING *; --- name: UpdateSong :one -UPDATE song -SET title = ?, spotify_id = ?, duration_ms = ? -WHERE id = ? -RETURNING *; - --- name: DeleteSong :execrows -DELETE FROM song -WHERE id = ?; - -- Other @@ -79,7 +60,7 @@ FROM song_artist WHERE name = ?; -- name: GetLastSongFull :many -SELECT s.title AS song_title, s.spotify_id, s.duration_ms, a.name AS artist_name, g.genre AS genre +SELECT s.title AS song_title, s.spotify_id, s.album, s.duration_ms, s.lyrics_type, s.lyrics, a.name AS artist_name, g.genre AS genre 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 diff --git a/go.mod b/go.mod index 0a6f16f..174bb6a 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,19 @@ module github.com/zeusWPI/scc go 1.23.1 require ( + github.com/NimbleMarkets/ntcharts v0.1.2 github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/lipgloss v1.0.0 github.com/disintegration/imaging v1.6.2 + github.com/go-playground/validator/v10 v10.22.1 + 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/joho/godotenv v1.5.1 github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/mattn/go-sqlite3 v1.14.24 github.com/spf13/viper v1.19.0 + go.uber.org/zap v1.27.0 ) @@ -23,6 +30,7 @@ require ( github.com/charmbracelet/bubbles v0.18.0 // indirect github.com/charmbracelet/x/ansi v0.4.2 // indirect github.com/containerd/console v1.0.4 // 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 @@ -30,58 +38,46 @@ require ( 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/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-20240125042004-b7bafc493195 // 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.15 // 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/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // 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 - golang.org/x/crypto v0.27.0 // indirect - golang.org/x/image v0.11.0 // indirect - golang.org/x/net v0.29.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/term v0.24.0 // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.33.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect -) - -require ( - github.com/NimbleMarkets/ntcharts v0.1.2 - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-playground/validator/v10 v10.22.1 - 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/hashicorp/hcl v1.0.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-sqlite3 v1.14.24 - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // 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 - go.uber.org/zap v1.27.0 + 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.8.0 // indirect golang.org/x/sys v0.25.0 // indirect + golang.org/x/term v0.24.0 // indirect golang.org/x/text v0.18.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 ) diff --git a/go.sum b/go.sum index 9499d22..e8f6556 100644 --- a/go.sum +++ b/go.sum @@ -75,11 +75,8 @@ github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8Nz 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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -172,9 +169,8 @@ 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/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo= -golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8= 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= @@ -214,7 +210,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -230,8 +225,8 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ 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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= diff --git a/internal/pkg/db/dto/song.go b/internal/pkg/db/dto/song.go index 4476d3c..3caaeaa 100644 --- a/internal/pkg/db/dto/song.go +++ b/internal/pkg/db/dto/song.go @@ -1,6 +1,8 @@ package dto import ( + "database/sql" + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" ) @@ -8,8 +10,11 @@ import ( type Song struct { ID int64 `json:"id"` Title string `json:"title"` + Album string `json:"album"` SpotifyID string `json:"spotify_id" validate:"required"` DurationMS int64 `json:"duration_ms"` + LyricsType string `json:"lyrics_type"` // Either 'synced' or 'plain' + Lyrics string `json:"lyrics"` Artists []SongArtist `json:"artists"` } @@ -31,20 +36,43 @@ type SongGenre struct { // 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, } } // CreateSongParams converts a Song DTO to a sqlc CreateSongParams object func (s *Song) CreateSongParams() *sqlc.CreateSongParams { + lyricsType := sql.NullString{String: s.LyricsType, Valid: false} + if s.LyricsType != "" { + lyricsType.Valid = true + } + lyrics := sql.NullString{String: s.Lyrics, Valid: false} + if s.Lyrics != "" { + lyrics.Valid = true + } return &sqlc.CreateSongParams{ Title: s.Title, + Album: s.Album, SpotifyID: s.SpotifyID, DurationMs: s.DurationMS, + LyricsType: lyricsType, + Lyrics: lyrics, } } diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index 5bff769..2aaef06 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -5,6 +5,7 @@ package sqlc import ( + "database/sql" "time" ) @@ -49,6 +50,9 @@ type Song struct { Title string SpotifyID string DurationMs int64 + Album string + LyricsType sql.NullString + Lyrics sql.NullString } type SongArtist struct { diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go index 8ba42fe..05bdbbc 100644 --- a/internal/pkg/db/sqlc/song.sql.go +++ b/internal/pkg/db/sqlc/song.sql.go @@ -11,25 +11,40 @@ import ( ) const createSong = `-- name: CreateSong :one -INSERT INTO song (title, spotify_id, duration_ms) -VALUES (?, ?, ?) -RETURNING id, title, spotify_id, duration_ms + +INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) +VALUES (?, ?, ?, ?, ?, ?) +RETURNING id, title, spotify_id, duration_ms, album, lyrics_type, lyrics ` type CreateSongParams struct { Title string + Album string SpotifyID string DurationMs int64 + LyricsType sql.NullString + Lyrics sql.NullString } +// CRUD func (q *Queries) CreateSong(ctx context.Context, arg CreateSongParams) (Song, error) { - row := q.db.QueryRowContext(ctx, createSong, arg.Title, arg.SpotifyID, arg.DurationMs) + row := q.db.QueryRowContext(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 } @@ -127,56 +142,8 @@ func (q *Queries) CreateSongHistory(ctx context.Context, songID int64) (SongHist return i, err } -const deleteSong = `-- name: DeleteSong :execrows -DELETE FROM song -WHERE id = ? -` - -func (q *Queries) DeleteSong(ctx context.Context, id int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteSong, id) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -const getAllSongs = `-- name: GetAllSongs :many - -SELECT id, title, spotify_id, duration_ms -FROM song -` - -// CRUD -func (q *Queries) GetAllSongs(ctx context.Context) ([]Song, error) { - rows, err := q.db.QueryContext(ctx, getAllSongs) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Song - for rows.Next() { - var i Song - if err := rows.Scan( - &i.ID, - &i.Title, - &i.SpotifyID, - &i.DurationMs, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getLastSongFull = `-- name: GetLastSongFull :many -SELECT s.title AS song_title, s.spotify_id, s.duration_ms, a.name AS artist_name, g.genre AS genre +SELECT s.title AS song_title, s.spotify_id, s.album, s.duration_ms, s.lyrics_type, s.lyrics, a.name AS artist_name, g.genre AS genre 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 @@ -190,7 +157,10 @@ ORDER BY a.name, g.genre type GetLastSongFullRow struct { SongTitle string SpotifyID string + Album string DurationMs int64 + LyricsType sql.NullString + Lyrics sql.NullString ArtistName sql.NullString Genre sql.NullString } @@ -207,7 +177,10 @@ func (q *Queries) GetLastSongFull(ctx context.Context) ([]GetLastSongFullRow, er if err := rows.Scan( &i.SongTitle, &i.SpotifyID, + &i.Album, &i.DurationMs, + &i.LyricsType, + &i.Lyrics, &i.ArtistName, &i.Genre, ); err != nil { @@ -276,27 +249,9 @@ func (q *Queries) GetSongArtistBySpotifyID(ctx context.Context, spotifyID string return i, err } -const getSongByID = `-- name: GetSongByID :one -SELECT id, title, spotify_id, duration_ms -FROM song -WHERE id = ? -` - -func (q *Queries) GetSongByID(ctx context.Context, id int64) (Song, error) { - row := q.db.QueryRowContext(ctx, getSongByID, id) - var i Song - err := row.Scan( - &i.ID, - &i.Title, - &i.SpotifyID, - &i.DurationMs, - ) - return i, err -} - const getSongBySpotifyID = `-- name: GetSongBySpotifyID :one -SELECT id, title, spotify_id, duration_ms +SELECT id, title, spotify_id, duration_ms, album, lyrics_type, lyrics FROM song WHERE spotify_id = ? ` @@ -310,6 +265,9 @@ func (q *Queries) GetSongBySpotifyID(ctx context.Context, spotifyID string) (Son &i.Title, &i.SpotifyID, &i.DurationMs, + &i.Album, + &i.LyricsType, + &i.Lyrics, ) return i, err } @@ -326,34 +284,3 @@ func (q *Queries) GetSongGenreByName(ctx context.Context, genre string) (SongGen err := row.Scan(&i.ID, &i.Genre) return i, err } - -const updateSong = `-- name: UpdateSong :one -UPDATE song -SET title = ?, spotify_id = ?, duration_ms = ? -WHERE id = ? -RETURNING id, title, spotify_id, duration_ms -` - -type UpdateSongParams struct { - Title string - SpotifyID string - DurationMs int64 - ID int64 -} - -func (q *Queries) UpdateSong(ctx context.Context, arg UpdateSongParams) (Song, error) { - row := q.db.QueryRowContext(ctx, updateSong, - arg.Title, - arg.SpotifyID, - arg.DurationMs, - arg.ID, - ) - var i Song - err := row.Scan( - &i.ID, - &i.Title, - &i.SpotifyID, - &i.DurationMs, - ) - return i, err -} diff --git a/internal/pkg/song/api.go b/internal/pkg/song/api.go index b58d31c..c0d0869 100644 --- a/internal/pkg/song/api.go +++ b/internal/pkg/song/api.go @@ -3,6 +3,7 @@ package song import ( "errors" "fmt" + "net/url" "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" @@ -17,8 +18,13 @@ type trackArtist struct { 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 int64 `json:"duration_ms"` } @@ -39,6 +45,7 @@ func (s *Song) getTrack(track *dto.Song) error { } track.Title = res.Name + track.Album = res.Album.Name track.DurationMS = res.DurationMS for _, a := range res.Artists { @@ -85,3 +92,52 @@ func (s *Song) getArtist(artist *dto.SongArtist) error { return nil } + +type lyricsResponse struct { + PlainLyrics string `json:"plainLyrics"` + SyncedLyrics string `json:"SyncedLyrics"` +} + +func (s *Song) getLyrics(track *dto.Song) error { + // 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", config.GetDefaultString("song.lrclib_api", "https://lrclib.net/api"), 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 index 34eec0d..3abdbb7 100644 --- a/internal/pkg/song/song.go +++ b/internal/pkg/song/song.go @@ -36,6 +36,8 @@ func New(db *db.DB) (*Song, error) { // 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") } @@ -78,15 +80,20 @@ func (s *Song) Track(track *dto.Song) error { 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 { - return err + errs = append(errs, err) + return errors.Join(errs...) } track.ID = trackDB.ID // Handle artists - var errs []error for i, artist := range track.Artists { a, err := s.db.Queries.GetSongArtistBySpotifyID(context.Background(), artist.SpotifyID) if err != nil && err != sql.ErrNoRows { From 0a6eb387f0d76e94d9d7323581c429218c86161e Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 28 Nov 2024 16:48:10 +0100 Subject: [PATCH 27/46] chore(workflow): update sqlc diff --- .github/workflows/sqlc-diff.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sqlc-diff.yml b/.github/workflows/sqlc-diff.yml index 636b3c8..7d003ec 100644 --- a/.github/workflows/sqlc-diff.yml +++ b/.github/workflows/sqlc-diff.yml @@ -8,5 +8,5 @@ jobs: - uses: actions/checkout@v3 - uses: sqlc-dev/setup-sqlc@v3 with: - sqlc-version: '1.26.0' + sqlc-version: '1.27.0' - run: sqlc diff From 56de0fd506c92325eb36f53c5e85a3f4fbbf26e7 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sat, 30 Nov 2024 17:30:10 +0100 Subject: [PATCH 28/46] feat(lyric): add package --- internal/pkg/lyrics/lrc.go | 111 ++++++++++++++++++++++++++++++++++ internal/pkg/lyrics/lyrics.go | 31 ++++++++++ internal/pkg/lyrics/plain.go | 47 ++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 internal/pkg/lyrics/lrc.go create mode 100644 internal/pkg/lyrics/lyrics.go create mode 100644 internal/pkg/lyrics/plain.go diff --git a/internal/pkg/lyrics/lrc.go b/internal/pkg/lyrics/lrc.go new file mode 100644 index 0000000..7c6b3a2 --- /dev/null +++ b/internal/pkg/lyrics/lrc.go @@ -0,0 +1,111 @@ +package lyrics + +import ( + "regexp" + "strconv" + "strings" + "time" + + "github.com/zeusWPI/scc/internal/pkg/db/dto" +) + +// 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 < 0 { + break + } + + lyrics = append(lyrics, l.lyrics[l.i-1]) + } + + return lyrics +} + +// 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 >= 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 := 1; i <= amount; i++ { + if i+l.i >= len(l.lyrics) { + break + } + + lyrics = append(lyrics, l.lyrics[i+l.i]) + } + + return lyrics +} + +func parseLRC(text string, totalDuration time.Duration) []Lyric { + lines := strings.Split(text, "\n") + + lyrics := make([]Lyric, 0, len(lines)) + var previousTimestamp time.Duration + + re, err := regexp.Compile(`^\[(\d{2}):(\d{2})\.(\d{2})\] (.+)$`) + if err != nil { + return lyrics + } + + // Add first lyric (no text) + lyrics = append(lyrics, Lyric{Text: ""}) + previousTimestamp = time.Duration(0) + + for i, line := range lines { + match := re.FindStringSubmatch(line) + if match == nil { + continue + } + + // Construct timestamp + minutes, _ := strconv.Atoi(match[1]) + seconds, _ := strconv.Atoi(match[2]) + hundredths, _ := strconv.Atoi(match[3]) + timestamp := time.Duration(minutes)*time.Minute + + time.Duration(seconds)*time.Second + + time.Duration(hundredths)*10*time.Millisecond + + t := match[4] + + lyrics = append(lyrics, Lyric{Text: t}) + + // Set duration of previous lyric + lyrics[i-1].Duration = timestamp - previousTimestamp + previousTimestamp = timestamp + } + + // Set duration of last lyric + lyrics[len(lines)-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..f66cbd3 --- /dev/null +++ b/internal/pkg/lyrics/lyrics.go @@ -0,0 +1,31 @@ +// 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 + Next() (Lyric, bool) + Upcoming(int) []Lyric +} + +// 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..4afaa48 --- /dev/null +++ b/internal/pkg/lyrics/plain.go @@ -0,0 +1,47 @@ +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(amount int) []Lyric { + return []Lyric{} +} + +// 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 + } + + return p.lyrics, true +} + +// Upcoming provides the next `amount` lyrics without affecting the current lyric +func (p *Plain) Upcoming(amount int) []Lyric { + return []Lyric{} +} From 31ec12ae2394a7d074df8c571e835dee8da5332a Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 1 Dec 2024 11:35:34 +0100 Subject: [PATCH 29/46] feat(lyric): add view --- config/development.toml | 4 + ...1128115057_alter_song_table_add_lyrics.sql | 6 +- db/queries/song.sql | 39 ++- internal/cmd/tui.go | 9 +- internal/pkg/db/dto/song.go | 63 ++++ internal/pkg/db/sqlc/song.sql.go | 185 ++++++++++- internal/pkg/lyrics/lrc.go | 28 +- internal/pkg/lyrics/lyrics.go | 1 + internal/pkg/lyrics/plain.go | 16 +- ui/screen/cammie.go | 1 - ui/screen/song.go | 50 +++ ui/view/gamification/gamification.go | 28 +- ui/view/message/message.go | 6 +- ui/view/song/song.go | 305 ++++++++++++++++++ ui/view/song/style.go | 24 ++ ui/view/song/util.go | 80 +++++ ui/view/song/view.go | 63 ++++ ui/view/tap/tap.go | 42 +-- ui/view/view.go | 3 +- ui/view/zess/zess.go | 94 +++--- 20 files changed, 931 insertions(+), 116 deletions(-) create mode 100644 ui/screen/song.go create mode 100644 ui/view/song/song.go create mode 100644 ui/view/song/style.go create mode 100644 ui/view/song/util.go create mode 100644 ui/view/song/view.go diff --git a/config/development.toml b/config/development.toml index e8ddddd..4028f99 100644 --- a/config/development.toml +++ b/config/development.toml @@ -65,3 +65,7 @@ interval_s = 60 [tui.gamification] interval_s = 3600 + +[tui.song] +interval_current_s = 5 +interval_top_s = 3600 diff --git a/db/migrations/20241128115057_alter_song_table_add_lyrics.sql b/db/migrations/20241128115057_alter_song_table_add_lyrics.sql index 7219ea7..4189979 100644 --- a/db/migrations/20241128115057_alter_song_table_add_lyrics.sql +++ b/db/migrations/20241128115057_alter_song_table_add_lyrics.sql @@ -13,11 +13,11 @@ ADD COLUMN lyrics TEXT; -- +goose Down -- +goose StatementBegin ALTER TABLE song -DROP COLUMN isrc_id; +DROP COLUMN lyrics; ALTER TABLE song -DROP COLUMN lyrics; +DROP COLUMN lyrics_type; ALTER TABLE song -DROP COLUMN common_id; +DROP COLUMN album; -- +goose StatementEnd diff --git a/db/queries/song.sql b/db/queries/song.sql index 66707ba..cb09a81 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -60,7 +60,7 @@ FROM song_artist WHERE name = ?; -- name: GetLastSongFull :many -SELECT s.title AS song_title, s.spotify_id, s.album, s.duration_ms, s.lyrics_type, s.lyrics, a.name AS artist_name, g.genre AS genre +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 @@ -69,3 +69,40 @@ 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 5; + +-- 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 5; + +-- 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 5; + +-- 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 5; diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index dccdedf..591b7fd 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -17,6 +17,7 @@ import ( var screens = map[string]func(*db.DB) screen.Screen{ "cammie": screen.NewCammie, + "song": screen.NewSong, "test": screen.NewTest, } @@ -42,7 +43,7 @@ func TUI(db *db.DB) error { for _, updateData := range screen.GetUpdateViews() { done := make(chan bool) dones = append(dones, done) - go tuiPeriodicUpdates(db, p, updateData, done) + go tuiPeriodicUpdates(p, updateData, done) } _, err := p.Run() @@ -54,14 +55,14 @@ func TUI(db *db.DB) error { return err } -func tuiPeriodicUpdates(db *db.DB, p *tea.Program, updateData view.UpdateData, done chan bool) { +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(db, updateData.View) + msg, err := updateData.Update(updateData.View) if err != nil { zap.S().Error("TUI: Error updating ", updateData.Name, "\n", err) } @@ -77,7 +78,7 @@ func tuiPeriodicUpdates(db *db.DB, p *tea.Program, updateData view.UpdateData, d return case <-ticker.C: // Update - msg, err := updateData.Update(db, updateData.View) + msg, err := updateData.Update(updateData.View) if err != nil { zap.S().Error("TUI: Error updating ", updateData.Name, "\n", err) } diff --git a/internal/pkg/db/dto/song.go b/internal/pkg/db/dto/song.go index 3caaeaa..4839db0 100644 --- a/internal/pkg/db/dto/song.go +++ b/internal/pkg/db/dto/song.go @@ -2,6 +2,7 @@ package dto import ( "database/sql" + "time" "github.com/zeusWPI/scc/internal/pkg/db/sqlc" ) @@ -15,6 +16,7 @@ type Song struct { DurationMS int64 `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"` } @@ -56,6 +58,67 @@ func SongDTO(song sqlc.Song) *Song { } } +// 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[int64]SongArtist) + for _, song := range songs { + if !song.ArtistID.Valid { + continue + } + + // Get artist + artist, ok := artistsMap[song.ArtistID.Int64] + if !ok { + // Artist doesn't exist yet, add him + artist = SongArtist{ + ID: song.ArtistID.Int64, + Name: song.ArtistName.String, + SpotifyID: song.ArtistSpotifyID.String, + Followers: song.ArtistFollowers.Int64, + Popularity: song.ArtistPopularity.Int64, + Genres: make([]SongGenre, 0), + } + artistsMap[song.ArtistID.Int64] = artist + } + + // Add genre + artist.Genres = append(artist.Genres, SongGenre{ + ID: song.GenreID.Int64, + 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, + Artists: artists, + } +} + // CreateSongParams converts a Song DTO to a sqlc CreateSongParams object func (s *Song) CreateSongParams() *sqlc.CreateSongParams { lyricsType := sql.NullString{String: s.LyricsType, Valid: false} diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go index 05bdbbc..e37f873 100644 --- a/internal/pkg/db/sqlc/song.sql.go +++ b/internal/pkg/db/sqlc/song.sql.go @@ -8,6 +8,7 @@ package sqlc import ( "context" "database/sql" + "time" ) const createSong = `-- name: CreateSong :one @@ -143,7 +144,7 @@ func (q *Queries) CreateSongHistory(ctx context.Context, songID int64) (SongHist } const getLastSongFull = `-- name: GetLastSongFull :many -SELECT s.title AS song_title, s.spotify_id, s.album, s.duration_ms, s.lyrics_type, s.lyrics, a.name AS artist_name, g.genre AS genre +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 @@ -155,14 +156,22 @@ ORDER BY a.name, g.genre ` type GetLastSongFullRow struct { - SongTitle string - SpotifyID string - Album string - DurationMs int64 - LyricsType sql.NullString - Lyrics sql.NullString - ArtistName sql.NullString - Genre sql.NullString + ID int64 + SongTitle string + SpotifyID string + Album string + DurationMs int64 + LyricsType sql.NullString + Lyrics sql.NullString + CreatedAt time.Time + ArtistID sql.NullInt64 + ArtistName sql.NullString + ArtistSpotifyID sql.NullString + ArtistFollowers sql.NullInt64 + ArtistPopularity sql.NullInt64 + GenreID sql.NullInt64 + Genre sql.NullString + CreatedAt_2 time.Time } func (q *Queries) GetLastSongFull(ctx context.Context) ([]GetLastSongFullRow, error) { @@ -175,14 +184,22 @@ func (q *Queries) GetLastSongFull(ctx context.Context) ([]GetLastSongFullRow, er 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 } @@ -284,3 +301,153 @@ func (q *Queries) GetSongGenreByName(ctx context.Context, genre string) (SongGen 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 5 +` + +func (q *Queries) GetSongHistory(ctx context.Context) ([]string, error) { + rows, err := q.db.QueryContext(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.Close(); err != nil { + return nil, err + } + 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 5 +` + +type GetTopArtistsRow struct { + ArtistID int64 + ArtistName string + TotalPlays int64 +} + +func (q *Queries) GetTopArtists(ctx context.Context) ([]GetTopArtistsRow, error) { + rows, err := q.db.QueryContext(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.Close(); err != nil { + return nil, err + } + 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 5 +` + +type GetTopGenresRow struct { + GenreName string + TotalPlays int64 +} + +func (q *Queries) GetTopGenres(ctx context.Context) ([]GetTopGenresRow, error) { + rows, err := q.db.QueryContext(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.Close(); err != nil { + return nil, err + } + 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 5 +` + +type GetTopSongsRow struct { + SongID int64 + Title string + PlayCount int64 +} + +func (q *Queries) GetTopSongs(ctx context.Context) ([]GetTopSongsRow, error) { + rows, err := q.db.QueryContext(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.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/pkg/lyrics/lrc.go b/internal/pkg/lyrics/lrc.go index 7c6b3a2..f8f9452 100644 --- a/internal/pkg/lyrics/lrc.go +++ b/internal/pkg/lyrics/lrc.go @@ -30,20 +30,30 @@ func (l *LRC) Previous(amount int) []Lyric { lyrics := make([]Lyric, 0, amount) for i := 1; i <= amount; i++ { - if l.i-i < 0 { + if l.i-i-1 < 0 { break } - lyrics = append(lyrics, l.lyrics[l.i-1]) + 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 >= len(l.lyrics) { + if l.i+1 >= len(l.lyrics) { return Lyric{}, false } @@ -55,12 +65,12 @@ func (l *LRC) Next() (Lyric, bool) { func (l *LRC) Upcoming(amount int) []Lyric { lyrics := make([]Lyric, 0, amount) - for i := 1; i <= amount; i++ { - if i+l.i >= len(l.lyrics) { + for i := 0; i < amount; i++ { + if l.i+i >= len(l.lyrics) { break } - lyrics = append(lyrics, l.lyrics[i+l.i]) + lyrics = append(lyrics, l.lyrics[l.i+i]) } return lyrics @@ -69,7 +79,7 @@ func (l *LRC) Upcoming(amount int) []Lyric { func parseLRC(text string, totalDuration time.Duration) []Lyric { lines := strings.Split(text, "\n") - lyrics := make([]Lyric, 0, len(lines)) + lyrics := make([]Lyric, 0, len(lines)+1) var previousTimestamp time.Duration re, err := regexp.Compile(`^\[(\d{2}):(\d{2})\.(\d{2})\] (.+)$`) @@ -100,12 +110,12 @@ func parseLRC(text string, totalDuration time.Duration) []Lyric { lyrics = append(lyrics, Lyric{Text: t}) // Set duration of previous lyric - lyrics[i-1].Duration = timestamp - previousTimestamp + lyrics[i].Duration = timestamp - previousTimestamp previousTimestamp = timestamp } // Set duration of last lyric - lyrics[len(lines)-1].Duration = totalDuration - previousTimestamp + lyrics[len(lyrics)-1].Duration = totalDuration - previousTimestamp return lyrics } diff --git a/internal/pkg/lyrics/lyrics.go b/internal/pkg/lyrics/lyrics.go index f66cbd3..80e964f 100644 --- a/internal/pkg/lyrics/lyrics.go +++ b/internal/pkg/lyrics/lyrics.go @@ -11,6 +11,7 @@ import ( type Lyrics interface { GetSong() dto.Song Previous(int) []Lyric + Current() (Lyric, bool) Next() (Lyric, bool) Upcoming(int) []Lyric } diff --git a/internal/pkg/lyrics/plain.go b/internal/pkg/lyrics/plain.go index 4afaa48..6ce3946 100644 --- a/internal/pkg/lyrics/plain.go +++ b/internal/pkg/lyrics/plain.go @@ -27,10 +27,20 @@ func (p *Plain) GetSong() dto.Song { } // Previous provides the previous `amount` of lyrics without affecting the current lyric -func (p *Plain) Previous(amount int) []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) { @@ -38,10 +48,12 @@ func (p *Plain) Next() (Lyric, bool) { 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(amount int) []Lyric { +func (p *Plain) Upcoming(_ int) []Lyric { return []Lyric{} } diff --git a/ui/screen/cammie.go b/ui/screen/cammie.go index 3c1aed2..6e7151d 100644 --- a/ui/screen/cammie.go +++ b/ui/screen/cammie.go @@ -1,4 +1,3 @@ -// Package screen provides difference screens for the tui package screen import ( diff --git a/ui/screen/song.go b/ui/screen/song.go new file mode 100644 index 0000000..6788715 --- /dev/null +++ b/ui/screen/song.go @@ -0,0 +1,50 @@ +package screen + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/ui/view" + "github.com/zeusWPI/scc/ui/view/song" +) + +// Song represents the song screen +type Song struct { + db *db.DB + song view.View +} + +// NewSong creates a new song screen +func NewSong(db *db.DB) Screen { + return &Song{db: db, song: song.NewModel(db)} +} + +// Init initializes the song screen +func (s *Song) Init() tea.Cmd { + return s.song.Init() +} + +// Update updates the song screen +func (s *Song) Update(msg tea.Msg) (Screen, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + default: + song, cmd := s.song.Update(msg) + s.song = song + + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + return s, tea.Batch(cmds...) +} + +// View returns the song screen view +func (s *Song) View() string { + return s.song.View() +} + +// GetUpdateViews returns all the update functions for the song screen +func (s *Song) GetUpdateViews() []view.UpdateData { + return s.song.GetUpdateDatas() +} diff --git a/ui/view/gamification/gamification.go b/ui/view/gamification/gamification.go index ac25c35..93da300 100644 --- a/ui/view/gamification/gamification.go +++ b/ui/view/gamification/gamification.go @@ -58,25 +58,25 @@ func NewModel(db *db.DB) view.View { } // Init starts the gamification view -func (g *Model) Init() tea.Cmd { +func (m *Model) Init() tea.Cmd { return nil } // Update updates the gamification view -func (g *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case Msg: - g.leaderboard = msg.leaderboard + m.leaderboard = msg.leaderboard } - return g, nil + return m, nil } // View draws the gamification view -func (g *Model) View() string { - columns := make([]string, 0, len(g.leaderboard)) +func (m *Model) View() string { + columns := make([]string, 0, len(m.leaderboard)) - for i, item := range g.leaderboard { + for i, item := range m.leaderboard { user := lipgloss.JoinVertical(lipgloss.Left, nameStyles[i%len(nameStyles)].Render(fmt.Sprintf("%d. %s", i+1, item.item.Name)), scoreStyle.Render(strconv.Itoa(int(item.item.Score))), @@ -92,21 +92,21 @@ func (g *Model) View() string { } // GetUpdateDatas get all update functions for the gamification view -func (g *Model) GetUpdateDatas() []view.UpdateData { +func (m *Model) GetUpdateDatas() []view.UpdateData { return []view.UpdateData{ { Name: "gamification leaderboard", - View: g, + View: m, Update: updateLeaderboard, Interval: config.GetDefaultInt("tui.gamification.interval_s", 3600), }, } } -func updateLeaderboard(db *db.DB, view view.View) (tea.Msg, error) { - g := view.(*Model) +func updateLeaderboard(view view.View) (tea.Msg, error) { + m := view.(*Model) - gams, err := db.Queries.GetAllGamificationByScore(context.Background()) + gams, err := m.db.Queries.GetAllGamificationByScore(context.Background()) if err != nil { if err == sql.ErrNoRows { err = nil @@ -116,9 +116,9 @@ func updateLeaderboard(db *db.DB, view view.View) (tea.Msg, error) { // Check if both leaderboards are equal equal := false - if len(g.leaderboard) == len(gams) { + if len(m.leaderboard) == len(gams) { equal = true - for i, l := range g.leaderboard { + for i, l := range m.leaderboard { if !l.item.Equal(*dto.GamificationDTO(gams[i])) { equal = false break diff --git a/ui/view/message/message.go b/ui/view/message/message.go index 4e5d33a..fd71013 100644 --- a/ui/view/message/message.go +++ b/ui/view/message/message.go @@ -79,11 +79,11 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { } } -func updateMessages(db *db.DB, view view.View) (tea.Msg, error) { +func updateMessages(view view.View) (tea.Msg, error) { m := view.(*Model) lastMessageID := m.lastMessageID - message, err := db.Queries.GetLastMessage(context.Background()) + message, err := m.db.Queries.GetLastMessage(context.Background()) if err != nil { if err == sql.ErrNoRows { err = nil @@ -95,7 +95,7 @@ func updateMessages(db *db.DB, view view.View) (tea.Msg, error) { return Msg{lastMessageID: lastMessageID, messages: []string{}}, nil } - messages, err := db.Queries.GetMessageSinceID(context.Background(), lastMessageID) + messages, err := m.db.Queries.GetMessageSinceID(context.Background(), lastMessageID) if err != nil { zap.S().Error("DB: Failed to get messages", err) return Msg{lastMessageID: lastMessageID, messages: []string{}}, err diff --git a/ui/view/song/song.go b/ui/view/song/song.go new file mode 100644 index 0000000..691b8b3 --- /dev/null +++ b/ui/view/song/song.go @@ -0,0 +1,305 @@ +// Package song provides the functions to draw an overview of the song integration +package song + +import ( + "context" + "database/sql" + "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/internal/pkg/lyrics" + "github.com/zeusWPI/scc/pkg/config" + "github.com/zeusWPI/scc/ui/view" + "go.uber.org/zap" +) + +var ( + previousAmount = 5 // Amount of passed lyrics to show + upcomingAmount = 10 // Amount of upcoming lyrics to show +) + +type playing struct { + song *dto.Song + 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 +} + +// Msg triggers a song data update +// Required for the View interface +type Msg struct{} + +type msgPlaying struct { + current playing +} + +type msgTop struct { + topSongs []topStat + topGenres []topStat + topArtists []topStat +} + +type msgLyrics struct { + song dto.Song + previous []string + current string + upcoming []string + startNext time.Time + done bool +} + +type topStat struct { + name string + amount int +} + +// NewModel initializes a new song model +func NewModel(db *db.DB) view.View { + // Get history, afterwards it gets updated when a new currentSong is detected + history, _ := db.Queries.GetSongHistory(context.Background()) + + return &Model{ + db: db, + current: playing{}, + history: history, + topSongs: make([]topStat, 0, 5), + topGenres: make([]topStat, 0, 5), + topArtists: make([]topStat, 0, 5), + } +} + +// Init starts the song view +func (m *Model) Init() tea.Cmd { + return nil +} + +// Update updates the song view +func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { + switch msg := msg.(type) { + case msgPlaying: + m.history = append(m.history, msg.current.song.Title) + if len(m.history) > 5 { + m.history = m.history[1:] + } + + m.current = msg.current + // New song, start the commands to update the lyrics + lyric, ok := m.current.lyrics.Current() + zap.S().Info("Song ", ok) + if !ok { + // Song already done (shouldn't happen) + m.current = playing{song: nil} + return m, nil + } + zap.S().Info("Starting") + startTime := m.current.song.CreatedAt.Add(lyric.Duration) + zap.S().Info("Startime: ", startTime) + 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, nil + } + startTime = startTime.Add(lyric.Duration) + zap.S().Info("Startime: ", startTime) + } + m.current.upcoming = lyricsToString(m.current.lyrics.Upcoming(upcomingAmount)) + return m, updateLyrics(m.current, startTime) + case msgTop: + if msg.topSongs != nil { + m.topSongs = msg.topSongs + } + if msg.topGenres != nil { + m.topGenres = msg.topGenres + } + if msg.topArtists != nil { + m.topArtists = msg.topArtists + } + 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, nil + } + + // 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) + } + + return m, nil +} + +// 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.song.interval_current_s", 5), + }, + { + Name: "top stats", + View: m, + Update: updateTopStats, + Interval: config.GetDefaultInt("tui.song.interval_top_s", 3600), + }, + } +} + +func updateCurrentSong(view view.View) (tea.Msg, error) { + zap.S().Info("Updating current song") + m := view.(*Model) + + songs, err := m.db.Queries.GetLastSongFull(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + err = nil + } + zap.S().Info("Updating song: DB error") + return nil, err + } + if len(songs) == 0 { + zap.S().Info("Updating song: No lenght for songs") + return nil, nil + } + + // Check if song is still playing + if songs[0].CreatedAt.Add(time.Duration(songs[0].DurationMs) * time.Millisecond).Before(time.Now()) { + // Song is finished + zap.S().Info("Updating song: Song finished") + return nil, nil + } + + if m.current.song != nil && songs[0].ID == m.current.song.ID { + // Song is already set to current + zap.S().Info("Updating song: already set to current") + return nil, nil + } + + song := dto.SongDTOHistory(songs) + + zap.S().Info("Updating song: returning") + return msgPlaying{current: playing{song: song, lyrics: lyrics.New(song)}}, 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 != sql.ErrNoRows { + return nil, err + } + + if !equalTopSongs(m.topSongs, songs) { + msg.topSongs = topStatSqlcSong(songs) + change = true + } + + genres, err := m.db.Queries.GetTopGenres(context.Background()) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + + if !equalTopGenres(m.topGenres, genres) { + msg.topGenres = topStatSqlcGenre(genres) + change = true + } + + artists, err := m.db.Queries.GetTopArtists(context.Background()) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + + if !equalTopArtists(m.topArtists, 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) + } + zap.S().Info("Lyrics: updating in ", timeout) + + return tea.Tick(timeout, func(_ time.Time) tea.Msg { + // Next lyric + zap.S().Info("Lyrics: Getting next lyric") + lyric, ok := song.lyrics.Next() + if !ok { + // Song finished + zap.S().Info("Lyrics: song finished") + return msgLyrics{song: *song.song, done: true} + } + + previous := song.lyrics.Previous(previousAmount) + upcoming := song.lyrics.Upcoming(upcomingAmount) + + end := start.Add(lyric.Duration) + + zap.S().Info("Lyrics: Returning: ", msgLyrics{ + previous: lyricsToString(previous), + current: lyric.Text, + upcoming: lyricsToString(upcoming), + startNext: end, + done: false, + }) + + return msgLyrics{ + song: *song.song, + previous: lyricsToString(previous), + current: lyric.Text, + upcoming: lyricsToString(upcoming), + startNext: end, + done: false, + } + }) +} + +// TODO: It always start at the first lyric but if it's behind it should skip forward. diff --git a/ui/view/song/style.go b/ui/view/song/style.go new file mode 100644 index 0000000..f7a2da2 --- /dev/null +++ b/ui/view/song/style.go @@ -0,0 +1,24 @@ +package song + +import "github.com/charmbracelet/lipgloss" + +// Colors +var ( + cZeus = lipgloss.Color("#FF7F00") + cSpotify = lipgloss.Color("#1DB954") +) + +// Styles +var ( + sBase = lipgloss.NewStyle() + sStat = sBase.MarginRight(3) + sStatTitle = sBase.Foreground(cZeus). + BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cSpotify) + sStatAmount = sBase.Foreground(cZeus).MarginLeft(2) + sListEnum = sBase.Foreground(cSpotify).MarginRight(1) + + sLyricBase = sBase.Width(50).Align(lipgloss.Center) + sLyricPrevious = sLyricBase.Foreground(cZeus).Faint(true) + sLyricCurrent = sLyricBase.Foreground(cZeus) + sLyricUpcoming = sLyricBase.Foreground(cSpotify) +) diff --git a/ui/view/song/util.go b/ui/view/song/util.go new file mode 100644 index 0000000..182cf89 --- /dev/null +++ b/ui/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 []topStat, 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) []topStat { + topstats := make([]topStat, 0, len(songs)) + for _, s := range songs { + topstats = append(topstats, topStat{name: s.Title, amount: int(s.PlayCount)}) + } + return topstats +} + +func equalTopGenres(s1 []topStat, 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) []topStat { + topstats := make([]topStat, 0, len(songs)) + for _, s := range songs { + topstats = append(topstats, topStat{name: s.GenreName, amount: int(s.TotalPlays)}) + } + return topstats +} + +func equalTopArtists(s1 []topStat, 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) []topStat { + topstats := make([]topStat, 0, len(songs)) + for _, s := range songs { + topstats = append(topstats, topStat{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/ui/view/song/view.go b/ui/view/song/view.go new file mode 100644 index 0000000..6ab936a --- /dev/null +++ b/ui/view/song/view.go @@ -0,0 +1,63 @@ +package song + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/list" +) + +func (m *Model) viewPlaying() 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 sBase.MarginLeft(5).Render(lipgloss.JoinVertical(lipgloss.Left, previous, current, upcoming)) +} + +func (m *Model) viewNotPlaying() string { + columns := make([]string, 0, 3) + + // Recently played + l := list.New(m.history).Enumerator(list.Arabic).EnumeratorStyle(sListEnum).String() + t := sStatTitle.Width(lipgloss.Width(l)).Align(lipgloss.Center).Render("Recently played") + + column := lipgloss.JoinVertical(lipgloss.Left, t, l) + columns = append(columns, sStat.Render(column)) + + // Top stats + topStats := map[string][]topStat{ + "Top Tracks": m.topSongs, + "Top Artists": m.topArtists, + "Top Genres": m.topGenres, + } + + for title, stat := range topStats { + var statInfos []string + for _, statInfo := range stat { + statInfos = append(statInfos, lipgloss.JoinHorizontal(lipgloss.Top, statInfo.name, sStatAmount.Render(fmt.Sprintf("%d", statInfo.amount)))) + } + l := list.New(statInfos).Enumerator(list.Arabic).EnumeratorStyle(sListEnum).String() + t := sStatTitle.Width(lipgloss.Width(l)).Align(lipgloss.Center).Render(title) + + column := lipgloss.JoinVertical(lipgloss.Left, t, l) + columns = append(columns, sStat.Render(column)) + } + + return lipgloss.JoinHorizontal(lipgloss.Top, columns...) +} diff --git a/ui/view/tap/tap.go b/ui/view/tap/tap.go index 5c39477..5278d6a 100644 --- a/ui/view/tap/tap.go +++ b/ui/view/tap/tap.go @@ -47,44 +47,44 @@ func NewModel(db *db.DB) view.View { } // Init initializes the tap model -func (t *Model) Init() tea.Cmd { +func (m *Model) Init() tea.Cmd { return nil } // Update updates the tap model -func (t *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { case Msg: - t.lastOrderID = msg.lastOrderID + m.lastOrderID = msg.lastOrderID for _, msg := range msg.items { switch msg.category { case "Mate": - t.mate += msg.amount + m.mate += msg.amount case "Soft": - t.soft += msg.amount + m.soft += msg.amount case "Beer": - t.beer += msg.amount + m.beer += msg.amount case "Food": - t.food += msg.amount + m.food += msg.amount } } - return t, nil + return m, nil } - return t, nil + return m, nil } // View returns the tap view -func (t *Model) View() string { +func (m *Model) View() string { chart := barchart.New(20, 20) barMate := barchart.BarData{ Label: "Mate", Values: []barchart.BarValue{{ Name: "Mate", - Value: t.mate, + Value: m.mate, Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Mate"]), }}, } @@ -92,7 +92,7 @@ func (t *Model) View() string { Label: "Soft", Values: []barchart.BarValue{{ Name: "Soft", - Value: t.soft, + Value: m.soft, Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Soft"]), }}, } @@ -100,7 +100,7 @@ func (t *Model) View() string { Label: "Beer", Values: []barchart.BarValue{{ Name: "Beer", - Value: t.beer, + Value: m.beer, Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Beer"]), }}, } @@ -108,7 +108,7 @@ func (t *Model) View() string { Label: "Food", Values: []barchart.BarValue{{ Name: "Food", - Value: t.food, + Value: m.food, Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Food"]), }}, } @@ -120,22 +120,22 @@ func (t *Model) View() string { } // GetUpdateDatas returns all the update functions for the tap model -func (t *Model) GetUpdateDatas() []view.UpdateData { +func (m *Model) GetUpdateDatas() []view.UpdateData { return []view.UpdateData{ { Name: "tap orders", - View: t, + View: m, Update: updateOrders, Interval: config.GetDefaultInt("tui.tap.interval_s", 60), }, } } -func updateOrders(db *db.DB, view view.View) (tea.Msg, error) { - t := view.(*Model) - lastOrderID := t.lastOrderID +func updateOrders(view view.View) (tea.Msg, error) { + m := view.(*Model) + lastOrderID := m.lastOrderID - order, err := db.Queries.GetLastOrderByOrderID(context.Background()) + order, err := m.db.Queries.GetLastOrderByOrderID(context.Background()) if err != nil { if err == sql.ErrNoRows { err = nil @@ -147,7 +147,7 @@ func updateOrders(db *db.DB, view view.View) (tea.Msg, error) { return Msg{lastOrderID: lastOrderID, items: []tapItem{}}, nil } - orders, err := db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) + orders, err := m.db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) if err != nil { return Msg{lastOrderID: lastOrderID, items: []tapItem{}}, err } diff --git a/ui/view/view.go b/ui/view/view.go index 8168d26..7770c17 100644 --- a/ui/view/view.go +++ b/ui/view/view.go @@ -3,14 +3,13 @@ package view import ( tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/internal/pkg/db" ) // UpdateData represents the data needed to update a view type UpdateData struct { Name string View View - Update func(db *db.DB, view View) (tea.Msg, error) + Update func(view View) (tea.Msg, error) Interval int } diff --git a/ui/view/zess/zess.go b/ui/view/zess/zess.go index 07681e4..85ec91b 100644 --- a/ui/view/zess/zess.go +++ b/ui/view/zess/zess.go @@ -55,7 +55,7 @@ type seasonMsg struct { // NewModel creates a new zess model view func NewModel(db *db.DB) view.View { - z := &Model{ + m := &Model{ db: db, lastScanID: -1, scans: make([]weekScan, 0), @@ -66,43 +66,43 @@ func NewModel(db *db.DB) view.View { // Populate with data // The order in which this is called is important! - msgScans, err := updateScans(db, z) + msgScans, err := updateScans(m) if err != nil { zap.S().Error("TUI: Unable to update zess scans\n", err) - return z + return m } - _, _ = z.Update(msgScans) + _, _ = m.Update(msgScans) - msgSeason, err := updateSeason(db, z) + msgSeason, err := updateSeason(m) if err != nil { zap.S().Error("TUI: Unable to update zess seasons\n", err) - return z + return m } - _, _ = z.Update(msgSeason) + _, _ = m.Update(msgSeason) - return z + return m } // Init created a new zess model -func (z *Model) Init() tea.Cmd { +func (m *Model) Init() tea.Cmd { return nil } // Update updates the zess model -func (z *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { // New scan(s) case scanMsg: - z.lastScanID = msg.lastScanID + m.lastScanID = msg.lastScanID // Add new scans for _, newScan := range msg.scans { found := false - for i, modelScan := range z.scans { + for i, modelScan := range m.scans { if newScan.time.equal(modelScan.time) { - z.scans[i].amount++ + m.scans[i].amount++ // Check for maxWeekScans - if z.scans[i].amount > z.maxWeekScans { - z.maxWeekScans = modelScan.amount + if m.scans[i].amount > m.maxWeekScans { + m.maxWeekScans = modelScan.amount } found = true @@ -111,54 +111,54 @@ func (z *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { } if !found { - z.scans = append(z.scans, newScan) + m.scans = append(m.scans, newScan) // Check for maxWeekScans - if newScan.amount > z.maxWeekScans { - z.maxWeekScans = newScan.amount + if newScan.amount > m.maxWeekScans { + m.maxWeekScans = newScan.amount } // Make sure the array doesn't get too big - if len(z.scans) > config.GetDefaultInt("tui.zess.weeks", 10) { - z.scans = z.scans[:1] + if len(m.scans) > config.GetDefaultInt("tui.zess.weeks", 10) { + m.scans = m.scans[:1] } } // Update seasonScans - z.seasonScans += newScan.amount + m.seasonScans += newScan.amount } // New season! // Update variables accordinly case seasonMsg: - z.currentSeason = msg.start - z.seasonScans = 0 - z.maxWeekScans = 0 + m.currentSeason = msg.start + m.seasonScans = 0 + m.maxWeekScans = 0 - validScans := make([]weekScan, 0, len(z.scans)) + validScans := make([]weekScan, 0, len(m.scans)) - for _, scan := range z.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(z.currentSeason) || scan.time.after(z.currentSeason) { + if scan.time.equal(m.currentSeason) || scan.time.after(m.currentSeason) { validScans = append(validScans, scan) - if scan.amount > z.maxWeekScans { - z.maxWeekScans = scan.amount + if scan.amount > m.maxWeekScans { + m.maxWeekScans = scan.amount } - z.seasonScans += scan.amount + m.seasonScans += scan.amount } } - z.scans = validScans + m.scans = validScans } - return z, nil + return m, nil } // View returns the view for the zess model -func (z *Model) View() string { +func (m *Model) View() string { chart := barchart.New(20, 20) - for _, scan := range z.scans { + for _, scan := range m.scans { bar := barchart.BarData{ Label: scan.label, Values: []barchart.BarValue{{ @@ -174,8 +174,8 @@ func (z *Model) View() string { chart.Draw() style := lipgloss.NewStyle().Height(20).Align(lipgloss.Bottom).Render(lipgloss.JoinVertical(lipgloss.Left, - fmt.Sprintf("Season scans\n%d", z.seasonScans), - fmt.Sprintf("Max scans in a week\n%d", z.maxWeekScans), + fmt.Sprintf("Season scans\n%d", m.seasonScans), + fmt.Sprintf("Max scans in a week\n%d", m.maxWeekScans), )) return lipgloss.JoinHorizontal(lipgloss.Top, @@ -185,17 +185,17 @@ func (z *Model) View() string { } // GetUpdateDatas returns all the update functions for the zess model -func (z *Model) GetUpdateDatas() []view.UpdateData { +func (m *Model) GetUpdateDatas() []view.UpdateData { return []view.UpdateData{ { Name: "zess scans", - View: z, + View: m, Update: updateScans, Interval: config.GetDefaultInt("tui.zess.interval_scan_s", 60), }, { Name: "zess season", - View: z, + View: m, Update: updateSeason, Interval: config.GetDefaultInt("tui.zess.interval_season_s", 3600), }, @@ -203,12 +203,12 @@ func (z *Model) GetUpdateDatas() []view.UpdateData { } // Check for any new scans -func updateScans(db *db.DB, view view.View) (tea.Msg, error) { - z := view.(*Model) - lastScanID := z.lastScanID +func updateScans(view view.View) (tea.Msg, error) { + m := view.(*Model) + lastScanID := m.lastScanID // Get new scans - scans, err := db.Queries.GetAllScansSinceID(context.Background(), lastScanID) + scans, err := m.db.Queries.GetAllScansSinceID(context.Background(), lastScanID) if err != nil { if err == sql.ErrNoRows { // No rows shouldn't be considered an error @@ -253,10 +253,10 @@ func updateScans(db *db.DB, view view.View) (tea.Msg, error) { } // Check if a new season started -func updateSeason(db *db.DB, view view.View) (tea.Msg, error) { - z := view.(*Model) +func updateSeason(view view.View) (tea.Msg, error) { + m := view.(*Model) - season, err := db.Queries.GetSeasonCurrent(context.Background()) + season, err := m.db.Queries.GetSeasonCurrent(context.Background()) if err != nil { if err == sql.ErrNoRows { // No rows shouldn't be considered an error @@ -268,7 +268,7 @@ func updateSeason(db *db.DB, view view.View) (tea.Msg, error) { // Check if we have a new season yearNumber, weekNumber := season.Start.ISOWeek() seasonStart := time{year: yearNumber, week: weekNumber} - if z.currentSeason.equal(seasonStart) { + if m.currentSeason.equal(seasonStart) { // Same season return nil, nil } From e176f04f44b76d43f007a883d51d8bf043ed4f7d Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 4 Dec 2024 00:14:10 +0100 Subject: [PATCH 30/46] feat(event): fetch poster --- .../20241203173952_event_add_poster.sql | 11 ++++++ internal/pkg/event/api.go | 37 +++++++++++++++++++ internal/pkg/event/event.go | 33 +++++++++++++++-- 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 db/migrations/20241203173952_event_add_poster.sql diff --git a/db/migrations/20241203173952_event_add_poster.sql b/db/migrations/20241203173952_event_add_poster.sql new file mode 100644 index 0000000..f577ef7 --- /dev/null +++ b/db/migrations/20241203173952_event_add_poster.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE event +add COLUMN poster BLOB; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE event +DROP column poster; +-- +goose StatementEnd diff --git a/internal/pkg/event/api.go b/internal/pkg/event/api.go index 37a5358..7b25535 100644 --- a/internal/pkg/event/api.go +++ b/internal/pkg/event/api.go @@ -3,10 +3,12 @@ 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" ) @@ -69,3 +71,38 @@ func (e *Event) getEvents() ([]dto.Event, error) { return events, errors.Join(errs...) } + +func (e *Event) getPoster(event *dto.Event) error { + 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.apiPoster, year, event.Name) + + req := fiber.Get(url) + status, body, errs := req.Bytes() + if len(errs) != 0 { + // No poster in repository + 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 index 18140f3..8bc556a 100644 --- a/internal/pkg/event/event.go +++ b/internal/pkg/event/event.go @@ -4,7 +4,9 @@ package event import ( "context" "errors" + "fmt" "slices" + "sync" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/internal/pkg/db/dto" @@ -13,15 +15,17 @@ import ( // Event represents a event instance type Event struct { - db *db.DB - api string + db *db.DB + api string + apiPoster string } // New creates a new event instance func New(db *db.DB) *Event { api := config.GetDefaultString("event.api", "https://zeus.gent/events") + apiPoster := config.GetDefaultString("event.api_poster", "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master") - return &Event{db: db, api: api} + return &Event{db: db, api: api, apiPoster: apiPoster} } // Update gets all events from the website of this academic year @@ -61,7 +65,30 @@ func (e *Event) Update() error { return err } var errs []error + + var wg sync.WaitGroup + for _, event := range events { + wg.Add(1) + + go func(event *dto.Event) { + defer wg.Done() + + err := e.getPoster(event) + if err != nil { + errs = append(errs, err) + } + }(&event) + } + fmt.Printf("Waiting\n") + wg.Wait() + fmt.Printf("Done\n") + 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) From de3e0a93833cf0a9e5a53e8a593ab33b136da8c7 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 4 Dec 2024 00:45:46 +0100 Subject: [PATCH 31/46] chore(gammification): store avatar in db --- .gitignore | 1 - .../20241203173952_event_add_poster.sql | 4 +- ...20241203231431_gamification_add_avatar.sql | 17 ++++++ db/queries/event.sql | 13 ++++- db/queries/gamification.sql | 3 + internal/pkg/db/sqlc/event.sql.go | 55 ++++++++++++++++-- internal/pkg/db/sqlc/gamification.sql.go | 14 ++++- internal/pkg/event/api.go | 1 - internal/pkg/gamification/api.go | 57 ++++++++++++------- internal/pkg/gamification/gamification.go | 41 ++----------- ui/view/gamification/gamification.go | 49 +++++----------- ui/view/gamification/styles.go | 29 ++++++++++ 12 files changed, 179 insertions(+), 105 deletions(-) create mode 100644 db/migrations/20241203231431_gamification_add_avatar.sql create mode 100644 ui/view/gamification/styles.go diff --git a/.gitignore b/.gitignore index 00e13bd..115f324 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,6 @@ backend tui .data/ -/public/ # Log files logs/ diff --git a/db/migrations/20241203173952_event_add_poster.sql b/db/migrations/20241203173952_event_add_poster.sql index f577ef7..e063186 100644 --- a/db/migrations/20241203173952_event_add_poster.sql +++ b/db/migrations/20241203173952_event_add_poster.sql @@ -1,11 +1,11 @@ -- +goose Up -- +goose StatementBegin ALTER TABLE event -add COLUMN poster BLOB; +ADD COLUMN poster BLOB; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin ALTER TABLE event -DROP column poster; +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..bcaab0b --- /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 BLOB; +-- +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/queries/event.sql b/db/queries/event.sql index e421a05..c5b52e5 100644 --- a/db/queries/event.sql +++ b/db/queries/event.sql @@ -6,8 +6,8 @@ SELECT * FROM event; -- name: CreateEvent :one -INSERT INTO event (name, date, academic_year, location) -VALUES (?, ?, ?, ?) +INSERT INTO event (name, date, academic_year, location, poster) +VALUES (?, ?, ?, ?, ?) RETURNING *; -- name: DeleteEvent :exec @@ -26,3 +26,12 @@ WHERE academic_year = ?; -- name: DeleteEventByAcademicYear :exec DELETE FROM event WHERE academic_year = ?; + +-- 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 index 7081864..3d54972 100644 --- a/db/queries/gamification.sql +++ b/db/queries/gamification.sql @@ -13,6 +13,9 @@ RETURNING *; DELETE FROM gamification WHERE id = ?; +-- name: DeleteGamificationAll :execrows +DELETE FROM gamification; + -- Other diff --git a/internal/pkg/db/sqlc/event.sql.go b/internal/pkg/db/sqlc/event.sql.go index 4759542..a6983e4 100644 --- a/internal/pkg/db/sqlc/event.sql.go +++ b/internal/pkg/db/sqlc/event.sql.go @@ -11,9 +11,9 @@ import ( ) const createEvent = `-- name: CreateEvent :one -INSERT INTO event (name, date, academic_year, location) -VALUES (?, ?, ?, ?) -RETURNING id, name, date, academic_year, location +INSERT INTO event (name, date, academic_year, location, poster) +VALUES (?, ?, ?, ?, ?) +RETURNING id, name, date, academic_year, location, poster ` type CreateEventParams struct { @@ -21,6 +21,7 @@ type CreateEventParams struct { Date time.Time AcademicYear string Location string + Poster []byte } func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { @@ -29,6 +30,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event arg.Date, arg.AcademicYear, arg.Location, + arg.Poster, ) var i Event err := row.Scan( @@ -37,6 +39,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event &i.Date, &i.AcademicYear, &i.Location, + &i.Poster, ) return i, err } @@ -64,7 +67,7 @@ func (q *Queries) DeleteEventByAcademicYear(ctx context.Context, academicYear st const getAllEvents = `-- name: GetAllEvents :many -SELECT id, name, date, academic_year, location +SELECT id, name, date, academic_year, location, poster FROM event ` @@ -84,6 +87,7 @@ func (q *Queries) GetAllEvents(ctx context.Context) ([]Event, error) { &i.Date, &i.AcademicYear, &i.Location, + &i.Poster, ); err != nil { return nil, err } @@ -101,7 +105,7 @@ func (q *Queries) GetAllEvents(ctx context.Context) ([]Event, error) { const getEventByAcademicYear = `-- name: GetEventByAcademicYear :many -SELECT id, name, date, academic_year, location +SELECT id, name, date, academic_year, location, poster FROM event WHERE academic_year = ? ` @@ -122,6 +126,47 @@ func (q *Queries) GetEventByAcademicYear(ctx context.Context, academicYear strin &i.Date, &i.AcademicYear, &i.Location, + &i.Poster, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + 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.QueryContext(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 } diff --git a/internal/pkg/db/sqlc/gamification.sql.go b/internal/pkg/db/sqlc/gamification.sql.go index 7da2289..87a8735 100644 --- a/internal/pkg/db/sqlc/gamification.sql.go +++ b/internal/pkg/db/sqlc/gamification.sql.go @@ -18,7 +18,7 @@ RETURNING id, name, score, avatar type CreateGamificationParams struct { Name string Score int64 - Avatar string + Avatar []byte } func (q *Queries) CreateGamification(ctx context.Context, arg CreateGamificationParams) (Gamification, error) { @@ -46,6 +46,18 @@ func (q *Queries) DeleteGamification(ctx context.Context, id int64) (int64, erro return result.RowsAffected() } +const deleteGamificationAll = `-- name: DeleteGamificationAll :execrows +DELETE FROM gamification +` + +func (q *Queries) DeleteGamificationAll(ctx context.Context) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteGamificationAll) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const getAllGamification = `-- name: GetAllGamification :many SELECT id, name, score, avatar diff --git a/internal/pkg/event/api.go b/internal/pkg/event/api.go index 7b25535..3a818bc 100644 --- a/internal/pkg/event/api.go +++ b/internal/pkg/event/api.go @@ -94,7 +94,6 @@ func (e *Event) getPoster(event *dto.Event) error { req := fiber.Get(url) status, body, errs := req.Bytes() if len(errs) != 0 { - // No poster in repository return errors.Join(append(errs, errors.New("Event: Download poster request failed"))...) } if status != fiber.StatusOK { diff --git a/internal/pkg/gamification/api.go b/internal/pkg/gamification/api.go index f1f7272..5ac1382 100644 --- a/internal/pkg/gamification/api.go +++ b/internal/pkg/gamification/api.go @@ -1,23 +1,27 @@ package gamification import ( - "bytes" "errors" "fmt" - "io" - "os" "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" "go.uber.org/zap" ) -func (g *Gamification) getLeaderboard() (*[]*dto.Gamification, error) { +type gamificationItem struct { + ID int64 `json:"id"` + Name string `json:"github_name"` + Score int64 `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([]*dto.Gamification) + 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"))...) @@ -33,29 +37,40 @@ func (g *Gamification) getLeaderboard() (*[]*dto.Gamification, error) { } } - return res, errors.Join(errs...) + 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 dto.Gamification) (string, error) { - req := fiber.Get(gam.Avatar) +func downloadAvatar(gam gamificationItem) (dto.Gamification, error) { + req := fiber.Get(gam.AvatarURL) status, body, errs := req.Bytes() - if errs != nil { - return "", errors.Join(append(errs, errors.New("Gamification: Download avatar request failed"))...) + if len(errs) != 0 { + return dto.Gamification{}, errors.Join(append(errs, errors.New("Gamification: Download avatar request failed"))...) } if status != fiber.StatusOK { - return "", fmt.Errorf("Gamification: Download avatar returned bad status code %d", status) + return dto.Gamification{}, fmt.Errorf("Gamification: Download avatar returned bad status code %d", status) } - location := fmt.Sprintf("public/%s.png", gam.Name) - out, err := os.Create(location) - if err != nil && err != os.ErrExist { - return "", err + g := dto.Gamification{ + ID: gam.ID, + Name: gam.Name, + Score: gam.Score, + Avatar: body, } - defer func() { - _ = out.Close() - }() - - _, err = io.Copy(out, bytes.NewReader(body)) - return location, err + return g, nil } diff --git a/internal/pkg/gamification/gamification.go b/internal/pkg/gamification/gamification.go index c8d95a7..6b36bef 100644 --- a/internal/pkg/gamification/gamification.go +++ b/internal/pkg/gamification/gamification.go @@ -3,9 +3,7 @@ package gamification import ( "context" - "database/sql" "errors" - "os" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/pkg/config" @@ -26,47 +24,18 @@ func New(db *db.DB) *Gamification { // Update gets the current leaderboard from gamification func (g *Gamification) Update() error { - leaderboard, err := g.db.Queries.GetAllGamification(context.Background()) - if err != nil { - if err != sql.ErrNoRows { - return err - } + if _, err := g.db.Queries.DeleteGamificationAll(context.Background()); err != nil { + return err } - gamLeaderboard, err := g.getLeaderboard() + leaderboard, err := g.getLeaderboard() if err != nil { return err } - // Delete old var errs []error - var errsOS []error // OS specific errors. - for _, l := range leaderboard { - // Remove picture - if err := os.Remove(l.Avatar); err != nil && err != os.ErrNotExist { - errsOS = append(errsOS, err) - } - - // Remove DB entry - if _, err = g.db.Queries.DeleteGamification(context.Background(), l.ID); err != nil { - errs = append(errs, err) - } - } - - // Don't quit if the only error(s) are os related - if errs != nil { - return errors.Join(append(errs, errsOS...)...) - } - - // Insert new ones - for _, gamL := range *gamLeaderboard { - location, err := downloadAvatar(*gamL) - if err != nil { - errs = append(errs, err) - } - gamL.Avatar = location - - if _, err = g.db.Queries.CreateGamification(context.Background(), gamL.CreateParams()); err != nil { + for _, item := range leaderboard { + if _, err := g.db.Queries.CreateGamification(context.Background(), item.CreateParams()); err != nil { errs = append(errs, err) } } diff --git a/ui/view/gamification/gamification.go b/ui/view/gamification/gamification.go index 93da300..c91c5ae 100644 --- a/ui/view/gamification/gamification.go +++ b/ui/view/gamification/gamification.go @@ -2,12 +2,11 @@ package gamification import ( + "bytes" "context" "database/sql" "fmt" "image" - "os" - "path/filepath" "strconv" "strings" @@ -21,21 +20,6 @@ import ( "github.com/zeusWPI/scc/ui/view" ) -var width = 20 - -var ( - base = lipgloss.NewStyle() - columnStyle = base.MarginLeft(1) - nameBase = base.BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(lipgloss.Color("#383838")).Width(width).Align(lipgloss.Center) - nameStyles = []lipgloss.Style{ - nameBase.Foreground(lipgloss.Color("#FFD700")), - nameBase.Foreground(lipgloss.Color("#FF7F00")), - nameBase.Foreground(lipgloss.Color("#CD7F32")), - nameBase, - } - scoreStyle = base.Width(width).Align(lipgloss.Center) -) - // Model represents the view model for gamification type Model struct { db *db.DB @@ -62,6 +46,11 @@ func (m *Model) Init() tea.Cmd { return nil } +// Name returns the name of the view +func (m *Model) Name() string { + return "Gammification" +} + // Update updates the gamification view func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { switch msg := msg.(type) { @@ -76,14 +65,16 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { 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, - nameStyles[i%len(nameStyles)].Render(fmt.Sprintf("%d. %s", i+1, item.item.Name)), - scoreStyle.Render(strconv.Itoa(int(item.item.Score))), + 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, gamificationToString(width, item.image), user) - columns = append(columns, columnStyle.Render(column)) + columns = append(columns, sColumn.Render(column)) } list := lipgloss.JoinHorizontal(lipgloss.Top, columns...) @@ -132,26 +123,12 @@ func updateLeaderboard(view view.View) (tea.Msg, error) { msg := Msg{leaderboard: []gamificationItem{}} for _, gam := range gams { - if gam.Avatar == "" { - // No avatar downloaded - msg.leaderboard = append(msg.leaderboard, gamificationItem{image: nil, item: *dto.GamificationDTO(gam)}) - continue - } - - file, err := os.Open(filepath.Clean(gam.Avatar)) - if err != nil { - return nil, err - } - defer func() { - _ = file.Close() - }() - - img, _, err := image.Decode(file) + im, _, err := image.Decode(bytes.NewReader(gam.Avatar)) if err != nil { return nil, err } - msg.leaderboard = append(msg.leaderboard, gamificationItem{image: img, item: *dto.GamificationDTO(gam)}) + msg.leaderboard = append(msg.leaderboard, gamificationItem{image: im, item: *dto.GamificationDTO(gam)}) } return msg, nil diff --git a/ui/view/gamification/styles.go b/ui/view/gamification/styles.go new file mode 100644 index 0000000..9b5ce4e --- /dev/null +++ b/ui/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 +) From 99befcb45b12331914ac564a5395439101ada973 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 4 Dec 2024 16:19:16 +0100 Subject: [PATCH 32/46] feat(event): add view --- ui/view/event/event.go | 121 +++++++++++++++++++++++++++++++++++++++++ ui/view/event/style.go | 56 +++++++++++++++++++ ui/view/event/view.go | 111 +++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 ui/view/event/event.go create mode 100644 ui/view/event/style.go create mode 100644 ui/view/event/view.go diff --git a/ui/view/event/event.go b/ui/view/event/event.go new file mode 100644 index 0000000..b6bf13f --- /dev/null +++ b/ui/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/ui/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.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/ui/view/event/style.go b/ui/view/event/style.go new file mode 100644 index 0000000..6c2f928 --- /dev/null +++ b/ui/view/event/style.go @@ -0,0 +1,56 @@ +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 ( + 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/ui/view/event/view.go b/ui/view/event/view.go new file mode 100644 index 0000000..16deb16 --- /dev/null +++ b/ui/view/event/view.go @@ -0,0 +1,111 @@ +package event + +import ( + "bytes" + "image" + + "github.com/charmbracelet/lipgloss" + "github.com/zeusWPI/scc/ui/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) + + // 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 +} From 5f443fbb849a1e9a3671425b72170868145061b0 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 4 Dec 2024 18:07:12 +0100 Subject: [PATCH 33/46] chore(tap): enhance view --- db/queries/tap.sql | 2 +- internal/pkg/db/sqlc/tap.sql.go | 9 +- ui/view/tap/style.go | 50 ++++++++++ ui/view/tap/tap.go | 159 +++++++++++++++----------------- ui/view/tap/view.go | 55 +++++++++++ 5 files changed, 183 insertions(+), 92 deletions(-) create mode 100644 ui/view/tap/style.go create mode 100644 ui/view/tap/view.go diff --git a/db/queries/tap.sql b/db/queries/tap.sql index 36072da..742aa7f 100644 --- a/db/queries/tap.sql +++ b/db/queries/tap.sql @@ -50,7 +50,7 @@ FROM tap GROUP BY category; -- name: GetOrderCountByCategorySinceOrderID :many -SELECT category, COUNT(*) +SELECT category, COUNT(*), CAST(MAX(order_created_at) AS INTEGER) AS latest_order_created_at FROM tap WHERE order_id >= ? GROUP BY category; diff --git a/internal/pkg/db/sqlc/tap.sql.go b/internal/pkg/db/sqlc/tap.sql.go index 66dfd70..33664e2 100644 --- a/internal/pkg/db/sqlc/tap.sql.go +++ b/internal/pkg/db/sqlc/tap.sql.go @@ -148,15 +148,16 @@ func (q *Queries) GetOrderCount(ctx context.Context) ([]GetOrderCountRow, error) } const getOrderCountByCategorySinceOrderID = `-- name: GetOrderCountByCategorySinceOrderID :many -SELECT category, COUNT(*) +SELECT category, COUNT(*), CAST(MAX(order_created_at) AS INTEGER) AS latest_order_created_at FROM tap WHERE order_id >= ? GROUP BY category ` type GetOrderCountByCategorySinceOrderIDRow struct { - Category string - Count int64 + Category string + Count int64 + LatestOrderCreatedAt int64 } func (q *Queries) GetOrderCountByCategorySinceOrderID(ctx context.Context, orderID int64) ([]GetOrderCountByCategorySinceOrderIDRow, error) { @@ -168,7 +169,7 @@ func (q *Queries) GetOrderCountByCategorySinceOrderID(ctx context.Context, order var items []GetOrderCountByCategorySinceOrderIDRow for rows.Next() { var i GetOrderCountByCategorySinceOrderIDRow - if err := rows.Scan(&i.Category, &i.Count); err != nil { + if err := rows.Scan(&i.Category, &i.Count, &i.LatestOrderCreatedAt); err != nil { return nil, err } items = append(items, i) diff --git a/ui/view/tap/style.go b/ui/view/tap/style.go new file mode 100644 index 0000000..d72b0e4 --- /dev/null +++ b/ui/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/ui/view/tap/tap.go b/ui/view/tap/tap.go index 5278d6a..b46ce69 100644 --- a/ui/view/tap/tap.go +++ b/ui/view/tap/tap.go @@ -4,8 +4,9 @@ package tap import ( "context" "database/sql" + "slices" + "time" - "github.com/NimbleMarkets/ntcharts/barchart" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/zeusWPI/scc/internal/pkg/db" @@ -13,14 +14,27 @@ import ( "github.com/zeusWPI/scc/ui/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 int64 - mate float64 - soft float64 - beer float64 - food float64 + items []tapItem } // Msg represents a tap message @@ -30,15 +44,9 @@ type Msg struct { } type tapItem struct { - category string - amount float64 -} - -var tapCategoryColor = map[string]lipgloss.Color{ - "Mate": lipgloss.Color("208"), - "Soft": lipgloss.Color("86"), - "Beer": lipgloss.Color("160"), - "Food": lipgloss.Color("40"), + category category + amount int + last time.Time } // NewModel creates a new tap model @@ -51,25 +59,38 @@ 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 _, msg := range msg.items { - switch msg.category { - case "Mate": - m.mate += msg.amount - case "Soft": - m.soft += msg.amount - case "Beer": - m.beer += msg.amount - case "Food": - m.food += msg.amount + 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 } @@ -78,45 +99,15 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // View returns the tap view func (m *Model) View() string { - chart := barchart.New(20, 20) - - barMate := barchart.BarData{ - Label: "Mate", - Values: []barchart.BarValue{{ - Name: "Mate", - Value: m.mate, - Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Mate"]), - }}, - } - barSoft := barchart.BarData{ - Label: "Soft", - Values: []barchart.BarValue{{ - Name: "Soft", - Value: m.soft, - Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Soft"]), - }}, - } - barBeer := barchart.BarData{ - Label: "Beer", - Values: []barchart.BarValue{{ - Name: "Beer", - Value: m.beer, - Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Beer"]), - }}, - } - barFood := barchart.BarData{ - Label: "Food", - Values: []barchart.BarValue{{ - Name: "Food", - Value: m.food, - Style: lipgloss.NewStyle().Foreground(tapCategoryColor["Food"]), - }}, - } + chart := m.viewChart() + stats := m.viewStats() - chart.PushAll([]barchart.BarData{barMate, barSoft, barBeer, barFood}) - chart.Draw() + // Give them same height + stats = sStats.Height(lipgloss.Height(chart)).Render(stats) - return chart.View() + // Join them together + view := lipgloss.JoinHorizontal(lipgloss.Top, chart, stats) + return view } // GetUpdateDatas returns all the update functions for the tap model @@ -140,45 +131,39 @@ func updateOrders(view view.View) (tea.Msg, error) { if err == sql.ErrNoRows { err = nil } - return Msg{lastOrderID: lastOrderID, items: []tapItem{}}, err + return nil, err } if order.OrderID <= lastOrderID { - return Msg{lastOrderID: lastOrderID, items: []tapItem{}}, nil + return nil, nil } orders, err := m.db.Queries.GetOrderCountByCategorySinceOrderID(context.Background(), lastOrderID) if err != nil { - return Msg{lastOrderID: lastOrderID, items: []tapItem{}}, err + return nil, err } - mate, soft, beer, food := 0.0, 0.0, 0.0, 0.0 + counts := make(map[category]tapItem) + for _, order := range orders { - switch order.Category { - case "Mate": - mate += float64(order.Count) - case "Soft": - soft += float64(order.Count) - case "Beer": - beer += float64(order.Count) - case "Food": - food += float64(order.Count) + if entry, ok := counts[category(order.Category)]; ok { + entry.amount += int(order.Count) + counts[category(order.Category)] = entry + continue } - } - messages := make([]tapItem, 0, 4) - if mate > 0 { - messages = append(messages, tapItem{"Mate", mate}) - } - if soft > 0 { - messages = append(messages, tapItem{"Soft", soft}) - } - if beer > 0 { - messages = append(messages, tapItem{"Beer", beer}) + counts[category(order.Category)] = tapItem{ + category: category(order.Category), + amount: int(order.Count), + last: time.Unix(order.LatestOrderCreatedAt, 0), + } } - if food > 0 { - messages = append(messages, tapItem{"Food", food}) + + items := make([]tapItem, 0, len(counts)) + + for _, v := range counts { + items = append(items, v) } - return Msg{lastOrderID: order.OrderID, items: messages}, err + return Msg{lastOrderID: order.OrderID, items: items}, nil } diff --git a/ui/view/tap/view.go b/ui/view/tap/view.go new file mode 100644 index 0000000..4c4a850 --- /dev/null +++ b/ui/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("02/01 15:04")) + + 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 +} From db49b3b8fdab342e708584e3873836afd438d362 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 4 Dec 2024 19:30:50 +0100 Subject: [PATCH 34/46] chore(zess): enhance view --- ui/view/zess/style.go | 46 +++++++++++++++++++++++++++++++ ui/view/zess/view.go | 63 +++++++++++++++++++++++++++++++++++++++++++ ui/view/zess/zess.go | 60 ++++++++++++++++------------------------- 3 files changed, 132 insertions(+), 37 deletions(-) create mode 100644 ui/view/zess/style.go create mode 100644 ui/view/zess/view.go diff --git a/ui/view/zess/style.go b/ui/view/zess/style.go new file mode 100644 index 0000000..d74af4e --- /dev/null +++ b/ui/view/zess/style.go @@ -0,0 +1,46 @@ +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 ( + cBarChart = lipgloss.Color("#FDEDCA") + + cBorder = lipgloss.Color("#383838") + cZeus = lipgloss.Color("#FF7F00") + cStatsTitle = lipgloss.Color("#EE4B2B") +) + +// Styles chart +var ( + sBar = base.Foreground(cBarChart) +) + +// 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/ui/view/zess/view.go b/ui/view/zess/view.go new file mode 100644 index 0000000..f00d1c0 --- /dev/null +++ b/ui/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, + }}, + } + + 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/ui/view/zess/zess.go b/ui/view/zess/zess.go index 85ec91b..cd04ed2 100644 --- a/ui/view/zess/zess.go +++ b/ui/view/zess/zess.go @@ -4,9 +4,7 @@ package zess import ( "context" "database/sql" - "fmt" - "github.com/NimbleMarkets/ntcharts/barchart" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/zeusWPI/scc/internal/pkg/db" @@ -15,14 +13,14 @@ import ( "go.uber.org/zap" ) -// time represents a time object by keeping the year and week number -type time struct { +// yearWeek represents a yearWeek object by keeping the year and week number +type yearWeek struct { year int week int } type weekScan struct { - time time + time yearWeek amount int64 label string } @@ -31,9 +29,9 @@ type weekScan struct { type Model struct { db *db.DB lastScanID int64 - scans []weekScan // Queue of scans per week + scans []weekScan // Scans per week maxWeekScans int64 - currentSeason time // Start week of the season + currentSeason yearWeek // Start week of the season seasonScans int64 } @@ -50,7 +48,7 @@ type scanMsg struct { // seasonMsg is used to indicate that the current season changed. type seasonMsg struct { Msg - start time + start yearWeek } // NewModel creates a new zess model view @@ -60,7 +58,7 @@ func NewModel(db *db.DB) view.View { lastScanID: -1, scans: make([]weekScan, 0), maxWeekScans: -1, - currentSeason: time{year: -1, week: -1}, + currentSeason: yearWeek{year: -1, week: -1}, seasonScans: 0, } @@ -88,6 +86,11 @@ 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) { @@ -156,32 +159,15 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // View returns the view for the zess model func (m *Model) View() string { - chart := barchart.New(20, 20) - - for _, scan := range m.scans { - bar := barchart.BarData{ - Label: scan.label, - Values: []barchart.BarValue{{ - Name: scan.label, - Value: float64(scan.amount), - Style: lipgloss.NewStyle().Foreground(lipgloss.Color("21")), - }}, - } - - chart.Push(bar) - } - - chart.Draw() + chart := m.viewChart() + overview := m.viewStats() - style := lipgloss.NewStyle().Height(20).Align(lipgloss.Bottom).Render(lipgloss.JoinVertical(lipgloss.Left, - fmt.Sprintf("Season scans\n%d", m.seasonScans), - fmt.Sprintf("Max scans in a week\n%d", m.maxWeekScans), - )) + // Give them the same height + overview = sStats.Height(lipgloss.Height(chart)).Render(overview) - return lipgloss.JoinHorizontal(lipgloss.Top, - chart.View(), - style, - ) + // Join them together + view := lipgloss.JoinHorizontal(lipgloss.Top, chart, overview) + return view } // GetUpdateDatas returns all the update functions for the zess model @@ -227,7 +213,7 @@ func updateScans(view view.View) (tea.Msg, error) { // Add new scans to scan msg for _, newScan := range scans { yearNumber, weekNumber := newScan.ScanTime.ISOWeek() - newTime := time{year: yearNumber, week: weekNumber} + newTime := yearWeek{year: yearNumber, week: weekNumber} found := false for i, scan := range zessScanMsg.scans { @@ -267,7 +253,7 @@ func updateSeason(view view.View) (tea.Msg, error) { // Check if we have a new season yearNumber, weekNumber := season.Start.ISOWeek() - seasonStart := time{year: yearNumber, week: weekNumber} + seasonStart := yearWeek{year: yearNumber, week: weekNumber} if m.currentSeason.equal(seasonStart) { // Same season return nil, nil @@ -276,10 +262,10 @@ func updateSeason(view view.View) (tea.Msg, error) { return seasonMsg{start: seasonStart}, nil } -func (z *time) equal(z2 time) bool { +func (z *yearWeek) equal(z2 yearWeek) bool { return z.week == z2.week && z.year == z2.year } -func (z *time) after(z2 time) bool { +func (z *yearWeek) after(z2 yearWeek) bool { if z.year > z2.year { return true } else if z.year < z2.year { From 7245f2e8895fc50d3b2f5f7099016c017e9e321d Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sat, 7 Dec 2024 14:07:40 +0100 Subject: [PATCH 35/46] feat(view): enhance cammie view --- config/development.toml | 7 ++ internal/cmd/tui.go | 3 +- internal/pkg/db/dto/event.go | 3 + internal/pkg/db/dto/gamification.go | 10 +- internal/pkg/db/sqlc/models.go | 3 +- internal/pkg/event/event.go | 22 ++-- ui/screen/cammie.go | 55 --------- ui/screen/cammie/cammie.go | 174 +++++++++++++++++++++++++++ ui/screen/cammie/style.go | 50 ++++++++ ui/tui.go | 2 +- ui/view/gamification/gamification.go | 34 +----- ui/view/message/message.go | 94 ++++++++------- ui/view/message/style.go | 38 ++++++ ui/view/message/view.go | 48 ++++++++ ui/view/song/song.go | 29 +---- ui/view/song/style.go | 23 +++- ui/view/song/view.go | 63 ++++++---- ui/view/util.go | 44 +++++++ ui/view/view.go | 13 ++ ui/view/zess/style.go | 2 +- 20 files changed, 515 insertions(+), 202 deletions(-) delete mode 100644 ui/screen/cammie.go create mode 100644 ui/screen/cammie/cammie.go create mode 100644 ui/screen/cammie/style.go create mode 100644 ui/view/message/style.go create mode 100644 ui/view/message/view.go create mode 100644 ui/view/util.go diff --git a/config/development.toml b/config/development.toml index 4028f99..d32a4b3 100644 --- a/config/development.toml +++ b/config/development.toml @@ -35,6 +35,7 @@ interval_s = 3600 [event] api = "https://zeus.gent/events" +api_poster = "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master" interval_s = 86400 [buzzer] @@ -52,6 +53,9 @@ song = [ [tui] +[tui.screen] +cammie_interval_change_s = 300 + [tui.zess] weeks = 10 interval_scan_s = 60 @@ -69,3 +73,6 @@ interval_s = 3600 [tui.song] interval_current_s = 5 interval_top_s = 3600 + +[tui.event] +interval_s = 3600 diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index 591b7fd..bfb001c 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -11,12 +11,13 @@ import ( "github.com/zeusWPI/scc/pkg/util" tui "github.com/zeusWPI/scc/ui" "github.com/zeusWPI/scc/ui/screen" + "github.com/zeusWPI/scc/ui/screen/cammie" "github.com/zeusWPI/scc/ui/view" "go.uber.org/zap" ) var screens = map[string]func(*db.DB) screen.Screen{ - "cammie": screen.NewCammie, + "cammie": cammie.New, "song": screen.NewSong, "test": screen.NewTest, } diff --git a/internal/pkg/db/dto/event.go b/internal/pkg/db/dto/event.go index 838d833..59226c9 100644 --- a/internal/pkg/db/dto/event.go +++ b/internal/pkg/db/dto/event.go @@ -13,6 +13,7 @@ type Event struct { Date time.Time AcademicYear string Location string + Poster []byte } // EventDTO converts a sqlc Event object to a DTO Event @@ -23,6 +24,7 @@ func EventDTO(e sqlc.Event) *Event { Date: e.Date, AcademicYear: e.AcademicYear, Location: e.Location, + Poster: e.Poster, } } @@ -38,5 +40,6 @@ func (e *Event) CreateParams() sqlc.CreateEventParams { Date: e.Date, 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 index e00a6e4..3ee8548 100644 --- a/internal/pkg/db/dto/gamification.go +++ b/internal/pkg/db/dto/gamification.go @@ -1,13 +1,17 @@ package dto -import "github.com/zeusWPI/scc/internal/pkg/db/sqlc" +import ( + "bytes" + + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" +) // Gamification represents the DTO object for gamification type Gamification struct { ID int64 `json:"id"` Name string `json:"github_name"` Score int64 `json:"score"` - Avatar string `json:"avatar_url"` + Avatar []byte `json:"avatar"` } // GamificationDTO converts a sqlc Gamification object to a DTO gamification @@ -22,7 +26,7 @@ func GamificationDTO(gam sqlc.Gamification) *Gamification { // Equal compares 2 Gamification objects for equality func (g *Gamification) Equal(g2 Gamification) bool { - return g.Name == g2.Name && g.Score == g2.Score && g.Avatar == g2.Avatar + 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 diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index 2aaef06..4a7cb71 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -15,13 +15,14 @@ type Event struct { Date time.Time AcademicYear string Location string + Poster []byte } type Gamification struct { ID int64 Name string Score int64 - Avatar string + Avatar []byte } type Message struct { diff --git a/internal/pkg/event/event.go b/internal/pkg/event/event.go index 8bc556a..8f1d373 100644 --- a/internal/pkg/event/event.go +++ b/internal/pkg/event/event.go @@ -4,7 +4,6 @@ package event import ( "context" "errors" - "fmt" "slices" "sync" @@ -43,15 +42,18 @@ func (e *Event) Update() error { return err } - equal := false - if len(events) == len(eventsDB) { - for _, event := range eventsDB { - found := slices.ContainsFunc(events, func(ev dto.Event) bool { return ev.Equal(*dto.EventDTO(event)) }) - if !found { - break - } + // 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(*dto.EventDTO(event)) }) + if !found { + equal = false + break } - equal = true + } + + if len(events) != len(eventsDB) { + equal = false } // Both are equal, nothing to be done @@ -79,9 +81,7 @@ func (e *Event) Update() error { } }(&event) } - fmt.Printf("Waiting\n") wg.Wait() - fmt.Printf("Done\n") for _, event := range events { err = e.getPoster(&event) diff --git a/ui/screen/cammie.go b/ui/screen/cammie.go deleted file mode 100644 index 6e7151d..0000000 --- a/ui/screen/cammie.go +++ /dev/null @@ -1,55 +0,0 @@ -package screen - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/ui/view" - "github.com/zeusWPI/scc/ui/view/zess" -) - -// Cammie represents the cammie screen -type Cammie struct { - db *db.DB - zess view.View -} - -// NewCammie creates a new cammie screen -func NewCammie(db *db.DB) Screen { - return &Cammie{db: db, zess: zess.NewModel(db)} -} - -// Init initializes the cammie screen -func (c *Cammie) Init() tea.Cmd { - return c.zess.Init() -} - -// Update updates the cammie screen -func (c *Cammie) Update(msg tea.Msg) (Screen, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case zess.Msg: - zess, cmd := c.zess.Update(msg) - c.zess = zess - - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - var cmd tea.Cmd - if cmds != nil { - cmd = tea.Batch(cmds...) - } - - return c, cmd -} - -// View returns the cammie screen view -func (c *Cammie) View() string { - return c.zess.View() -} - -// GetUpdateViews returns all the update functions for the cammie screen -func (c *Cammie) GetUpdateViews() []view.UpdateData { - return c.zess.GetUpdateDatas() -} diff --git a/ui/screen/cammie/cammie.go b/ui/screen/cammie/cammie.go new file mode 100644 index 0000000..03bcfed --- /dev/null +++ b/ui/screen/cammie/cammie.go @@ -0,0 +1,174 @@ +// 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/ui/screen" + "github.com/zeusWPI/scc/ui/view" + "github.com/zeusWPI/scc/ui/view/event" + "github.com/zeusWPI/scc/ui/view/gamification" + "github.com/zeusWPI/scc/ui/view/message" + "github.com/zeusWPI/scc/ui/view/tap" + "github.com/zeusWPI/scc/ui/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: 2, 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 - sMsg.GetHorizontalFrameSize() - sMsg.GetHorizontalPadding()).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, updateSize(*c) + 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 +} + +func updateBottomIndex(cammie Cammie) tea.Cmd { + timeout := time.Duration(config.GetDefaultInt("tui.screen.cammie_interval_change_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} + }) +} + +func updateSize(cammie Cammie) tea.Cmd { + return func() tea.Msg { + msg := view.MsgSize{ + Sizes: make(map[string]view.Size), + } + + msgW := sMsg.GetWidth() + msgH := sMsg.GetHeight() + msg.Sizes[cammie.messages.Name()] = view.Size{Width: msgW, Height: msgH} + + bottomW := sBottom.GetWidth() + bottomH := sBottom.GetHeight() + msg.Sizes[cammie.bottom.Name()] = view.Size{Width: bottomW, Height: bottomH} + + for _, top := range cammie.top { + topW := sTop.GetWidth() + topH := sTop.GetHeight() + msg.Sizes[top.Name()] = view.Size{Width: topW, Height: topH} + } + + return msg + } +} diff --git a/ui/screen/cammie/style.go b/ui/screen/cammie/style.go new file mode 100644 index 0000000..832683c --- /dev/null +++ b/ui/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/ui/tui.go b/ui/tui.go index 5ff68f8..b5b4635 100644 --- a/ui/tui.go +++ b/ui/tui.go @@ -38,7 +38,7 @@ func (t *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC: zap.S().Info("Exiting") - cmds = append(cmds, tea.ExitAltScreen) + // cmds = append(cmds, tea.ExitAltScreen) cmds = append(cmds, tea.Quit) } } diff --git a/ui/view/gamification/gamification.go b/ui/view/gamification/gamification.go index c91c5ae..43eb54c 100644 --- a/ui/view/gamification/gamification.go +++ b/ui/view/gamification/gamification.go @@ -8,12 +8,9 @@ import ( "fmt" "image" "strconv" - "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/disintegration/imaging" - "github.com/lucasb-eyer/go-colorful" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/internal/pkg/db/dto" "github.com/zeusWPI/scc/pkg/config" @@ -48,7 +45,7 @@ func (m *Model) Init() tea.Cmd { // Name returns the name of the view func (m *Model) Name() string { - return "Gammification" + return "Gamification" } // Update updates the gamification view @@ -73,7 +70,7 @@ func (m *Model) View() string { sScore.Render(strconv.Itoa(int(item.item.Score))), ) - column := lipgloss.JoinVertical(lipgloss.Left, gamificationToString(width, item.image), user) + column := lipgloss.JoinVertical(lipgloss.Left, view.ImagetoString(width, item.image), user) columns = append(columns, sColumn.Render(column)) } @@ -133,30 +130,3 @@ func updateLeaderboard(view view.View) (tea.Msg, error) { return msg, nil } - -func gamificationToString(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()) - str.WriteString(lipgloss.NewStyle().Foreground(color1). - Background(color2).Render("▀")) - } - - str.WriteString("\n") - } - - return str.String() -} diff --git a/ui/view/message/message.go b/ui/view/message/message.go index fd71013..d30b770 100644 --- a/ui/view/message/message.go +++ b/ui/view/message/message.go @@ -4,40 +4,40 @@ package message import ( "context" "database/sql" - "fmt" "hash/fnv" + "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/list" "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/ui/view" - "go.uber.org/zap" ) // Model represents the model for the message view type Model struct { + width int + height int db *db.DB lastMessageID int64 - messages []string + 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 int64 - messages []string -} - -var messageColor = []string{ - "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0", - "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff", + messages []message } // NewModel creates a new message model view func NewModel(db *db.DB) view.View { - return &Model{db: db, lastMessageID: -1, messages: []string{}} + return &Model{db: db, lastMessageID: -1, messages: []message{}} } // Init initializes the message model view @@ -45,9 +45,22 @@ 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...) @@ -60,11 +73,11 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // View returns the view for the message model func (m *Model) View() string { - // TODO: Limit the amount of messages shown - // TODO: Wrap messages - zap.S().Info("Viewing messages") - l := list.New(m.messages).Enumerator(func(_ list.Items, _ int) string { return "" }) - return l.String() + if m.width == 0 || m.height == 0 { + return "Initializing..." + } + + return m.viewAll() } // GetUpdateDatas returns all the update functions for the message model @@ -83,48 +96,39 @@ func updateMessages(view view.View) (tea.Msg, error) { m := view.(*Model) lastMessageID := m.lastMessageID - message, err := m.db.Queries.GetLastMessage(context.Background()) + messagesDB, err := m.db.Queries.GetMessageSinceID(context.Background(), lastMessageID) if err != nil { if err == sql.ErrNoRows { err = nil } - return Msg{lastMessageID: lastMessageID, messages: []string{}}, err + return nil, err } - if message.ID <= lastMessageID { - return Msg{lastMessageID: lastMessageID, messages: []string{}}, nil + if len(messagesDB) == 0 { + return nil, nil } - messages, err := m.db.Queries.GetMessageSinceID(context.Background(), lastMessageID) - if err != nil { - zap.S().Error("DB: Failed to get messages", err) - return Msg{lastMessageID: lastMessageID, messages: []string{}}, err - } + messages := make([]message, 0, len(messagesDB)) + lastID := m.lastMessageID + for _, m := range messagesDB { + if m.ID > lastID { + lastID = m.ID + } - formattedMessages := make([]string, 0, len(messages)) - for _, message := range messages { - formattedMessages = append(formattedMessages, formatMessage(message)) + messages = append(messages, message{ + sender: m.Name, + message: m.Message, + color: hashColor(m.Name), + date: m.CreatedAt, + }) } - return Msg{lastMessageID: message.ID, messages: formattedMessages}, nil + return Msg{lastMessageID: lastID, messages: messages}, nil } func hashColor(s string) string { h := fnv.New32a() h.Write([]byte(s)) hash := h.Sum32() - return messageColor[hash%uint32(len(messageColor))] -} - -func formatMessage(msg sqlc.Message) string { - dateStyle := lipgloss.NewStyle().Faint(true) - date := dateStyle.Render(fmt.Sprintf("%s ", msg.CreatedAt.Format("02/01"))) - - color := hashColor(msg.Name) - colorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) - - sender := fmt.Sprintf("%s %s ", colorStyle.Bold(true).Render(msg.Name), colorStyle.Render("|")) - message := colorStyle.Render(msg.Message) - - return fmt.Sprintf("%s%s%s", date, sender, message) + return colors[hash%uint32(len(colors))] } diff --git a/ui/view/message/style.go b/ui/view/message/style.go new file mode 100644 index 0000000..89ac4fc --- /dev/null +++ b/ui/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/ui/view/message/view.go b/ui/view/message/view.go new file mode 100644 index 0000000..77d9812 --- /dev/null +++ b/ui/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/ui/view/song/song.go b/ui/view/song/song.go index 691b8b3..c77aad8 100644 --- a/ui/view/song/song.go +++ b/ui/view/song/song.go @@ -12,7 +12,6 @@ import ( "github.com/zeusWPI/scc/internal/pkg/lyrics" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/ui/view" - "go.uber.org/zap" ) var ( @@ -86,6 +85,11 @@ func (m *Model) Init() tea.Cmd { return nil } +// 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) { @@ -98,15 +102,12 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { m.current = msg.current // New song, start the commands to update the lyrics lyric, ok := m.current.lyrics.Current() - zap.S().Info("Song ", ok) if !ok { // Song already done (shouldn't happen) m.current = playing{song: nil} return m, nil } - zap.S().Info("Starting") startTime := m.current.song.CreatedAt.Add(lyric.Duration) - zap.S().Info("Startime: ", startTime) for startTime.Before(time.Now()) { lyric, ok := m.current.lyrics.Next() if !ok { @@ -115,7 +116,6 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil } startTime = startTime.Add(lyric.Duration) - zap.S().Info("Startime: ", startTime) } m.current.upcoming = lyricsToString(m.current.lyrics.Upcoming(upcomingAmount)) return m, updateLyrics(m.current, startTime) @@ -183,7 +183,6 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { } func updateCurrentSong(view view.View) (tea.Msg, error) { - zap.S().Info("Updating current song") m := view.(*Model) songs, err := m.db.Queries.GetLastSongFull(context.Background()) @@ -191,30 +190,25 @@ func updateCurrentSong(view view.View) (tea.Msg, error) { if err == sql.ErrNoRows { err = nil } - zap.S().Info("Updating song: DB error") return nil, err } if len(songs) == 0 { - zap.S().Info("Updating song: No lenght for songs") return nil, nil } // Check if song is still playing if songs[0].CreatedAt.Add(time.Duration(songs[0].DurationMs) * time.Millisecond).Before(time.Now()) { // Song is finished - zap.S().Info("Updating song: Song finished") return nil, nil } if m.current.song != nil && songs[0].ID == m.current.song.ID { // Song is already set to current - zap.S().Info("Updating song: already set to current") return nil, nil } song := dto.SongDTOHistory(songs) - zap.S().Info("Updating song: returning") return msgPlaying{current: playing{song: song, lyrics: lyrics.New(song)}}, nil } @@ -266,15 +260,12 @@ func updateLyrics(song playing, start time.Time) tea.Cmd { if start.After(now) { timeout = start.Sub(now) } - zap.S().Info("Lyrics: updating in ", timeout) return tea.Tick(timeout, func(_ time.Time) tea.Msg { // Next lyric - zap.S().Info("Lyrics: Getting next lyric") lyric, ok := song.lyrics.Next() if !ok { // Song finished - zap.S().Info("Lyrics: song finished") return msgLyrics{song: *song.song, done: true} } @@ -283,14 +274,6 @@ func updateLyrics(song playing, start time.Time) tea.Cmd { end := start.Add(lyric.Duration) - zap.S().Info("Lyrics: Returning: ", msgLyrics{ - previous: lyricsToString(previous), - current: lyric.Text, - upcoming: lyricsToString(upcoming), - startNext: end, - done: false, - }) - return msgLyrics{ song: *song.song, previous: lyricsToString(previous), @@ -301,5 +284,3 @@ func updateLyrics(song playing, start time.Time) tea.Cmd { } }) } - -// TODO: It always start at the first lyric but if it's behind it should skip forward. diff --git a/ui/view/song/style.go b/ui/view/song/style.go index f7a2da2..d25ecef 100644 --- a/ui/view/song/style.go +++ b/ui/view/song/style.go @@ -8,15 +8,26 @@ var ( cSpotify = lipgloss.Color("#1DB954") ) -// Styles +// Base style +var sBase = lipgloss.NewStyle() + +// Styles for the stats var ( - sBase = lipgloss.NewStyle() - sStat = sBase.MarginRight(3) - sStatTitle = sBase.Foreground(cZeus). + wStatTotal = 30 + wStatEnum = 3 + wStatAmount = 4 + wStatBody = wStatTotal - wStatEnum - wStatAmount + + sStat = sBase.Width(wStatTotal).MarginRight(3).MarginBottom(2) + sStatTitle = sBase.Foreground(cZeus).Width(wStatTotal).Align(lipgloss.Center). BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(cSpotify) - sStatAmount = sBase.Foreground(cZeus).MarginLeft(2) - sListEnum = sBase.Foreground(cSpotify).MarginRight(1) + sStatEnum = sBase.Foreground(cSpotify).Width(wStatEnum).Align(lipgloss.Left) + sStatBody = sBase.Width(wStatBody) + sStatAmount = sBase.Width(wStatAmount).Align(lipgloss.Right).Foreground(cZeus) +) +// Styles for the lyrics +var ( sLyricBase = sBase.Width(50).Align(lipgloss.Center) sLyricPrevious = sLyricBase.Foreground(cZeus).Faint(true) sLyricCurrent = sLyricBase.Foreground(cZeus) diff --git a/ui/view/song/view.go b/ui/view/song/view.go index 6ab936a..52ad541 100644 --- a/ui/view/song/view.go +++ b/ui/view/song/view.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/list" ) func (m *Model) viewPlaying() string { @@ -31,33 +30,53 @@ func (m *Model) viewPlaying() string { } func (m *Model) viewNotPlaying() string { - columns := make([]string, 0, 3) + rows := make([][]string, 0, 2) + for i := 0; i < 2; i++ { + rows = append(rows, make([]string, 0, 2)) + } // Recently played - l := list.New(m.history).Enumerator(list.Arabic).EnumeratorStyle(sListEnum).String() - t := sStatTitle.Width(lipgloss.Width(l)).Align(lipgloss.Center).Render("Recently played") - - column := lipgloss.JoinVertical(lipgloss.Left, t, l) - columns = append(columns, sStat.Render(column)) - - // Top stats - topStats := map[string][]topStat{ - "Top Tracks": m.topSongs, - "Top Artists": m.topArtists, - "Top Genres": m.topGenres, + 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") + rows[0] = append(rows[0], sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l))) - for title, stat := range topStats { - var statInfos []string - for _, statInfo := range stat { - statInfos = append(statInfos, lipgloss.JoinHorizontal(lipgloss.Top, statInfo.name, sStatAmount.Render(fmt.Sprintf("%d", statInfo.amount)))) + // All other stats + topStats := [][]topStat{m.topSongs, m.topArtists, m.topGenres} + for i, topStat := range topStats { + items := make([]string, 0, len(topStat)) + for i, stat := range topStat { + 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 := list.New(statInfos).Enumerator(list.Arabic).EnumeratorStyle(sListEnum).String() - t := sStatTitle.Width(lipgloss.Width(l)).Align(lipgloss.Center).Render(title) + l := lipgloss.JoinVertical(lipgloss.Left, items...) + + var row int + if i == 0 { + title = sStatTitle.Render("Top Tracks") + row = 0 + } else if i == 1 { + title = sStatTitle.Render("Top Artists") + row = 1 + } else { + title = sStatTitle.Render("Top Genres") + row = 1 + } + + rows[row] = append(rows[row], sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l))) + } - column := lipgloss.JoinVertical(lipgloss.Left, t, l) - columns = append(columns, sStat.Render(column)) + renderedRows := make([]string, 0, 2) + for _, row := range rows { + renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, row...)) } - return lipgloss.JoinHorizontal(lipgloss.Top, columns...) + return lipgloss.JoinVertical(lipgloss.Left, renderedRows...) } diff --git a/ui/view/util.go b/ui/view/util.go new file mode 100644 index 0000000..332808a --- /dev/null +++ b/ui/view/util.go @@ -0,0 +1,44 @@ +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() +} diff --git a/ui/view/view.go b/ui/view/view.go index 7770c17..fa57bcc 100644 --- a/ui/view/view.go +++ b/ui/view/view.go @@ -16,7 +16,20 @@ type UpdateData struct { // 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/ui/view/zess/style.go b/ui/view/zess/style.go index d74af4e..97e7986 100644 --- a/ui/view/zess/style.go +++ b/ui/view/zess/style.go @@ -21,7 +21,7 @@ var ( // Colors var ( - cBarChart = lipgloss.Color("#FDEDCA") + cBarChart = lipgloss.Color("#32012F") cBorder = lipgloss.Color("#383838") cZeus = lipgloss.Color("#FF7F00") From fd6715ad208483303e98c5c1546e752944bb796c Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sat, 7 Dec 2024 23:19:14 +0100 Subject: [PATCH 36/46] chore(song): enhance view --- .github/workflows/sqlc-diff.yml | 2 +- db/queries/song.sql | 2 +- go.mod | 9 +- go.sum | 14 +++ internal/cmd/tui.go | 4 +- internal/pkg/db/sqlc/song.sql.go | 2 +- internal/pkg/lyrics/lrc.go | 32 +++---- internal/pkg/lyrics/lyrics.go | 1 + internal/pkg/lyrics/plain.go | 9 ++ ui/components/stopwatch/stopwatch.go | 128 +++++++++++++++++++++++++++ ui/screen/cammie/cammie.go | 47 +++++----- ui/screen/screen.go | 1 + ui/screen/song.go | 50 ----------- ui/screen/song/song.go | 77 ++++++++++++++++ ui/screen/song/style.go | 10 +++ ui/view/song/song.go | 60 ++++++++++--- ui/view/song/style.go | 21 +++-- ui/view/song/view.go | 47 +++++++++- 18 files changed, 395 insertions(+), 121 deletions(-) create mode 100644 ui/components/stopwatch/stopwatch.go delete mode 100644 ui/screen/song.go create mode 100644 ui/screen/song/song.go create mode 100644 ui/screen/song/style.go diff --git a/.github/workflows/sqlc-diff.yml b/.github/workflows/sqlc-diff.yml index 7d003ec..d5b3ce0 100644 --- a/.github/workflows/sqlc-diff.yml +++ b/.github/workflows/sqlc-diff.yml @@ -5,7 +5,7 @@ jobs: diff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: sqlc-dev/setup-sqlc@v3 with: sqlc-version: '1.27.0' diff --git a/db/queries/song.sql b/db/queries/song.sql index cb09a81..02db229 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -73,7 +73,7 @@ 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 +JOIN song s ON sh.song_id = s.id ORDER BY created_at DESC LIMIT 5; diff --git a/go.mod b/go.mod index 174bb6a..409d5d2 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.23.1 require ( github.com/NimbleMarkets/ntcharts v0.1.2 - github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/bubbletea v1.1.0 github.com/charmbracelet/lipgloss v1.0.0 github.com/disintegration/imaging v1.6.2 github.com/go-playground/validator/v10 v10.22.1 @@ -27,9 +27,12 @@ require ( 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/bubbles v0.18.0 // indirect + github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.4.2 // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect github.com/containerd/console v1.0.4 // 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 @@ -47,7 +50,7 @@ require ( 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.15 // 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 diff --git a/go.sum b/go.sum index e8f6556..b07f473 100644 --- a/go.sum +++ b/go.sum @@ -18,14 +18,23 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +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 v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= +github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +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.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -34,6 +43,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 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= @@ -97,6 +108,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -189,6 +202,7 @@ golang.org/x/sync v0.8.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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index bfb001c..98cfb46 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -12,14 +12,14 @@ import ( tui "github.com/zeusWPI/scc/ui" "github.com/zeusWPI/scc/ui/screen" "github.com/zeusWPI/scc/ui/screen/cammie" + songScreen "github.com/zeusWPI/scc/ui/screen/song" "github.com/zeusWPI/scc/ui/view" "go.uber.org/zap" ) var screens = map[string]func(*db.DB) screen.Screen{ "cammie": cammie.New, - "song": screen.NewSong, - "test": screen.NewTest, + "song": songScreen.New, } // TUI starts the terminal user interface diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go index e37f873..d9947c0 100644 --- a/internal/pkg/db/sqlc/song.sql.go +++ b/internal/pkg/db/sqlc/song.sql.go @@ -305,7 +305,7 @@ func (q *Queries) GetSongGenreByName(ctx context.Context, genre string) (SongGen const getSongHistory = `-- name: GetSongHistory :many SELECT s.title FROM song_history sh -JOIN song s ON sh.song_id = s.id +JOIN song s ON sh.song_id = s.id ORDER BY created_at DESC LIMIT 5 ` diff --git a/internal/pkg/lyrics/lrc.go b/internal/pkg/lyrics/lrc.go index f8f9452..a4ec841 100644 --- a/internal/pkg/lyrics/lrc.go +++ b/internal/pkg/lyrics/lrc.go @@ -9,6 +9,8 @@ import ( "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 @@ -76,40 +78,40 @@ func (l *LRC) Upcoming(amount int) []Lyric { return lyrics } +// Progress shows the fraction of lyrics that have been used. +func (l *LRC) Progress() float64 { + return float64(l.i) / float64(len(l.lyrics)) +} + func parseLRC(text string, totalDuration time.Duration) []Lyric { lines := strings.Split(text, "\n") lyrics := make([]Lyric, 0, len(lines)+1) var previousTimestamp time.Duration - re, err := regexp.Compile(`^\[(\d{2}):(\d{2})\.(\d{2})\] (.+)$`) - if err != nil { - return lyrics - } - // Add first lyric (no text) lyrics = append(lyrics, Lyric{Text: ""}) previousTimestamp = time.Duration(0) for i, line := range lines { - match := re.FindStringSubmatch(line) - if match == nil { + parts := strings.SplitN(line, " ", 2) + if len(parts) != 2 { continue } - // Construct timestamp - minutes, _ := strconv.Atoi(match[1]) - seconds, _ := strconv.Atoi(match[2]) - hundredths, _ := strconv.Atoi(match[3]) + // 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 - t := match[4] - - lyrics = append(lyrics, Lyric{Text: t}) + // Actual lyric + lyric := parts[1] - // Set duration of previous lyric + lyrics = append(lyrics, Lyric{Text: lyric}) lyrics[i].Duration = timestamp - previousTimestamp previousTimestamp = timestamp } diff --git a/internal/pkg/lyrics/lyrics.go b/internal/pkg/lyrics/lyrics.go index 80e964f..69a6750 100644 --- a/internal/pkg/lyrics/lyrics.go +++ b/internal/pkg/lyrics/lyrics.go @@ -14,6 +14,7 @@ type Lyrics interface { Current() (Lyric, bool) Next() (Lyric, bool) Upcoming(int) []Lyric + Progress() float64 } // Lyric represents a single lyric line. diff --git a/internal/pkg/lyrics/plain.go b/internal/pkg/lyrics/plain.go index 6ce3946..24cbd7f 100644 --- a/internal/pkg/lyrics/plain.go +++ b/internal/pkg/lyrics/plain.go @@ -57,3 +57,12 @@ func (p *Plain) Next() (Lyric, bool) { 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/ui/components/stopwatch/stopwatch.go b/ui/components/stopwatch/stopwatch.go new file mode 100644 index 0000000..a96a681 --- /dev/null +++ b/ui/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() int { + return int(atomic.AddInt64(&lastID, 1)) +} + +// TickMsg is a message that is sent on every stopwatch tick +type TickMsg struct { + id int +} + +// 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 int + duration time.Duration + running bool +} + +// New created a new stopwatch with a given interval +func New() Model { + return Model{ + id: nextID(), + duration: 0, + running: false, + } +} + +// Init initiates 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 int) tea.Cmd { + return tea.Tick(time.Second, func(_ time.Time) tea.Msg { + return TickMsg{id: id} + }) +} diff --git a/ui/screen/cammie/cammie.go b/ui/screen/cammie/cammie.go index 03bcfed..22e78b7 100644 --- a/ui/screen/cammie/cammie.go +++ b/ui/screen/cammie/cammie.go @@ -63,7 +63,7 @@ func (c *Cammie) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { 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, updateSize(*c) + return c, c.GetSizeMsg case msgIndex: c.indexTop = msg.indexBottom @@ -140,6 +140,27 @@ func (c *Cammie) GetUpdateViews() []view.UpdateData { 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_change_s", 300) * int(time.Second)) return tea.Tick(timeout, func(_ time.Time) tea.Msg { @@ -148,27 +169,3 @@ func updateBottomIndex(cammie Cammie) tea.Cmd { return msgIndex{indexBottom: newIndex} }) } - -func updateSize(cammie Cammie) tea.Cmd { - return func() tea.Msg { - msg := view.MsgSize{ - Sizes: make(map[string]view.Size), - } - - msgW := sMsg.GetWidth() - msgH := sMsg.GetHeight() - msg.Sizes[cammie.messages.Name()] = view.Size{Width: msgW, Height: msgH} - - bottomW := sBottom.GetWidth() - bottomH := sBottom.GetHeight() - msg.Sizes[cammie.bottom.Name()] = view.Size{Width: bottomW, Height: bottomH} - - for _, top := range cammie.top { - topW := sTop.GetWidth() - topH := sTop.GetHeight() - msg.Sizes[top.Name()] = view.Size{Width: topW, Height: topH} - } - - return msg - } -} diff --git a/ui/screen/screen.go b/ui/screen/screen.go index 6b2a4d9..e4edd1c 100644 --- a/ui/screen/screen.go +++ b/ui/screen/screen.go @@ -12,4 +12,5 @@ type Screen interface { Update(tea.Msg) (Screen, tea.Cmd) View() string GetUpdateViews() []view.UpdateData + GetSizeMsg() tea.Msg } diff --git a/ui/screen/song.go b/ui/screen/song.go deleted file mode 100644 index 6788715..0000000 --- a/ui/screen/song.go +++ /dev/null @@ -1,50 +0,0 @@ -package screen - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/ui/view" - "github.com/zeusWPI/scc/ui/view/song" -) - -// Song represents the song screen -type Song struct { - db *db.DB - song view.View -} - -// NewSong creates a new song screen -func NewSong(db *db.DB) Screen { - return &Song{db: db, song: song.NewModel(db)} -} - -// Init initializes the song screen -func (s *Song) Init() tea.Cmd { - return s.song.Init() -} - -// Update updates the song screen -func (s *Song) Update(msg tea.Msg) (Screen, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - default: - song, cmd := s.song.Update(msg) - s.song = song - - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - return s, tea.Batch(cmds...) -} - -// View returns the song screen view -func (s *Song) View() string { - return s.song.View() -} - -// GetUpdateViews returns all the update functions for the song screen -func (s *Song) GetUpdateViews() []view.UpdateData { - return s.song.GetUpdateDatas() -} diff --git a/ui/screen/song/song.go b/ui/screen/song/song.go new file mode 100644 index 0000000..3532205 --- /dev/null +++ b/ui/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/ui/screen" + "github.com/zeusWPI/scc/ui/view" + "github.com/zeusWPI/scc/ui/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.NewModel(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 - sSong.GetHorizontalFrameSize() - sSong.GetHorizontalPadding()).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/ui/screen/song/style.go b/ui/screen/song/style.go new file mode 100644 index 0000000..e8962d6 --- /dev/null +++ b/ui/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/ui/view/song/song.go b/ui/view/song/song.go index c77aad8..7b6d7fa 100644 --- a/ui/view/song/song.go +++ b/ui/view/song/song.go @@ -6,11 +6,13 @@ import ( "database/sql" "time" + "github.com/charmbracelet/bubbles/progress" tea "github.com/charmbracelet/bubbletea" "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/ui/components/stopwatch" "github.com/zeusWPI/scc/ui/view" ) @@ -20,11 +22,13 @@ var ( ) type playing struct { - song *dto.Song - lyrics lyrics.Lyrics - previous []string // Lyrics already sang - current string // Current lyric - upcoming []string // Lyrics that are coming up + song *dto.Song + progress progress.Model + stopwatch stopwatch.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 @@ -35,6 +39,8 @@ type Model struct { topSongs []topStat topGenres []topStat topArtists []topStat + width int + height int } // Msg triggers a song data update @@ -72,17 +78,19 @@ func NewModel(db *db.DB) view.View { return &Model{ db: db, - current: playing{}, + current: playing{stopwatch: stopwatch.New(), progress: progress.New()}, history: history, topSongs: make([]topStat, 0, 5), topGenres: make([]topStat, 0, 5), topArtists: make([]topStat, 0, 5), + width: 0, + height: 0, } } // Init starts the song view func (m *Model) Init() tea.Cmd { - return nil + return m.current.stopwatch.Init() } // Name returns the name of the view @@ -93,6 +101,14 @@ func (m *Model) Name() string { // 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 + } + + return m, nil case msgPlaying: m.history = append(m.history, msg.current.song.Title) if len(m.history) > 5 { @@ -101,24 +117,28 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { m.current = msg.current // New song, start the commands to update the lyrics - lyric, ok := m.current.lyrics.Current() + lyric, ok := m.current.lyrics.Next() if !ok { - // Song already done (shouldn't happen) + // Song already done m.current = playing{song: nil} - return m, 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, nil + return m, m.current.stopwatch.Reset() } startTime = startTime.Add(lyric.Duration) } + m.current.upcoming = lyricsToString(m.current.lyrics.Upcoming(upcomingAmount)) - return m, updateLyrics(m.current, startTime) + return m, tea.Batch(updateLyrics(m.current, startTime), m.current.stopwatch.Start(time.Since(m.current.song.CreatedAt))) + case msgTop: if msg.topSongs != nil { m.topSongs = msg.topSongs @@ -129,6 +149,9 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { if msg.topArtists != nil { m.topArtists = msg.topArtists } + + return m, nil + case msgLyrics: // Check if it's still relevant if msg.song.ID != m.current.song.ID { @@ -139,7 +162,7 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { if msg.done { // Song has finished. Reset variables m.current = playing{song: nil} - return m, nil + return m, m.current.stopwatch.Reset() } // Msg is relevant, update values @@ -149,9 +172,18 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // Start the cmd to update the lyrics return m, updateLyrics(m.current, msg.startNext) + + case progress.FrameMsg: + progressModel, cmd := m.current.progress.Update(msg) + m.current.progress = progressModel.(progress.Model) + + return m, cmd } - return m, nil + var cmd tea.Cmd + m.current.stopwatch, cmd = m.current.stopwatch.Update(msg) + + return m, cmd } // View draws the song view diff --git a/ui/view/song/style.go b/ui/view/song/style.go index d25ecef..783dbf0 100644 --- a/ui/view/song/style.go +++ b/ui/view/song/style.go @@ -9,7 +9,7 @@ var ( ) // Base style -var sBase = lipgloss.NewStyle() +var base = lipgloss.NewStyle() // Styles for the stats var ( @@ -18,17 +18,24 @@ var ( wStatAmount = 4 wStatBody = wStatTotal - wStatEnum - wStatAmount - sStat = sBase.Width(wStatTotal).MarginRight(3).MarginBottom(2) - sStatTitle = sBase.Foreground(cZeus).Width(wStatTotal).Align(lipgloss.Center). + 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 = sBase.Foreground(cSpotify).Width(wStatEnum).Align(lipgloss.Left) - sStatBody = sBase.Width(wStatBody) - sStatAmount = sBase.Width(wStatAmount).Align(lipgloss.Right).Foreground(cZeus) + 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 ( + sStatusSong = base + sStatusStopwatch = base.Faint(true) + sStatusProgress = base ) // Styles for the lyrics var ( - sLyricBase = sBase.Width(50).Align(lipgloss.Center) + sLyricBase = base.Width(50).Align(lipgloss.Center) sLyricPrevious = sLyricBase.Foreground(cZeus).Faint(true) sLyricCurrent = sLyricBase.Foreground(cZeus) sLyricUpcoming = sLyricBase.Foreground(cSpotify) diff --git a/ui/view/song/view.go b/ui/view/song/view.go index 52ad541..8c1949c 100644 --- a/ui/view/song/view.go +++ b/ui/view/song/view.go @@ -2,12 +2,53 @@ package song import ( "fmt" + "math" "strings" "github.com/charmbracelet/lipgloss" ) func (m *Model) viewPlaying() string { + status := m.viewPlayingStatus() + lyrics := m.viewPlayingLyrics() + + view := lipgloss.JoinVertical(lipgloss.Left, status, lyrics) + + return 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(m.width - lipgloss.Width(stopwatch)).Render(fmt.Sprintf("%s | %s", m.current.song.Title, artist)) + + // Progress bar + // zap.S().Info(m.current.lyrics.Progress()) + // progress := sStatusProgress.Width(m.width).Render(m.current.progress.ViewAs(m.current.lyrics.Progress())) + // zap.S().Info(progress) + + progress := sStatusProgress.Width(m.width).Render(strings.Repeat("▄", int(m.current.lyrics.Progress()*float64(m.width)))) + + 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) @@ -26,7 +67,7 @@ func (m *Model) viewPlaying() string { } upcoming := sLyricUpcoming.Render(upcomingB.String()) - return sBase.MarginLeft(5).Render(lipgloss.JoinVertical(lipgloss.Left, previous, current, upcoming)) + return base.MarginLeft(5).Render(lipgloss.JoinVertical(lipgloss.Left, previous, current, upcoming)) } func (m *Model) viewNotPlaying() string { @@ -78,5 +119,7 @@ func (m *Model) viewNotPlaying() string { renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, row...)) } - return lipgloss.JoinVertical(lipgloss.Left, renderedRows...) + view := lipgloss.JoinVertical(lipgloss.Left, renderedRows...) + + return view } From acdd8f66a7fa29d8b211fa228e5ebe7034abce8e Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 8 Dec 2024 18:57:58 +0100 Subject: [PATCH 37/46] feat(db): switch to postgres --- .gitignore | 10 +- README.md | 7 +- cmd/backend/backend.go | 54 ++++++++ cmd/tui/tui.go | 39 ++++++ .../20241114125504_add_messages_table.sql | 4 +- .../20241114135818_add_spotify_table.sql | 4 +- .../20241114152122_add_tap_table.sql | 6 +- .../20241121141143_add_zess_table.sql | 8 +- .../20241125113707_add_gamification_table.sql | 2 +- .../20241127133125_add_events_table.sql | 4 +- .../20241127162048_add_song_history_table.sql | 6 +- .../20241127165609_add_song_genre.sql | 16 +-- .../20241203173952_event_add_poster.sql | 2 +- ...20241203231431_gamification_add_avatar.sql | 2 +- db/queries/event.sql | 8 +- db/queries/gamification.sql | 8 +- db/queries/message.sql | 12 +- db/queries/scan.sql | 12 +- db/queries/season.sql | 12 +- db/queries/song.sql | 20 +-- db/queries/tap.sql | 16 +-- docker-compose.yml | 14 ++ go.mod | 13 +- go.sum | 35 ++--- internal/cmd/tui.go | 10 +- internal/pkg/db/db.go | 26 +++- internal/pkg/db/dto/event.go | 7 +- internal/pkg/db/dto/gamification.go | 4 +- internal/pkg/db/dto/message.go | 4 +- internal/pkg/db/dto/scan.go | 11 +- internal/pkg/db/dto/season.go | 15 ++- internal/pkg/db/dto/song.go | 42 +++--- internal/pkg/db/sqlc/db.go | 13 +- internal/pkg/db/sqlc/event.sql.go | 36 ++---- internal/pkg/db/sqlc/gamification.sql.go | 38 +++--- internal/pkg/db/sqlc/message.sql.go | 42 +++--- internal/pkg/db/sqlc/models.go | 67 +++++----- internal/pkg/db/sqlc/scan.sql.go | 51 ++++---- internal/pkg/db/sqlc/season.sql.go | 56 ++++---- internal/pkg/db/sqlc/song.sql.go | 121 ++++++++---------- internal/pkg/db/sqlc/tap.sql.go | 73 +++++------ internal/pkg/gamification/api.go | 4 +- internal/pkg/song/api.go | 6 +- internal/pkg/song/song.go | 10 +- internal/pkg/tap/api.go | 2 +- internal/pkg/tap/tap.go | 7 +- internal/pkg/zess/zess.go | 11 +- makefile | 36 ++++-- sqlc.yml | 3 +- {ui => tui}/components/stopwatch/stopwatch.go | 0 {ui => tui}/screen/cammie/cammie.go | 14 +- {ui => tui}/screen/cammie/style.go | 0 {ui => tui}/screen/screen.go | 2 +- {ui => tui}/screen/song/song.go | 6 +- {ui => tui}/screen/song/style.go | 0 {ui => tui}/tui.go | 2 +- {ui => tui}/view/event/event.go | 2 +- {ui => tui}/view/event/style.go | 0 {ui => tui}/view/event/view.go | 2 +- {ui => tui}/view/gamification/gamification.go | 6 +- {ui => tui}/view/gamification/styles.go | 0 {ui => tui}/view/message/message.go | 12 +- {ui => tui}/view/message/style.go | 0 {ui => tui}/view/message/view.go | 0 {ui => tui}/view/song/song.go | 16 +-- {ui => tui}/view/song/style.go | 0 {ui => tui}/view/song/util.go | 0 {ui => tui}/view/song/view.go | 0 {ui => tui}/view/tap/style.go | 0 {ui => tui}/view/tap/tap.go | 12 +- {ui => tui}/view/tap/view.go | 0 {ui => tui}/view/util.go | 0 {ui => tui}/view/view.go | 0 {ui => tui}/view/zess/style.go | 0 {ui => tui}/view/zess/view.go | 0 {ui => tui}/view/zess/zess.go | 18 +-- 76 files changed, 579 insertions(+), 522 deletions(-) create mode 100644 cmd/backend/backend.go create mode 100644 cmd/tui/tui.go create mode 100644 docker-compose.yml rename {ui => tui}/components/stopwatch/stopwatch.go (100%) rename {ui => tui}/screen/cammie/cammie.go (94%) rename {ui => tui}/screen/cammie/style.go (100%) rename {ui => tui}/screen/screen.go (89%) rename {ui => tui}/screen/song/song.go (94%) rename {ui => tui}/screen/song/style.go (100%) rename {ui => tui}/tui.go (96%) rename {ui => tui}/view/event/event.go (98%) rename {ui => tui}/view/event/style.go (100%) rename {ui => tui}/view/event/view.go (98%) rename {ui => tui}/view/gamification/gamification.go (97%) rename {ui => tui}/view/gamification/styles.go (100%) rename {ui => tui}/view/message/message.go (94%) rename {ui => tui}/view/message/style.go (100%) rename {ui => tui}/view/message/view.go (100%) rename {ui => tui}/view/song/song.go (94%) rename {ui => tui}/view/song/style.go (100%) rename {ui => tui}/view/song/util.go (100%) rename {ui => tui}/view/song/view.go (100%) rename {ui => tui}/view/tap/style.go (100%) rename {ui => tui}/view/tap/tap.go (94%) rename {ui => tui}/view/tap/view.go (100%) rename {ui => tui}/view/util.go (100%) rename {ui => tui}/view/view.go (100%) rename {ui => tui}/view/zess/style.go (100%) rename {ui => tui}/view/zess/view.go (100%) rename {ui => tui}/view/zess/zess.go (94%) diff --git a/.gitignore b/.gitignore index 115f324..9191adc 100644 --- a/.gitignore +++ b/.gitignore @@ -28,16 +28,10 @@ tmp/ !.env.example # Project build -backend -tui +backend_bin +tui_bin .data/ # Log files logs/ - -# DB -*.db - -# UI Test file -ui/screen/test.go diff --git a/README.md b/README.md index 2dacb28..d0176ca 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,12 @@ Displays the cammie chat along with some other statistics. ## DB -This project uses an SQLite database. +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. @@ -60,9 +61,9 @@ 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](./ui/screen/), and you must add them to the startup command list in [tui.go](./internal/cmd/tui.go). +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](./ui/view/). +A screen is responsible for creating and managing the layout, consisting of various [views](./tui/view/). ### Logs diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go new file mode 100644 index 0000000..678b4e4 --- /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/db/migrations/20241114125504_add_messages_table.sql b/db/migrations/20241114125504_add_messages_table.sql index 2777928..8060357 100644 --- a/db/migrations/20241114125504_add_messages_table.sql +++ b/db/migrations/20241114125504_add_messages_table.sql @@ -1,11 +1,11 @@ -- +goose Up -- +goose StatementBegin CREATE TABLE IF NOT EXISTS message ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, name TEXT NOT NULL, ip TEXT NOT NULL, message TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- +goose StatementEnd diff --git a/db/migrations/20241114135818_add_spotify_table.sql b/db/migrations/20241114135818_add_spotify_table.sql index acbee16..35802e9 100644 --- a/db/migrations/20241114135818_add_spotify_table.sql +++ b/db/migrations/20241114135818_add_spotify_table.sql @@ -1,12 +1,12 @@ -- +goose Up -- +goose StatementBegin CREATE TABLE IF NOT EXISTS spotify ( - id INTEGER PRIMARY KEY, + 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 NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- +goose StatementEnd diff --git a/db/migrations/20241114152122_add_tap_table.sql b/db/migrations/20241114152122_add_tap_table.sql index 0492937..61d6a24 100644 --- a/db/migrations/20241114152122_add_tap_table.sql +++ b/db/migrations/20241114152122_add_tap_table.sql @@ -1,12 +1,12 @@ -- +goose Up -- +goose StatementBegin CREATE TABLE IF NOT EXISTS tap ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, order_id INTEGER NOT NULL, - order_created_at TIMESTAMP NOT NULL, + order_created_at TIMESTAMP WITH TIME ZONE NOT NULL, name TEXT NOT NULL, category TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ) -- +goose StatementEnd diff --git a/db/migrations/20241121141143_add_zess_table.sql b/db/migrations/20241121141143_add_zess_table.sql index d679be8..a5a6f67 100644 --- a/db/migrations/20241121141143_add_zess_table.sql +++ b/db/migrations/20241121141143_add_zess_table.sql @@ -1,15 +1,15 @@ -- +goose Up -- +goose StatementBegin CREATE TABLE IF NOT EXISTS season ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, - start TIMESTAMP NOT NULL, - end TIMESTAMP NOT NULL + start TIMESTAMP WITH TIME ZONE NOT NULL, + "end" TIMESTAMP WITH TIME ZONE NOT NULL ); CREATE TABLE IF NOT EXISTS scan ( id INTEGER PRIMARY KEY, - scan_time TIMESTAMP NOT NULL + scan_time TIMESTAMP WITH TIME ZONE NOT NULL ); -- +goose StatementEnd diff --git a/db/migrations/20241125113707_add_gamification_table.sql b/db/migrations/20241125113707_add_gamification_table.sql index 46ba33f..2da01bf 100644 --- a/db/migrations/20241125113707_add_gamification_table.sql +++ b/db/migrations/20241125113707_add_gamification_table.sql @@ -1,7 +1,7 @@ -- +goose Up -- +goose StatementBegin CREATE TABLE IF NOT EXISTS gamification ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, score INTEGER NOT NULL, avatar VARCHAR(255) NOT NULL diff --git a/db/migrations/20241127133125_add_events_table.sql b/db/migrations/20241127133125_add_events_table.sql index a083569..11369d4 100644 --- a/db/migrations/20241127133125_add_events_table.sql +++ b/db/migrations/20241127133125_add_events_table.sql @@ -1,9 +1,9 @@ -- +goose Up -- +goose StatementBegin CREATE TABLE IF NOT EXISTS event ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, name TEXT NOT NULL, - date TIMESTAMP NOT NULL, + date TIMESTAMP WITH TIME ZONE NOT NULL, academic_year TEXT NOT NULL, location TEXT NOT NULL ); diff --git a/db/migrations/20241127162048_add_song_history_table.sql b/db/migrations/20241127162048_add_song_history_table.sql index 03cc0a6..aedaaa9 100644 --- a/db/migrations/20241127162048_add_song_history_table.sql +++ b/db/migrations/20241127162048_add_song_history_table.sql @@ -6,9 +6,9 @@ DROP COLUMN created_at; ALTER TABLE spotify RENAME TO song; CREATE TABLE IF NOT EXISTS song_history ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, song_id INTEGER NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(song_id) REFERENCES song(id) ); -- +goose StatementEnd @@ -20,5 +20,5 @@ DROP TABLE IF EXISTS song_history; ALTER TABLE song RENAME TO spotify; ALTER TABLE spotify -ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; +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 index 75b1b12..5f214b4 100644 --- a/db/migrations/20241127165609_add_song_genre.sql +++ b/db/migrations/20241127165609_add_song_genre.sql @@ -4,12 +4,12 @@ ALTER TABLE song DROP COLUMN artists; CREATE TABLE IF NOT EXISTS song_genre ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, genre TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS song_artist ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, name TEXT NOT NULL, spotify_id TEXT NOT NULL, followers INTEGER NOT NULL, @@ -17,19 +17,19 @@ CREATE TABLE IF NOT EXISTS song_artist ( ); CREATE TABLE IF NOT EXISTS song_artist_song ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, artist_id INTEGER NOT NULL, song_id INTEGER NOT NULL, - FOREIGN KEY(artist_id) REFERENCES artist(id), - FOREIGN KEY(song_id) REFERENCES song(id) + 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 INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, artist_id INTEGER NOT NULL, genre_id INTEGER NOT NULL, - FOREIGN KEY(artist_id) REFERENCES artist(id), - FOREIGN KEY(genre_id) REFERENCES genre(id) + FOREIGN KEY(artist_id) REFERENCES song_artist(id) ON DELETE CASCADE, + FOREIGN KEY(genre_id) REFERENCES song_genre(id) ON DELETE CASCADE ); -- +goose StatementEnd diff --git a/db/migrations/20241203173952_event_add_poster.sql b/db/migrations/20241203173952_event_add_poster.sql index e063186..3e195e5 100644 --- a/db/migrations/20241203173952_event_add_poster.sql +++ b/db/migrations/20241203173952_event_add_poster.sql @@ -1,7 +1,7 @@ -- +goose Up -- +goose StatementBegin ALTER TABLE event -ADD COLUMN poster BLOB; +ADD COLUMN poster BYTEA; -- +goose StatementEnd -- +goose Down diff --git a/db/migrations/20241203231431_gamification_add_avatar.sql b/db/migrations/20241203231431_gamification_add_avatar.sql index bcaab0b..0da53a0 100644 --- a/db/migrations/20241203231431_gamification_add_avatar.sql +++ b/db/migrations/20241203231431_gamification_add_avatar.sql @@ -4,7 +4,7 @@ ALTER TABLE gamification DROP COLUMN avatar; ALTER TABLE gamification -ADD COLUMN avatar BLOB; +ADD COLUMN avatar BYTEA; -- +goose StatementEnd -- +goose Down diff --git a/db/queries/event.sql b/db/queries/event.sql index c5b52e5..9646c2b 100644 --- a/db/queries/event.sql +++ b/db/queries/event.sql @@ -7,12 +7,12 @@ FROM event; -- name: CreateEvent :one INSERT INTO event (name, date, academic_year, location, poster) -VALUES (?, ?, ?, ?, ?) +VALUES ($1, $2, $3, $4, $5) RETURNING *; -- name: DeleteEvent :exec DELETE FROM event -WHERE id = ?; +WHERE id = $1; -- Other @@ -21,11 +21,11 @@ WHERE id = ?; -- name: GetEventByAcademicYear :many SELECT * FROM event -WHERE academic_year = ?; +WHERE academic_year = $1; -- name: DeleteEventByAcademicYear :exec DELETE FROM event -WHERE academic_year = ?; +WHERE academic_year = $1; -- name: GetEventsCurrentAcademicYear :many SELECT * diff --git a/db/queries/gamification.sql b/db/queries/gamification.sql index 3d54972..3113a19 100644 --- a/db/queries/gamification.sql +++ b/db/queries/gamification.sql @@ -6,12 +6,12 @@ FROM gamification; -- name: CreateGamification :one INSERT INTO gamification (name, score, avatar) -VALUES (?, ?, ?) +VALUES ($1, $2, $3) RETURNING *; -- name: DeleteGamification :execrows DELETE FROM gamification -WHERE id = ?; +WHERE id = $1; -- name: DeleteGamificationAll :execrows DELETE FROM gamification; @@ -22,8 +22,8 @@ DELETE FROM gamification; -- name: UpdateGamificationScore :one UPDATE gamification -SET score = ? -WHERE id = ? +SET score = $1 +WHERE id = $2 RETURNING *; -- name: GetAllGamificationByScore :many diff --git a/db/queries/message.sql b/db/queries/message.sql index 5617dd1..2b615ed 100644 --- a/db/queries/message.sql +++ b/db/queries/message.sql @@ -7,22 +7,22 @@ FROM message; -- name: GetMessageByID :one SELECT * FROM message -WHERE id = ?; +WHERE id = $1; -- name: CreateMessage :one INSERT INTO message (name, ip, message) -VALUES (?, ?, ?) +VALUES ($1, $2, $3) RETURNING *; -- name: UpdateMessage :one UPDATE message -SET name = ?, ip = ?, message = ? -WHERE id = ? +SET name = $1, ip = $2, message = $3 +WHERE id = $4 RETURNING *; -- name: DeleteMessage :execrows DELETE FROM message -WHERE id = ?; +WHERE id = $1; -- Other @@ -37,5 +37,5 @@ LIMIT 1; -- name: GetMessageSinceID :many SELECT * FROM message -WHERE id > ? +WHERE id > $1 ORDER BY created_at ASC; diff --git a/db/queries/scan.sql b/db/queries/scan.sql index f30cecf..7c9665a 100644 --- a/db/queries/scan.sql +++ b/db/queries/scan.sql @@ -7,22 +7,22 @@ FROM scan; -- name: GetScanByID :one SELECT * FROM scan -WHERE id = ?; +WHERE id = $1; -- name: CreateScan :one INSERT INTO scan (scan_time) -VALUES (?) +VALUES ($1) RETURNING *; -- name: UpdateScan :one UPDATE scan -SET scan_time = ? -WHERE id = ? +SET scan_time = $1 +WHERE id = $2 RETURNING *; -- name: DeleteScan :execrows DELETE FROM scan -WHERE id = ?; +WHERE id = $1; -- Other @@ -37,7 +37,7 @@ LIMIT 1; -- name: GetAllScansSinceID :many SELECT * FROM scan -WHERE id > ? +WHERE id > $1 ORDER BY scan_time ASC; -- name: GetScansInCurrentSeason :one diff --git a/db/queries/season.sql b/db/queries/season.sql index a39f2fc..90682ad 100644 --- a/db/queries/season.sql +++ b/db/queries/season.sql @@ -7,22 +7,22 @@ FROM season; -- name: GetSeasonByID :one SELECT * FROM season -WHERE id = ?; +WHERE id = $1; -- name: CreateSeason :one -INSERT INTO season (name, start, end, current) -VALUES (?, ?, ?, ?) +INSERT INTO season (name, start, "end", current) +VALUES ($1, $2, $3, $4) RETURNING *; -- name: UpdateSeason :one UPDATE season -SET name = ?, start = ?, end = ?, current = ? -WHERE id = ? +SET name = $1, start = $2, "end" = $3, current = $4 +WHERE id = $5 RETURNING *; -- name: DeleteSeason :execrows DELETE FROM season -WHERE id = ?; +WHERE id = $1; -- Other diff --git a/db/queries/song.sql b/db/queries/song.sql index 02db229..7e03b50 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -2,32 +2,32 @@ -- name: CreateSong :one INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) -VALUES (?, ?, ?, ?, ?, ?) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: CreateSongHistory :one INSERT INTO song_history (song_id) -VALUES (?) +VALUES ($1) RETURNING *; -- name: CreateSongGenre :one INSERT INTO song_genre (genre) -VALUES (?) +VALUES ($1) RETURNING *; -- name: CreateSongArtist :one INSERT INTO song_artist (name, spotify_id, followers, popularity) -VALUES (?, ?, ?, ?) +VALUES ($1, $2, $3, $4) RETURNING *; -- name: CreateSongArtistSong :one INSERT INTO song_artist_song (artist_id, song_id) -VALUES (?, ?) +VALUES ($1, $2) RETURNING *; -- name: CreateSongArtistGenre :one INSERT INTO song_artist_genre (artist_id, genre_id) -VALUES (?, ?) +VALUES ($1, $2) RETURNING *; @@ -36,12 +36,12 @@ RETURNING *; -- name: GetSongBySpotifyID :one SELECT * FROM song -WHERE spotify_id = ?; +WHERE spotify_id = $1; -- name: GetSongArtistBySpotifyID :one SELECT * FROM song_artist -WHERE spotify_id = ?; +WHERE spotify_id = $1; -- name: GetLastSongHistory :one SELECT * @@ -52,12 +52,12 @@ LIMIT 1; -- name: GetSongGenreByName :one SELECT * FROM song_genre -WHERE genre = ?; +WHERE genre = $1; -- name: GetSongArtistByName :one SELECT * FROM song_artist -WHERE name = ?; +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 diff --git a/db/queries/tap.sql b/db/queries/tap.sql index 742aa7f..88394e7 100644 --- a/db/queries/tap.sql +++ b/db/queries/tap.sql @@ -7,22 +7,22 @@ FROM tap; -- name: GetTapByID :one SELECT * FROM tap -WHERE id = ?; +WHERE id = $1; -- name: CreateTap :one INSERT INTO tap (order_id, order_created_at, name, category) -VALUES (?, ?, ?, ?) +VALUES ($1, $2, $3, $4) RETURNING *; -- name: UpdateTap :one UPDATE tap -SET order_id = ?, order_created_at = ?, name = ?, category = ? -WHERE id = ? +SET order_id = $1, order_created_at = $2, name = $3, category = $4 +WHERE id = $5 RETURNING *; -- name: DeleteTap :execrows DELETE FROM tap -WHERE id = ?; +WHERE id = $1; -- Other @@ -31,12 +31,12 @@ WHERE id = ?; -- name: GetTapByOrderID :one SELECT * FROM tap -WHERE order_id = ?; +WHERE order_id = $1; -- name: GetTapByCategory :many SELECT * FROM tap -WHERE category = ?; +WHERE category = $1; -- name: GetLastOrderByOrderID :one SELECT * @@ -52,5 +52,5 @@ GROUP BY category; -- name: GetOrderCountByCategorySinceOrderID :many SELECT category, COUNT(*), CAST(MAX(order_created_at) AS INTEGER) AS latest_order_created_at FROM tap -WHERE order_id >= ? +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 409d5d2..202bb50 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.1 require ( github.com/NimbleMarkets/ntcharts v0.1.2 + github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.1.0 github.com/charmbracelet/lipgloss v1.0.0 github.com/disintegration/imaging v1.6.2 @@ -11,14 +12,20 @@ require ( 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/mattn/go-sqlite3 v1.14.24 github.com/spf13/viper v1.19.0 go.uber.org/zap v1.27.0 ) +require ( + 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 +) + require ( github.com/PuerkitoBio/goquery v1.10.0 // indirect github.com/andybalholm/brotli v1.0.5 // indirect @@ -27,11 +34,9 @@ require ( 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/bubbles v0.20.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.4.2 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect - github.com/containerd/console v1.0.4 // 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 @@ -54,7 +59,6 @@ require ( 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/reflow v0.3.0 // 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 @@ -77,7 +81,6 @@ require ( golang.org/x/net v0.29.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.25.0 // indirect - golang.org/x/term v0.24.0 // indirect golang.org/x/text v0.18.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.33.0 // indirect diff --git a/go.sum b/go.sum index b07f473..f5151f6 100644 --- a/go.sum +++ b/go.sum @@ -14,14 +14,8 @@ 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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 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 v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= -github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= @@ -30,13 +24,8 @@ github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= -github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= -github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -80,6 +69,14 @@ 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= @@ -105,21 +102,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE 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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 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= @@ -127,7 +117,6 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -154,6 +143,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS 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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -206,7 +196,6 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -216,8 +205,6 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= -golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= 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= @@ -239,8 +226,8 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ 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-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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= diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index 98cfb46..ea097d5 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -9,11 +9,11 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/pkg/util" - tui "github.com/zeusWPI/scc/ui" - "github.com/zeusWPI/scc/ui/screen" - "github.com/zeusWPI/scc/ui/screen/cammie" - songScreen "github.com/zeusWPI/scc/ui/screen/song" - "github.com/zeusWPI/scc/ui/view" + "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" ) diff --git a/internal/pkg/db/db.go b/internal/pkg/db/db.go index 6de1d04..35b2223 100644 --- a/internal/pkg/db/db.go +++ b/internal/pkg/db/db.go @@ -2,30 +2,42 @@ package db import ( - "database/sql" + "context" - _ "github.com/mattn/go-sqlite3" // SQLite driver + "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 { - DB *sql.DB + Pool *pgxpool.Pool Queries *sqlc.Queries } // New creates a new database connection func New() (*DB, error) { - db, err := sql.Open("sqlite3", "./sqlite.db") + pgConfig, err := pgxpool.ParseConfig("") if err != nil { return nil, err } - if err := db.Ping(); err != nil { + 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(db) + queries := sqlc.New(pool) - return &DB{DB: db, Queries: queries}, nil + return &DB{Pool: pool, Queries: queries}, nil } diff --git a/internal/pkg/db/dto/event.go b/internal/pkg/db/dto/event.go index 59226c9..5fb2614 100644 --- a/internal/pkg/db/dto/event.go +++ b/internal/pkg/db/dto/event.go @@ -3,12 +3,13 @@ package dto import ( "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 int64 + ID int32 Name string Date time.Time AcademicYear string @@ -21,7 +22,7 @@ func EventDTO(e sqlc.Event) *Event { return &Event{ ID: e.ID, Name: e.Name, - Date: e.Date, + Date: e.Date.Time, AcademicYear: e.AcademicYear, Location: e.Location, Poster: e.Poster, @@ -37,7 +38,7 @@ func (e *Event) Equal(e2 Event) bool { func (e *Event) CreateParams() sqlc.CreateEventParams { return sqlc.CreateEventParams{ Name: e.Name, - Date: e.Date, + 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 index 3ee8548..bb2181e 100644 --- a/internal/pkg/db/dto/gamification.go +++ b/internal/pkg/db/dto/gamification.go @@ -8,9 +8,9 @@ import ( // Gamification represents the DTO object for gamification type Gamification struct { - ID int64 `json:"id"` + ID int32 `json:"id"` Name string `json:"github_name"` - Score int64 `json:"score"` + Score int32 `json:"score"` Avatar []byte `json:"avatar"` } diff --git a/internal/pkg/db/dto/message.go b/internal/pkg/db/dto/message.go index fa20de9..75672a8 100644 --- a/internal/pkg/db/dto/message.go +++ b/internal/pkg/db/dto/message.go @@ -8,7 +8,7 @@ import ( // Message is the DTO for the message type Message struct { - ID int64 `json:"id"` + ID int32 `json:"id"` Name string `json:"name" validate:"required"` IP string `json:"ip" validate:"required"` Message string `json:"message" validate:"required"` @@ -22,7 +22,7 @@ func MessageDTO(message sqlc.Message) *Message { Name: message.Name, IP: message.Ip, Message: message.Message, - CreatedAt: message.CreatedAt, + CreatedAt: message.CreatedAt.Time, } } diff --git a/internal/pkg/db/dto/scan.go b/internal/pkg/db/dto/scan.go index 63628fc..ea0183c 100644 --- a/internal/pkg/db/dto/scan.go +++ b/internal/pkg/db/dto/scan.go @@ -3,12 +3,13 @@ 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 int64 `json:"id"` + ID int32 `json:"id"` ScanTime time.Time `json:"scan_time" validate:"required"` } @@ -16,19 +17,19 @@ type Scan struct { func ScanDTO(scan sqlc.Scan) *Scan { return &Scan{ ID: scan.ID, - ScanTime: scan.ScanTime, + ScanTime: scan.ScanTime.Time, } } // CreateParams converts a Scan to sqlc.CreateScanParams -func (s *Scan) CreateParams() time.Time { - return s.ScanTime +func (s *Scan) CreateParams() pgtype.Timestamptz { + return pgtype.Timestamptz{Time: s.ScanTime} } // UpdateParams converts a Scan to sqlc.UpdateScanParams func (s *Scan) UpdateParams() sqlc.UpdateScanParams { return sqlc.UpdateScanParams{ ID: s.ID, - ScanTime: s.ScanTime, + ScanTime: pgtype.Timestamptz{Time: s.ScanTime, Valid: true}, } } diff --git a/internal/pkg/db/dto/season.go b/internal/pkg/db/dto/season.go index 9dbab42..6b33e17 100644 --- a/internal/pkg/db/dto/season.go +++ b/internal/pkg/db/dto/season.go @@ -3,12 +3,13 @@ package dto import ( "time" + "github.com/jackc/pgx/v5/pgtype" "github.com/zeusWPI/scc/internal/pkg/db/sqlc" ) // Season is the DTO for the season type Season struct { - ID int64 `json:"id"` + ID int32 `json:"id"` Name string `json:"name" validate:"required"` Start time.Time `json:"start" validate:"required"` End time.Time `json:"end" validate:"required"` @@ -20,8 +21,8 @@ func SeasonDTO(season sqlc.Season) *Season { return &Season{ ID: season.ID, Name: season.Name, - Start: season.Start, - End: season.End, + Start: season.Start.Time, + End: season.End.Time, Current: season.Current, } } @@ -40,8 +41,8 @@ func SeasonCmp(s1, s2 *Season) int { func (s *Season) CreateParams() sqlc.CreateSeasonParams { return sqlc.CreateSeasonParams{ Name: s.Name, - Start: s.Start, - End: s.End, + Start: pgtype.Timestamptz{Time: s.Start, Valid: true}, + End: pgtype.Timestamptz{Time: s.End, Valid: true}, Current: s.Current, } } @@ -51,8 +52,8 @@ func (s *Season) UpdateParams() sqlc.UpdateSeasonParams { return sqlc.UpdateSeasonParams{ ID: s.ID, Name: s.Name, - Start: s.Start, - End: s.End, + Start: pgtype.Timestamptz{Time: s.Start, Valid: true}, + End: pgtype.Timestamptz{Time: s.End, Valid: true}, Current: s.Current, } } diff --git a/internal/pkg/db/dto/song.go b/internal/pkg/db/dto/song.go index 4839db0..b678297 100644 --- a/internal/pkg/db/dto/song.go +++ b/internal/pkg/db/dto/song.go @@ -1,19 +1,19 @@ package dto import ( - "database/sql" "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 int64 `json:"id"` + ID int32 `json:"id"` Title string `json:"title"` Album string `json:"album"` SpotifyID string `json:"spotify_id" validate:"required"` - DurationMS int64 `json:"duration_ms"` + DurationMS int32 `json:"duration_ms"` LyricsType string `json:"lyrics_type"` // Either 'synced' or 'plain' Lyrics string `json:"lyrics"` CreatedAt time.Time `json:"created_at"` @@ -22,17 +22,17 @@ type Song struct { // SongArtist is the DTO for a song artist type SongArtist struct { - ID int64 `json:"id"` + ID int32 `json:"id"` Name string `json:"name"` SpotifyID string `json:"spotify_id"` - Followers int64 `json:"followers"` - Popularity int64 `json:"popularity"` + Followers int32 `json:"followers"` + Popularity int32 `json:"popularity"` Genres []SongGenre `json:"genres"` } // SongGenre is the DTO for a song genre type SongGenre struct { - ID int64 `json:"id"` + ID int32 `json:"id"` Genre string `json:"genre"` } @@ -73,30 +73,30 @@ func SongDTOHistory(songs []sqlc.GetLastSongFullRow) *Song { lyrics = songs[0].Lyrics.String } - artistsMap := make(map[int64]SongArtist) + artistsMap := make(map[int32]SongArtist) for _, song := range songs { if !song.ArtistID.Valid { continue } // Get artist - artist, ok := artistsMap[song.ArtistID.Int64] + artist, ok := artistsMap[song.ArtistID.Int32] if !ok { // Artist doesn't exist yet, add him artist = SongArtist{ - ID: song.ArtistID.Int64, + ID: song.ArtistID.Int32, Name: song.ArtistName.String, SpotifyID: song.ArtistSpotifyID.String, - Followers: song.ArtistFollowers.Int64, - Popularity: song.ArtistPopularity.Int64, + Followers: song.ArtistFollowers.Int32, + Popularity: song.ArtistPopularity.Int32, Genres: make([]SongGenre, 0), } - artistsMap[song.ArtistID.Int64] = artist + artistsMap[song.ArtistID.Int32] = artist } // Add genre artist.Genres = append(artist.Genres, SongGenre{ - ID: song.GenreID.Int64, + ID: song.GenreID.Int32, Genre: song.Genre.String, }) } @@ -114,28 +114,20 @@ func SongDTOHistory(songs []sqlc.GetLastSongFullRow) *Song { DurationMS: songs[0].DurationMs, LyricsType: lyricsType, Lyrics: lyrics, - CreatedAt: songs[0].CreatedAt, + CreatedAt: songs[0].CreatedAt.Time, Artists: artists, } } // CreateSongParams converts a Song DTO to a sqlc CreateSongParams object func (s *Song) CreateSongParams() *sqlc.CreateSongParams { - lyricsType := sql.NullString{String: s.LyricsType, Valid: false} - if s.LyricsType != "" { - lyricsType.Valid = true - } - lyrics := sql.NullString{String: s.Lyrics, Valid: false} - if s.Lyrics != "" { - lyrics.Valid = true - } return &sqlc.CreateSongParams{ Title: s.Title, Album: s.Album, SpotifyID: s.SpotifyID, DurationMs: s.DurationMS, - LyricsType: lyricsType, - Lyrics: lyrics, + LyricsType: pgtype.Text{String: s.LyricsType, Valid: true}, + Lyrics: pgtype.Text{String: s.Lyrics, Valid: true}, } } diff --git a/internal/pkg/db/sqlc/db.go b/internal/pkg/db/sqlc/db.go index 2248616..b931bc5 100644 --- a/internal/pkg/db/sqlc/db.go +++ b/internal/pkg/db/sqlc/db.go @@ -6,14 +6,15 @@ package sqlc import ( "context" - "database/sql" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" ) type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row + 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 { @@ -24,7 +25,7 @@ type Queries struct { db DBTX } -func (q *Queries) WithTx(tx *sql.Tx) *Queries { +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 index a6983e4..02a56b8 100644 --- a/internal/pkg/db/sqlc/event.sql.go +++ b/internal/pkg/db/sqlc/event.sql.go @@ -7,25 +7,26 @@ package sqlc import ( "context" - "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createEvent = `-- name: CreateEvent :one INSERT INTO event (name, date, academic_year, location, poster) -VALUES (?, ?, ?, ?, ?) +VALUES ($1, $2, $3, $4, $5) RETURNING id, name, date, academic_year, location, poster ` type CreateEventParams struct { Name string - Date time.Time + Date pgtype.Timestamptz AcademicYear string Location string Poster []byte } func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { - row := q.db.QueryRowContext(ctx, createEvent, + row := q.db.QueryRow(ctx, createEvent, arg.Name, arg.Date, arg.AcademicYear, @@ -46,21 +47,21 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event const deleteEvent = `-- name: DeleteEvent :exec DELETE FROM event -WHERE id = ? +WHERE id = $1 ` -func (q *Queries) DeleteEvent(ctx context.Context, id int64) error { - _, err := q.db.ExecContext(ctx, deleteEvent, id) +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 = ? +WHERE academic_year = $1 ` func (q *Queries) DeleteEventByAcademicYear(ctx context.Context, academicYear string) error { - _, err := q.db.ExecContext(ctx, deleteEventByAcademicYear, academicYear) + _, err := q.db.Exec(ctx, deleteEventByAcademicYear, academicYear) return err } @@ -73,7 +74,7 @@ FROM event // CRUD func (q *Queries) GetAllEvents(ctx context.Context) ([]Event, error) { - rows, err := q.db.QueryContext(ctx, getAllEvents) + rows, err := q.db.Query(ctx, getAllEvents) if err != nil { return nil, err } @@ -93,9 +94,6 @@ func (q *Queries) GetAllEvents(ctx context.Context) ([]Event, error) { } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -107,12 +105,12 @@ const getEventByAcademicYear = `-- name: GetEventByAcademicYear :many SELECT id, name, date, academic_year, location, poster FROM event -WHERE academic_year = ? +WHERE academic_year = $1 ` // Other func (q *Queries) GetEventByAcademicYear(ctx context.Context, academicYear string) ([]Event, error) { - rows, err := q.db.QueryContext(ctx, getEventByAcademicYear, academicYear) + rows, err := q.db.Query(ctx, getEventByAcademicYear, academicYear) if err != nil { return nil, err } @@ -132,9 +130,6 @@ func (q *Queries) GetEventByAcademicYear(ctx context.Context, academicYear strin } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -152,7 +147,7 @@ ORDER BY date ASC ` func (q *Queries) GetEventsCurrentAcademicYear(ctx context.Context) ([]Event, error) { - rows, err := q.db.QueryContext(ctx, getEventsCurrentAcademicYear) + rows, err := q.db.Query(ctx, getEventsCurrentAcademicYear) if err != nil { return nil, err } @@ -172,9 +167,6 @@ func (q *Queries) GetEventsCurrentAcademicYear(ctx context.Context) ([]Event, er } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } diff --git a/internal/pkg/db/sqlc/gamification.sql.go b/internal/pkg/db/sqlc/gamification.sql.go index 87a8735..50d75df 100644 --- a/internal/pkg/db/sqlc/gamification.sql.go +++ b/internal/pkg/db/sqlc/gamification.sql.go @@ -11,18 +11,18 @@ import ( const createGamification = `-- name: CreateGamification :one INSERT INTO gamification (name, score, avatar) -VALUES (?, ?, ?) +VALUES ($1, $2, $3) RETURNING id, name, score, avatar ` type CreateGamificationParams struct { Name string - Score int64 + Score int32 Avatar []byte } func (q *Queries) CreateGamification(ctx context.Context, arg CreateGamificationParams) (Gamification, error) { - row := q.db.QueryRowContext(ctx, createGamification, arg.Name, arg.Score, arg.Avatar) + row := q.db.QueryRow(ctx, createGamification, arg.Name, arg.Score, arg.Avatar) var i Gamification err := row.Scan( &i.ID, @@ -35,15 +35,15 @@ func (q *Queries) CreateGamification(ctx context.Context, arg CreateGamification const deleteGamification = `-- name: DeleteGamification :execrows DELETE FROM gamification -WHERE id = ? +WHERE id = $1 ` -func (q *Queries) DeleteGamification(ctx context.Context, id int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteGamification, id) +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() + return result.RowsAffected(), nil } const deleteGamificationAll = `-- name: DeleteGamificationAll :execrows @@ -51,11 +51,11 @@ DELETE FROM gamification ` func (q *Queries) DeleteGamificationAll(ctx context.Context) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteGamificationAll) + result, err := q.db.Exec(ctx, deleteGamificationAll) if err != nil { return 0, err } - return result.RowsAffected() + return result.RowsAffected(), nil } const getAllGamification = `-- name: GetAllGamification :many @@ -66,7 +66,7 @@ FROM gamification // CRUD func (q *Queries) GetAllGamification(ctx context.Context) ([]Gamification, error) { - rows, err := q.db.QueryContext(ctx, getAllGamification) + rows, err := q.db.Query(ctx, getAllGamification) if err != nil { return nil, err } @@ -84,9 +84,6 @@ func (q *Queries) GetAllGamification(ctx context.Context) ([]Gamification, error } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -100,7 +97,7 @@ ORDER BY score DESC ` func (q *Queries) GetAllGamificationByScore(ctx context.Context) ([]Gamification, error) { - rows, err := q.db.QueryContext(ctx, getAllGamificationByScore) + rows, err := q.db.Query(ctx, getAllGamificationByScore) if err != nil { return nil, err } @@ -118,9 +115,6 @@ func (q *Queries) GetAllGamificationByScore(ctx context.Context) ([]Gamification } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -131,19 +125,19 @@ const updateGamificationScore = `-- name: UpdateGamificationScore :one UPDATE gamification -SET score = ? -WHERE id = ? +SET score = $1 +WHERE id = $2 RETURNING id, name, score, avatar ` type UpdateGamificationScoreParams struct { - Score int64 - ID int64 + Score int32 + ID int32 } // Other func (q *Queries) UpdateGamificationScore(ctx context.Context, arg UpdateGamificationScoreParams) (Gamification, error) { - row := q.db.QueryRowContext(ctx, updateGamificationScore, arg.Score, arg.ID) + row := q.db.QueryRow(ctx, updateGamificationScore, arg.Score, arg.ID) var i Gamification err := row.Scan( &i.ID, diff --git a/internal/pkg/db/sqlc/message.sql.go b/internal/pkg/db/sqlc/message.sql.go index 14626f9..194b970 100644 --- a/internal/pkg/db/sqlc/message.sql.go +++ b/internal/pkg/db/sqlc/message.sql.go @@ -11,7 +11,7 @@ import ( const createMessage = `-- name: CreateMessage :one INSERT INTO message (name, ip, message) -VALUES (?, ?, ?) +VALUES ($1, $2, $3) RETURNING id, name, ip, message, created_at ` @@ -22,7 +22,7 @@ type CreateMessageParams struct { } func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) { - row := q.db.QueryRowContext(ctx, createMessage, arg.Name, arg.Ip, arg.Message) + row := q.db.QueryRow(ctx, createMessage, arg.Name, arg.Ip, arg.Message) var i Message err := row.Scan( &i.ID, @@ -36,15 +36,15 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M const deleteMessage = `-- name: DeleteMessage :execrows DELETE FROM message -WHERE id = ? +WHERE id = $1 ` -func (q *Queries) DeleteMessage(ctx context.Context, id int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteMessage, id) +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() + return result.RowsAffected(), nil } const getAllMessages = `-- name: GetAllMessages :many @@ -55,7 +55,7 @@ FROM message // CRUD func (q *Queries) GetAllMessages(ctx context.Context) ([]Message, error) { - rows, err := q.db.QueryContext(ctx, getAllMessages) + rows, err := q.db.Query(ctx, getAllMessages) if err != nil { return nil, err } @@ -74,9 +74,6 @@ func (q *Queries) GetAllMessages(ctx context.Context) ([]Message, error) { } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -94,7 +91,7 @@ LIMIT 1 // Other func (q *Queries) GetLastMessage(ctx context.Context) (Message, error) { - row := q.db.QueryRowContext(ctx, getLastMessage) + row := q.db.QueryRow(ctx, getLastMessage) var i Message err := row.Scan( &i.ID, @@ -109,11 +106,11 @@ func (q *Queries) GetLastMessage(ctx context.Context) (Message, error) { const getMessageByID = `-- name: GetMessageByID :one SELECT id, name, ip, message, created_at FROM message -WHERE id = ? +WHERE id = $1 ` -func (q *Queries) GetMessageByID(ctx context.Context, id int64) (Message, error) { - row := q.db.QueryRowContext(ctx, getMessageByID, id) +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, @@ -128,12 +125,12 @@ func (q *Queries) GetMessageByID(ctx context.Context, id int64) (Message, error) const getMessageSinceID = `-- name: GetMessageSinceID :many SELECT id, name, ip, message, created_at FROM message -WHERE id > ? +WHERE id > $1 ORDER BY created_at ASC ` -func (q *Queries) GetMessageSinceID(ctx context.Context, id int64) ([]Message, error) { - rows, err := q.db.QueryContext(ctx, getMessageSinceID, id) +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 } @@ -152,9 +149,6 @@ func (q *Queries) GetMessageSinceID(ctx context.Context, id int64) ([]Message, e } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -163,8 +157,8 @@ func (q *Queries) GetMessageSinceID(ctx context.Context, id int64) ([]Message, e const updateMessage = `-- name: UpdateMessage :one UPDATE message -SET name = ?, ip = ?, message = ? -WHERE id = ? +SET name = $1, ip = $2, message = $3 +WHERE id = $4 RETURNING id, name, ip, message, created_at ` @@ -172,11 +166,11 @@ type UpdateMessageParams struct { Name string Ip string Message string - ID int64 + ID int32 } func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) (Message, error) { - row := q.db.QueryRowContext(ctx, updateMessage, + row := q.db.QueryRow(ctx, updateMessage, arg.Name, arg.Ip, arg.Message, diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index 4a7cb71..ac286c0 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -5,93 +5,92 @@ package sqlc import ( - "database/sql" - "time" + "github.com/jackc/pgx/v5/pgtype" ) type Event struct { - ID int64 + ID int32 Name string - Date time.Time + Date pgtype.Timestamptz AcademicYear string Location string Poster []byte } type Gamification struct { - ID int64 + ID int32 Name string - Score int64 + Score int32 Avatar []byte } type Message struct { - ID int64 + ID int32 Name string Ip string Message string - CreatedAt time.Time + CreatedAt pgtype.Timestamptz } type Scan struct { - ID int64 - ScanTime time.Time + ID int32 + ScanTime pgtype.Timestamptz } type Season struct { - ID int64 + ID int32 Name string - Start time.Time - End time.Time + Start pgtype.Timestamptz + End pgtype.Timestamptz Current bool } type Song struct { - ID int64 + ID int32 Title string SpotifyID string - DurationMs int64 + DurationMs int32 Album string - LyricsType sql.NullString - Lyrics sql.NullString + LyricsType pgtype.Text + Lyrics pgtype.Text } type SongArtist struct { - ID int64 + ID int32 Name string SpotifyID string - Followers int64 - Popularity int64 + Followers int32 + Popularity int32 } type SongArtistGenre struct { - ID int64 - ArtistID int64 - GenreID int64 + ID int32 + ArtistID int32 + GenreID int32 } type SongArtistSong struct { - ID int64 - ArtistID int64 - SongID int64 + ID int32 + ArtistID int32 + SongID int32 } type SongGenre struct { - ID int64 + ID int32 Genre string } type SongHistory struct { - ID int64 - SongID int64 - CreatedAt time.Time + ID int32 + SongID int32 + CreatedAt pgtype.Timestamptz } type Tap struct { - ID int64 - OrderID int64 - OrderCreatedAt time.Time + ID int32 + OrderID int32 + OrderCreatedAt pgtype.Timestamptz Name string Category string - CreatedAt time.Time + CreatedAt pgtype.Timestamptz } diff --git a/internal/pkg/db/sqlc/scan.sql.go b/internal/pkg/db/sqlc/scan.sql.go index 58f294e..6782c7f 100644 --- a/internal/pkg/db/sqlc/scan.sql.go +++ b/internal/pkg/db/sqlc/scan.sql.go @@ -7,17 +7,18 @@ package sqlc import ( "context" - "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createScan = `-- name: CreateScan :one INSERT INTO scan (scan_time) -VALUES (?) +VALUES ($1) RETURNING id, scan_time ` -func (q *Queries) CreateScan(ctx context.Context, scanTime time.Time) (Scan, error) { - row := q.db.QueryRowContext(ctx, createScan, scanTime) +func (q *Queries) CreateScan(ctx context.Context, scanTime pgtype.Timestamptz) (Scan, error) { + row := q.db.QueryRow(ctx, createScan, scanTime) var i Scan err := row.Scan(&i.ID, &i.ScanTime) return i, err @@ -25,15 +26,15 @@ func (q *Queries) CreateScan(ctx context.Context, scanTime time.Time) (Scan, err const deleteScan = `-- name: DeleteScan :execrows DELETE FROM scan -WHERE id = ? +WHERE id = $1 ` -func (q *Queries) DeleteScan(ctx context.Context, id int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteScan, id) +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() + return result.RowsAffected(), nil } const getAllScans = `-- name: GetAllScans :many @@ -44,7 +45,7 @@ FROM scan // CRUD func (q *Queries) GetAllScans(ctx context.Context) ([]Scan, error) { - rows, err := q.db.QueryContext(ctx, getAllScans) + rows, err := q.db.Query(ctx, getAllScans) if err != nil { return nil, err } @@ -57,9 +58,6 @@ func (q *Queries) GetAllScans(ctx context.Context) ([]Scan, error) { } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -69,12 +67,12 @@ func (q *Queries) GetAllScans(ctx context.Context) ([]Scan, error) { const getAllScansSinceID = `-- name: GetAllScansSinceID :many SELECT id, scan_time FROM scan -WHERE id > ? +WHERE id > $1 ORDER BY scan_time ASC ` -func (q *Queries) GetAllScansSinceID(ctx context.Context, id int64) ([]Scan, error) { - rows, err := q.db.QueryContext(ctx, getAllScansSinceID, id) +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 } @@ -87,9 +85,6 @@ func (q *Queries) GetAllScansSinceID(ctx context.Context, id int64) ([]Scan, err } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -107,7 +102,7 @@ LIMIT 1 // Other func (q *Queries) GetLastScan(ctx context.Context) (Scan, error) { - row := q.db.QueryRowContext(ctx, getLastScan) + row := q.db.QueryRow(ctx, getLastScan) var i Scan err := row.Scan(&i.ID, &i.ScanTime) return i, err @@ -116,11 +111,11 @@ func (q *Queries) GetLastScan(ctx context.Context) (Scan, error) { const getScanByID = `-- name: GetScanByID :one SELECT id, scan_time FROM scan -WHERE id = ? +WHERE id = $1 ` -func (q *Queries) GetScanByID(ctx context.Context, id int64) (Scan, error) { - row := q.db.QueryRowContext(ctx, getScanByID, id) +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) return i, err @@ -134,7 +129,7 @@ WHERE scan_time >= (SELECT start_date FROM season WHERE current = true) AND ` func (q *Queries) GetScansInCurrentSeason(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, getScansInCurrentSeason) + row := q.db.QueryRow(ctx, getScansInCurrentSeason) var amount int64 err := row.Scan(&amount) return amount, err @@ -142,18 +137,18 @@ func (q *Queries) GetScansInCurrentSeason(ctx context.Context) (int64, error) { const updateScan = `-- name: UpdateScan :one UPDATE scan -SET scan_time = ? -WHERE id = ? +SET scan_time = $1 +WHERE id = $2 RETURNING id, scan_time ` type UpdateScanParams struct { - ScanTime time.Time - ID int64 + ScanTime pgtype.Timestamptz + ID int32 } func (q *Queries) UpdateScan(ctx context.Context, arg UpdateScanParams) (Scan, error) { - row := q.db.QueryRowContext(ctx, updateScan, arg.ScanTime, arg.ID) + row := q.db.QueryRow(ctx, updateScan, arg.ScanTime, arg.ID) var i Scan err := row.Scan(&i.ID, &i.ScanTime) return i, err diff --git a/internal/pkg/db/sqlc/season.sql.go b/internal/pkg/db/sqlc/season.sql.go index 7da8781..dec33ce 100644 --- a/internal/pkg/db/sqlc/season.sql.go +++ b/internal/pkg/db/sqlc/season.sql.go @@ -7,24 +7,25 @@ package sqlc import ( "context" - "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createSeason = `-- name: CreateSeason :one -INSERT INTO season (name, start, end, current) -VALUES (?, ?, ?, ?) -RETURNING id, name, start, "end", "current" +INSERT INTO season (name, start, "end", current) +VALUES ($1, $2, $3, $4) +RETURNING id, name, start, "end", current ` type CreateSeasonParams struct { Name string - Start time.Time - End time.Time + Start pgtype.Timestamptz + End pgtype.Timestamptz Current bool } func (q *Queries) CreateSeason(ctx context.Context, arg CreateSeasonParams) (Season, error) { - row := q.db.QueryRowContext(ctx, createSeason, + row := q.db.QueryRow(ctx, createSeason, arg.Name, arg.Start, arg.End, @@ -43,26 +44,26 @@ func (q *Queries) CreateSeason(ctx context.Context, arg CreateSeasonParams) (Sea const deleteSeason = `-- name: DeleteSeason :execrows DELETE FROM season -WHERE id = ? +WHERE id = $1 ` -func (q *Queries) DeleteSeason(ctx context.Context, id int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteSeason, id) +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() + return result.RowsAffected(), nil } const getAllSeasons = `-- name: GetAllSeasons :many -SELECT id, name, start, "end", "current" +SELECT id, name, start, "end", current FROM season ` // CRUD func (q *Queries) GetAllSeasons(ctx context.Context) ([]Season, error) { - rows, err := q.db.QueryContext(ctx, getAllSeasons) + rows, err := q.db.Query(ctx, getAllSeasons) if err != nil { return nil, err } @@ -81,9 +82,6 @@ func (q *Queries) GetAllSeasons(ctx context.Context) ([]Season, error) { } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -91,13 +89,13 @@ func (q *Queries) GetAllSeasons(ctx context.Context) ([]Season, error) { } const getSeasonByID = `-- name: GetSeasonByID :one -SELECT id, name, start, "end", "current" +SELECT id, name, start, "end", current FROM season -WHERE id = ? +WHERE id = $1 ` -func (q *Queries) GetSeasonByID(ctx context.Context, id int64) (Season, error) { - row := q.db.QueryRowContext(ctx, getSeasonByID, id) +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, @@ -112,14 +110,14 @@ func (q *Queries) GetSeasonByID(ctx context.Context, id int64) (Season, error) { const getSeasonCurrent = `-- name: GetSeasonCurrent :one -SELECT id, name, start, "end", "current" +SELECT id, name, start, "end", current FROM season WHERE current = true ` // Other func (q *Queries) GetSeasonCurrent(ctx context.Context) (Season, error) { - row := q.db.QueryRowContext(ctx, getSeasonCurrent) + row := q.db.QueryRow(ctx, getSeasonCurrent) var i Season err := row.Scan( &i.ID, @@ -133,21 +131,21 @@ func (q *Queries) GetSeasonCurrent(ctx context.Context) (Season, error) { const updateSeason = `-- name: UpdateSeason :one UPDATE season -SET name = ?, start = ?, end = ?, current = ? -WHERE id = ? -RETURNING id, name, start, "end", "current" +SET name = $1, start = $2, "end" = $3, current = $4 +WHERE id = $5 +RETURNING id, name, start, "end", current ` type UpdateSeasonParams struct { Name string - Start time.Time - End time.Time + Start pgtype.Timestamptz + End pgtype.Timestamptz Current bool - ID int64 + ID int32 } func (q *Queries) UpdateSeason(ctx context.Context, arg UpdateSeasonParams) (Season, error) { - row := q.db.QueryRowContext(ctx, updateSeason, + row := q.db.QueryRow(ctx, updateSeason, arg.Name, arg.Start, arg.End, diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go index d9947c0..fa718f5 100644 --- a/internal/pkg/db/sqlc/song.sql.go +++ b/internal/pkg/db/sqlc/song.sql.go @@ -7,14 +7,14 @@ package sqlc import ( "context" - "database/sql" - "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createSong = `-- name: CreateSong :one INSERT INTO song (title, album, spotify_id, duration_ms, lyrics_type, lyrics) -VALUES (?, ?, ?, ?, ?, ?) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, title, spotify_id, duration_ms, album, lyrics_type, lyrics ` @@ -22,14 +22,14 @@ type CreateSongParams struct { Title string Album string SpotifyID string - DurationMs int64 - LyricsType sql.NullString - Lyrics sql.NullString + DurationMs int32 + LyricsType pgtype.Text + Lyrics pgtype.Text } // CRUD func (q *Queries) CreateSong(ctx context.Context, arg CreateSongParams) (Song, error) { - row := q.db.QueryRowContext(ctx, createSong, + row := q.db.QueryRow(ctx, createSong, arg.Title, arg.Album, arg.SpotifyID, @@ -52,19 +52,19 @@ func (q *Queries) CreateSong(ctx context.Context, arg CreateSongParams) (Song, e const createSongArtist = `-- name: CreateSongArtist :one INSERT INTO song_artist (name, spotify_id, followers, popularity) -VALUES (?, ?, ?, ?) +VALUES ($1, $2, $3, $4) RETURNING id, name, spotify_id, followers, popularity ` type CreateSongArtistParams struct { Name string SpotifyID string - Followers int64 - Popularity int64 + Followers int32 + Popularity int32 } func (q *Queries) CreateSongArtist(ctx context.Context, arg CreateSongArtistParams) (SongArtist, error) { - row := q.db.QueryRowContext(ctx, createSongArtist, + row := q.db.QueryRow(ctx, createSongArtist, arg.Name, arg.SpotifyID, arg.Followers, @@ -83,17 +83,17 @@ func (q *Queries) CreateSongArtist(ctx context.Context, arg CreateSongArtistPara const createSongArtistGenre = `-- name: CreateSongArtistGenre :one INSERT INTO song_artist_genre (artist_id, genre_id) -VALUES (?, ?) +VALUES ($1, $2) RETURNING id, artist_id, genre_id ` type CreateSongArtistGenreParams struct { - ArtistID int64 - GenreID int64 + ArtistID int32 + GenreID int32 } func (q *Queries) CreateSongArtistGenre(ctx context.Context, arg CreateSongArtistGenreParams) (SongArtistGenre, error) { - row := q.db.QueryRowContext(ctx, createSongArtistGenre, arg.ArtistID, arg.GenreID) + 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 @@ -101,17 +101,17 @@ func (q *Queries) CreateSongArtistGenre(ctx context.Context, arg CreateSongArtis const createSongArtistSong = `-- name: CreateSongArtistSong :one INSERT INTO song_artist_song (artist_id, song_id) -VALUES (?, ?) +VALUES ($1, $2) RETURNING id, artist_id, song_id ` type CreateSongArtistSongParams struct { - ArtistID int64 - SongID int64 + ArtistID int32 + SongID int32 } func (q *Queries) CreateSongArtistSong(ctx context.Context, arg CreateSongArtistSongParams) (SongArtistSong, error) { - row := q.db.QueryRowContext(ctx, createSongArtistSong, arg.ArtistID, arg.SongID) + 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 @@ -119,12 +119,12 @@ func (q *Queries) CreateSongArtistSong(ctx context.Context, arg CreateSongArtist const createSongGenre = `-- name: CreateSongGenre :one INSERT INTO song_genre (genre) -VALUES (?) +VALUES ($1) RETURNING id, genre ` func (q *Queries) CreateSongGenre(ctx context.Context, genre string) (SongGenre, error) { - row := q.db.QueryRowContext(ctx, createSongGenre, genre) + row := q.db.QueryRow(ctx, createSongGenre, genre) var i SongGenre err := row.Scan(&i.ID, &i.Genre) return i, err @@ -132,12 +132,12 @@ func (q *Queries) CreateSongGenre(ctx context.Context, genre string) (SongGenre, const createSongHistory = `-- name: CreateSongHistory :one INSERT INTO song_history (song_id) -VALUES (?) +VALUES ($1) RETURNING id, song_id, created_at ` -func (q *Queries) CreateSongHistory(ctx context.Context, songID int64) (SongHistory, error) { - row := q.db.QueryRowContext(ctx, createSongHistory, songID) +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 @@ -156,26 +156,26 @@ ORDER BY a.name, g.genre ` type GetLastSongFullRow struct { - ID int64 + ID int32 SongTitle string SpotifyID string Album string - DurationMs int64 - LyricsType sql.NullString - Lyrics sql.NullString - CreatedAt time.Time - ArtistID sql.NullInt64 - ArtistName sql.NullString - ArtistSpotifyID sql.NullString - ArtistFollowers sql.NullInt64 - ArtistPopularity sql.NullInt64 - GenreID sql.NullInt64 - Genre sql.NullString - CreatedAt_2 time.Time + 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.QueryContext(ctx, getLastSongFull) + rows, err := q.db.Query(ctx, getLastSongFull) if err != nil { return nil, err } @@ -205,9 +205,6 @@ func (q *Queries) GetLastSongFull(ctx context.Context) ([]GetLastSongFullRow, er } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -222,7 +219,7 @@ LIMIT 1 ` func (q *Queries) GetLastSongHistory(ctx context.Context) (SongHistory, error) { - row := q.db.QueryRowContext(ctx, getLastSongHistory) + row := q.db.QueryRow(ctx, getLastSongHistory) var i SongHistory err := row.Scan(&i.ID, &i.SongID, &i.CreatedAt) return i, err @@ -231,11 +228,11 @@ func (q *Queries) GetLastSongHistory(ctx context.Context) (SongHistory, error) { const getSongArtistByName = `-- name: GetSongArtistByName :one SELECT id, name, spotify_id, followers, popularity FROM song_artist -WHERE name = ? +WHERE name = $1 ` func (q *Queries) GetSongArtistByName(ctx context.Context, name string) (SongArtist, error) { - row := q.db.QueryRowContext(ctx, getSongArtistByName, name) + row := q.db.QueryRow(ctx, getSongArtistByName, name) var i SongArtist err := row.Scan( &i.ID, @@ -250,11 +247,11 @@ func (q *Queries) GetSongArtistByName(ctx context.Context, name string) (SongArt const getSongArtistBySpotifyID = `-- name: GetSongArtistBySpotifyID :one SELECT id, name, spotify_id, followers, popularity FROM song_artist -WHERE spotify_id = ? +WHERE spotify_id = $1 ` func (q *Queries) GetSongArtistBySpotifyID(ctx context.Context, spotifyID string) (SongArtist, error) { - row := q.db.QueryRowContext(ctx, getSongArtistBySpotifyID, spotifyID) + row := q.db.QueryRow(ctx, getSongArtistBySpotifyID, spotifyID) var i SongArtist err := row.Scan( &i.ID, @@ -270,12 +267,12 @@ const getSongBySpotifyID = `-- name: GetSongBySpotifyID :one SELECT id, title, spotify_id, duration_ms, album, lyrics_type, lyrics FROM song -WHERE spotify_id = ? +WHERE spotify_id = $1 ` // Other func (q *Queries) GetSongBySpotifyID(ctx context.Context, spotifyID string) (Song, error) { - row := q.db.QueryRowContext(ctx, getSongBySpotifyID, spotifyID) + row := q.db.QueryRow(ctx, getSongBySpotifyID, spotifyID) var i Song err := row.Scan( &i.ID, @@ -292,11 +289,11 @@ func (q *Queries) GetSongBySpotifyID(ctx context.Context, spotifyID string) (Son const getSongGenreByName = `-- name: GetSongGenreByName :one SELECT id, genre FROM song_genre -WHERE genre = ? +WHERE genre = $1 ` func (q *Queries) GetSongGenreByName(ctx context.Context, genre string) (SongGenre, error) { - row := q.db.QueryRowContext(ctx, getSongGenreByName, genre) + row := q.db.QueryRow(ctx, getSongGenreByName, genre) var i SongGenre err := row.Scan(&i.ID, &i.Genre) return i, err @@ -311,7 +308,7 @@ LIMIT 5 ` func (q *Queries) GetSongHistory(ctx context.Context) ([]string, error) { - rows, err := q.db.QueryContext(ctx, getSongHistory) + rows, err := q.db.Query(ctx, getSongHistory) if err != nil { return nil, err } @@ -324,9 +321,6 @@ func (q *Queries) GetSongHistory(ctx context.Context) ([]string, error) { } items = append(items, title) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -345,13 +339,13 @@ LIMIT 5 ` type GetTopArtistsRow struct { - ArtistID int64 + ArtistID int32 ArtistName string TotalPlays int64 } func (q *Queries) GetTopArtists(ctx context.Context) ([]GetTopArtistsRow, error) { - rows, err := q.db.QueryContext(ctx, getTopArtists) + rows, err := q.db.Query(ctx, getTopArtists) if err != nil { return nil, err } @@ -364,9 +358,6 @@ func (q *Queries) GetTopArtists(ctx context.Context) ([]GetTopArtistsRow, error) } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -392,7 +383,7 @@ type GetTopGenresRow struct { } func (q *Queries) GetTopGenres(ctx context.Context) ([]GetTopGenresRow, error) { - rows, err := q.db.QueryContext(ctx, getTopGenres) + rows, err := q.db.Query(ctx, getTopGenres) if err != nil { return nil, err } @@ -405,9 +396,6 @@ func (q *Queries) GetTopGenres(ctx context.Context) ([]GetTopGenresRow, error) { } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -424,13 +412,13 @@ LIMIT 5 ` type GetTopSongsRow struct { - SongID int64 + SongID int32 Title string PlayCount int64 } func (q *Queries) GetTopSongs(ctx context.Context) ([]GetTopSongsRow, error) { - rows, err := q.db.QueryContext(ctx, getTopSongs) + rows, err := q.db.Query(ctx, getTopSongs) if err != nil { return nil, err } @@ -443,9 +431,6 @@ func (q *Queries) GetTopSongs(ctx context.Context) ([]GetTopSongsRow, error) { } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } diff --git a/internal/pkg/db/sqlc/tap.sql.go b/internal/pkg/db/sqlc/tap.sql.go index 33664e2..8a5c7e1 100644 --- a/internal/pkg/db/sqlc/tap.sql.go +++ b/internal/pkg/db/sqlc/tap.sql.go @@ -7,24 +7,25 @@ package sqlc import ( "context" - "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createTap = `-- name: CreateTap :one INSERT INTO tap (order_id, order_created_at, name, category) -VALUES (?, ?, ?, ?) +VALUES ($1, $2, $3, $4) RETURNING id, order_id, order_created_at, name, category, created_at ` type CreateTapParams struct { - OrderID int64 - OrderCreatedAt time.Time + OrderID int32 + OrderCreatedAt pgtype.Timestamptz Name string Category string } func (q *Queries) CreateTap(ctx context.Context, arg CreateTapParams) (Tap, error) { - row := q.db.QueryRowContext(ctx, createTap, + row := q.db.QueryRow(ctx, createTap, arg.OrderID, arg.OrderCreatedAt, arg.Name, @@ -44,15 +45,15 @@ func (q *Queries) CreateTap(ctx context.Context, arg CreateTapParams) (Tap, erro const deleteTap = `-- name: DeleteTap :execrows DELETE FROM tap -WHERE id = ? +WHERE id = $1 ` -func (q *Queries) DeleteTap(ctx context.Context, id int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteTap, id) +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() + return result.RowsAffected(), nil } const getAllTaps = `-- name: GetAllTaps :many @@ -63,7 +64,7 @@ FROM tap // CRUD func (q *Queries) GetAllTaps(ctx context.Context) ([]Tap, error) { - rows, err := q.db.QueryContext(ctx, getAllTaps) + rows, err := q.db.Query(ctx, getAllTaps) if err != nil { return nil, err } @@ -83,9 +84,6 @@ func (q *Queries) GetAllTaps(ctx context.Context) ([]Tap, error) { } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -100,7 +98,7 @@ LIMIT 1 ` func (q *Queries) GetLastOrderByOrderID(ctx context.Context) (Tap, error) { - row := q.db.QueryRowContext(ctx, getLastOrderByOrderID) + row := q.db.QueryRow(ctx, getLastOrderByOrderID) var i Tap err := row.Scan( &i.ID, @@ -125,7 +123,7 @@ type GetOrderCountRow struct { } func (q *Queries) GetOrderCount(ctx context.Context) ([]GetOrderCountRow, error) { - rows, err := q.db.QueryContext(ctx, getOrderCount) + rows, err := q.db.Query(ctx, getOrderCount) if err != nil { return nil, err } @@ -138,9 +136,6 @@ func (q *Queries) GetOrderCount(ctx context.Context) ([]GetOrderCountRow, error) } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -150,18 +145,18 @@ func (q *Queries) GetOrderCount(ctx context.Context) ([]GetOrderCountRow, error) const getOrderCountByCategorySinceOrderID = `-- name: GetOrderCountByCategorySinceOrderID :many SELECT category, COUNT(*), CAST(MAX(order_created_at) AS INTEGER) AS latest_order_created_at FROM tap -WHERE order_id >= ? +WHERE order_id >= $1 GROUP BY category ` type GetOrderCountByCategorySinceOrderIDRow struct { Category string Count int64 - LatestOrderCreatedAt int64 + LatestOrderCreatedAt int32 } -func (q *Queries) GetOrderCountByCategorySinceOrderID(ctx context.Context, orderID int64) ([]GetOrderCountByCategorySinceOrderIDRow, error) { - rows, err := q.db.QueryContext(ctx, getOrderCountByCategorySinceOrderID, orderID) +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 } @@ -174,9 +169,6 @@ func (q *Queries) GetOrderCountByCategorySinceOrderID(ctx context.Context, order } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -186,11 +178,11 @@ func (q *Queries) GetOrderCountByCategorySinceOrderID(ctx context.Context, order const getTapByCategory = `-- name: GetTapByCategory :many SELECT id, order_id, order_created_at, name, category, created_at FROM tap -WHERE category = ? +WHERE category = $1 ` func (q *Queries) GetTapByCategory(ctx context.Context, category string) ([]Tap, error) { - rows, err := q.db.QueryContext(ctx, getTapByCategory, category) + rows, err := q.db.Query(ctx, getTapByCategory, category) if err != nil { return nil, err } @@ -210,9 +202,6 @@ func (q *Queries) GetTapByCategory(ctx context.Context, category string) ([]Tap, } items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err - } if err := rows.Err(); err != nil { return nil, err } @@ -222,11 +211,11 @@ func (q *Queries) GetTapByCategory(ctx context.Context, category string) ([]Tap, const getTapByID = `-- name: GetTapByID :one SELECT id, order_id, order_created_at, name, category, created_at FROM tap -WHERE id = ? +WHERE id = $1 ` -func (q *Queries) GetTapByID(ctx context.Context, id int64) (Tap, error) { - row := q.db.QueryRowContext(ctx, getTapByID, id) +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, @@ -244,12 +233,12 @@ const getTapByOrderID = `-- name: GetTapByOrderID :one SELECT id, order_id, order_created_at, name, category, created_at FROM tap -WHERE order_id = ? +WHERE order_id = $1 ` // Other -func (q *Queries) GetTapByOrderID(ctx context.Context, orderID int64) (Tap, error) { - row := q.db.QueryRowContext(ctx, getTapByOrderID, orderID) +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, @@ -264,21 +253,21 @@ func (q *Queries) GetTapByOrderID(ctx context.Context, orderID int64) (Tap, erro const updateTap = `-- name: UpdateTap :one UPDATE tap -SET order_id = ?, order_created_at = ?, name = ?, category = ? -WHERE id = ? +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 int64 - OrderCreatedAt time.Time + OrderID int32 + OrderCreatedAt pgtype.Timestamptz Name string Category string - ID int64 + ID int32 } func (q *Queries) UpdateTap(ctx context.Context, arg UpdateTapParams) (Tap, error) { - row := q.db.QueryRowContext(ctx, updateTap, + row := q.db.QueryRow(ctx, updateTap, arg.OrderID, arg.OrderCreatedAt, arg.Name, diff --git a/internal/pkg/gamification/api.go b/internal/pkg/gamification/api.go index 5ac1382..b899fb8 100644 --- a/internal/pkg/gamification/api.go +++ b/internal/pkg/gamification/api.go @@ -10,9 +10,9 @@ import ( ) type gamificationItem struct { - ID int64 `json:"id"` + ID int32 `json:"id"` Name string `json:"github_name"` - Score int64 `json:"score"` + Score int32 `json:"score"` AvatarURL string `json:"avatar_url"` } diff --git a/internal/pkg/song/api.go b/internal/pkg/song/api.go index c0d0869..b4eef5c 100644 --- a/internal/pkg/song/api.go +++ b/internal/pkg/song/api.go @@ -26,7 +26,7 @@ type trackResponse struct { Name string `json:"name"` Album trackAlbum `json:"album"` Artists []trackArtist `json:"artists"` - DurationMS int64 `json:"duration_ms"` + DurationMS int32 `json:"duration_ms"` } func (s *Song) getTrack(track *dto.Song) error { @@ -83,8 +83,8 @@ func (s *Song) getArtist(artist *dto.SongArtist) error { return fmt.Errorf("Song: Artist request wrong status code %d", status) } - artist.Popularity = int64(res.Popularity) - artist.Followers = int64(res.Followers.Total) + 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}) diff --git a/internal/pkg/song/song.go b/internal/pkg/song/song.go index 3abdbb7..ccdd76e 100644 --- a/internal/pkg/song/song.go +++ b/internal/pkg/song/song.go @@ -3,10 +3,10 @@ package song import ( "context" - "database/sql" "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" @@ -44,7 +44,7 @@ func (s *Song) Track(track *dto.Song) error { // Check if song is already in DB trackDB, err := s.db.Queries.GetSongBySpotifyID(context.Background(), track.SpotifyID) - if err != nil && err != sql.ErrNoRows { + if err != nil && err != pgx.ErrNoRows { return err } @@ -52,7 +52,7 @@ func (s *Song) Track(track *dto.Song) error { // 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 != sql.ErrNoRows { + if err != nil && err != pgx.ErrNoRows { return err } @@ -96,7 +96,7 @@ func (s *Song) Track(track *dto.Song) error { // Handle artists for i, artist := range track.Artists { a, err := s.db.Queries.GetSongArtistBySpotifyID(context.Background(), artist.SpotifyID) - if err != nil && err != sql.ErrNoRows { + if err != nil && err != pgx.ErrNoRows { errs = append(errs, err) continue } @@ -133,7 +133,7 @@ func (s *Song) Track(track *dto.Song) error { // 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 != sql.ErrNoRows { + if err != nil && err != pgx.ErrNoRows { errs = append(errs, err) continue } diff --git a/internal/pkg/tap/api.go b/internal/pkg/tap/api.go index 8dd1cca..522b16e 100644 --- a/internal/pkg/tap/api.go +++ b/internal/pkg/tap/api.go @@ -9,7 +9,7 @@ import ( ) type orderResponseItem struct { - OrderID int64 `json:"order_id"` + OrderID int32 `json:"order_id"` OrderCreatedAt time.Time `json:"order_created_at"` ProductName string `json:"product_name"` ProductCategory string `json:"product_category"` diff --git a/internal/pkg/tap/tap.go b/internal/pkg/tap/tap.go index 2dd2cd2..678d0fa 100644 --- a/internal/pkg/tap/tap.go +++ b/internal/pkg/tap/tap.go @@ -3,11 +3,12 @@ package tap import ( "context" - "database/sql" "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" @@ -48,7 +49,7 @@ func (t *Tap) Update() error { // Get latest order lastOrder, err := t.db.Queries.GetLastOrderByOrderID(context.Background()) if err != nil { - if err != sql.ErrNoRows { + if err != pgx.ErrNoRows { return err } @@ -76,7 +77,7 @@ func (t *Tap) Update() error { for _, order := range orders { _, err := t.db.Queries.CreateTap(context.Background(), sqlc.CreateTapParams{ OrderID: order.OrderID, - OrderCreatedAt: order.OrderCreatedAt, + OrderCreatedAt: pgtype.Timestamptz{Time: order.OrderCreatedAt}, Name: order.ProductName, Category: order.ProductCategory, }) diff --git a/internal/pkg/zess/zess.go b/internal/pkg/zess/zess.go index 47a8d72..c524b2c 100644 --- a/internal/pkg/zess/zess.go +++ b/internal/pkg/zess/zess.go @@ -3,10 +3,11 @@ package zess import ( "context" - "database/sql" "errors" "slices" + "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/dto" "github.com/zeusWPI/scc/internal/pkg/db/sqlc" @@ -30,7 +31,7 @@ func New(db *db.DB) *Zess { func (z *Zess) UpdateSeasons() error { seasons, err := z.db.Queries.GetAllSeasons(context.Background()) if err != nil { - if err != sql.ErrNoRows { + if err != pgx.ErrNoRows { return err } } @@ -56,8 +57,8 @@ func (z *Zess) UpdateSeasons() error { // Update seasons seasons[i].ID = season.ID seasons[i].Name = season.Name - seasons[i].Start = season.Start - seasons[i].End = season.End + seasons[i].Start = pgtype.Timestamptz{Time: season.Start} + seasons[i].End = pgtype.Timestamptz{Time: season.End} _, err := z.db.Queries.UpdateSeason(context.Background(), dto.SeasonDTO(seasons[i]).UpdateParams()) if err != nil { @@ -87,7 +88,7 @@ func (z *Zess) UpdateSeasons() error { func (z *Zess) UpdateScans() error { lastScan, err := z.db.Queries.GetLastScan(context.Background()) if err != nil { - if err != sql.ErrNoRows { + if err != pgx.ErrNoRows { return err } diff --git a/makefile b/makefile index 6900102..cda5bb3 100644 --- a/makefile +++ b/makefile @@ -1,13 +1,15 @@ # Variables -BACKEND_BIN := backend -TUI_BIN := tui +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_FILE := ./sqlite.db +DB_USER := postgres +DB_PASSWORD := postgres +DB_NAME := scc # Phony targets -.PHONY: all build clean run run-backend run-tui sqlc create-migration goose migrate watch +.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 @@ -15,14 +17,12 @@ all: build # Build targets build: clean build-backend build-tui -build-backend: +build-backend: clean-backend @echo "Building $(BACKEND_BIN)..." - @rm -f $(BACKEND_BIN) @go build -o $(BACKEND_BIN) $(BACKEND_SRC) -build-tui: +build-tui: clean-tui @echo "Building $(TUI_BIN)..." - @rm -f $(TUI_BIN) @go build -o $(TUI_BIN) $(TUI_SRC) # Run targets @@ -37,16 +37,24 @@ run-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: - @echo "Cleaning $(BACKEND_BIN)..." - @rm -f $(BACKEND_BIN) + @if [ -f "$(BACKEND_BIN)" ]; then \ + echo "Cleaning $(BACKEND_BIN)..."; \ + rm -f "$(BACKEND_BIN)"; \ + fi clean-tui: - @echo "Cleaning $(TUI_BIN)..." - @rm -f $(TUI_BIN) + @if [ -f "$(TUI_BIN)" ]; then \ + echo "Cleaning $(TUI_BIN)..."; \ + rm -f "$(TUI_BIN)"; \ + fi # SQL and migration targets sqlc: @@ -57,8 +65,8 @@ create-migration: goose -dir $(DB_DIR) create $$name sql migrate: - @goose -dir $(DB_DIR) sqlite3 $(DB_FILE) up + @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) sqlite3 $(DB_FILE) $$action + goose -dir $(DB_DIR) postgres "user=$(DB_USER) password=$(DB_PASSWORD) dbname=$(DB_NAME) host=localhost sslmode=disable" $$action diff --git a/sqlc.yml b/sqlc.yml index 2fe8046..a4821a6 100644 --- a/sqlc.yml +++ b/sqlc.yml @@ -1,6 +1,6 @@ version: "2" sql: - - engine: "sqlite" + - engine: "postgresql" queries: - "db/queries/*.sql" schema: "db/migrations" @@ -8,3 +8,4 @@ sql: go: package: "sqlc" out: "internal/pkg/db/sqlc" + sql_package: "pgx/v5" diff --git a/ui/components/stopwatch/stopwatch.go b/tui/components/stopwatch/stopwatch.go similarity index 100% rename from ui/components/stopwatch/stopwatch.go rename to tui/components/stopwatch/stopwatch.go diff --git a/ui/screen/cammie/cammie.go b/tui/screen/cammie/cammie.go similarity index 94% rename from ui/screen/cammie/cammie.go rename to tui/screen/cammie/cammie.go index 22e78b7..f3d763b 100644 --- a/ui/screen/cammie/cammie.go +++ b/tui/screen/cammie/cammie.go @@ -9,13 +9,13 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/pkg/config" - "github.com/zeusWPI/scc/ui/screen" - "github.com/zeusWPI/scc/ui/view" - "github.com/zeusWPI/scc/ui/view/event" - "github.com/zeusWPI/scc/ui/view/gamification" - "github.com/zeusWPI/scc/ui/view/message" - "github.com/zeusWPI/scc/ui/view/tap" - "github.com/zeusWPI/scc/ui/view/zess" + "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 diff --git a/ui/screen/cammie/style.go b/tui/screen/cammie/style.go similarity index 100% rename from ui/screen/cammie/style.go rename to tui/screen/cammie/style.go diff --git a/ui/screen/screen.go b/tui/screen/screen.go similarity index 89% rename from ui/screen/screen.go rename to tui/screen/screen.go index e4edd1c..b6c0a37 100644 --- a/ui/screen/screen.go +++ b/tui/screen/screen.go @@ -3,7 +3,7 @@ package screen import ( tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/ui/view" + "github.com/zeusWPI/scc/tui/view" ) // Screen represents a screen diff --git a/ui/screen/song/song.go b/tui/screen/song/song.go similarity index 94% rename from ui/screen/song/song.go rename to tui/screen/song/song.go index 3532205..a7c8b50 100644 --- a/ui/screen/song/song.go +++ b/tui/screen/song/song.go @@ -4,9 +4,9 @@ package song import ( tea "github.com/charmbracelet/bubbletea" "github.com/zeusWPI/scc/internal/pkg/db" - "github.com/zeusWPI/scc/ui/screen" - "github.com/zeusWPI/scc/ui/view" - "github.com/zeusWPI/scc/ui/view/song" + "github.com/zeusWPI/scc/tui/screen" + "github.com/zeusWPI/scc/tui/view" + "github.com/zeusWPI/scc/tui/view/song" ) // Song represents the song screen diff --git a/ui/screen/song/style.go b/tui/screen/song/style.go similarity index 100% rename from ui/screen/song/style.go rename to tui/screen/song/style.go diff --git a/ui/tui.go b/tui/tui.go similarity index 96% rename from ui/tui.go rename to tui/tui.go index b5b4635..eb7008e 100644 --- a/ui/tui.go +++ b/tui/tui.go @@ -3,7 +3,7 @@ package tui import ( tea "github.com/charmbracelet/bubbletea" - "github.com/zeusWPI/scc/ui/screen" + "github.com/zeusWPI/scc/tui/screen" "go.uber.org/zap" ) diff --git a/ui/view/event/event.go b/tui/view/event/event.go similarity index 98% rename from ui/view/event/event.go rename to tui/view/event/event.go index b6bf13f..6dcf550 100644 --- a/ui/view/event/event.go +++ b/tui/view/event/event.go @@ -10,7 +10,7 @@ import ( "github.com/zeusWPI/scc/internal/pkg/db/dto" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/util" - "github.com/zeusWPI/scc/ui/view" + "github.com/zeusWPI/scc/tui/view" ) var ( diff --git a/ui/view/event/style.go b/tui/view/event/style.go similarity index 100% rename from ui/view/event/style.go rename to tui/view/event/style.go diff --git a/ui/view/event/view.go b/tui/view/event/view.go similarity index 98% rename from ui/view/event/view.go rename to tui/view/event/view.go index 16deb16..79b75fc 100644 --- a/ui/view/event/view.go +++ b/tui/view/event/view.go @@ -5,7 +5,7 @@ import ( "image" "github.com/charmbracelet/lipgloss" - "github.com/zeusWPI/scc/ui/view" + "github.com/zeusWPI/scc/tui/view" ) func (m *Model) viewToday() string { diff --git a/ui/view/gamification/gamification.go b/tui/view/gamification/gamification.go similarity index 97% rename from ui/view/gamification/gamification.go rename to tui/view/gamification/gamification.go index 43eb54c..15d7282 100644 --- a/ui/view/gamification/gamification.go +++ b/tui/view/gamification/gamification.go @@ -4,17 +4,17 @@ package gamification import ( "bytes" "context" - "database/sql" "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/ui/view" + "github.com/zeusWPI/scc/tui/view" ) // Model represents the view model for gamification @@ -96,7 +96,7 @@ func updateLeaderboard(view view.View) (tea.Msg, error) { gams, err := m.db.Queries.GetAllGamificationByScore(context.Background()) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { err = nil } return nil, err diff --git a/ui/view/gamification/styles.go b/tui/view/gamification/styles.go similarity index 100% rename from ui/view/gamification/styles.go rename to tui/view/gamification/styles.go diff --git a/ui/view/message/message.go b/tui/view/message/message.go similarity index 94% rename from ui/view/message/message.go rename to tui/view/message/message.go index d30b770..0ed52df 100644 --- a/ui/view/message/message.go +++ b/tui/view/message/message.go @@ -3,14 +3,14 @@ package message import ( "context" - "database/sql" "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/ui/view" + "github.com/zeusWPI/scc/tui/view" ) // Model represents the model for the message view @@ -18,7 +18,7 @@ type Model struct { width int height int db *db.DB - lastMessageID int64 + lastMessageID int32 messages []message } @@ -31,7 +31,7 @@ type message struct { // Msg represents the message to update the message view type Msg struct { - lastMessageID int64 + lastMessageID int32 messages []message } @@ -98,7 +98,7 @@ func updateMessages(view view.View) (tea.Msg, error) { messagesDB, err := m.db.Queries.GetMessageSinceID(context.Background(), lastMessageID) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { err = nil } return nil, err @@ -119,7 +119,7 @@ func updateMessages(view view.View) (tea.Msg, error) { sender: m.Name, message: m.Message, color: hashColor(m.Name), - date: m.CreatedAt, + date: m.CreatedAt.Time, }) } diff --git a/ui/view/message/style.go b/tui/view/message/style.go similarity index 100% rename from ui/view/message/style.go rename to tui/view/message/style.go diff --git a/ui/view/message/view.go b/tui/view/message/view.go similarity index 100% rename from ui/view/message/view.go rename to tui/view/message/view.go diff --git a/ui/view/song/song.go b/tui/view/song/song.go similarity index 94% rename from ui/view/song/song.go rename to tui/view/song/song.go index 7b6d7fa..1a4e33a 100644 --- a/ui/view/song/song.go +++ b/tui/view/song/song.go @@ -3,17 +3,17 @@ package song import ( "context" - "database/sql" "time" "github.com/charmbracelet/bubbles/progress" 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/ui/components/stopwatch" - "github.com/zeusWPI/scc/ui/view" + "github.com/zeusWPI/scc/tui/components/stopwatch" + "github.com/zeusWPI/scc/tui/view" ) var ( @@ -219,7 +219,7 @@ func updateCurrentSong(view view.View) (tea.Msg, error) { songs, err := m.db.Queries.GetLastSongFull(context.Background()) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { err = nil } return nil, err @@ -229,7 +229,7 @@ func updateCurrentSong(view view.View) (tea.Msg, error) { } // Check if song is still playing - if songs[0].CreatedAt.Add(time.Duration(songs[0].DurationMs) * time.Millisecond).Before(time.Now()) { + if songs[0].CreatedAt.Time.Add(time.Duration(songs[0].DurationMs) * time.Millisecond).Before(time.Now()) { // Song is finished return nil, nil } @@ -250,7 +250,7 @@ func updateTopStats(view view.View) (tea.Msg, error) { change := false songs, err := m.db.Queries.GetTopSongs(context.Background()) - if err != nil && err != sql.ErrNoRows { + if err != nil && err != pgx.ErrNoRows { return nil, err } @@ -260,7 +260,7 @@ func updateTopStats(view view.View) (tea.Msg, error) { } genres, err := m.db.Queries.GetTopGenres(context.Background()) - if err != nil && err != sql.ErrNoRows { + if err != nil && err != pgx.ErrNoRows { return nil, err } @@ -270,7 +270,7 @@ func updateTopStats(view view.View) (tea.Msg, error) { } artists, err := m.db.Queries.GetTopArtists(context.Background()) - if err != nil && err != sql.ErrNoRows { + if err != nil && err != pgx.ErrNoRows { return nil, err } diff --git a/ui/view/song/style.go b/tui/view/song/style.go similarity index 100% rename from ui/view/song/style.go rename to tui/view/song/style.go diff --git a/ui/view/song/util.go b/tui/view/song/util.go similarity index 100% rename from ui/view/song/util.go rename to tui/view/song/util.go diff --git a/ui/view/song/view.go b/tui/view/song/view.go similarity index 100% rename from ui/view/song/view.go rename to tui/view/song/view.go diff --git a/ui/view/tap/style.go b/tui/view/tap/style.go similarity index 100% rename from ui/view/tap/style.go rename to tui/view/tap/style.go diff --git a/ui/view/tap/tap.go b/tui/view/tap/tap.go similarity index 94% rename from ui/view/tap/tap.go rename to tui/view/tap/tap.go index b46ce69..d752685 100644 --- a/ui/view/tap/tap.go +++ b/tui/view/tap/tap.go @@ -3,15 +3,15 @@ package tap import ( "context" - "database/sql" "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/ui/view" + "github.com/zeusWPI/scc/tui/view" ) type category string @@ -33,13 +33,13 @@ var categoryToStyle = map[category]lipgloss.Style{ // Model represents the tap model type Model struct { db *db.DB - lastOrderID int64 + lastOrderID int32 items []tapItem } // Msg represents a tap message type Msg struct { - lastOrderID int64 + lastOrderID int32 items []tapItem } @@ -128,7 +128,7 @@ func updateOrders(view view.View) (tea.Msg, error) { order, err := m.db.Queries.GetLastOrderByOrderID(context.Background()) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { err = nil } return nil, err @@ -155,7 +155,7 @@ func updateOrders(view view.View) (tea.Msg, error) { counts[category(order.Category)] = tapItem{ category: category(order.Category), amount: int(order.Count), - last: time.Unix(order.LatestOrderCreatedAt, 0), + last: time.Unix(int64(order.LatestOrderCreatedAt), 0), } } diff --git a/ui/view/tap/view.go b/tui/view/tap/view.go similarity index 100% rename from ui/view/tap/view.go rename to tui/view/tap/view.go diff --git a/ui/view/util.go b/tui/view/util.go similarity index 100% rename from ui/view/util.go rename to tui/view/util.go diff --git a/ui/view/view.go b/tui/view/view.go similarity index 100% rename from ui/view/view.go rename to tui/view/view.go diff --git a/ui/view/zess/style.go b/tui/view/zess/style.go similarity index 100% rename from ui/view/zess/style.go rename to tui/view/zess/style.go diff --git a/ui/view/zess/view.go b/tui/view/zess/view.go similarity index 100% rename from ui/view/zess/view.go rename to tui/view/zess/view.go diff --git a/ui/view/zess/zess.go b/tui/view/zess/zess.go similarity index 94% rename from ui/view/zess/zess.go rename to tui/view/zess/zess.go index cd04ed2..2d672b6 100644 --- a/ui/view/zess/zess.go +++ b/tui/view/zess/zess.go @@ -3,13 +3,13 @@ package zess import ( "context" - "database/sql" 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/ui/view" + "github.com/zeusWPI/scc/tui/view" "go.uber.org/zap" ) @@ -28,7 +28,7 @@ type weekScan struct { // Model represents the Model for the zess view type Model struct { db *db.DB - lastScanID int64 + lastScanID int32 scans []weekScan // Scans per week maxWeekScans int64 currentSeason yearWeek // Start week of the season @@ -41,7 +41,7 @@ type Msg struct{} // scanMsg is used to indicate that the zess view should be updated with new scans type scanMsg struct { Msg - lastScanID int64 + lastScanID int32 scans []weekScan } @@ -196,7 +196,7 @@ func updateScans(view view.View) (tea.Msg, error) { // Get new scans scans, err := m.db.Queries.GetAllScansSinceID(context.Background(), lastScanID) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { // No rows shouldn't be considered an error err = nil } @@ -212,7 +212,7 @@ func updateScans(view view.View) (tea.Msg, error) { // Add new scans to scan msg for _, newScan := range scans { - yearNumber, weekNumber := newScan.ScanTime.ISOWeek() + yearNumber, weekNumber := newScan.ScanTime.Time.ISOWeek() newTime := yearWeek{year: yearNumber, week: weekNumber} found := false @@ -225,7 +225,7 @@ func updateScans(view view.View) (tea.Msg, error) { } if !found { - zessScanMsg.scans = append(zessScanMsg.scans, weekScan{time: newTime, amount: 1, label: newScan.ScanTime.Format("02/01")}) + zessScanMsg.scans = append(zessScanMsg.scans, weekScan{time: newTime, amount: 1, label: newScan.ScanTime.Time.Format("02/01")}) } // Update scan ID @@ -244,7 +244,7 @@ func updateSeason(view view.View) (tea.Msg, error) { season, err := m.db.Queries.GetSeasonCurrent(context.Background()) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { // No rows shouldn't be considered an error err = nil } @@ -252,7 +252,7 @@ func updateSeason(view view.View) (tea.Msg, error) { } // Check if we have a new season - yearNumber, weekNumber := season.Start.ISOWeek() + yearNumber, weekNumber := season.Start.Time.ISOWeek() seasonStart := yearWeek{year: yearNumber, week: weekNumber} if m.currentSeason.equal(seasonStart) { // Same season From c8aca6978674e53b2dc6a0175007b231afcebd55 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 8 Dec 2024 18:59:10 +0100 Subject: [PATCH 38/46] deps: cleanup --- go.mod | 28 ++++++++++++---------------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index 202bb50..5c0c539 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,12 @@ module github.com/zeusWPI/scc go 1.23.1 require ( - github.com/NimbleMarkets/ntcharts v0.1.2 + github.com/NimbleMarkets/ntcharts v0.2.0 github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/bubbletea v1.1.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.22.1 + 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 @@ -17,13 +17,6 @@ require ( 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/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 ) require ( @@ -35,8 +28,8 @@ require ( 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.2 // indirect - github.com/charmbracelet/x/term 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 @@ -47,10 +40,13 @@ require ( 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-20240125042004-b7bafc493195 // 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 @@ -79,9 +75,9 @@ require ( 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.8.0 // indirect - golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.18.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 diff --git a/go.sum b/go.sum index f5151f6..c64bca3 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/NimbleMarkets/ntcharts v0.1.2 h1:iW1aiOif/Dm74sQd18opi10RMED5589cVhy9SGp98Tw= -github.com/NimbleMarkets/ntcharts v0.1.2/go.mod h1:WcHS7kc8oQctN1543DeV9a+gOrS4DDVfKp1N9RZFUqc= +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= @@ -16,16 +16,16 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE 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.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= -github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +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.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= -github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= -github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -46,8 +46,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o 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.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= -github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +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= @@ -89,8 +89,8 @@ 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-20240125042004-b7bafc493195 h1:zcxmFnwisGZSaEzgvkOrs4belfcRlKyIUfa3sOQSttQ= -github.com/lrstanley/bubblezone v0.0.0-20240125042004-b7bafc493195/go.mod h1:v5lEwWaguF1o2MW/ucO0ZIA/IZymdBYJJ+2cMRLE7LU= +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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= @@ -187,8 +187,8 @@ 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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/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= @@ -199,8 +199,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/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= @@ -211,8 +211,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +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= From 649371d2d47cf05ac857a289cc2ba428e73d5fdb Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 8 Dec 2024 21:49:10 +0100 Subject: [PATCH 39/46] chore(config): make multi threaded safe --- pkg/config/config.go | 53 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 1d172d7..3344fec 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,14 +4,22 @@ package config import ( "fmt" "strings" + "sync" "github.com/joho/godotenv" "github.com/spf13/viper" ) +// FIXME: Add mutex for map writes + +var mu sync.Mutex + func bindEnv(key string) { envName := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) - // nolint:errcheck // we do not care if it can get binded + + mu.Lock() + defer mu.Unlock() + viper.BindEnv(key, envName) } @@ -20,11 +28,15 @@ func Init() error { if err := godotenv.Load(); err != nil { return err } + + mu.Lock() + defer mu.Unlock() + viper.AutomaticEnv() env := GetDefaultString("app.env", "development") - viper.SetConfigName(fmt.Sprintf("%s.toml", env)) - viper.SetConfigType("toml") + viper.SetConfigName(fmt.Sprintf("%s.yaml", strings.ToLower(env))) + viper.SetConfigType("yaml") viper.AddConfigPath("./config") return viper.ReadInConfig() @@ -33,11 +45,18 @@ func Init() error { // GetString returns the value of the key in string func GetString(key string) string { bindEnv(key) + + mu.Lock() + defer mu.Unlock() + return viper.GetString(key) } // GetDefaultString returns the value of the key in string or a default value func GetDefaultString(key, defaultValue string) string { + mu.Lock() + defer mu.Unlock() + viper.SetDefault(key, defaultValue) return GetString(key) } @@ -45,11 +64,18 @@ func GetDefaultString(key, defaultValue string) string { // GetStringSlice returns the value of the key in string slice func GetStringSlice(key string) []string { bindEnv(key) + + mu.Lock() + defer mu.Unlock() + 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 { + mu.Lock() + defer mu.Unlock() + viper.SetDefault(key, defaultValue) return GetStringSlice(key) } @@ -57,11 +83,18 @@ func GetDefaultStringSlice(key string, defaultValue []string) []string { // GetInt returns the value of the key in int func GetInt(key string) int { bindEnv(key) + + mu.Lock() + defer mu.Unlock() + return viper.GetInt(key) } // GetDefaultInt returns the value of the key in int or a default value func GetDefaultInt(key string, defaultVal int) int { + mu.Lock() + defer mu.Unlock() + viper.SetDefault(key, defaultVal) return GetInt(key) } @@ -69,11 +102,18 @@ func GetDefaultInt(key string, defaultVal int) int { // GetUint16 returns the value of the key in uint16 func GetUint16(key string) uint16 { bindEnv(key) + + mu.Lock() + defer mu.Unlock() + return viper.GetUint16(key) } // GetDefaultUint16 returns the value of the key in uint16 or a default value func GetDefaultUint16(key string, defaultVal uint16) uint16 { + mu.Lock() + defer mu.Unlock() + viper.SetDefault(key, defaultVal) return GetUint16(key) } @@ -81,11 +121,18 @@ func GetDefaultUint16(key string, defaultVal uint16) uint16 { // GetBool returns the value of the key in bool func GetBool(key string) bool { bindEnv(key) + + mu.Lock() + defer mu.Unlock() + return viper.GetBool(key) } // GetDefaultBool returns the value of the key in bool or a default value func GetDefaultBool(key string, defaultVal bool) bool { + mu.Lock() + defer mu.Unlock() + viper.SetDefault(key, defaultVal) return GetBool(key) } From 02c156cb26699c8d4a488d1e67482a69d5e75321 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 8 Dec 2024 21:49:37 +0100 Subject: [PATCH 40/46] chore(config): switch to yaml --- README.md | 6 +- cmd/backend/backend.go | 12 +- config/development.toml | 78 ------------ config/development.yaml | 113 ++++++++++++++++++ config/production.toml | 0 config/production.yaml | 86 +++++++++++++ .../20241121141143_add_zess_table.sql | 6 +- db/queries/season.sql | 4 + internal/cmd/api.go | 2 +- internal/cmd/event.go | 4 +- internal/cmd/gamification.go | 2 +- internal/cmd/tap.go | 2 +- internal/cmd/zess.go | 4 +- internal/pkg/buzzer/buzzer.go | 2 +- internal/pkg/db/dto/dto.go | 4 +- internal/pkg/db/dto/scan.go | 2 +- internal/pkg/db/dto/season.go | 21 ++-- internal/pkg/db/sqlc/models.go | 4 +- internal/pkg/db/sqlc/season.sql.go | 20 +++- internal/pkg/event/api.go | 15 ++- internal/pkg/event/event.go | 10 +- internal/pkg/gamification/api.go | 6 +- internal/pkg/gamification/gamification.go | 8 +- internal/pkg/song/account.go | 2 +- internal/pkg/song/api.go | 8 +- internal/pkg/song/song.go | 4 +- internal/pkg/tap/api.go | 4 +- internal/pkg/tap/tap.go | 8 +- internal/pkg/zess/api.go | 7 +- internal/pkg/zess/zess.go | 52 ++------ pkg/config/config.go | 4 +- pkg/date/date.go | 44 +++++++ tui/screen/cammie/cammie.go | 4 +- tui/view/event/event.go | 2 +- tui/view/gamification/gamification.go | 2 +- tui/view/message/message.go | 2 +- tui/view/song/song.go | 40 +++++-- tui/view/tap/tap.go | 2 +- tui/view/zess/zess.go | 6 +- 39 files changed, 392 insertions(+), 210 deletions(-) delete mode 100644 config/development.toml create mode 100644 config/development.yaml delete mode 100644 config/production.toml create mode 100644 config/production.yaml create mode 100644 pkg/date/date.go diff --git a/README.md b/README.md index d0176ca..8efdf8f 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ Displays the cammie chat along with some other statistics. - `APP_ENV`. Available options are: - `development` - `production` - - `SONG_SPOTIFY_CLIENT_ID` - - `SONG_SPOTIFY_CLIENT_SECRET` -2. Configure the appropriate settings in the corresponding configuration file located in the [config directory](./config) + - `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 diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go index 678b4e4..6c464bc 100644 --- a/cmd/backend/backend.go +++ b/cmd/backend/backend.go @@ -31,14 +31,14 @@ func main() { zap.S().Fatal("DB: Fatal error\n", err) } - // // Tap - // _, _ = cmd.Tap(db) + // Tap + _, _ = cmd.Tap(db) - // // Zess - // _, _, _ = cmd.Zess(db) + // Zess + _, _, _ = cmd.Zess(db) - // // Gamification - // _, _ = cmd.Gamification(db) + // Gamification + _, _ = cmd.Gamification(db) // Event _, _ = cmd.Event(db) diff --git a/config/development.toml b/config/development.toml deleted file mode 100644 index d32a4b3..0000000 --- a/config/development.toml +++ /dev/null @@ -1,78 +0,0 @@ -[server] -host = "localhost" -port = 3000 - -[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_top_s = 3600 - -[tui.event] -interval_s = 3600 diff --git a/config/development.yaml b/config/development.yaml new file mode 100644 index 0000000..157510d --- /dev/null +++ b/config/development.yaml @@ -0,0 +1,113 @@ +server: + host: "localhost" + 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: 3600 + + tap: + interval_s: 60 + + zess: + weeks: 10 + interval_scan_s: 60 + interval_season_s: 3600 diff --git a/config/production.toml b/config/production.toml deleted file mode 100644 index e69de29..0000000 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/20241121141143_add_zess_table.sql b/db/migrations/20241121141143_add_zess_table.sql index a5a6f67..f202edc 100644 --- a/db/migrations/20241121141143_add_zess_table.sql +++ b/db/migrations/20241121141143_add_zess_table.sql @@ -3,12 +3,12 @@ CREATE TABLE IF NOT EXISTS season ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, - start TIMESTAMP WITH TIME ZONE NOT NULL, - "end" TIMESTAMP WITH TIME ZONE NOT NULL + start TIMESTAMP WITHOUT TIME ZONE NOT NULL, + "end" TIMESTAMP WITHOUT TIME ZONE NOT NULL ); CREATE TABLE IF NOT EXISTS scan ( - id INTEGER PRIMARY KEY, + id SERIAL PRIMARY KEY, scan_time TIMESTAMP WITH TIME ZONE NOT NULL ); -- +goose StatementEnd diff --git a/db/queries/season.sql b/db/queries/season.sql index 90682ad..94287be 100644 --- a/db/queries/season.sql +++ b/db/queries/season.sql @@ -24,6 +24,10 @@ RETURNING *; DELETE FROM season WHERE id = $1; +-- name: DeleteSeasonAll :execrows +DELETE FROM season; + + -- Other diff --git a/internal/cmd/api.go b/internal/cmd/api.go index c021fd7..1c70276 100644 --- a/internal/cmd/api.go +++ b/internal/cmd/api.go @@ -31,7 +31,7 @@ func API(db *db.DB, song *song.Song) { apiGroup := app.Group("/api") api.New(apiGroup, db, song) - host := config.GetDefaultString("server.host", "127.0.0.1") + 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 index ff392a6..aad0111 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -20,8 +20,8 @@ func Event(db *db.DB) (*event.Event, chan bool) { } func eventPeriodicUpdate(ev *event.Event, done chan bool) { - interval := config.GetDefaultInt("event.interval_s", 3600) - zap.S().Info("EventL Starting periodic leaderboard update with an interval of ", interval, " seconds") + interval := config.GetDefaultInt("backend.event.interval_s", 3600) + 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() diff --git a/internal/cmd/gamification.go b/internal/cmd/gamification.go index aae1918..b1d88ce 100644 --- a/internal/cmd/gamification.go +++ b/internal/cmd/gamification.go @@ -20,7 +20,7 @@ func Gamification(db *db.DB) (*gamification.Gamification, chan bool) { } func gamificationPeriodicUpdate(gam *gamification.Gamification, done chan bool) { - interval := config.GetDefaultInt("gamification.interval_s", 3600) + interval := config.GetDefaultInt("backend.gamification.interval_s", 3600) zap.S().Info("Gamification: Starting periodic leaderboard update with an interval of ", interval, " seconds") ticker := time.NewTicker(time.Duration(interval) * time.Second) diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go index a2fcc8f..5c80425 100644 --- a/internal/cmd/tap.go +++ b/internal/cmd/tap.go @@ -20,7 +20,7 @@ func Tap(db *db.DB) (*tap.Tap, chan bool) { } func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { - interval := config.GetDefaultInt("tap.interval_s", 60) + interval := config.GetDefaultInt("backend.tap.interval_s", 60) zap.S().Info("Tap: Starting periodic update with an interval of ", interval, " seconds") ticker := time.NewTicker(time.Duration(interval) * time.Second) diff --git a/internal/cmd/zess.go b/internal/cmd/zess.go index c40ab34..f2fd499 100644 --- a/internal/cmd/zess.go +++ b/internal/cmd/zess.go @@ -22,7 +22,7 @@ func Zess(db *db.DB) (*zess.Zess, chan bool, chan bool) { } func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool) { - interval := config.GetDefaultInt("zess.interval_season_s", 300) + interval := config.GetDefaultInt("backend.zess.interval_season_s", 300) zap.S().Info("Zess: Starting periodic season update with an interval of ", interval, " seconds") ticker := time.NewTicker(time.Duration(interval) * time.Second) @@ -50,7 +50,7 @@ func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool) { } func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool) { - interval := config.GetDefaultInt("zess.interval_scan_s", 60) + interval := config.GetDefaultInt("backend.zess.interval_scan_s", 60) zap.S().Info("Zess: Starting periodic scan update with an interval of ", interval, " seconds") ticker := time.NewTicker(time.Duration(interval) * time.Second) diff --git a/internal/pkg/buzzer/buzzer.go b/internal/pkg/buzzer/buzzer.go index ab19a3b..6b7a3c8 100644 --- a/internal/pkg/buzzer/buzzer.go +++ b/internal/pkg/buzzer/buzzer.go @@ -27,7 +27,7 @@ var defaultSong = []string{ // New returns a new buzzer instance func New() *Buzzer { - song := config.GetDefaultStringSlice("buzzer.song", defaultSong) + song := config.GetDefaultStringSlice("backend.buzzer.song", defaultSong) return &Buzzer{ Song: song, } diff --git a/internal/pkg/db/dto/dto.go b/internal/pkg/db/dto/dto.go index 9382061..08133bd 100644 --- a/internal/pkg/db/dto/dto.go +++ b/internal/pkg/db/dto/dto.go @@ -1,7 +1,9 @@ // Package dto provides the data transfer objects for the database package dto -import "github.com/go-playground/validator/v10" +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/scan.go b/internal/pkg/db/dto/scan.go index ea0183c..03eb265 100644 --- a/internal/pkg/db/dto/scan.go +++ b/internal/pkg/db/dto/scan.go @@ -23,7 +23,7 @@ func ScanDTO(scan sqlc.Scan) *Scan { // CreateParams converts a Scan to sqlc.CreateScanParams func (s *Scan) CreateParams() pgtype.Timestamptz { - return pgtype.Timestamptz{Time: s.ScanTime} + return pgtype.Timestamptz{Time: s.ScanTime, Valid: true} } // UpdateParams converts a Scan to sqlc.UpdateScanParams diff --git a/internal/pkg/db/dto/season.go b/internal/pkg/db/dto/season.go index 6b33e17..08329d2 100644 --- a/internal/pkg/db/dto/season.go +++ b/internal/pkg/db/dto/season.go @@ -1,19 +1,18 @@ package dto import ( - "time" - "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 time.Time `json:"start" validate:"required"` - End time.Time `json:"end" validate:"required"` - Current bool `json:"is_current" validate:"required"` + Start date.Date `json:"start" validate:"required"` + End date.Date `json:"end" validate:"required"` + Current bool `json:"is_current"` // FIXME: This should have `required`. However when added the validation fails even though it's present } // SeasonDTO converts a sqlc.Season to a Season @@ -21,8 +20,8 @@ func SeasonDTO(season sqlc.Season) *Season { return &Season{ ID: season.ID, Name: season.Name, - Start: season.Start.Time, - End: season.End.Time, + Start: date.Date(season.Start.Time), + End: date.Date(season.End.Time), Current: season.Current, } } @@ -41,8 +40,8 @@ func SeasonCmp(s1, s2 *Season) int { func (s *Season) CreateParams() sqlc.CreateSeasonParams { return sqlc.CreateSeasonParams{ Name: s.Name, - Start: pgtype.Timestamptz{Time: s.Start, Valid: true}, - End: pgtype.Timestamptz{Time: s.End, Valid: true}, + Start: pgtype.Timestamp{Time: s.Start.ToTime(), Valid: true}, + End: pgtype.Timestamp{Time: s.End.ToTime(), Valid: true}, Current: s.Current, } } @@ -52,8 +51,8 @@ func (s *Season) UpdateParams() sqlc.UpdateSeasonParams { return sqlc.UpdateSeasonParams{ ID: s.ID, Name: s.Name, - Start: pgtype.Timestamptz{Time: s.Start, Valid: true}, - End: pgtype.Timestamptz{Time: s.End, Valid: true}, + 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/sqlc/models.go b/internal/pkg/db/sqlc/models.go index ac286c0..4083a8a 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -40,8 +40,8 @@ type Scan struct { type Season struct { ID int32 Name string - Start pgtype.Timestamptz - End pgtype.Timestamptz + Start pgtype.Timestamp + End pgtype.Timestamp Current bool } diff --git a/internal/pkg/db/sqlc/season.sql.go b/internal/pkg/db/sqlc/season.sql.go index dec33ce..e40e0d8 100644 --- a/internal/pkg/db/sqlc/season.sql.go +++ b/internal/pkg/db/sqlc/season.sql.go @@ -19,8 +19,8 @@ RETURNING id, name, start, "end", current type CreateSeasonParams struct { Name string - Start pgtype.Timestamptz - End pgtype.Timestamptz + Start pgtype.Timestamp + End pgtype.Timestamp Current bool } @@ -55,6 +55,18 @@ func (q *Queries) DeleteSeason(ctx context.Context, id int32) (int64, error) { 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 @@ -138,8 +150,8 @@ RETURNING id, name, start, "end", current type UpdateSeasonParams struct { Name string - Start pgtype.Timestamptz - End pgtype.Timestamptz + Start pgtype.Timestamp + End pgtype.Timestamp Current bool ID int32 } diff --git a/internal/pkg/event/api.go b/internal/pkg/event/api.go index 3a818bc..2302ef6 100644 --- a/internal/pkg/event/api.go +++ b/internal/pkg/event/api.go @@ -10,11 +10,19 @@ import ( "github.com/gocolly/colly" "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/pkg/config" + "go.uber.org/zap" ) +// TODO: Look at https://github.com/PuerkitoBio/goquery + var layout = "Monday 02 January, 15:04 2006" func (e *Event) getEvents() ([]dto.Event, error) { + zap.S().Info("Events: Getting all events") + + website := config.GetDefaultString("backend.event.website", "https://zeus.gent/events") + var events []dto.Event var errs []error c := colly.NewCollector() @@ -62,7 +70,7 @@ func (e *Event) getEvents() ([]dto.Event, error) { events = append(events, event) }) - err := c.Visit(e.api) + err := c.Visit(website) if err != nil { return nil, err } @@ -73,6 +81,9 @@ func (e *Event) getEvents() ([]dto.Event, error) { } func (e *Event) getPoster(event *dto.Event) error { + zap.S().Info("Events: Getting poster for ", event.Name) + + website := config.GetDefaultString("backend.event.website_poster", "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master") yearParts := strings.Split(event.AcademicYear, "-") if len(yearParts) != 2 { return fmt.Errorf("Event: Academic year not properly formatted %s", event.AcademicYear) @@ -89,7 +100,7 @@ func (e *Event) getPoster(event *dto.Event) error { year := fmt.Sprintf("20%d-20%d", yearStart, yearEnd) - url := fmt.Sprintf("%s/%s/%s/scc.png", e.apiPoster, year, event.Name) + url := fmt.Sprintf("%s/%s/%s/scc.png", website, year, event.Name) req := fiber.Get(url) status, body, errs := req.Bytes() diff --git a/internal/pkg/event/event.go b/internal/pkg/event/event.go index 8f1d373..d319347 100644 --- a/internal/pkg/event/event.go +++ b/internal/pkg/event/event.go @@ -9,22 +9,16 @@ import ( "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/pkg/config" ) // Event represents a event instance type Event struct { - db *db.DB - api string - apiPoster string + db *db.DB } // New creates a new event instance func New(db *db.DB) *Event { - api := config.GetDefaultString("event.api", "https://zeus.gent/events") - apiPoster := config.GetDefaultString("event.api_poster", "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master") - - return &Event{db: db, api: api, apiPoster: apiPoster} + return &Event{db: db} } // Update gets all events from the website of this academic year diff --git a/internal/pkg/gamification/api.go b/internal/pkg/gamification/api.go index b899fb8..280dd1e 100644 --- a/internal/pkg/gamification/api.go +++ b/internal/pkg/gamification/api.go @@ -6,6 +6,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) @@ -19,7 +20,8 @@ type gamificationItem struct { func (g *Gamification) getLeaderboard() ([]dto.Gamification, error) { zap.S().Info("Gamification: Getting leaderboard") - req := fiber.Get(g.api+"/top4").Set("Accept", "application/json") + api := config.GetDefaultString("backend.gamification.api", "https://gamification.zeus.gent") + req := fiber.Get(api+"/top4").Set("Accept", "application/json") res := new([]gamificationItem) status, _, errs := req.Struct(res) @@ -56,6 +58,8 @@ func (g *Gamification) getLeaderboard() ([]dto.Gamification, error) { } 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 { diff --git a/internal/pkg/gamification/gamification.go b/internal/pkg/gamification/gamification.go index 6b36bef..d6763ac 100644 --- a/internal/pkg/gamification/gamification.go +++ b/internal/pkg/gamification/gamification.go @@ -6,20 +6,16 @@ import ( "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 + db *db.DB } // New creates a new gamification instance func New(db *db.DB) *Gamification { - api := config.GetDefaultString("gamification.api", "https://gamification.zeus.gent") - - return &Gamification{db: db, api: api} + return &Gamification{db: db} } // Update gets the current leaderboard from gamification diff --git a/internal/pkg/song/account.go b/internal/pkg/song/account.go index be29ffb..9c779b0 100644 --- a/internal/pkg/song/account.go +++ b/internal/pkg/song/account.go @@ -21,7 +21,7 @@ func (s *Song) refreshToken() error { form := &fiber.Args{} form.Add("grant_type", "client_credentials") - api := config.GetDefaultString("song.spotify_account", "https://accounts.spotify.com/api/token") + api := config.GetDefaultString("backend.song.spotify_api_account", "https://accounts.spotify.com/api/token") req := fiber.Post(api).Form(form).BasicAuth(s.ClientID, s.ClientSecret) res := new(accountResponse) diff --git a/internal/pkg/song/api.go b/internal/pkg/song/api.go index b4eef5c..cf3eb49 100644 --- a/internal/pkg/song/api.go +++ b/internal/pkg/song/api.go @@ -11,7 +11,7 @@ import ( "go.uber.org/zap" ) -var api = config.GetDefaultString("song.spotify_api", "https://api.spotify.com/v1") +var api = config.GetDefaultString("backend.song.spotify_api", "https://api.spotify.com/v1") type trackArtist struct { ID string `json:"id"` @@ -71,6 +71,8 @@ type artistResponse struct { } func (s *Song) getArtist(artist *dto.SongArtist) error { + zap.S().Info("Song: Getting artists info for ", artist.ID) + req := fiber.Get(fmt.Sprintf("%s/%s/%s", api, "artists", artist.SpotifyID)). Set("Authorization", fmt.Sprintf("Bearer %s", s.AccessToken)) @@ -99,6 +101,8 @@ type lyricsResponse struct { } 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) @@ -117,7 +121,7 @@ func (s *Song) getLyrics(track *dto.Song) error { params.Set("album_name", track.Album) params.Set("duration", fmt.Sprintf("%d", track.DurationMS/1000)) - req := fiber.Get(fmt.Sprintf("%s/get?%s", config.GetDefaultString("song.lrclib_api", "https://lrclib.net/api"), params.Encode())) + req := fiber.Get(fmt.Sprintf("%s/get?%s", config.GetDefaultString("backend.song.lrclib_api", "https://lrclib.net/api"), params.Encode())) res := new(lyricsResponse) status, _, errs := req.Struct(res) diff --git a/internal/pkg/song/song.go b/internal/pkg/song/song.go index ccdd76e..b50841f 100644 --- a/internal/pkg/song/song.go +++ b/internal/pkg/song/song.go @@ -24,8 +24,8 @@ type Song struct { // New creates a new song instance func New(db *db.DB) (*Song, error) { - clientID := config.GetDefaultString("song.spotify_client_id", "") - clientSecret := config.GetDefaultString("song.spotify_client_secret", "") + 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") diff --git a/internal/pkg/tap/api.go b/internal/pkg/tap/api.go index 522b16e..bfb76e3 100644 --- a/internal/pkg/tap/api.go +++ b/internal/pkg/tap/api.go @@ -5,6 +5,7 @@ import ( "time" "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) @@ -22,7 +23,8 @@ type orderResponse struct { func (t *Tap) getOrders() ([]orderResponseItem, error) { zap.S().Info("Tap: Getting orders") - req := fiber.Get(t.api + "/recent") + api := config.GetDefaultString("backend.tap.api", "https://tap.zeus.gent") + req := fiber.Get(api + "/recent") res := new(orderResponse) status, _, errs := req.Struct(res) diff --git a/internal/pkg/tap/tap.go b/internal/pkg/tap/tap.go index 678d0fa..2988a10 100644 --- a/internal/pkg/tap/tap.go +++ b/internal/pkg/tap/tap.go @@ -18,7 +18,6 @@ import ( // Tap represents a tap instance type Tap struct { db *db.DB - api string beers []string } @@ -38,10 +37,9 @@ var defaultBeers = []string{ // New creates a new tap instance func New(db *db.DB) *Tap { - api := config.GetDefaultString("tap.api", "https://tap.zeus.gent") - beers := config.GetDefaultStringSlice("tap.beers", defaultBeers) + beers := config.GetDefaultStringSlice("backend.tap.beers", defaultBeers) - return &Tap{db: db, api: api, beers: beers} + return &Tap{db: db, beers: beers} } // Update gets all new orders from tap @@ -77,7 +75,7 @@ func (t *Tap) Update() error { for _, order := range orders { _, err := t.db.Queries.CreateTap(context.Background(), sqlc.CreateTapParams{ OrderID: order.OrderID, - OrderCreatedAt: pgtype.Timestamptz{Time: order.OrderCreatedAt}, + OrderCreatedAt: pgtype.Timestamptz{Time: order.OrderCreatedAt, Valid: true}, Name: order.ProductName, Category: order.ProductCategory, }) diff --git a/internal/pkg/zess/api.go b/internal/pkg/zess/api.go index 3b26ff1..9bc8afb 100644 --- a/internal/pkg/zess/api.go +++ b/internal/pkg/zess/api.go @@ -6,13 +6,15 @@ import ( "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) func (z *Zess) getSeasons() (*[]*dto.Season, error) { zap.S().Info("Zess: Getting seasons") - req := fiber.Get(z.api + "/seasons") + api := config.GetDefaultString("backend.zess.api", "https://zess.zeus.gent") + req := fiber.Get(api + "/seasons") res := new([]*dto.Season) status, _, errs := req.Struct(res) @@ -36,7 +38,8 @@ func (z *Zess) getSeasons() (*[]*dto.Season, error) { func (z *Zess) getScans() (*[]*dto.Scan, error) { zap.S().Info("Zess: Getting scans") - req := fiber.Get(z.api + "/recent_scans") + api := config.GetDefaultString("backend.zess.api", "https://zess.zeus.gent") + req := fiber.Get(api + "/recent_scans") res := new([]*dto.Scan) status, _, errs := req.Struct(res) diff --git a/internal/pkg/zess/zess.go b/internal/pkg/zess/zess.go index c524b2c..9b10f27 100644 --- a/internal/pkg/zess/zess.go +++ b/internal/pkg/zess/zess.go @@ -7,33 +7,27 @@ import ( "slices" "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/dto" "github.com/zeusWPI/scc/internal/pkg/db/sqlc" - "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/util" ) // Zess represents a zess instance type Zess struct { - db *db.DB - api string + db *db.DB } // New creates a new zess instance func New(db *db.DB) *Zess { - api := config.GetDefaultString("zess.api", "https://zess.zeus.gent") - return &Zess{db: db, api: api} + return &Zess{db: db} } // UpdateSeasons updates the seasons func (z *Zess) UpdateSeasons() error { seasons, err := z.db.Queries.GetAllSeasons(context.Background()) - if err != nil { - if err != pgx.ErrNoRows { - return err - } + if err != nil && err != pgx.ErrNoRows { + return err } // Get all seasons from zess @@ -42,41 +36,19 @@ func (z *Zess) UpdateSeasons() error { return err } - equal := slices.CompareFunc(util.SliceMap(seasons, dto.SeasonDTO), *zessSeasons, dto.SeasonCmp) - - // Same seasons - if equal == 0 { + if slices.CompareFunc(util.SliceMap(seasons, dto.SeasonDTO), *zessSeasons, dto.SeasonCmp) == 0 { return nil } - // Update seasons - errs := make([]error, 0) - - for i, season := range *zessSeasons { - if i < len(seasons) { - // Update seasons - seasons[i].ID = season.ID - seasons[i].Name = season.Name - seasons[i].Start = pgtype.Timestamptz{Time: season.Start} - seasons[i].End = pgtype.Timestamptz{Time: season.End} - - _, err := z.db.Queries.UpdateSeason(context.Background(), dto.SeasonDTO(seasons[i]).UpdateParams()) - if err != nil { - errs = append(errs, err) - } - } else { - // Create seasons - _, err := z.db.Queries.CreateSeason(context.Background(), season.CreateParams()) - if err != nil { - errs = append(errs, err) - } - } + // The seasons differ + // Delete all existing and enter the new ones + if _, err := z.db.Queries.DeleteSeasonAll(context.Background()); err != nil { + return err } - // Delete seasons - for i := len(*zessSeasons); i < len(seasons); i++ { - _, err := z.db.Queries.DeleteSeason(context.Background(), seasons[i].ID) - if err != nil { + var errs []error + for _, season := range *zessSeasons { + if _, err := z.db.Queries.CreateSeason(context.Background(), season.CreateParams()); err != nil { errs = append(errs, err) } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 3344fec..6c3e8b0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,8 +10,6 @@ import ( "github.com/spf13/viper" ) -// FIXME: Add mutex for map writes - var mu sync.Mutex func bindEnv(key string) { @@ -20,7 +18,7 @@ func bindEnv(key string) { mu.Lock() defer mu.Unlock() - viper.BindEnv(key, envName) + _ = viper.BindEnv(key, envName) } // Init initializes the configuration 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/tui/screen/cammie/cammie.go b/tui/screen/cammie/cammie.go index f3d763b..29187db 100644 --- a/tui/screen/cammie/cammie.go +++ b/tui/screen/cammie/cammie.go @@ -39,7 +39,7 @@ 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: 2, width: 0, height: 0} + return &Cammie{db: db, messages: messages, bottom: top, top: bottom, indexTop: 0, width: 0, height: 0} } // Init initializes the cammie screen @@ -162,7 +162,7 @@ func (c *Cammie) GetSizeMsg() tea.Msg { } func updateBottomIndex(cammie Cammie) tea.Cmd { - timeout := time.Duration(config.GetDefaultInt("tui.screen.cammie_interval_change_s", 300) * int(time.Second)) + 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) diff --git a/tui/view/event/event.go b/tui/view/event/event.go index 6dcf550..755ad70 100644 --- a/tui/view/event/event.go +++ b/tui/view/event/event.go @@ -76,7 +76,7 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { Name: "event update", View: m, Update: updateEvents, - Interval: config.GetDefaultInt("tui.event.interval_s", 3600), + Interval: config.GetDefaultInt("tui.view.event.interval_s", 3600), }, } } diff --git a/tui/view/gamification/gamification.go b/tui/view/gamification/gamification.go index 15d7282..d47bec6 100644 --- a/tui/view/gamification/gamification.go +++ b/tui/view/gamification/gamification.go @@ -86,7 +86,7 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { Name: "gamification leaderboard", View: m, Update: updateLeaderboard, - Interval: config.GetDefaultInt("tui.gamification.interval_s", 3600), + Interval: config.GetDefaultInt("tui.view.gamification.interval_s", 3600), }, } } diff --git a/tui/view/message/message.go b/tui/view/message/message.go index 0ed52df..abe6704 100644 --- a/tui/view/message/message.go +++ b/tui/view/message/message.go @@ -87,7 +87,7 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { Name: "cammie messages", View: m, Update: updateMessages, - Interval: config.GetDefaultInt("tui.message.interval_s", 1), + Interval: config.GetDefaultInt("tui.view.message.interval_s", 1), }, } } diff --git a/tui/view/song/song.go b/tui/view/song/song.go index 1a4e33a..c4ab914 100644 --- a/tui/view/song/song.go +++ b/tui/view/song/song.go @@ -57,6 +57,10 @@ type msgTop struct { topArtists []topStat } +type msgHistory struct { + history []string +} + type msgLyrics struct { song dto.Song previous []string @@ -73,13 +77,10 @@ type topStat struct { // NewModel initializes a new song model func NewModel(db *db.DB) view.View { - // Get history, afterwards it gets updated when a new currentSong is detected - history, _ := db.Queries.GetSongHistory(context.Background()) - return &Model{ db: db, current: playing{stopwatch: stopwatch.New(), progress: progress.New()}, - history: history, + history: make([]string, 0, 5), topSongs: make([]topStat, 0, 5), topGenres: make([]topStat, 0, 5), topArtists: make([]topStat, 0, 5), @@ -110,11 +111,6 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { return m, nil case msgPlaying: - m.history = append(m.history, msg.current.song.Title) - if len(m.history) > 5 { - m.history = m.history[1:] - } - m.current = msg.current // New song, start the commands to update the lyrics lyric, ok := m.current.lyrics.Next() @@ -139,6 +135,11 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { 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))) + case msgHistory: + m.history = msg.history + + return m, nil + case msgTop: if msg.topSongs != nil { m.topSongs = msg.topSongs @@ -203,13 +204,19 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { Name: "update current song", View: m, Update: updateCurrentSong, - Interval: config.GetDefaultInt("tui.song.interval_current_s", 5), + 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.song.interval_top_s", 3600), + Interval: config.GetDefaultInt("tui.view.song.interval_top_s", 3600), }, } } @@ -244,6 +251,17 @@ func updateCurrentSong(view view.View) (tea.Msg, error) { return msgPlaying{current: playing{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{} diff --git a/tui/view/tap/tap.go b/tui/view/tap/tap.go index d752685..3f51a39 100644 --- a/tui/view/tap/tap.go +++ b/tui/view/tap/tap.go @@ -117,7 +117,7 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { Name: "tap orders", View: m, Update: updateOrders, - Interval: config.GetDefaultInt("tui.tap.interval_s", 60), + Interval: config.GetDefaultInt("tui.view.tap.interval_s", 60), }, } } diff --git a/tui/view/zess/zess.go b/tui/view/zess/zess.go index 2d672b6..53bc632 100644 --- a/tui/view/zess/zess.go +++ b/tui/view/zess/zess.go @@ -120,7 +120,7 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { m.maxWeekScans = newScan.amount } // Make sure the array doesn't get too big - if len(m.scans) > config.GetDefaultInt("tui.zess.weeks", 10) { + if len(m.scans) > config.GetDefaultInt("tui.view.zess.weeks", 10) { m.scans = m.scans[:1] } } @@ -177,13 +177,13 @@ func (m *Model) GetUpdateDatas() []view.UpdateData { Name: "zess scans", View: m, Update: updateScans, - Interval: config.GetDefaultInt("tui.zess.interval_scan_s", 60), + Interval: config.GetDefaultInt("tui.view.zess.interval_scan_s", 60), }, { Name: "zess season", View: m, Update: updateSeason, - Interval: config.GetDefaultInt("tui.zess.interval_season_s", 3600), + Interval: config.GetDefaultInt("tui.view.zess.interval_season_s", 3600), }, } } From 97ef20cf19a73946fa38f03446aa3737ea14714c Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 8 Dec 2024 22:19:27 +0100 Subject: [PATCH 41/46] feat(config): yeet thread safety, just hope for the best --- internal/cmd/event.go | 6 ++--- internal/cmd/gamification.go | 6 ++--- internal/cmd/tap.go | 6 ++--- internal/cmd/zess.go | 14 +++++----- internal/pkg/event/api.go | 10 +++++--- internal/pkg/gamification/api.go | 3 ++- internal/pkg/song/account.go | 5 ++-- internal/pkg/song/api.go | 7 +++-- internal/pkg/tap/api.go | 3 ++- internal/pkg/zess/api.go | 6 +++-- pkg/config/config.go | 44 -------------------------------- tui/screen/cammie/cammie.go | 4 ++- tui/view/zess/zess.go | 4 ++- 13 files changed, 45 insertions(+), 73 deletions(-) diff --git a/internal/cmd/event.go b/internal/cmd/event.go index aad0111..6957ac2 100644 --- a/internal/cmd/event.go +++ b/internal/cmd/event.go @@ -13,14 +13,14 @@ import ( 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) + go eventPeriodicUpdate(ev, done, interval) return ev, done } -func eventPeriodicUpdate(ev *event.Event, done chan bool) { - interval := config.GetDefaultInt("backend.event.interval_s", 3600) +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) diff --git a/internal/cmd/gamification.go b/internal/cmd/gamification.go index b1d88ce..8561625 100644 --- a/internal/cmd/gamification.go +++ b/internal/cmd/gamification.go @@ -13,14 +13,14 @@ import ( 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) + go gamificationPeriodicUpdate(gam, done, interval) return gam, done } -func gamificationPeriodicUpdate(gam *gamification.Gamification, done chan bool) { - interval := config.GetDefaultInt("backend.gamification.interval_s", 3600) +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) diff --git a/internal/cmd/tap.go b/internal/cmd/tap.go index 5c80425..00e97f5 100644 --- a/internal/cmd/tap.go +++ b/internal/cmd/tap.go @@ -13,14 +13,14 @@ import ( 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) + go tapPeriodicUpdate(tap, done, interval) return tap, done } -func tapPeriodicUpdate(tap *tap.Tap, done chan bool) { - interval := config.GetDefaultInt("backend.tap.interval_s", 60) +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) diff --git a/internal/cmd/zess.go b/internal/cmd/zess.go index f2fd499..03e3888 100644 --- a/internal/cmd/zess.go +++ b/internal/cmd/zess.go @@ -12,17 +12,20 @@ import ( // 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) - go zessPeriodicScanUpdate(zess, doneScan) + go zessPeriodicSeasonUpdate(zess, doneSeason, intervalSeason) + go zessPeriodicScanUpdate(zess, doneScan, intervalScan) return zess, doneSeason, doneScan } -func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool) { - interval := config.GetDefaultInt("backend.zess.interval_season_s", 300) +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) @@ -49,8 +52,7 @@ func zessPeriodicSeasonUpdate(zess *zess.Zess, done chan bool) { } } -func zessPeriodicScanUpdate(zess *zess.Zess, done chan bool) { - interval := config.GetDefaultInt("backend.zess.interval_scan_s", 60) +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) diff --git a/internal/pkg/event/api.go b/internal/pkg/event/api.go index 2302ef6..926437e 100644 --- a/internal/pkg/event/api.go +++ b/internal/pkg/event/api.go @@ -18,11 +18,14 @@ import ( var layout = "Monday 02 January, 15:04 2006" +var ( + 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") +) + func (e *Event) getEvents() ([]dto.Event, error) { zap.S().Info("Events: Getting all events") - website := config.GetDefaultString("backend.event.website", "https://zeus.gent/events") - var events []dto.Event var errs []error c := colly.NewCollector() @@ -83,7 +86,6 @@ func (e *Event) getEvents() ([]dto.Event, error) { func (e *Event) getPoster(event *dto.Event) error { zap.S().Info("Events: Getting poster for ", event.Name) - website := config.GetDefaultString("backend.event.website_poster", "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master") yearParts := strings.Split(event.AcademicYear, "-") if len(yearParts) != 2 { return fmt.Errorf("Event: Academic year not properly formatted %s", event.AcademicYear) @@ -100,7 +102,7 @@ func (e *Event) getPoster(event *dto.Event) error { year := fmt.Sprintf("20%d-20%d", yearStart, yearEnd) - url := fmt.Sprintf("%s/%s/%s/scc.png", website, year, event.Name) + url := fmt.Sprintf("%s/%s/%s/scc.png", websitePoster, year, event.Name) req := fiber.Get(url) status, body, errs := req.Bytes() diff --git a/internal/pkg/gamification/api.go b/internal/pkg/gamification/api.go index 280dd1e..161a36a 100644 --- a/internal/pkg/gamification/api.go +++ b/internal/pkg/gamification/api.go @@ -10,6 +10,8 @@ import ( "go.uber.org/zap" ) +var api = config.GetDefaultString("backend.gamification.api", "https://gamification.zeus.gent") + type gamificationItem struct { ID int32 `json:"id"` Name string `json:"github_name"` @@ -20,7 +22,6 @@ type gamificationItem struct { func (g *Gamification) getLeaderboard() ([]dto.Gamification, error) { zap.S().Info("Gamification: Getting leaderboard") - api := config.GetDefaultString("backend.gamification.api", "https://gamification.zeus.gent") req := fiber.Get(api+"/top4").Set("Accept", "application/json") res := new([]gamificationItem) diff --git a/internal/pkg/song/account.go b/internal/pkg/song/account.go index 9c779b0..7687121 100644 --- a/internal/pkg/song/account.go +++ b/internal/pkg/song/account.go @@ -9,6 +9,8 @@ import ( "go.uber.org/zap" ) +var apiAccount = config.GetDefaultString("backend.song.spotify_api_account", "https://accounts.spotify.com/api/token") + type accountResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` @@ -21,8 +23,7 @@ func (s *Song) refreshToken() error { form := &fiber.Args{} form.Add("grant_type", "client_credentials") - api := config.GetDefaultString("backend.song.spotify_api_account", "https://accounts.spotify.com/api/token") - req := fiber.Post(api).Form(form).BasicAuth(s.ClientID, s.ClientSecret) + req := fiber.Post(apiAccount).Form(form).BasicAuth(s.ClientID, s.ClientSecret) res := new(accountResponse) status, _, errs := req.Struct(res) diff --git a/internal/pkg/song/api.go b/internal/pkg/song/api.go index cf3eb49..48c2f8c 100644 --- a/internal/pkg/song/api.go +++ b/internal/pkg/song/api.go @@ -11,7 +11,10 @@ import ( "go.uber.org/zap" ) -var api = config.GetDefaultString("backend.song.spotify_api", "https://api.spotify.com/v1") +var ( + api = config.GetDefaultString("backend.song.spotify_api", "https://api.spotify.com/v1") + apiLrclib = config.GetDefaultString("backend.song.lrclib_api", "https://lrclib.net/api") +) type trackArtist struct { ID string `json:"id"` @@ -121,7 +124,7 @@ func (s *Song) getLyrics(track *dto.Song) error { params.Set("album_name", track.Album) params.Set("duration", fmt.Sprintf("%d", track.DurationMS/1000)) - req := fiber.Get(fmt.Sprintf("%s/get?%s", config.GetDefaultString("backend.song.lrclib_api", "https://lrclib.net/api"), params.Encode())) + req := fiber.Get(fmt.Sprintf("%s/get?%s", apiLrclib, params.Encode())) res := new(lyricsResponse) status, _, errs := req.Struct(res) diff --git a/internal/pkg/tap/api.go b/internal/pkg/tap/api.go index bfb76e3..e0803b5 100644 --- a/internal/pkg/tap/api.go +++ b/internal/pkg/tap/api.go @@ -9,6 +9,8 @@ import ( "go.uber.org/zap" ) +var api = config.GetDefaultString("backend.tap.api", "https://tap.zeus.gent") + type orderResponseItem struct { OrderID int32 `json:"order_id"` OrderCreatedAt time.Time `json:"order_created_at"` @@ -23,7 +25,6 @@ type orderResponse struct { func (t *Tap) getOrders() ([]orderResponseItem, error) { zap.S().Info("Tap: Getting orders") - api := config.GetDefaultString("backend.tap.api", "https://tap.zeus.gent") req := fiber.Get(api + "/recent") res := new(orderResponse) diff --git a/internal/pkg/zess/api.go b/internal/pkg/zess/api.go index 9bc8afb..1e88f6b 100644 --- a/internal/pkg/zess/api.go +++ b/internal/pkg/zess/api.go @@ -10,10 +10,13 @@ import ( "go.uber.org/zap" ) +var ( + api = config.GetDefaultString("backend.zess.api", "https://zess.zeus.gent") +) + func (z *Zess) getSeasons() (*[]*dto.Season, error) { zap.S().Info("Zess: Getting seasons") - api := config.GetDefaultString("backend.zess.api", "https://zess.zeus.gent") req := fiber.Get(api + "/seasons") res := new([]*dto.Season) @@ -38,7 +41,6 @@ func (z *Zess) getSeasons() (*[]*dto.Season, error) { func (z *Zess) getScans() (*[]*dto.Scan, error) { zap.S().Info("Zess: Getting scans") - api := config.GetDefaultString("backend.zess.api", "https://zess.zeus.gent") req := fiber.Get(api + "/recent_scans") res := new([]*dto.Scan) diff --git a/pkg/config/config.go b/pkg/config/config.go index 6c3e8b0..b8ace59 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,20 +4,14 @@ package config import ( "fmt" "strings" - "sync" "github.com/joho/godotenv" "github.com/spf13/viper" ) -var mu sync.Mutex - func bindEnv(key string) { envName := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) - mu.Lock() - defer mu.Unlock() - _ = viper.BindEnv(key, envName) } @@ -27,9 +21,6 @@ func Init() error { return err } - mu.Lock() - defer mu.Unlock() - viper.AutomaticEnv() env := GetDefaultString("app.env", "development") @@ -43,18 +34,11 @@ func Init() error { // GetString returns the value of the key in string func GetString(key string) string { bindEnv(key) - - mu.Lock() - defer mu.Unlock() - return viper.GetString(key) } // GetDefaultString returns the value of the key in string or a default value func GetDefaultString(key, defaultValue string) string { - mu.Lock() - defer mu.Unlock() - viper.SetDefault(key, defaultValue) return GetString(key) } @@ -62,18 +46,11 @@ func GetDefaultString(key, defaultValue string) string { // GetStringSlice returns the value of the key in string slice func GetStringSlice(key string) []string { bindEnv(key) - - mu.Lock() - defer mu.Unlock() - 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 { - mu.Lock() - defer mu.Unlock() - viper.SetDefault(key, defaultValue) return GetStringSlice(key) } @@ -81,18 +58,11 @@ func GetDefaultStringSlice(key string, defaultValue []string) []string { // GetInt returns the value of the key in int func GetInt(key string) int { bindEnv(key) - - mu.Lock() - defer mu.Unlock() - return viper.GetInt(key) } // GetDefaultInt returns the value of the key in int or a default value func GetDefaultInt(key string, defaultVal int) int { - mu.Lock() - defer mu.Unlock() - viper.SetDefault(key, defaultVal) return GetInt(key) } @@ -100,18 +70,11 @@ func GetDefaultInt(key string, defaultVal int) int { // GetUint16 returns the value of the key in uint16 func GetUint16(key string) uint16 { bindEnv(key) - - mu.Lock() - defer mu.Unlock() - return viper.GetUint16(key) } // GetDefaultUint16 returns the value of the key in uint16 or a default value func GetDefaultUint16(key string, defaultVal uint16) uint16 { - mu.Lock() - defer mu.Unlock() - viper.SetDefault(key, defaultVal) return GetUint16(key) } @@ -119,18 +82,11 @@ func GetDefaultUint16(key string, defaultVal uint16) uint16 { // GetBool returns the value of the key in bool func GetBool(key string) bool { bindEnv(key) - - mu.Lock() - defer mu.Unlock() - return viper.GetBool(key) } // GetDefaultBool returns the value of the key in bool or a default value func GetDefaultBool(key string, defaultVal bool) bool { - mu.Lock() - defer mu.Unlock() - viper.SetDefault(key, defaultVal) return GetBool(key) } diff --git a/tui/screen/cammie/cammie.go b/tui/screen/cammie/cammie.go index 29187db..62b5a1b 100644 --- a/tui/screen/cammie/cammie.go +++ b/tui/screen/cammie/cammie.go @@ -18,6 +18,8 @@ import ( "github.com/zeusWPI/scc/tui/view/zess" ) +var bottomTimeout = config.GetDefaultInt("tui.screen.cammie.interval_s", 300) + // Cammie represents the cammie screen type Cammie struct { db *db.DB @@ -162,7 +164,7 @@ func (c *Cammie) GetSizeMsg() tea.Msg { } func updateBottomIndex(cammie Cammie) tea.Cmd { - timeout := time.Duration(config.GetDefaultInt("tui.screen.cammie.interval_s", 300) * int(time.Second)) + timeout := time.Duration(bottomTimeout * int(time.Second)) return tea.Tick(timeout, func(_ time.Time) tea.Msg { newIndex := (cammie.indexTop + 1) % len(cammie.top) diff --git a/tui/view/zess/zess.go b/tui/view/zess/zess.go index 53bc632..e9d9963 100644 --- a/tui/view/zess/zess.go +++ b/tui/view/zess/zess.go @@ -13,6 +13,8 @@ import ( "go.uber.org/zap" ) +var maxWeeks = config.GetDefaultInt("tui.view.zess.weeks", 10) + // yearWeek represents a yearWeek object by keeping the year and week number type yearWeek struct { year int @@ -120,7 +122,7 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { m.maxWeekScans = newScan.amount } // Make sure the array doesn't get too big - if len(m.scans) > config.GetDefaultInt("tui.view.zess.weeks", 10) { + if len(m.scans) > maxWeeks { m.scans = m.scans[:1] } } From 290cf7d6fb79b3d5d5071ed16d7aa7d6750ec9d3 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 8 Dec 2024 22:40:07 +0100 Subject: [PATCH 42/46] fix(zess): season validation --- cmd/backend/backend.go | 12 ++++++------ internal/pkg/buzzer/buzzer.go | 3 +-- internal/pkg/db/dto/season.go | 2 +- internal/pkg/event/api.go | 10 ++-------- internal/pkg/event/event.go | 11 +++++++++-- internal/pkg/gamification/api.go | 5 +---- internal/pkg/gamification/gamification.go | 9 +++++++-- internal/pkg/song/account.go | 5 +---- internal/pkg/song/api.go | 12 +++--------- internal/pkg/song/song.go | 14 +++++++++++++- internal/pkg/tap/api.go | 5 +---- internal/pkg/tap/tap.go | 9 ++++++--- internal/pkg/zess/api.go | 9 ++------- internal/pkg/zess/zess.go | 9 +++++++-- tui/screen/cammie/cammie.go | 4 +--- tui/view/zess/zess.go | 6 +++--- 16 files changed, 64 insertions(+), 61 deletions(-) diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go index 6c464bc..58a611b 100644 --- a/cmd/backend/backend.go +++ b/cmd/backend/backend.go @@ -31,17 +31,17 @@ func main() { zap.S().Fatal("DB: Fatal error\n", err) } - // Tap - _, _ = cmd.Tap(db) + // // Tap + // _, _ = cmd.Tap(db) // Zess _, _, _ = cmd.Zess(db) - // Gamification - _, _ = cmd.Gamification(db) + // // Gamification + // _, _ = cmd.Gamification(db) - // Event - _, _ = cmd.Event(db) + // // Event + // _, _ = cmd.Event(db) // Spotify spotify, err := cmd.Song(db) diff --git a/internal/pkg/buzzer/buzzer.go b/internal/pkg/buzzer/buzzer.go index 6b7a3c8..2a20b6b 100644 --- a/internal/pkg/buzzer/buzzer.go +++ b/internal/pkg/buzzer/buzzer.go @@ -27,9 +27,8 @@ var defaultSong = []string{ // New returns a new buzzer instance func New() *Buzzer { - song := config.GetDefaultStringSlice("backend.buzzer.song", defaultSong) return &Buzzer{ - Song: song, + Song: config.GetDefaultStringSlice("backend.buzzer.song", defaultSong), } } diff --git a/internal/pkg/db/dto/season.go b/internal/pkg/db/dto/season.go index 08329d2..ca75630 100644 --- a/internal/pkg/db/dto/season.go +++ b/internal/pkg/db/dto/season.go @@ -12,7 +12,7 @@ type Season struct { 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"` // FIXME: This should have `required`. However when added the validation fails even though it's present + Current bool `json:"is_current" validate:"boolean"` } // SeasonDTO converts a sqlc.Season to a Season diff --git a/internal/pkg/event/api.go b/internal/pkg/event/api.go index 926437e..64ab0bb 100644 --- a/internal/pkg/event/api.go +++ b/internal/pkg/event/api.go @@ -10,7 +10,6 @@ import ( "github.com/gocolly/colly" "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) @@ -18,11 +17,6 @@ import ( var layout = "Monday 02 January, 15:04 2006" -var ( - 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") -) - func (e *Event) getEvents() ([]dto.Event, error) { zap.S().Info("Events: Getting all events") @@ -73,7 +67,7 @@ func (e *Event) getEvents() ([]dto.Event, error) { events = append(events, event) }) - err := c.Visit(website) + err := c.Visit(e.website) if err != nil { return nil, err } @@ -102,7 +96,7 @@ func (e *Event) getPoster(event *dto.Event) error { year := fmt.Sprintf("20%d-20%d", yearStart, yearEnd) - url := fmt.Sprintf("%s/%s/%s/scc.png", websitePoster, year, event.Name) + url := fmt.Sprintf("%s/%s/%s/scc.png", e.websitePoster, year, event.Name) req := fiber.Get(url) status, body, errs := req.Bytes() diff --git a/internal/pkg/event/event.go b/internal/pkg/event/event.go index d319347..ca4ef5d 100644 --- a/internal/pkg/event/event.go +++ b/internal/pkg/event/event.go @@ -9,16 +9,23 @@ import ( "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/pkg/config" ) // Event represents a event instance type Event struct { - db *db.DB + db *db.DB + website string + websitePoster string } // New creates a new event instance func New(db *db.DB) *Event { - return &Event{db: db} + 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 diff --git a/internal/pkg/gamification/api.go b/internal/pkg/gamification/api.go index 161a36a..6e3f1a3 100644 --- a/internal/pkg/gamification/api.go +++ b/internal/pkg/gamification/api.go @@ -6,12 +6,9 @@ import ( "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) -var api = config.GetDefaultString("backend.gamification.api", "https://gamification.zeus.gent") - type gamificationItem struct { ID int32 `json:"id"` Name string `json:"github_name"` @@ -22,7 +19,7 @@ type gamificationItem struct { func (g *Gamification) getLeaderboard() ([]dto.Gamification, error) { zap.S().Info("Gamification: Getting leaderboard") - req := fiber.Get(api+"/top4").Set("Accept", "application/json") + req := fiber.Get(g.api+"/top4").Set("Accept", "application/json") res := new([]gamificationItem) status, _, errs := req.Struct(res) diff --git a/internal/pkg/gamification/gamification.go b/internal/pkg/gamification/gamification.go index d6763ac..aeb2ed4 100644 --- a/internal/pkg/gamification/gamification.go +++ b/internal/pkg/gamification/gamification.go @@ -6,16 +6,21 @@ import ( "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 + db *db.DB + api string } // New creates a new gamification instance func New(db *db.DB) *Gamification { - return &Gamification{db: db} + return &Gamification{ + db: db, + api: config.GetDefaultString("backend.gamification.api", "https://gamification.zeus.gent"), + } } // Update gets the current leaderboard from gamification diff --git a/internal/pkg/song/account.go b/internal/pkg/song/account.go index 7687121..774953a 100644 --- a/internal/pkg/song/account.go +++ b/internal/pkg/song/account.go @@ -5,12 +5,9 @@ import ( "time" "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) -var apiAccount = config.GetDefaultString("backend.song.spotify_api_account", "https://accounts.spotify.com/api/token") - type accountResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` @@ -23,7 +20,7 @@ func (s *Song) refreshToken() error { form := &fiber.Args{} form.Add("grant_type", "client_credentials") - req := fiber.Post(apiAccount).Form(form).BasicAuth(s.ClientID, s.ClientSecret) + req := fiber.Post(s.apiAccount).Form(form).BasicAuth(s.ClientID, s.ClientSecret) res := new(accountResponse) status, _, errs := req.Struct(res) diff --git a/internal/pkg/song/api.go b/internal/pkg/song/api.go index 48c2f8c..5438a7d 100644 --- a/internal/pkg/song/api.go +++ b/internal/pkg/song/api.go @@ -7,15 +7,9 @@ import ( "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) -var ( - api = config.GetDefaultString("backend.song.spotify_api", "https://api.spotify.com/v1") - apiLrclib = config.GetDefaultString("backend.song.lrclib_api", "https://lrclib.net/api") -) - type trackArtist struct { ID string `json:"id"` Name string `json:"name"` @@ -35,7 +29,7 @@ type trackResponse struct { 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", api, "tracks", 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) @@ -76,7 +70,7 @@ type artistResponse struct { func (s *Song) getArtist(artist *dto.SongArtist) error { zap.S().Info("Song: Getting artists info for ", artist.ID) - req := fiber.Get(fmt.Sprintf("%s/%s/%s", api, "artists", 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) @@ -124,7 +118,7 @@ func (s *Song) getLyrics(track *dto.Song) error { params.Set("album_name", track.Album) params.Set("duration", fmt.Sprintf("%d", track.DurationMS/1000)) - req := fiber.Get(fmt.Sprintf("%s/get?%s", apiLrclib, params.Encode())) + req := fiber.Get(fmt.Sprintf("%s/get?%s", s.apiLrclib, params.Encode())) res := new(lyricsResponse) status, _, errs := req.Struct(res) diff --git a/internal/pkg/song/song.go b/internal/pkg/song/song.go index b50841f..18dab1b 100644 --- a/internal/pkg/song/song.go +++ b/internal/pkg/song/song.go @@ -20,6 +20,10 @@ type Song struct { ClientSecret string AccessToken string ExpiresTime int64 + + api string + apiAccount string + apiLrclib string } // New creates a new song instance @@ -31,7 +35,15 @@ func New(db *db.DB) (*Song, error) { return &Song{}, errors.New("Song: Spotify client id or secret not set") } - return &Song{db: db, ClientID: clientID, ClientSecret: clientSecret, ExpiresTime: 0}, nil + 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 diff --git a/internal/pkg/tap/api.go b/internal/pkg/tap/api.go index e0803b5..522b16e 100644 --- a/internal/pkg/tap/api.go +++ b/internal/pkg/tap/api.go @@ -5,12 +5,9 @@ import ( "time" "github.com/gofiber/fiber/v2" - "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) -var api = config.GetDefaultString("backend.tap.api", "https://tap.zeus.gent") - type orderResponseItem struct { OrderID int32 `json:"order_id"` OrderCreatedAt time.Time `json:"order_created_at"` @@ -25,7 +22,7 @@ type orderResponse struct { func (t *Tap) getOrders() ([]orderResponseItem, error) { zap.S().Info("Tap: Getting orders") - req := fiber.Get(api + "/recent") + req := fiber.Get(t.api + "/recent") res := new(orderResponse) status, _, errs := req.Struct(res) diff --git a/internal/pkg/tap/tap.go b/internal/pkg/tap/tap.go index 2988a10..808f8d0 100644 --- a/internal/pkg/tap/tap.go +++ b/internal/pkg/tap/tap.go @@ -19,6 +19,7 @@ import ( type Tap struct { db *db.DB beers []string + api string } var defaultBeers = []string{ @@ -37,9 +38,11 @@ var defaultBeers = []string{ // New creates a new tap instance func New(db *db.DB) *Tap { - beers := config.GetDefaultStringSlice("backend.tap.beers", defaultBeers) - - return &Tap{db: db, beers: beers} + 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 diff --git a/internal/pkg/zess/api.go b/internal/pkg/zess/api.go index 1e88f6b..3b26ff1 100644 --- a/internal/pkg/zess/api.go +++ b/internal/pkg/zess/api.go @@ -6,18 +6,13 @@ import ( "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/pkg/db/dto" - "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) -var ( - api = config.GetDefaultString("backend.zess.api", "https://zess.zeus.gent") -) - func (z *Zess) getSeasons() (*[]*dto.Season, error) { zap.S().Info("Zess: Getting seasons") - req := fiber.Get(api + "/seasons") + req := fiber.Get(z.api + "/seasons") res := new([]*dto.Season) status, _, errs := req.Struct(res) @@ -41,7 +36,7 @@ func (z *Zess) getSeasons() (*[]*dto.Season, error) { func (z *Zess) getScans() (*[]*dto.Scan, error) { zap.S().Info("Zess: Getting scans") - req := fiber.Get(api + "/recent_scans") + req := fiber.Get(z.api + "/recent_scans") res := new([]*dto.Scan) status, _, errs := req.Struct(res) diff --git a/internal/pkg/zess/zess.go b/internal/pkg/zess/zess.go index 9b10f27..eac8f8a 100644 --- a/internal/pkg/zess/zess.go +++ b/internal/pkg/zess/zess.go @@ -10,17 +10,22 @@ import ( "github.com/zeusWPI/scc/internal/pkg/db" "github.com/zeusWPI/scc/internal/pkg/db/dto" "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/util" ) // Zess represents a zess instance type Zess struct { - db *db.DB + db *db.DB + api string } // New creates a new zess instance func New(db *db.DB) *Zess { - return &Zess{db: db} + return &Zess{ + db: db, + api: config.GetDefaultString("backend.zess.api", "https://zess.zeus.gent/api"), + } } // UpdateSeasons updates the seasons diff --git a/tui/screen/cammie/cammie.go b/tui/screen/cammie/cammie.go index 62b5a1b..29187db 100644 --- a/tui/screen/cammie/cammie.go +++ b/tui/screen/cammie/cammie.go @@ -18,8 +18,6 @@ import ( "github.com/zeusWPI/scc/tui/view/zess" ) -var bottomTimeout = config.GetDefaultInt("tui.screen.cammie.interval_s", 300) - // Cammie represents the cammie screen type Cammie struct { db *db.DB @@ -164,7 +162,7 @@ func (c *Cammie) GetSizeMsg() tea.Msg { } func updateBottomIndex(cammie Cammie) tea.Cmd { - timeout := time.Duration(bottomTimeout * int(time.Second)) + 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) diff --git a/tui/view/zess/zess.go b/tui/view/zess/zess.go index e9d9963..429dbbd 100644 --- a/tui/view/zess/zess.go +++ b/tui/view/zess/zess.go @@ -13,8 +13,6 @@ import ( "go.uber.org/zap" ) -var maxWeeks = config.GetDefaultInt("tui.view.zess.weeks", 10) - // yearWeek represents a yearWeek object by keeping the year and week number type yearWeek struct { year int @@ -32,6 +30,7 @@ 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 @@ -59,6 +58,7 @@ func NewModel(db *db.DB) view.View { 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, @@ -122,7 +122,7 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { m.maxWeekScans = newScan.amount } // Make sure the array doesn't get too big - if len(m.scans) > maxWeeks { + if len(m.scans) > m.showWeeks { m.scans = m.scans[:1] } } From 1346c522fde70219ffb588b80b68148a0aa7f273 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 8 Dec 2024 23:16:19 +0100 Subject: [PATCH 43/46] chore(event): adjust default config value --- cmd/backend/backend.go | 12 ++++++------ config/development.yaml | 2 +- internal/pkg/event/api.go | 2 -- internal/pkg/event/event.go | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go index 58a611b..6c464bc 100644 --- a/cmd/backend/backend.go +++ b/cmd/backend/backend.go @@ -31,17 +31,17 @@ func main() { zap.S().Fatal("DB: Fatal error\n", err) } - // // Tap - // _, _ = cmd.Tap(db) + // Tap + _, _ = cmd.Tap(db) // Zess _, _, _ = cmd.Zess(db) - // // Gamification - // _, _ = cmd.Gamification(db) + // Gamification + _, _ = cmd.Gamification(db) - // // Event - // _, _ = cmd.Event(db) + // Event + _, _ = cmd.Event(db) // Spotify spotify, err := cmd.Song(db) diff --git a/config/development.yaml b/config/development.yaml index 157510d..b4999e3 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -49,7 +49,7 @@ backend: - "-d0" event: - website: "https://zeus.gent/events" + website: "https://zeus.gent/events/" website_poster: "https://git.zeus.gent/ZeusWPI/visueel/raw/branch/master" interval_s: 3600 diff --git a/internal/pkg/event/api.go b/internal/pkg/event/api.go index 64ab0bb..7be8e1d 100644 --- a/internal/pkg/event/api.go +++ b/internal/pkg/event/api.go @@ -13,8 +13,6 @@ import ( "go.uber.org/zap" ) -// TODO: Look at https://github.com/PuerkitoBio/goquery - var layout = "Monday 02 January, 15:04 2006" func (e *Event) getEvents() ([]dto.Event, error) { diff --git a/internal/pkg/event/event.go b/internal/pkg/event/event.go index ca4ef5d..3b92975 100644 --- a/internal/pkg/event/event.go +++ b/internal/pkg/event/event.go @@ -23,7 +23,7 @@ type Event struct { func New(db *db.DB) *Event { return &Event{ db: db, - website: config.GetDefaultString("backend.event.website", "https://zeus.gent/events"), + 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"), } } From 40b80cb4f94d69fd03a855dfa1d6eae9d76a2d51 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 10 Dec 2024 01:04:19 +0100 Subject: [PATCH 44/46] feat(progress): add a progression bar --- tui/components/progress/progress.go | 125 ++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tui/components/progress/progress.go 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} + }) +} From 97fcd10c41ef8b79f6b90e395b7cd837a29973a5 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 10 Dec 2024 01:04:31 +0100 Subject: [PATCH 45/46] feat(song): finish view --- config/development.yaml | 4 +- .../20241209222600_song_created_at.sql | 13 +++ db/queries/song.sql | 8 +- db/queries/tap.sql | 2 +- internal/pkg/db/dto/event.go | 3 +- internal/pkg/db/sqlc/song.sql.go | 8 +- internal/pkg/db/sqlc/tap.sql.go | 4 +- internal/pkg/event/event.go | 41 ++++---- internal/pkg/song/api.go | 2 +- internal/pkg/song/song.go | 1 + tui/components/stopwatch/stopwatch.go | 14 +-- tui/screen/cammie/cammie.go | 2 +- tui/screen/song/song.go | 4 +- tui/view/song/song.go | 93 ++++++++++++------- tui/view/song/style.go | 21 ++++- tui/view/song/util.go | 24 ++--- tui/view/song/view.go | 93 ++++++++++--------- tui/view/tap/tap.go | 2 +- tui/view/tap/view.go | 2 +- tui/view/util.go | 20 ++++ 20 files changed, 222 insertions(+), 139 deletions(-) create mode 100644 db/migrations/20241209222600_song_created_at.sql diff --git a/config/development.yaml b/config/development.yaml index b4999e3..5cb3c2e 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -1,5 +1,5 @@ server: - host: "localhost" + host: "0.0.0.0" port: 3000 db: @@ -102,7 +102,7 @@ tui: song: interval_current_s: 5 interval_history_s: 5 - interval_top_s: 3600 + interval_top_s: 300 tap: interval_s: 60 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/queries/song.sql b/db/queries/song.sql index 7e03b50..c81f777 100644 --- a/db/queries/song.sql +++ b/db/queries/song.sql @@ -75,7 +75,7 @@ SELECT s.title FROM song_history sh JOIN song s ON sh.song_id = s.id ORDER BY created_at DESC -LIMIT 5; +LIMIT 10; -- name: GetTopSongs :many SELECT s.id AS song_id, s.title, COUNT(sh.id) AS play_count @@ -83,7 +83,7 @@ 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 5; +LIMIT 10; -- name: GetTopArtists :many SELECT sa.id AS artist_id, sa.name AS artist_name, COUNT(sh.id) AS total_plays @@ -93,7 +93,7 @@ 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 5; +LIMIT 10; -- name: GetTopGenres :many SELECT g.genre AS genre_name, COUNT(sh.id) AS total_plays @@ -105,4 +105,4 @@ 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 5; +LIMIT 10; diff --git a/db/queries/tap.sql b/db/queries/tap.sql index 88394e7..d2a4929 100644 --- a/db/queries/tap.sql +++ b/db/queries/tap.sql @@ -50,7 +50,7 @@ FROM tap GROUP BY category; -- name: GetOrderCountByCategorySinceOrderID :many -SELECT category, COUNT(*), CAST(MAX(order_created_at) AS INTEGER) AS latest_order_created_at +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/internal/pkg/db/dto/event.go b/internal/pkg/db/dto/event.go index 5fb2614..dabf0c5 100644 --- a/internal/pkg/db/dto/event.go +++ b/internal/pkg/db/dto/event.go @@ -1,6 +1,7 @@ package dto import ( + "bytes" "time" "github.com/jackc/pgx/v5/pgtype" @@ -31,7 +32,7 @@ func EventDTO(e sqlc.Event) *Event { // 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 + 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 diff --git a/internal/pkg/db/sqlc/song.sql.go b/internal/pkg/db/sqlc/song.sql.go index fa718f5..91d0493 100644 --- a/internal/pkg/db/sqlc/song.sql.go +++ b/internal/pkg/db/sqlc/song.sql.go @@ -304,7 +304,7 @@ SELECT s.title FROM song_history sh JOIN song s ON sh.song_id = s.id ORDER BY created_at DESC -LIMIT 5 +LIMIT 10 ` func (q *Queries) GetSongHistory(ctx context.Context) ([]string, error) { @@ -335,7 +335,7 @@ 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 5 +LIMIT 10 ` type GetTopArtistsRow struct { @@ -374,7 +374,7 @@ 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 5 +LIMIT 10 ` type GetTopGenresRow struct { @@ -408,7 +408,7 @@ 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 5 +LIMIT 10 ` type GetTopSongsRow struct { diff --git a/internal/pkg/db/sqlc/tap.sql.go b/internal/pkg/db/sqlc/tap.sql.go index 8a5c7e1..56ac2eb 100644 --- a/internal/pkg/db/sqlc/tap.sql.go +++ b/internal/pkg/db/sqlc/tap.sql.go @@ -143,7 +143,7 @@ func (q *Queries) GetOrderCount(ctx context.Context) ([]GetOrderCountRow, error) } const getOrderCountByCategorySinceOrderID = `-- name: GetOrderCountByCategorySinceOrderID :many -SELECT category, COUNT(*), CAST(MAX(order_created_at) AS INTEGER) AS latest_order_created_at +SELECT category, COUNT(*), MAX(order_created_at)::TIMESTAMP AS latest_order_created_at FROM tap WHERE order_id >= $1 GROUP BY category @@ -152,7 +152,7 @@ GROUP BY category type GetOrderCountByCategorySinceOrderIDRow struct { Category string Count int64 - LatestOrderCreatedAt int32 + LatestOrderCreatedAt pgtype.Timestamp } func (q *Queries) GetOrderCountByCategorySinceOrderID(ctx context.Context, orderID int32) ([]GetOrderCountByCategorySinceOrderIDRow, error) { diff --git a/internal/pkg/event/event.go b/internal/pkg/event/event.go index 3b92975..9ccbb5f 100644 --- a/internal/pkg/event/event.go +++ b/internal/pkg/event/event.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -38,15 +39,35 @@ func (e *Event) Update() error { return nil } - eventsDB, err := e.db.Queries.GetEventByAcademicYear(context.Background(), events[0].AcademicYear) + 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(*dto.EventDTO(event)) }) + found := slices.ContainsFunc(events, func(ev dto.Event) bool { return ev.Equal(*event) }) if !found { equal = false break @@ -67,22 +88,6 @@ func (e *Event) Update() error { if err != nil { return err } - var errs []error - - var wg sync.WaitGroup - for _, event := range events { - wg.Add(1) - - go func(event *dto.Event) { - defer wg.Done() - - err := e.getPoster(event) - if err != nil { - errs = append(errs, err) - } - }(&event) - } - wg.Wait() for _, event := range events { err = e.getPoster(&event) diff --git a/internal/pkg/song/api.go b/internal/pkg/song/api.go index 5438a7d..1f651a7 100644 --- a/internal/pkg/song/api.go +++ b/internal/pkg/song/api.go @@ -68,7 +68,7 @@ type artistResponse struct { } func (s *Song) getArtist(artist *dto.SongArtist) error { - zap.S().Info("Song: Getting artists info for ", artist.ID) + 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)) diff --git a/internal/pkg/song/song.go b/internal/pkg/song/song.go index 18dab1b..5967d51 100644 --- a/internal/pkg/song/song.go +++ b/internal/pkg/song/song.go @@ -116,6 +116,7 @@ func (s *Song) Track(track *dto.Song) error { 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) } diff --git a/tui/components/stopwatch/stopwatch.go b/tui/components/stopwatch/stopwatch.go index a96a681..6bf702b 100644 --- a/tui/components/stopwatch/stopwatch.go +++ b/tui/components/stopwatch/stopwatch.go @@ -13,13 +13,13 @@ import ( var lastID int64 -func nextID() int { - return int(atomic.AddInt64(&lastID, 1)) +func nextID() int64 { + return atomic.AddInt64(&lastID, 1) } // TickMsg is a message that is sent on every stopwatch tick type TickMsg struct { - id int + id int64 } // StartStopMsg is a message that controls if the stopwatch is running or not @@ -34,12 +34,12 @@ type ResetMsg struct { // Model for the stopwatch component type Model struct { - id int + id int64 duration time.Duration running bool } -// New created a new stopwatch with a given interval +// New creates a new stopwatch with a given interval func New() Model { return Model{ id: nextID(), @@ -48,7 +48,7 @@ func New() Model { } } -// Init initiates the stopwatch component +// Init initializes the stopwatch component func (m Model) Init() tea.Cmd { return nil } @@ -121,7 +121,7 @@ func (m Model) View() string { return fmt.Sprintf("%02d:%02d", min, sec) } -func tick(id int) tea.Cmd { +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 index 29187db..bbb1ee3 100644 --- a/tui/screen/cammie/cammie.go +++ b/tui/screen/cammie/cammie.go @@ -59,7 +59,7 @@ func (c *Cammie) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { c.width = msg.Width c.height = msg.Height - sMsg = sMsg.Width(c.width/2 - sMsg.GetHorizontalFrameSize() - sMsg.GetHorizontalPadding()).Height(c.height - sMsg.GetVerticalFrameSize() - sMsg.GetVerticalPadding()) + 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()) diff --git a/tui/screen/song/song.go b/tui/screen/song/song.go index a7c8b50..57c42ac 100644 --- a/tui/screen/song/song.go +++ b/tui/screen/song/song.go @@ -19,7 +19,7 @@ type Song struct { // New creates a new song screen func New(db *db.DB) screen.Screen { - return &Song{db: db, song: song.NewModel(db), width: 0, height: 0} + return &Song{db: db, song: song.New(db), width: 0, height: 0} } // Init initializes the song screen @@ -34,7 +34,7 @@ func (s *Song) Update(msg tea.Msg) (screen.Screen, tea.Cmd) { s.width = msg.Width s.height = msg.Height - sSong = sSong.Width(s.width - sSong.GetHorizontalFrameSize() - sSong.GetHorizontalPadding()).Height(s.height - sSong.GetVerticalFrameSize() - sSong.GetVerticalPadding()) + sSong = sSong.Width(s.width - view.GetOuterWidth(sSong)).Height(s.height - sSong.GetVerticalFrameSize() - sSong.GetVerticalPadding()) return s, s.GetSizeMsg } diff --git a/tui/view/song/song.go b/tui/view/song/song.go index c4ab914..038c4e0 100644 --- a/tui/view/song/song.go +++ b/tui/view/song/song.go @@ -5,26 +5,26 @@ import ( "context" "time" - "github.com/charmbracelet/bubbles/progress" 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 = 10 // Amount of upcoming lyrics to show + upcomingAmount = 12 // Amount of upcoming lyrics to show ) type playing struct { song *dto.Song - progress progress.Model stopwatch stopwatch.Model + progress progress.Model lyrics lyrics.Lyrics previous []string // Lyrics already sang current string // Current lyric @@ -36,9 +36,9 @@ type Model struct { db *db.DB current playing history []string - topSongs []topStat - topGenres []topStat - topArtists []topStat + topSongs topStat + topGenres topStat + topArtists topStat width int height int } @@ -48,13 +48,14 @@ type Model struct { type Msg struct{} type msgPlaying struct { - current playing + song *dto.Song + lyrics lyrics.Lyrics } type msgTop struct { - topSongs []topStat - topGenres []topStat - topArtists []topStat + topSongs []topStatEntry + topGenres []topStatEntry + topArtists []topStatEntry } type msgHistory struct { @@ -62,7 +63,7 @@ type msgHistory struct { } type msgLyrics struct { - song dto.Song + song *dto.Song previous []string current string upcoming []string @@ -71,19 +72,24 @@ type msgLyrics struct { } type topStat struct { + title string + entries []topStatEntry +} + +type topStatEntry struct { name string amount int } -// NewModel initializes a new song model -func NewModel(db *db.DB) view.View { +// 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()}, + current: playing{stopwatch: stopwatch.New(), progress: progress.New(sStatusProgressFainted, sStatusProgressGlow)}, history: make([]string, 0, 5), - topSongs: make([]topStat, 0, 5), - topGenres: make([]topStat, 0, 5), - topArtists: make([]topStat, 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, } @@ -91,7 +97,10 @@ func NewModel(db *db.DB) view.View { // Init starts the song view func (m *Model) Init() tea.Cmd { - return m.current.stopwatch.Init() + return tea.Batch( + m.current.stopwatch.Init(), + m.current.progress.Init(), + ) } // Name returns the name of the view @@ -107,11 +116,19 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { 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 = msg.current + 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 { @@ -132,8 +149,13 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { 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))) + 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 @@ -142,13 +164,13 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { case msgTop: if msg.topSongs != nil { - m.topSongs = msg.topSongs + m.topSongs.entries = msg.topSongs } if msg.topGenres != nil { - m.topGenres = msg.topGenres + m.topGenres.entries = msg.topGenres } if msg.topArtists != nil { - m.topArtists = msg.topArtists + m.topArtists.entries = msg.topArtists } return m, nil @@ -173,16 +195,17 @@ func (m *Model) Update(msg tea.Msg) (view.View, tea.Cmd) { // Start the cmd to update the lyrics return m, updateLyrics(m.current, msg.startNext) - - case progress.FrameMsg: - progressModel, cmd := m.current.progress.Update(msg) - m.current.progress = progressModel.(progress.Model) - - return m, cmd } + // 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 } @@ -248,7 +271,7 @@ func updateCurrentSong(view view.View) (tea.Msg, error) { song := dto.SongDTOHistory(songs) - return msgPlaying{current: playing{song: song, lyrics: lyrics.New(song)}}, nil + return msgPlaying{song: song, lyrics: lyrics.New(song)}, nil } func updateHistory(view view.View) (tea.Msg, error) { @@ -272,7 +295,7 @@ func updateTopStats(view view.View) (tea.Msg, error) { return nil, err } - if !equalTopSongs(m.topSongs, songs) { + if !equalTopSongs(m.topSongs.entries, songs) { msg.topSongs = topStatSqlcSong(songs) change = true } @@ -282,7 +305,7 @@ func updateTopStats(view view.View) (tea.Msg, error) { return nil, err } - if !equalTopGenres(m.topGenres, genres) { + if !equalTopGenres(m.topGenres.entries, genres) { msg.topGenres = topStatSqlcGenre(genres) change = true } @@ -292,7 +315,7 @@ func updateTopStats(view view.View) (tea.Msg, error) { return nil, err } - if !equalTopArtists(m.topArtists, artists) { + if !equalTopArtists(m.topArtists.entries, artists) { msg.topArtists = topStatSqlcArtist(artists) change = true } @@ -316,7 +339,7 @@ func updateLyrics(song playing, start time.Time) tea.Cmd { lyric, ok := song.lyrics.Next() if !ok { // Song finished - return msgLyrics{song: *song.song, done: true} + return msgLyrics{song: song.song, done: true} } previous := song.lyrics.Previous(previousAmount) @@ -325,7 +348,7 @@ func updateLyrics(song playing, start time.Time) tea.Cmd { end := start.Add(lyric.Duration) return msgLyrics{ - song: *song.song, + song: song.song, previous: lyricsToString(previous), current: lyric.Text, upcoming: lyricsToString(upcoming), diff --git a/tui/view/song/style.go b/tui/view/song/style.go index 783dbf0..81fb366 100644 --- a/tui/view/song/style.go +++ b/tui/view/song/style.go @@ -6,6 +6,7 @@ import "github.com/charmbracelet/lipgloss" var ( cZeus = lipgloss.Color("#FF7F00") cSpotify = lipgloss.Color("#1DB954") + cBorder = lipgloss.Color("#383838") ) // Base style @@ -13,11 +14,12 @@ var base = lipgloss.NewStyle() // Styles for the stats var ( - wStatTotal = 30 + 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) @@ -28,15 +30,24 @@ var ( // Styles for the status var ( - sStatusSong = base - sStatusStopwatch = base.Faint(true) - sStatusProgress = base + 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) + 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 index 182cf89..8fb7b90 100644 --- a/tui/view/song/util.go +++ b/tui/view/song/util.go @@ -5,7 +5,7 @@ import ( "github.com/zeusWPI/scc/internal/pkg/lyrics" ) -func equalTopSongs(s1 []topStat, s2 []sqlc.GetTopSongsRow) bool { +func equalTopSongs(s1 []topStatEntry, s2 []sqlc.GetTopSongsRow) bool { if len(s1) != len(s2) { return false } @@ -19,15 +19,15 @@ func equalTopSongs(s1 []topStat, s2 []sqlc.GetTopSongsRow) bool { return true } -func topStatSqlcSong(songs []sqlc.GetTopSongsRow) []topStat { - topstats := make([]topStat, 0, len(songs)) +func topStatSqlcSong(songs []sqlc.GetTopSongsRow) []topStatEntry { + topstats := make([]topStatEntry, 0, len(songs)) for _, s := range songs { - topstats = append(topstats, topStat{name: s.Title, amount: int(s.PlayCount)}) + topstats = append(topstats, topStatEntry{name: s.Title, amount: int(s.PlayCount)}) } return topstats } -func equalTopGenres(s1 []topStat, s2 []sqlc.GetTopGenresRow) bool { +func equalTopGenres(s1 []topStatEntry, s2 []sqlc.GetTopGenresRow) bool { if len(s1) != len(s2) { return false } @@ -41,15 +41,15 @@ func equalTopGenres(s1 []topStat, s2 []sqlc.GetTopGenresRow) bool { return true } -func topStatSqlcGenre(songs []sqlc.GetTopGenresRow) []topStat { - topstats := make([]topStat, 0, len(songs)) +func topStatSqlcGenre(songs []sqlc.GetTopGenresRow) []topStatEntry { + topstats := make([]topStatEntry, 0, len(songs)) for _, s := range songs { - topstats = append(topstats, topStat{name: s.GenreName, amount: int(s.TotalPlays)}) + topstats = append(topstats, topStatEntry{name: s.GenreName, amount: int(s.TotalPlays)}) } return topstats } -func equalTopArtists(s1 []topStat, s2 []sqlc.GetTopArtistsRow) bool { +func equalTopArtists(s1 []topStatEntry, s2 []sqlc.GetTopArtistsRow) bool { if len(s1) != len(s2) { return false } @@ -63,10 +63,10 @@ func equalTopArtists(s1 []topStat, s2 []sqlc.GetTopArtistsRow) bool { return true } -func topStatSqlcArtist(songs []sqlc.GetTopArtistsRow) []topStat { - topstats := make([]topStat, 0, len(songs)) +func topStatSqlcArtist(songs []sqlc.GetTopArtistsRow) []topStatEntry { + topstats := make([]topStatEntry, 0, len(songs)) for _, s := range songs { - topstats = append(topstats, topStat{name: s.ArtistName, amount: int(s.TotalPlays)}) + topstats = append(topstats, topStatEntry{name: s.ArtistName, amount: int(s.TotalPlays)}) } return topstats } diff --git a/tui/view/song/view.go b/tui/view/song/view.go index 8c1949c..97bf487 100644 --- a/tui/view/song/view.go +++ b/tui/view/song/view.go @@ -10,11 +10,17 @@ import ( 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) + view := lipgloss.JoinVertical(lipgloss.Left, status, lyrics, stats) - return view + return sAll.Render(view) } func (m *Model) viewPlayingStatus() string { @@ -33,14 +39,11 @@ func (m *Model) viewPlayingStatus() string { artist = artist[:len(artist)-3] } - song := sStatusSong.Width(m.width - lipgloss.Width(stopwatch)).Render(fmt.Sprintf("%s | %s", m.current.song.Title, artist)) + song := sStatusSong.Width(sStatusSong.GetWidth() - lipgloss.Width(stopwatch)).Render(fmt.Sprintf("%s | %s", m.current.song.Title, artist)) // Progress bar - // zap.S().Info(m.current.lyrics.Progress()) - // progress := sStatusProgress.Width(m.width).Render(m.current.progress.ViewAs(m.current.lyrics.Progress())) - // zap.S().Info(progress) - - progress := sStatusProgress.Width(m.width).Render(strings.Repeat("▄", int(m.current.lyrics.Progress()*float64(m.width)))) + progress := m.current.progress.View() + progress = sStatusProgress.Render(progress) view := lipgloss.JoinHorizontal(lipgloss.Top, song, stopwatch) view = lipgloss.JoinVertical(lipgloss.Left, view, progress) @@ -67,7 +70,18 @@ func (m *Model) viewPlayingLyrics() string { } upcoming := sLyricUpcoming.Render(upcomingB.String()) - return base.MarginLeft(5).Render(lipgloss.JoinVertical(lipgloss.Left, previous, current, upcoming)) + 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 { @@ -76,7 +90,22 @@ func (m *Model) viewNotPlaying() string { rows = append(rows, make([]string, 0, 2)) } - // Recently played + 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)) @@ -85,41 +114,21 @@ func (m *Model) viewNotPlaying() string { } l := lipgloss.JoinVertical(lipgloss.Left, items...) title := sStatTitle.Render("Recently Played") - rows[0] = append(rows[0], sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l))) - - // All other stats - topStats := [][]topStat{m.topSongs, m.topArtists, m.topGenres} - for i, topStat := range topStats { - items := make([]string, 0, len(topStat)) - for i, stat := range topStat { - 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...) - - var row int - if i == 0 { - title = sStatTitle.Render("Top Tracks") - row = 0 - } else if i == 1 { - title = sStatTitle.Render("Top Artists") - row = 1 - } else { - title = sStatTitle.Render("Top Genres") - row = 1 - } - rows[row] = append(rows[row], sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l))) - } + return sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l)) +} - renderedRows := make([]string, 0, 2) - for _, row := range rows { - renderedRows = append(renderedRows, lipgloss.JoinHorizontal(lipgloss.Top, row...)) +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...) - view := lipgloss.JoinVertical(lipgloss.Left, renderedRows...) + title := sStatTitle.Render(topStat.title) - return view + return sStat.Render(lipgloss.JoinVertical(lipgloss.Left, title, l)) } diff --git a/tui/view/tap/tap.go b/tui/view/tap/tap.go index 3f51a39..499b030 100644 --- a/tui/view/tap/tap.go +++ b/tui/view/tap/tap.go @@ -155,7 +155,7 @@ func updateOrders(view view.View) (tea.Msg, error) { counts[category(order.Category)] = tapItem{ category: category(order.Category), amount: int(order.Count), - last: time.Unix(int64(order.LatestOrderCreatedAt), 0), + last: order.LatestOrderCreatedAt.Time, } } diff --git a/tui/view/tap/view.go b/tui/view/tap/view.go index 4c4a850..65433ae 100644 --- a/tui/view/tap/view.go +++ b/tui/view/tap/view.go @@ -39,7 +39,7 @@ func (m *Model) viewStats() string { 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("02/01 15:04")) + last := sStatsLast.Render(item.last.Format("15:04 02/01")) text := lipgloss.JoinHorizontal(lipgloss.Top, amount, category, last) rows = append(rows, text) diff --git a/tui/view/util.go b/tui/view/util.go index 332808a..d3fb57e 100644 --- a/tui/view/util.go +++ b/tui/view/util.go @@ -42,3 +42,23 @@ func ImagetoString(width int, img image.Image) string { 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) +} From dbefc43faf159e25831e392d16a20c343352d789 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 10 Dec 2024 02:09:47 +0100 Subject: [PATCH 46/46] feat(zess): bar colors --- db/migrations/20241210002653_add_scan_id.sql | 15 +++++++ db/queries/scan.sql | 10 ++--- internal/pkg/db/dto/scan.go | 10 ++++- internal/pkg/db/sqlc/models.go | 1 + internal/pkg/db/sqlc/scan.sql.go | 46 +++++++++++--------- internal/pkg/zess/zess.go | 7 ++- tui/view/event/style.go | 1 + tui/view/event/view.go | 5 +++ tui/view/zess/style.go | 29 ++++++++++-- tui/view/zess/view.go | 2 +- tui/view/zess/zess.go | 13 +++++- 11 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 db/migrations/20241210002653_add_scan_id.sql 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/scan.sql b/db/queries/scan.sql index 7c9665a..706ffc5 100644 --- a/db/queries/scan.sql +++ b/db/queries/scan.sql @@ -10,14 +10,14 @@ FROM scan WHERE id = $1; -- name: CreateScan :one -INSERT INTO scan (scan_time) -VALUES ($1) +INSERT INTO scan (scan_id, scan_time) +VALUES ($1, $2) RETURNING *; -- name: UpdateScan :one UPDATE scan -SET scan_time = $1 -WHERE id = $2 +SET scan_id = $1, scan_time = $2 +WHERE id = $3 RETURNING *; -- name: DeleteScan :execrows @@ -38,7 +38,7 @@ LIMIT 1; SELECT * FROM scan WHERE id > $1 -ORDER BY scan_time ASC; +ORDER BY scan_id, scan_time ASC; -- name: GetScansInCurrentSeason :one SELECT COUNT(*) AS amount diff --git a/internal/pkg/db/dto/scan.go b/internal/pkg/db/dto/scan.go index 03eb265..5622e24 100644 --- a/internal/pkg/db/dto/scan.go +++ b/internal/pkg/db/dto/scan.go @@ -10,6 +10,7 @@ import ( // 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"` } @@ -17,19 +18,24 @@ type Scan struct { 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() pgtype.Timestamptz { - return pgtype.Timestamptz{Time: s.ScanTime, Valid: true} +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/sqlc/models.go b/internal/pkg/db/sqlc/models.go index 4083a8a..f50e025 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -35,6 +35,7 @@ type Message struct { type Scan struct { ID int32 ScanTime pgtype.Timestamptz + ScanID int32 } type Season struct { diff --git a/internal/pkg/db/sqlc/scan.sql.go b/internal/pkg/db/sqlc/scan.sql.go index 6782c7f..38a6499 100644 --- a/internal/pkg/db/sqlc/scan.sql.go +++ b/internal/pkg/db/sqlc/scan.sql.go @@ -12,15 +12,20 @@ import ( ) const createScan = `-- name: CreateScan :one -INSERT INTO scan (scan_time) -VALUES ($1) -RETURNING id, scan_time +INSERT INTO scan (scan_id, scan_time) +VALUES ($1, $2) +RETURNING id, scan_time, scan_id ` -func (q *Queries) CreateScan(ctx context.Context, scanTime pgtype.Timestamptz) (Scan, error) { - row := q.db.QueryRow(ctx, createScan, scanTime) +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) + err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) return i, err } @@ -39,7 +44,7 @@ func (q *Queries) DeleteScan(ctx context.Context, id int32) (int64, error) { const getAllScans = `-- name: GetAllScans :many -SELECT id, scan_time +SELECT id, scan_time, scan_id FROM scan ` @@ -53,7 +58,7 @@ func (q *Queries) GetAllScans(ctx context.Context) ([]Scan, error) { var items []Scan for rows.Next() { var i Scan - if err := rows.Scan(&i.ID, &i.ScanTime); err != nil { + if err := rows.Scan(&i.ID, &i.ScanTime, &i.ScanID); err != nil { return nil, err } items = append(items, i) @@ -65,10 +70,10 @@ func (q *Queries) GetAllScans(ctx context.Context) ([]Scan, error) { } const getAllScansSinceID = `-- name: GetAllScansSinceID :many -SELECT id, scan_time +SELECT id, scan_time, scan_id FROM scan WHERE id > $1 -ORDER BY scan_time ASC +ORDER BY scan_id, scan_time ASC ` func (q *Queries) GetAllScansSinceID(ctx context.Context, id int32) ([]Scan, error) { @@ -80,7 +85,7 @@ func (q *Queries) GetAllScansSinceID(ctx context.Context, id int32) ([]Scan, err var items []Scan for rows.Next() { var i Scan - if err := rows.Scan(&i.ID, &i.ScanTime); err != nil { + if err := rows.Scan(&i.ID, &i.ScanTime, &i.ScanID); err != nil { return nil, err } items = append(items, i) @@ -94,7 +99,7 @@ func (q *Queries) GetAllScansSinceID(ctx context.Context, id int32) ([]Scan, err const getLastScan = `-- name: GetLastScan :one -SELECT id, scan_time +SELECT id, scan_time, scan_id FROM scan ORDER BY id DESC LIMIT 1 @@ -104,12 +109,12 @@ LIMIT 1 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) + err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) return i, err } const getScanByID = `-- name: GetScanByID :one -SELECT id, scan_time +SELECT id, scan_time, scan_id FROM scan WHERE id = $1 ` @@ -117,7 +122,7 @@ 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) + err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) return i, err } @@ -137,19 +142,20 @@ func (q *Queries) GetScansInCurrentSeason(ctx context.Context) (int64, error) { const updateScan = `-- name: UpdateScan :one UPDATE scan -SET scan_time = $1 -WHERE id = $2 -RETURNING id, scan_time +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.ScanTime, arg.ID) + row := q.db.QueryRow(ctx, updateScan, arg.ScanID, arg.ScanTime, arg.ID) var i Scan - err := row.Scan(&i.ID, &i.ScanTime) + err := row.Scan(&i.ID, &i.ScanTime, &i.ScanID) return i, err } diff --git a/internal/pkg/zess/zess.go b/internal/pkg/zess/zess.go index eac8f8a..f3800de 100644 --- a/internal/pkg/zess/zess.go +++ b/internal/pkg/zess/zess.go @@ -12,6 +12,7 @@ import ( "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 @@ -69,7 +70,7 @@ func (z *Zess) UpdateScans() error { return err } - lastScan = sqlc.Scan{ID: -1} + lastScan = sqlc.Scan{ScanID: -1} } // Get all scans @@ -78,9 +79,11 @@ func (z *Zess) UpdateScans() error { return err } + zap.S().Info(lastScan) + errs := make([]error, 0) for _, scan := range *zessScans { - if lastScan.ID >= scan.ID { + if scan.ScanID <= lastScan.ScanID { continue } diff --git a/tui/view/event/style.go b/tui/view/event/style.go index 6c2f928..bfbfbc9 100644 --- a/tui/view/event/style.go +++ b/tui/view/event/style.go @@ -43,6 +43,7 @@ var ( // 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) diff --git a/tui/view/event/view.go b/tui/view/event/view.go index 79b75fc..7d3a16c 100644 --- a/tui/view/event/view.go +++ b/tui/view/event/view.go @@ -60,6 +60,11 @@ func (m *Model) viewNormal() string { 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) diff --git a/tui/view/zess/style.go b/tui/view/zess/style.go index 97e7986..fe6f3ad 100644 --- a/tui/view/zess/style.go +++ b/tui/view/zess/style.go @@ -21,16 +21,39 @@ var ( // Colors var ( - cBarChart = lipgloss.Color("#32012F") - 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.Foreground(cBarChart) + sBar = base ) // Styles stats diff --git a/tui/view/zess/view.go b/tui/view/zess/view.go index f00d1c0..6828146 100644 --- a/tui/view/zess/view.go +++ b/tui/view/zess/view.go @@ -16,7 +16,7 @@ func (m *Model) viewChart() string { Values: []barchart.BarValue{{ Name: scan.label, Value: float64(scan.amount), - Style: sBar, + Style: sBar.Foreground(lipgloss.Color(scan.color)), }}, } diff --git a/tui/view/zess/zess.go b/tui/view/zess/zess.go index 429dbbd..a8d8c9d 100644 --- a/tui/view/zess/zess.go +++ b/tui/view/zess/zess.go @@ -3,6 +3,7 @@ package zess import ( "context" + "math/rand/v2" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -23,6 +24,7 @@ type weekScan struct { time yearWeek amount int64 label string + color string } // Model represents the Model for the zess view @@ -227,7 +229,12 @@ func updateScans(view view.View) (tea.Msg, error) { } if !found { - zessScanMsg.scans = append(zessScanMsg.scans, weekScan{time: newTime, amount: 1, label: newScan.ScanTime.Time.Format("02/01")}) + zessScanMsg.scans = append(zessScanMsg.scans, weekScan{ + time: newTime, + amount: 1, + label: newScan.ScanTime.Time.Format("02/01"), + color: randomColor(), + }) } // Update scan ID @@ -276,3 +283,7 @@ func (z *yearWeek) after(z2 yearWeek) bool { return z.week > z2.week } + +func randomColor() string { + return colors[rand.IntN(len(colors))] +}