diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc1ca57e..1bc18d1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ on: - main env: - VERSION_NUMBER: 'v1.8.7' + VERSION_NUMBER: 'v1.8.8' DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli' AWS_REGION: 'us-west-2' diff --git a/.goreleaser.yml b/.goreleaser.yml index e914dd78..17682f04 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -14,7 +14,7 @@ builds: - windows - darwin ldflags: - - -s -w -X main.version=v1.8.7 + - -s -w -X main.version=v1.8.8 archives: - formats: [ 'zip' ] diff --git a/Dockerfile b/Dockerfile index efc542d8..5aaa1e38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # build 1 -FROM golang:1.24.11-alpine3.23 AS build +FROM golang:1.24.12-alpine3.23 AS build WORKDIR /app @@ -8,7 +8,7 @@ RUN go mod download COPY . . -RUN go build -ldflags "-X main.version=v1.8.7" -o poke-cli . +RUN go build -ldflags "-X main.version=v1.8.8" -o poke-cli . # build 2 FROM --platform=$BUILDPLATFORM alpine:3.23 diff --git a/README.md b/README.md index 7254d01c..e1751aa3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ pokemon-logo

version-label - docker-image-size + docker-image-size ci-status-badge
@@ -96,11 +96,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an 3. Choose how to interact with the container: * Run a single command and exit: ```bash - docker run --rm -it digitalghostdev/poke-cli:v1.8.7 [subcommand] [flag] + docker run --rm -it digitalghostdev/poke-cli:v1.8.8 [subcommand] [flag] ``` * Enter the container and use its shell: ```bash - docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.7 -c "cd /app && exec sh" + docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.8 -c "cd /app && exec sh" # placed into the /app directory, run the program with './poke-cli' # example: ./poke-cli ability swift-swim ``` @@ -217,16 +217,17 @@ Below is a list of the planned/completed commands and flags: --- ## Tested Terminals -| Terminal | OS | Status | Issues | -|-------------------|:-------------------------:|:------:|----------------------------------------------------------------------------------------------| -| Alacritty | macOS, Ubuntu,
Windows | 🟡 | - Does not support sixel for TCG images. | -| Ghostty | macOS | 🟡 | - Does not support sixel for TCG images. | -| HyperJS | macOS | 🟡 | - Does not support sixel for TCG images. | -| iTerm2 | macOS | 🟢 | - None | -| Built-in Terminal | Ubuntu, Debian,
Fedora | 🟡 | - Does not support sixel for TCG images. | -| Built-in Terminal | Alpine | 🟡 | - Some colors aren't supported.
- `pokemon --image=xx` flag pixel issues. | -| Built-in Terminal | macOS | 🟠 | - Does not support sixel for TCG images.
- `pokemon --image=xx` flag pixel issues. | -| Foot | Ubuntu | 🟢 | - None | -| Tabby | Ubuntu | 🟢 | - None | -| WezTerm | macOS, Windows | 🟡 | - Windows version has issues with displaying TCG images. | -| Built-in Terminal | Windows | 🟢 | - None | \ No newline at end of file +| Terminal | OS | Status | Issues | +|--------------------|-------------------------------|:------:|-----------------------------------------------------------------------------------| +| Alacritty | macOS, Ubuntu, Windows | 🟡 | No support for TCG images | +| Foot | Ubuntu, Fedora | 🟢 | None | +| Ghostty | macOS | 🟢 | None | +| iTerm2 | macOS | 🟢 | None | +| Kitty | macOS, Ubuntu, Debian, Fedora | 🟢 | None | +| Rio | macOS | 🟢 | None | +| Tabby | Ubuntu | 🟢 | None | +| Terminal (Alpine) | Alpine | 🟡 | Some colors aren't supported
`pokemon --image=xx` flag has pixel issues | +| Terminal (Linux) | Ubuntu, Debian, Fedora | 🟡 | No support for TCG images | +| Terminal (macOS) | macOS | 🟠 | No support for TCG images
`pokemon --image=xx` flag has pixel issues | +| Terminal (Windows) | Windows | 🟢 | None | +| WezTerm | macOS, Windows | 🟡 | Windows version has issues with displaying TCG images | \ No newline at end of file diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml index ee0e8a3b..ee355c7d 100644 --- a/card_data/pipelines/poke_cli_dbt/dbt_project.yml +++ b/card_data/pipelines/poke_cli_dbt/dbt_project.yml @@ -1,5 +1,5 @@ name: 'poke_cli_dbt' -version: '1.8.7' +version: '1.8.8' profile: 'poke_cli_dbt' diff --git a/cmd/berry/berry.go b/cmd/berry/berry.go index 5409065b..2cbef101 100644 --- a/cmd/berry/berry.go +++ b/cmd/berry/berry.go @@ -104,7 +104,7 @@ func (m model) View() string { Width(50). Height(29). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FFCC00")). + BorderForeground(styling.YellowColor). Padding(1). Render(selectedBerry) @@ -142,11 +142,11 @@ func tableGeneration() error { s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#FFCC00")). + BorderForeground(styling.YellowColor). BorderBottom(true) s.Selected = s.Selected. Foreground(lipgloss.Color("#000")). - Background(lipgloss.Color("#FFCC00")) + Background(styling.YellowColor) t.SetStyles(s) m := model{table: t} diff --git a/cmd/berry/berry_test.go b/cmd/berry/berry_test.go index 9632ef5e..d813416d 100644 --- a/cmd/berry/berry_test.go +++ b/cmd/berry/berry_test.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/exp/teatest" + "github.com/digitalghost-dev/poke-cli/styling" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -186,11 +187,11 @@ func createTestModel() model { s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#FFCC00")). + BorderForeground(styling.YellowColor). BorderBottom(true) s.Selected = s.Selected. Foreground(lipgloss.Color("#000")). - Background(lipgloss.Color("#FFCC00")) + Background(styling.YellowColor) t.SetStyles(s) return model{table: t} diff --git a/cmd/card/card.go b/cmd/card/card.go index 3276a26c..a5cc8a9f 100644 --- a/cmd/card/card.go +++ b/cmd/card/card.go @@ -40,7 +40,7 @@ func CardCommand() (string, error) { seriesModel := SeriesList() // Program 1: Series selection - finalModel, err := tea.NewProgram(seriesModel).Run() + finalModel, err := tea.NewProgram(seriesModel, tea.WithAltScreen()).Run() if err != nil { return "", fmt.Errorf("error running series selection program: %w", err) } @@ -58,7 +58,7 @@ func CardCommand() (string, error) { return "", fmt.Errorf("error loading sets: %w", err) } - finalSetsModel, err := tea.NewProgram(setsModel).Run() + finalSetsModel, err := tea.NewProgram(setsModel, tea.WithAltScreen()).Run() if err != nil { return "", fmt.Errorf("error running sets selection program: %w", err) } diff --git a/cmd/card/cardinfo.go b/cmd/card/cardinfo.go index 19d8ed48..190430bd 100644 --- a/cmd/card/cardinfo.go +++ b/cmd/card/cardinfo.go @@ -8,9 +8,12 @@ import ( "io" "net/http" "net/url" + "os" + "strings" "time" "github.com/charmbracelet/x/ansi/sixel" + "github.com/dolmen-go/kittyimg" "golang.org/x/image/draw" ) @@ -20,45 +23,111 @@ func resizeImage(img image.Image, width, height int) image.Image { return dst } -func CardImage(imageURL string) (string, error) { +// supportsKittyGraphics checks if the terminal supports the Kitty graphics protocol +func supportsKittyGraphics() bool { + // Check Kitty-specific window ID + if os.Getenv("KITTY_WINDOW_ID") != "" { + return true + } + + // Check TERM_PROGRAM for known Kitty-compatible terminals + termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM")) + switch termProgram { + case "kitty", "ghostty", "wezterm": + return true + } + + // Check TERM variable for kitty or ghostty + term := strings.ToLower(os.Getenv("TERM")) + switch { + case strings.Contains(term, "kitty"): + return true + case strings.Contains(term, "ghostty"): + return true + } + + return false +} + +// supportsSixelGraphics checks if the terminal supports the Sixel graphics protocol +func supportsSixelGraphics() bool { + session := os.Getenv("WT_SESSION") + termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM")) + term := strings.ToLower(os.Getenv("TERM")) + + if session != "" { + return true + } + + // Check TERM_PROGRAM for known Sixel-supporting terminals + switch termProgram { + case "iterm.app", "wezterm", "konsole", "tabby", "rio": + return true + } + + // Check TERM variable for known Sixel-supporting terminals + switch { + case term == "foot" || strings.HasPrefix(term, "foot-"): + return true + case term == "xterm-sixel" || strings.Contains(term, "sixel"): + return true + } + + return false +} + +// CardImage downloads and renders an image using Kitty protocol if supported, otherwise Sixel. +func CardImage(imageURL string) (imageData string, protocol string, err error) { client := &http.Client{ Timeout: time.Second * 60, } parsedURL, err := url.Parse(imageURL) if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { - return "", errors.New("invalid URL scheme") + return "", "", errors.New("invalid URL scheme") } resp, err := client.Get(imageURL) if err != nil { - return "", fmt.Errorf("failed to fetch image: %w", err) + return "", "", fmt.Errorf("failed to fetch image: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("non-200 response: %d", resp.StatusCode) + return "", "", fmt.Errorf("non-200 response: %d", resp.StatusCode) } // Read body into memory first to avoid timeout during decode limitedBody := io.LimitReader(resp.Body, 10*1024*1024) bodyBytes, err := io.ReadAll(limitedBody) if err != nil { - return "", fmt.Errorf("failed to read image data: %w", err) + return "", "", fmt.Errorf("failed to read image data: %w", err) } img, _, err := image.Decode(bytes.NewReader(bodyBytes)) if err != nil { - return "", fmt.Errorf("failed to decode image: %w", err) + return "", "", fmt.Errorf("failed to decode image: %w", err) } resized := resizeImage(img, 500, 675) - // Build Sixel string to return var buf bytes.Buffer - buf.WriteString("\x1bPq") - if err := new(sixel.Encoder).Encode(&buf, resized); err != nil { - return "", fmt.Errorf("failed to encode sixel: %w", err) + + if supportsKittyGraphics() { + if err := kittyimg.Fprint(&buf, resized); err != nil { + return "", "", fmt.Errorf("failed to encode kitty image: %w", err) + } + return buf.String(), "Kitty", nil + } + + // Fall back to Sixel + if supportsSixelGraphics() { + buf.WriteString("\x1bPq") + if err := new(sixel.Encoder).Encode(&buf, resized); err != nil { + return "", "", fmt.Errorf("failed to encode sixel: %w", err) + } + buf.WriteString("\x1b\\") + return buf.String(), "Sixel", nil } - buf.WriteString("\x1b\\") - return buf.String(), nil + // Neither protocol is supported + return "", "", errors.New("your terminal does not support image rendering (Kitty or Sixel graphics protocols required)\n\nTry using: Kitty, Ghostty, WezTerm, iTerm2, or foot") } diff --git a/cmd/card/cardinfo_test.go b/cmd/card/cardinfo_test.go index ba23f7d3..2e01c4e0 100644 --- a/cmd/card/cardinfo_test.go +++ b/cmd/card/cardinfo_test.go @@ -6,6 +6,7 @@ import ( "image/png" "net/http" "net/http/httptest" + "os" "strings" "testing" ) @@ -90,20 +91,19 @@ func TestCardImage_Success(t *testing.T) { })) defer server.Close() - result, err := CardImage(server.URL) + // Set up a supported terminal environment (Sixel) + os.Setenv("TERM_PROGRAM", "iTerm.app") + defer os.Unsetenv("TERM_PROGRAM") + + result, protocol, err := CardImage(server.URL) if err != nil { t.Errorf("CardImage() error = %v, want nil", err) return } - // Check that result is a valid Sixel string - if !strings.HasPrefix(result, "\x1bPq") { - t.Error("CardImage() should return string starting with Sixel header") - } - - if !strings.HasSuffix(result, "\x1b\\") { - t.Error("CardImage() should return string ending with Sixel terminator") + if protocol == "" { + t.Error("CardImage() should return a protocol name") } if len(result) == 0 { @@ -123,7 +123,7 @@ func TestCardImage_EncodingError(t *testing.T) { })) defer server.Close() - result, err := CardImage(server.URL) + result, protocol, err := CardImage(server.URL) if err == nil { t.Error("CardImage() should return error for invalid image data") @@ -133,6 +133,10 @@ func TestCardImage_EncodingError(t *testing.T) { t.Errorf("CardImage() on error should return empty string, got %v", result) } + if protocol != "" { + t.Errorf("CardImage() on error should return empty protocol, got %v", protocol) + } + if !strings.Contains(err.Error(), "failed to decode image") { t.Errorf("Error message should mention 'failed to decode image', got: %v", err) } @@ -145,7 +149,7 @@ func TestCardImage_Non200Response(t *testing.T) { })) defer server.Close() - result, err := CardImage(server.URL) + result, protocol, err := CardImage(server.URL) if err == nil { t.Error("CardImage() should return error for non-200 response") @@ -155,7 +159,275 @@ func TestCardImage_Non200Response(t *testing.T) { t.Errorf("CardImage() on error should return empty string, got %v", result) } + if protocol != "" { + t.Errorf("CardImage() on error should return empty protocol, got %v", protocol) + } + if !strings.Contains(err.Error(), "non-200 response") { t.Errorf("Error message should mention 'non-200 response', got: %v", err) } } + +func TestSupportsKittyGraphics(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + wantSupport bool + }{ + { + name: "kitty terminal via KITTY_WINDOW_ID", + envVars: map[string]string{ + "KITTY_WINDOW_ID": "1", + }, + wantSupport: true, + }, + { + name: "kitty via TERM_PROGRAM", + envVars: map[string]string{ + "TERM_PROGRAM": "kitty", + }, + wantSupport: true, + }, + { + name: "kitty via TERM_PROGRAM uppercase", + envVars: map[string]string{ + "TERM_PROGRAM": "KITTY", + }, + wantSupport: true, + }, + { + name: "ghostty via TERM_PROGRAM", + envVars: map[string]string{ + "TERM_PROGRAM": "ghostty", + }, + wantSupport: true, + }, + { + name: "ghostty via TERM_PROGRAM uppercase", + envVars: map[string]string{ + "TERM_PROGRAM": "Ghostty", + }, + wantSupport: true, + }, + { + name: "wezterm via TERM_PROGRAM", + envVars: map[string]string{ + "TERM_PROGRAM": "WezTerm", + }, + wantSupport: true, + }, + { + name: "ghostty via TERM variable", + envVars: map[string]string{ + "TERM": "xterm-ghostty", + }, + wantSupport: true, + }, + { + name: "kitty via TERM variable", + envVars: map[string]string{ + "TERM": "xterm-kitty", + }, + wantSupport: true, + }, + { + name: "unsupported terminal - Apple Terminal", + envVars: map[string]string{ + "TERM_PROGRAM": "Apple_Terminal", + "TERM": "xterm-256color", + }, + wantSupport: false, + }, + { + name: "unsupported terminal - iTerm2", + envVars: map[string]string{ + "TERM_PROGRAM": "iTerm.app", + "TERM": "xterm-256color", + }, + wantSupport: false, + }, + { + name: "unsupported terminal - GNOME Terminal", + envVars: map[string]string{ + "TERM": "xterm", + }, + wantSupport: false, + }, + { + name: "no environment variables set", + envVars: map[string]string{}, + wantSupport: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original env vars + origVars := map[string]string{ + "KITTY_WINDOW_ID": os.Getenv("KITTY_WINDOW_ID"), + "TERM_PROGRAM": os.Getenv("TERM_PROGRAM"), + "TERM": os.Getenv("TERM"), + } + + // Clear all relevant env vars first + os.Unsetenv("KITTY_WINDOW_ID") + os.Unsetenv("TERM_PROGRAM") + os.Unsetenv("TERM") + + // Set test env vars + for key, val := range tt.envVars { + os.Setenv(key, val) + } + + // Cleanup after test + defer func() { + for key, val := range origVars { + if val == "" { + os.Unsetenv(key) + } else { + os.Setenv(key, val) + } + } + }() + + got := supportsKittyGraphics() + if got != tt.wantSupport { + t.Errorf("supportsKittyGraphics() = %v, want %v", got, tt.wantSupport) + } + }) + } +} + +func TestSupportsSixelGraphics(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + wantSupport bool + }{ + { + name: "iterm2 via TERM_PROGRAM", + envVars: map[string]string{ + "TERM_PROGRAM": "iTerm.app", + }, + wantSupport: true, + }, + { + name: "wezterm via TERM_PROGRAM", + envVars: map[string]string{ + "TERM_PROGRAM": "WezTerm", + }, + wantSupport: true, + }, + { + name: "wezterm lowercase", + envVars: map[string]string{ + "TERM_PROGRAM": "wezterm", + }, + wantSupport: true, + }, + { + name: "rio via TERM_PROGRAM", + envVars: map[string]string{ + "TERM_PROGRAM": "rio", + }, + wantSupport: true, + }, + { + name: "konsole via TERM_PROGRAM", + envVars: map[string]string{ + "TERM_PROGRAM": "Konsole", + }, + wantSupport: true, + }, + { + name: "foot via TERM", + envVars: map[string]string{ + "TERM": "foot", + }, + wantSupport: true, + }, + { + name: "foot with suffix", + envVars: map[string]string{ + "TERM": "foot-extra", + }, + wantSupport: true, + }, + { + name: "xterm-sixel via TERM", + envVars: map[string]string{ + "TERM": "xterm-sixel", + }, + wantSupport: true, + }, + { + name: "unsupported terminal - Apple Terminal", + envVars: map[string]string{ + "TERM_PROGRAM": "Apple_Terminal", + "TERM": "xterm-256color", + }, + wantSupport: false, + }, + { + name: "unsupported terminal - Alacritty", + envVars: map[string]string{ + "TERM": "alacritty", + }, + wantSupport: false, + }, + { + name: "unsupported terminal - standard xterm", + envVars: map[string]string{ + "TERM": "xterm", + }, + wantSupport: false, + }, + { + name: "unsupported terminal - xterm-256color", + envVars: map[string]string{ + "TERM": "xterm-256color", + }, + wantSupport: false, + }, + { + name: "no environment variables set", + envVars: map[string]string{}, + wantSupport: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original env vars + origVars := map[string]string{ + "TERM_PROGRAM": os.Getenv("TERM_PROGRAM"), + "TERM": os.Getenv("TERM"), + } + + // Clear all relevant env vars first + os.Unsetenv("TERM_PROGRAM") + os.Unsetenv("TERM") + + // Set test env vars + for key, val := range tt.envVars { + os.Setenv(key, val) + } + + // Cleanup + defer func() { + for key, val := range origVars { + if val == "" { + os.Unsetenv(key) + } else { + os.Setenv(key, val) + } + } + }() + + got := supportsSixelGraphics() + if got != tt.wantSupport { + t.Errorf("supportsSixelGraphics() = %v, want %v", got, tt.wantSupport) + } + }) + } +} diff --git a/cmd/card/cardlist.go b/cmd/card/cardlist.go index b8e46b47..09883720 100644 --- a/cmd/card/cardlist.go +++ b/cmd/card/cardlist.go @@ -3,44 +3,58 @@ package card import ( "encoding/json" "fmt" - "io" - "net/http" "strings" - "time" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/digitalghost-dev/poke-cli/connections" "github.com/digitalghost-dev/poke-cli/styling" ) +var getCardData = connections.CallTCGData + type CardsModel struct { + AllRows []table.Row Choice string IllustratorMap map[string]string ImageMap map[string]string + Loading bool PriceMap map[string]string - RegulationMarkMap map[string]string - AllRows []table.Row Quitting bool + RegulationMarkMap map[string]string Search textinput.Model SelectedOption string SeriesName string + SetID string + Spinner spinner.Model Table table.Model TableStyles table.Styles ViewImage bool } -const ( - activeTableSelectedBg lipgloss.Color = "#FFCC00" - inactiveTableSelectedBg lipgloss.Color = "#808080" +// Message type to carry fetched card data back to Update() +type cardDataMsg struct { + allRows []table.Row + priceMap map[string]string + imageMap map[string]string + illustratorMap map[string]string + regulationMarkMap map[string]string + err error +} + +var ( + activeTableSelectedBg = styling.YellowColor + inactiveTableSelectedBg = lipgloss.Color("#808080") ) func cardTableStyles(selectedBg lipgloss.Color) table.Styles { s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#FFCC00")). + BorderForeground(styling.YellowColor). BorderBottom(true) s.Selected = s.Selected. Foreground(lipgloss.Color("#000")). @@ -57,8 +71,65 @@ func (m *CardsModel) syncTableStylesForFocus() { m.Table.SetStyles(m.TableStyles) } +// fetchCardsCmd does the actual data fetching and returns a cardDataMsg +func fetchCardsCmd(setID string) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/card_pricing_view?set_id=eq.%s&select=number_plus_name,market_price,image_url,illustrator,regulation_mark&order=localId", setID) + body, err := getCardData(url) + if err != nil { + return cardDataMsg{err: err} + } + + var allCards []cardData + err = json.Unmarshal(body, &allCards) + if err != nil { + return cardDataMsg{err: err} + } + + rows := make([]table.Row, len(allCards)) + priceMap := make(map[string]string) + imageMap := make(map[string]string) + illustratorMap := make(map[string]string) + regulationMarkMap := make(map[string]string) + + for i, card := range allCards { + rows[i] = []string{card.NumberPlusName} + if card.MarketPrice != 0 { + priceMap[card.NumberPlusName] = fmt.Sprintf("Price: $%.2f", card.MarketPrice) + } else { + priceMap[card.NumberPlusName] = "Pricing not available" + } + + if card.Illustrator != "" { + illustratorMap[card.NumberPlusName] = "Illustrator: " + card.Illustrator + } else { + illustratorMap[card.NumberPlusName] = "Illustrator not available" + } + + if card.RegulationMark != "" { + regulationMarkMap[card.NumberPlusName] = "Regulation: " + card.RegulationMark + } else { + regulationMarkMap[card.NumberPlusName] = "Regulation not available" + } + + imageMap[card.NumberPlusName] = card.ImageURL + } + + return cardDataMsg{ + allRows: rows, + priceMap: priceMap, + imageMap: imageMap, + illustratorMap: illustratorMap, + regulationMarkMap: regulationMarkMap, + } + } +} + func (m CardsModel) Init() tea.Cmd { - return nil + return tea.Batch( + m.Spinner.Tick, + fetchCardsCmd(m.SetID), + ) } func (m CardsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -82,6 +153,10 @@ func (m CardsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "?": if !m.Search.Focused() { + // Sync the selected option before quitting to ensure the correct card is shown + if row := m.Table.SelectedRow(); len(row) > 0 { + m.SelectedOption = row[0] + } m.ViewImage = true return m, tea.Quit } @@ -96,23 +171,66 @@ func (m CardsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.syncTableStylesForFocus() return m, nil } - } - if m.Search.Focused() { - prev := m.Search.Value() - m.Search, bubbleCmd = m.Search.Update(msg) - if m.Search.Value() != prev { - m.applyFilter() + case cardDataMsg: + // Data arrived - stop loading and build the table + if msg.err != nil { + m.Quitting = true + return m, tea.Quit } - } else { - m.Table, bubbleCmd = m.Table.Update(msg) + + ti := textinput.New() + ti.Placeholder = "type name..." + ti.Prompt = "🔎 " + ti.CharLimit = 24 + ti.Width = 30 + ti.Blur() + + t := table.New( + table.WithColumns([]table.Column{{Title: "Card Name", Width: 35}}), + table.WithRows(msg.allRows), + table.WithFocused(true), + table.WithHeight(27), + ) + + styles := cardTableStyles(activeTableSelectedBg) + t.SetStyles(styles) + + m.AllRows = msg.allRows + m.PriceMap = msg.priceMap + m.ImageMap = msg.imageMap + m.IllustratorMap = msg.illustratorMap + m.RegulationMarkMap = msg.regulationMarkMap + m.Search = ti + m.Table = t + m.TableStyles = styles + m.Loading = false + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.Spinner, cmd = m.Spinner.Update(msg) + return m, cmd } - // Keep the selected option in sync on every update - if row := m.Table.SelectedRow(); len(row) > 0 { - name := row[0] - if name != m.SelectedOption { - m.SelectedOption = name + // Only handle search/table updates when not loading + if !m.Loading { + if m.Search.Focused() { + prev := m.Search.Value() + m.Search, bubbleCmd = m.Search.Update(msg) + if m.Search.Value() != prev { + m.applyFilter() + } + } else { + m.Table, bubbleCmd = m.Table.Update(msg) + } + + // Keep the selected option in sync on every update + if row := m.Table.SelectedRow(); len(row) > 0 { + name := row[0] + if name != m.SelectedOption { + m.SelectedOption = name + } } } @@ -145,6 +263,11 @@ func (m CardsModel) View() string { if m.Quitting { return "\n Quitting card search...\n\n" } + if m.Loading { + return lipgloss.NewStyle().Padding(2).Render( + m.Spinner.View() + " Loading cards...", + ) + } selectedCard := "" if row := m.Table.SelectedRow(); len(row) > 0 { @@ -165,7 +288,7 @@ func (m CardsModel) View() string { Width(40). Height(29). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FFCC00")). + BorderForeground(styling.YellowColor). Padding(1). Render(selectedCard) @@ -186,109 +309,15 @@ type cardData struct { RegulationMark string `json:"regulation_mark"` } -// CardsList creates and returns a new CardsModel with cards from a specific set +// CardsList returns a minimal model - data fetching happens via Init() func CardsList(setID string) (CardsModel, error) { - url := fmt.Sprintf("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/card_pricing_view?set_id=eq.%s&select=number_plus_name,market_price,image_url,illustrator,regulation_mark&order=localId", setID) - body, err := getCardData(url) - if err != nil { - return CardsModel{}, fmt.Errorf("failed to fetch card data: %w", err) - } - - var allCards []cardData - err = json.Unmarshal(body, &allCards) - if err != nil { - return CardsModel{}, fmt.Errorf("failed to unmarshal card data: %w", err) - } - - // Extract card names and build table rows + price map - rows := make([]table.Row, len(allCards)) - allRows := rows - - priceMap := make(map[string]string) - imageMap := make(map[string]string) - illustratorMap := make(map[string]string) - regulationMarkMap := make(map[string]string) - - for i, card := range allCards { - rows[i] = []string{card.NumberPlusName} - if card.MarketPrice != 0 { - priceMap[card.NumberPlusName] = fmt.Sprintf("Price: $%.2f", card.MarketPrice) - } else { - priceMap[card.NumberPlusName] = "Pricing not available" - } - - if card.Illustrator != "" { - illustratorMap[card.NumberPlusName] = "Illustrator: " + card.Illustrator - } else { - illustratorMap[card.NumberPlusName] = "Illustrator not available" - } - - if card.RegulationMark != "" { - regulationMarkMap[card.NumberPlusName] = "Regulation: " + card.RegulationMark - } else { - regulationMarkMap[card.NumberPlusName] = "Regulation not available" - } - - imageMap[card.NumberPlusName] = card.ImageURL - } - - ti := textinput.New() - ti.Placeholder = "type name..." - ti.Prompt = "🔎 " - ti.CharLimit = 24 - ti.Width = 30 - ti.Blur() - - t := table.New( - table.WithColumns([]table.Column{{Title: "Card Name", Width: 35}}), - table.WithRows(rows), - table.WithFocused(true), - table.WithHeight(27), - ) - - styles := cardTableStyles(activeTableSelectedBg) - t.SetStyles(styles) + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = styling.Yellow return CardsModel{ - AllRows: allRows, - IllustratorMap: illustratorMap, - ImageMap: imageMap, - PriceMap: priceMap, - RegulationMarkMap: regulationMarkMap, - Search: ti, - Table: t, - TableStyles: styles, + SetID: setID, + Loading: true, + Spinner: s, }, nil } - -// creating a function variable to swap the implementation in tests -var getCardData = CallCardData - -func CallCardData(url string) ([]byte, error) { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - - req.Header.Add("apikey", "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") - req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") - req.Header.Add("Content-Type", "application/json") - - client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("error making GET request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("error reading response body: %w", err) - } - - return body, nil -} diff --git a/cmd/card/cardlist_test.go b/cmd/card/cardlist_test.go index a016a01b..b4dd5cc5 100644 --- a/cmd/card/cardlist_test.go +++ b/cmd/card/cardlist_test.go @@ -2,8 +2,6 @@ package card import ( "errors" - "net/http" - "net/http/httptest" "strings" "testing" @@ -12,18 +10,12 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -// testSupabaseKey is the publishable API key used in tests. -// Extracted to a constant for easier maintenance if the key changes. -const testSupabaseKey = "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j" - func TestCardsModel_Init(t *testing.T) { - model := CardsModel{ - SeriesName: "sv", - } + model, _ := CardsList("sv01") cmd := model.Init() - if cmd != nil { - t.Error("Init() should return nil") + if cmd == nil { + t.Error("Init() should return commands (spinner tick + fetch)") } } @@ -282,161 +274,124 @@ func TestCardsModel_View_MissingPrice(t *testing.T) { } } -func TestCardsList_SuccessAndFallbacks(t *testing.T) { - // Save and restore getCardData stub - original := getCardData - defer func() { getCardData = original }() - - var capturedURL string - getCardData = func(url string) ([]byte, error) { - capturedURL = url - // Return two cards: one with price + illustrator, one with fallbacks - json := `[ - {"number_plus_name":"001/198 - Bulbasaur","market_price":1.5,"image_url":"https://example.com/bulba.png","illustrator":"Ken Sugimori"}, - {"number_plus_name":"002/198 - Ivysaur","market_price":0,"image_url":"https://example.com/ivy.png","illustrator":""} - ]` - return []byte(json), nil - } - +func TestCardsList_ReturnsLoadingModel(t *testing.T) { model, err := CardsList("set123") if err != nil { t.Fatalf("CardsList returned error: %v", err) } - // URL should target the correct set id and select fields - if !strings.Contains(capturedURL, "set_id=eq.set123") { - t.Errorf("expected URL to contain set_id filter, got %s", capturedURL) + if model.SetID != "set123" { + t.Errorf("expected SetID 'set123', got %s", model.SetID) + } + + if !model.Loading { + t.Error("expected Loading to be true") + } + + // View should show loading spinner + view := model.View() + if !strings.Contains(view, "Loading cards") { + t.Errorf("expected view to show loading state, got: %s", view) } - if !strings.Contains(capturedURL, "select=number_plus_name,market_price,image_url,illustrator") { - t.Errorf("expected URL to contain select fields, got %s", capturedURL) +} + +func TestCardDataMsg_PopulatesModel(t *testing.T) { + model, _ := CardsList("set123") + + // Simulate receiving data via cardDataMsg + msg := cardDataMsg{ + allRows: []table.Row{ + {"001/198 - Bulbasaur"}, + {"002/198 - Ivysaur"}, + }, + priceMap: map[string]string{ + "001/198 - Bulbasaur": "Price: $1.50", + "002/198 - Ivysaur": "Pricing not available", + }, + imageMap: map[string]string{ + "001/198 - Bulbasaur": "https://example.com/bulba.png", + "002/198 - Ivysaur": "https://example.com/ivy.png", + }, + illustratorMap: map[string]string{ + "001/198 - Bulbasaur": "Illustrator: Ken Sugimori", + "002/198 - Ivysaur": "Illustrator not available", + }, + regulationMarkMap: map[string]string{}, + } + + newModel, _ := model.Update(msg) + resultModel := newModel.(CardsModel) + + if resultModel.Loading { + t.Error("Loading should be false after receiving data") } // PriceMap expectations - if got := model.PriceMap["001/198 - Bulbasaur"]; got != "Price: $1.50" { + if got := resultModel.PriceMap["001/198 - Bulbasaur"]; got != "Price: $1.50" { t.Errorf("unexpected price for Bulbasaur: %s", got) } - if got := model.PriceMap["002/198 - Ivysaur"]; got != "Pricing not available" { + if got := resultModel.PriceMap["002/198 - Ivysaur"]; got != "Pricing not available" { t.Errorf("unexpected price for Ivysaur: %s", got) } // IllustratorMap expectations - if got := model.IllustratorMap["001/198 - Bulbasaur"]; got != "Illustrator: Ken Sugimori" { + if got := resultModel.IllustratorMap["001/198 - Bulbasaur"]; got != "Illustrator: Ken Sugimori" { t.Errorf("unexpected illustrator for Bulbasaur: %s", got) } - if got := model.IllustratorMap["002/198 - Ivysaur"]; got != "Illustrator not available" { + if got := resultModel.IllustratorMap["002/198 - Ivysaur"]; got != "Illustrator not available" { t.Errorf("unexpected illustrator for Ivysaur: %s", got) } // Image map - if model.ImageMap["001/198 - Bulbasaur"] != "https://example.com/bulba.png" { - t.Errorf("unexpected image url for Bulbasaur: %s", model.ImageMap["001/198 - Bulbasaur"]) - } - if model.ImageMap["002/198 - Ivysaur"] != "https://example.com/ivy.png" { - t.Errorf("unexpected image url for Ivysaur: %s", model.ImageMap["002/198 - Ivysaur"]) + if resultModel.ImageMap["001/198 - Bulbasaur"] != "https://example.com/bulba.png" { + t.Errorf("unexpected image url for Bulbasaur: %s", resultModel.ImageMap["001/198 - Bulbasaur"]) } - - if row := model.Table.SelectedRow(); len(row) == 0 { - if model.View() == "" { - t.Error("model view should render even if no row is selected") - } + if resultModel.ImageMap["002/198 - Ivysaur"] != "https://example.com/ivy.png" { + t.Errorf("unexpected image url for Ivysaur: %s", resultModel.ImageMap["002/198 - Ivysaur"]) } } -func TestCardsList_FetchError(t *testing.T) { - original := getCardData - defer func() { getCardData = original }() - - getCardData = func(url string) ([]byte, error) { - return nil, errors.New("network error") - } +func TestCardDataMsg_Error_QuitsModel(t *testing.T) { + model, _ := CardsList("set123") - _, err := CardsList("set123") - if err == nil { - t.Fatal("expected error when fetch fails") + // Simulate receiving an error via cardDataMsg + msg := cardDataMsg{ + err: errors.New("network error"), } -} -func TestCardsList_BadJSON(t *testing.T) { - original := getCardData - defer func() { getCardData = original }() + newModel, cmd := model.Update(msg) + resultModel := newModel.(CardsModel) - getCardData = func(url string) ([]byte, error) { - return []byte("not-json"), nil + if !resultModel.Quitting { + t.Error("Quitting should be true when error received") } - _, err := CardsList("set123") - if err == nil { - t.Fatal("expected error for bad JSON payload") + if cmd == nil { + t.Error("Should return tea.Quit command on error") } } -func TestCardsList_EmptyResult(t *testing.T) { - original := getCardData - defer func() { getCardData = original }() - - getCardData = func(url string) ([]byte, error) { - return []byte("[]"), nil - } - - model, err := CardsList("set123") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } +func TestCardDataMsg_EmptyResult(t *testing.T) { + model, _ := CardsList("set123") - if len(model.PriceMap) != 0 || len(model.IllustratorMap) != 0 || len(model.ImageMap) != 0 { - t.Errorf("expected empty maps, got price:%d illus:%d image:%d", len(model.PriceMap), len(model.IllustratorMap), len(model.ImageMap)) + // Simulate receiving empty data + msg := cardDataMsg{ + allRows: []table.Row{}, + priceMap: map[string]string{}, + imageMap: map[string]string{}, + illustratorMap: map[string]string{}, + regulationMarkMap: map[string]string{}, } - if model.View() == "" { - t.Error("expected view to render with empty data") - } -} + newModel, _ := model.Update(msg) + resultModel := newModel.(CardsModel) -func TestCallCardData_SendsHeadersAndReturnsBody(t *testing.T) { - // Start a test HTTP server that validates headers and returns a body - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("apikey"); got != testSupabaseKey { - t.Fatalf("missing or wrong apikey header: %q", got) - } - if got := r.Header.Get("Authorization"); got != "Bearer "+testSupabaseKey { - t.Fatalf("missing or wrong Authorization header: %q", got) - } - if got := r.Header.Get("Content-Type"); got != "application/json" { - t.Fatalf("missing or wrong Content-Type header: %q", got) - } - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer srv.Close() - - body, err := CallCardData(srv.URL) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if string(body) != `{"ok":true}` { - t.Fatalf("unexpected body: %s", string(body)) + if resultModel.Loading { + t.Error("Loading should be false after receiving data") } -} - -func TestCallCardData_Non200Error(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "boom", http.StatusInternalServerError) - })) - defer srv.Close() - _, err := CallCardData(srv.URL) - if err == nil { - t.Fatal("expected error for non-200 status") - } - if !strings.Contains(err.Error(), "unexpected status code: 500") { - t.Fatalf("error should mention status code, got: %v", err) + if len(resultModel.PriceMap) != 0 || len(resultModel.IllustratorMap) != 0 || len(resultModel.ImageMap) != 0 { + t.Errorf("expected empty maps, got price:%d illus:%d image:%d", len(resultModel.PriceMap), len(resultModel.IllustratorMap), len(resultModel.ImageMap)) } } -func TestCallCardData_BadURL(t *testing.T) { - _, err := CallCardData("http://%41:80/") // invalid URL host - if err == nil { - t.Fatal("expected error for bad URL") - } -} diff --git a/cmd/card/design.go b/cmd/card/design.go index 097144c1..975d09bd 100644 --- a/cmd/card/design.go +++ b/cmd/card/design.go @@ -8,12 +8,13 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/digitalghost-dev/poke-cli/styling" ) var ( titleStyle = lipgloss.NewStyle().MarginLeft(2) itemStyle = lipgloss.NewStyle().PaddingLeft(4) - selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.AdaptiveColor{Light: "#E1AD01", Dark: "#FFDE00"}) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(styling.YellowAdaptive) paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) diff --git a/cmd/card/imageviewer.go b/cmd/card/imageviewer.go index 3cc85a29..0eaed385 100644 --- a/cmd/card/imageviewer.go +++ b/cmd/card/imageviewer.go @@ -14,21 +14,26 @@ type ImageModel struct { Loading bool Spinner spinner.Model ImageData string + Protocol string } type imageReadyMsg struct { - sixelData string + imageData string + protocol string err error } // fetchImageCmd downloads and renders the image asynchronously func fetchImageCmd(imageURL string) tea.Cmd { return func() tea.Msg { - sixelData, err := CardImage(imageURL) + imageData, protocol, err := CardImage(imageURL) if err != nil { return imageReadyMsg{err: err} } - return imageReadyMsg{sixelData: sixelData} + return imageReadyMsg{ + imageData: imageData, + protocol: protocol, + } } } @@ -48,13 +53,16 @@ func (m ImageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ImageData = "" } else { m.Error = nil - m.ImageData = msg.sixelData + m.ImageData = msg.imageData + m.Protocol = msg.protocol } return m, nil + case spinner.TickMsg: var cmd tea.Cmd m.Spinner, cmd = m.Spinner.Update(msg) return m, cmd + case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": @@ -71,8 +79,14 @@ func (m ImageModel) View() string { ) } if m.Error != nil { - return styling.Red.Render(m.Error.Error()) + // Styling the error message with padding for better readability + return lipgloss.NewStyle(). + Padding(2). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(styling.YellowColor). + Render(styling.Red.Render(m.Error.Error())) } + return m.ImageData } diff --git a/cmd/card/imageviewer_test.go b/cmd/card/imageviewer_test.go index 4d86e98c..381014d9 100644 --- a/cmd/card/imageviewer_test.go +++ b/cmd/card/imageviewer_test.go @@ -136,7 +136,7 @@ func TestImageRenderer_InitializesCorrectly(t *testing.T) { func TestImageModel_Update_ImageReady(t *testing.T) { model := ImageRenderer("Charizard", "http://example.com/charizard.png") - msg := imageReadyMsg{sixelData: "test-sixel-data-456"} + msg := imageReadyMsg{imageData: "test-image-data-456", protocol: "Kitty"} newModel, cmd := model.Update(msg) if cmd != nil { @@ -148,9 +148,13 @@ func TestImageModel_Update_ImageReady(t *testing.T) { t.Error(`Update with imageReadyMsg should set Loading to false`) } - if updatedModel.ImageData != "test-sixel-data-456" { + if updatedModel.ImageData != "test-image-data-456" { t.Errorf("Update with imageReadyMsg should set ImageData, got %v", updatedModel.ImageData) } + + if updatedModel.Protocol != "Kitty" { + t.Errorf("Update with imageReadyMsg should set Protocol, got %v", updatedModel.Protocol) + } } func TestImageModel_Update_SpinnerTick(t *testing.T) { diff --git a/cmd/card/setslist.go b/cmd/card/setslist.go index df75aefb..69afbaff 100644 --- a/cmd/card/setslist.go +++ b/cmd/card/setslist.go @@ -3,25 +3,72 @@ package card import ( "encoding/json" "fmt" - "io" - "net/http" - "time" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/digitalghost-dev/poke-cli/connections" + "github.com/digitalghost-dev/poke-cli/styling" ) +var getSetsData = connections.CallTCGData + type SetsModel struct { - List list.Model Choice string - SetID string + Loading bool + List list.Model Quitting bool SeriesName string - setsIDMap map[string]string // Maps set name -> set_id + SetID string + SetsIDMap map[string]string // Maps set name -> set_id + Spinner spinner.Model +} + +// Message type to carry fetched data back to Update() +type setsDataMsg struct { + items []list.Item + setsIDMap map[string]string + seriesID string + err error +} + +func fetchSetsCmd(seriesID string) tea.Cmd { + return func() tea.Msg { + body, err := getSetsData("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/sets") + if err != nil { + return setsDataMsg{err: err} + } + + var allSets []setData + err = json.Unmarshal(body, &allSets) + if err != nil { + return setsDataMsg{err: err} + } + + // Filter sets by series_id and build ID map + var items []list.Item + setsIDMap := make(map[string]string) + for _, set := range allSets { + if set.SeriesID == seriesID { + items = append(items, item(set.SetName)) + setsIDMap[set.SetName] = set.SetID + } + } + + return setsDataMsg{ + items: items, + setsIDMap: setsIDMap, + seriesID: seriesID, + } + } } func (m SetsModel) Init() tea.Cmd { - return nil + return tea.Batch( + m.Spinner.Tick, + fetchSetsCmd(m.SeriesName), + ) } func (m SetsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -35,28 +82,67 @@ func (m SetsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { i, ok := m.List.SelectedItem().(item) if ok { m.Choice = string(i) - m.SetID = m.setsIDMap[string(i)] // Look up the set_id + m.SetID = m.SetsIDMap[string(i)] } return m, tea.Quit } + case setsDataMsg: + // Data arrived - stop loading and build the list + if msg.err != nil { + m.Quitting = true + return m, tea.Quit + } + + const listWidth = 20 + const listHeight = 20 + + l := list.New(msg.items, itemDelegate{}, listWidth, listHeight) + l.Title = fmt.Sprintf("Pick a set from the %s series", msg.seriesID) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = titleStyle + l.Styles.PaginationStyle = paginationStyle + l.Styles.HelpStyle = helpStyle + + m.List = l + m.SetsIDMap = msg.setsIDMap + m.Loading = false + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.Spinner, cmd = m.Spinner.Update(msg) + return m, cmd + case tea.WindowSizeMsg: - m.List.SetWidth(msg.Width) + if !m.Loading { + m.List.SetWidth(msg.Width) + } return m, nil } - var cmd tea.Cmd - m.List, cmd = m.List.Update(msg) - return m, cmd + // Only update the list if it's been initialized + if !m.Loading { + var cmd tea.Cmd + m.List, cmd = m.List.Update(msg) + return m, cmd + } + return m, nil } func (m SetsModel) View() string { - if m.Quitting { - return "\n Quitting card search...\n\n" - } if m.Choice != "" { return quitTextStyle.Render("Set selected:", m.Choice) } + if m.Loading { + return lipgloss.NewStyle().Padding(2).Render( + m.Spinner.View() + "Loading sets...", + ) + } + if m.Quitting { + return "\n Quitting card search...\n\n" + } return "\n" + m.List.View() } @@ -71,75 +157,16 @@ type setData struct { Symbol string `json:"symbol"` } -// creating a function variable to swap the implementation in tests -var getSetsData = callSetsData - +// SetsList returns a minimal model - data fetching happens via Init() func SetsList(seriesID string) (SetsModel, error) { - body, err := getSetsData("https://uoddayfnfkebrijlpfbh.supabase.co/rest/v1/sets") - if err != nil { - return SetsModel{}, fmt.Errorf("error getting sets data: %v", err) - } - var allSets []setData - - err = json.Unmarshal(body, &allSets) - if err != nil { - return SetsModel{}, fmt.Errorf("error parsing sets data: %v", err) - } - - // Filter sets by series_id and build ID map - var items []list.Item - setsIDMap := make(map[string]string) - for _, set := range allSets { - if set.SeriesID == seriesID { - items = append(items, item(set.SetName)) - setsIDMap[set.SetName] = set.SetID // Map name -> ID - } - } - - const listWidth = 20 - const listHeight = 20 - - l := list.New(items, itemDelegate{}, listWidth, listHeight) - l.Title = fmt.Sprintf("Pick a set from the %s series", seriesID) - l.SetShowStatusBar(false) - l.SetFilteringEnabled(false) - l.Styles.Title = titleStyle - l.Styles.PaginationStyle = paginationStyle - l.Styles.HelpStyle = helpStyle + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = styling.Yellow return SetsModel{ - List: l, - SeriesName: seriesID, - setsIDMap: setsIDMap, - }, - nil + SeriesName: seriesID, + Loading: true, + Spinner: s, + }, nil } -func callSetsData(url string) ([]byte, error) { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - - req.Header.Add("apikey", "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") - req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") - req.Header.Add("Content-Type", "application/json") - - client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("error making GET request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("error reading response body: %w", err) - } - - return body, nil -} diff --git a/cmd/card/setslist_test.go b/cmd/card/setslist_test.go index 1fcdc806..ee2d9178 100644 --- a/cmd/card/setslist_test.go +++ b/cmd/card/setslist_test.go @@ -2,8 +2,6 @@ package card import ( "errors" - "net/http" - "net/http/httptest" "strings" "testing" @@ -12,14 +10,11 @@ import ( ) func TestSetsModel_Init(t *testing.T) { - model := SetsModel{ - SeriesName: "sv", - Quitting: false, - } + model, _ := SetsList("sv") cmd := model.Init() - if cmd != nil { - t.Error("Init() should return nil") + if cmd == nil { + t.Error("Init() should return commands (spinner tick + fetch)") } } @@ -179,7 +174,7 @@ func TestSetsModel_Update_EnterKey(t *testing.T) { model := SetsModel{ List: l, - setsIDMap: setsIDMap, + SetsIDMap: setsIDMap, } msg := tea.KeyMsg{Type: tea.KeyEnter} @@ -190,169 +185,97 @@ func TestSetsModel_Update_EnterKey(t *testing.T) { } } -func TestCallSetsData_SendsHeadersAndReturnsBody(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("apikey"); got != testSupabaseKey { - t.Fatalf("missing or wrong apikey header: %q", got) - } - if got := r.Header.Get("Authorization"); got != "Bearer "+testSupabaseKey { - t.Fatalf("missing or wrong Authorization header: %q", got) - } - if got := r.Header.Get("Content-Type"); got != "application/json" { - t.Fatalf("missing or wrong Content-Type header: %q", got) - } - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer srv.Close() - - body, err := callSetsData(srv.URL) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if string(body) != `{"ok":true}` { - t.Fatalf("unexpected body: %s", string(body)) - } -} - -func TestCallSetsData_Non200Error(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "boom", http.StatusInternalServerError) - })) - defer srv.Close() - - _, err := callSetsData(srv.URL) - if err == nil { - t.Fatal("expected error for non-200 status") - } - if !strings.Contains(err.Error(), "unexpected status code: 500") { - t.Fatalf("error should mention status code, got: %v", err) - } -} - -func TestCallSetsData_BadURL(t *testing.T) { - _, err := callSetsData("http://%41:80/") // invalid URL host - if err == nil { - t.Fatal("expected error for bad URL") - } -} - func TestSetsList_Success(t *testing.T) { - original := getSetsData - defer func() { getSetsData = original }() - - getSetsData = func(url string) ([]byte, error) { - json := `[ - {"series_id":"sv","set_id":"sv01","set_name":"Scarlet & Violet","official_card_count":198,"total_card_count":258,"logo":"https://example.com/sv01.png","symbol":"https://example.com/sv01-symbol.png"}, - {"series_id":"sv","set_id":"sv02","set_name":"Paldea Evolved","official_card_count":193,"total_card_count":279,"logo":"https://example.com/sv02.png","symbol":"https://example.com/sv02-symbol.png"}, - {"series_id":"swsh","set_id":"swsh01","set_name":"Sword & Shield","official_card_count":202,"total_card_count":216,"logo":"https://example.com/swsh01.png","symbol":"https://example.com/swsh01-symbol.png"} - ]` - return []byte(json), nil - } - model, err := SetsList("sv") if err != nil { t.Fatalf("SetsList returned error: %v", err) } - // Should only have 2 sets (filtered by series_id "sv") + // SetsList now returns minimal model with Loading=true if model.SeriesName != "sv" { t.Errorf("expected SeriesName 'sv', got %s", model.SeriesName) } - // Check setsIDMap has correct mappings - if model.setsIDMap["Scarlet & Violet"] != "sv01" { - t.Errorf("expected setsIDMap['Scarlet & Violet'] = 'sv01', got %s", model.setsIDMap["Scarlet & Violet"]) - } - if model.setsIDMap["Paldea Evolved"] != "sv02" { - t.Errorf("expected setsIDMap['Paldea Evolved'] = 'sv02', got %s", model.setsIDMap["Paldea Evolved"]) - } - - // swsh set should not be in the map - if _, exists := model.setsIDMap["Sword & Shield"]; exists { - t.Error("Sword & Shield should not be in setsIDMap (different series)") + if !model.Loading { + t.Error("expected Loading to be true") } + // View should show loading spinner if model.View() == "" { - t.Error("model view should render") + t.Error("model view should render loading state") } } -func TestSetsList_FetchError(t *testing.T) { - original := getSetsData - defer func() { getSetsData = original }() +func TestSetsDataMsg_PopulatesModel(t *testing.T) { + // Start with a loading model + model, _ := SetsList("sv") - getSetsData = func(url string) ([]byte, error) { - return nil, errors.New("network error") + // Simulate receiving data via setsDataMsg + msg := setsDataMsg{ + items: []list.Item{ + item("Scarlet & Violet"), + item("Paldea Evolved"), + }, + setsIDMap: map[string]string{ + "Scarlet & Violet": "sv01", + "Paldea Evolved": "sv02", + }, + seriesID: "sv", } - _, err := SetsList("sv") - if err == nil { - t.Fatal("expected error when fetch fails") - } - if !strings.Contains(err.Error(), "error getting sets data") { - t.Errorf("error should mention 'error getting sets data', got: %v", err) - } -} + newModel, _ := model.Update(msg) + resultModel := newModel.(SetsModel) -func TestSetsList_BadJSON(t *testing.T) { - original := getSetsData - defer func() { getSetsData = original }() - - getSetsData = func(url string) ([]byte, error) { - return []byte("not-json"), nil + if resultModel.Loading { + t.Error("Loading should be false after receiving data") } - _, err := SetsList("sv") - if err == nil { - t.Fatal("expected error for bad JSON payload") + if resultModel.SetsIDMap["Scarlet & Violet"] != "sv01" { + t.Errorf("expected SetsIDMap['Scarlet & Violet'] = 'sv01', got %s", resultModel.SetsIDMap["Scarlet & Violet"]) } - if !strings.Contains(err.Error(), "error parsing sets data") { - t.Errorf("error should mention 'error parsing sets data', got: %v", err) + if resultModel.SetsIDMap["Paldea Evolved"] != "sv02" { + t.Errorf("expected SetsIDMap['Paldea Evolved'] = 'sv02', got %s", resultModel.SetsIDMap["Paldea Evolved"]) } } -func TestSetsList_EmptyResult(t *testing.T) { - original := getSetsData - defer func() { getSetsData = original }() +func TestSetsDataMsg_Error_QuitsModel(t *testing.T) { + model, _ := SetsList("sv") - getSetsData = func(url string) ([]byte, error) { - return []byte("[]"), nil + // Simulate receiving an error via setsDataMsg + msg := setsDataMsg{ + err: errors.New("network error"), } - model, err := SetsList("sv") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + newModel, cmd := model.Update(msg) + resultModel := newModel.(SetsModel) - if len(model.setsIDMap) != 0 { - t.Errorf("expected empty setsIDMap, got %d entries", len(model.setsIDMap)) + if !resultModel.Quitting { + t.Error("Quitting should be true when error received") } - if model.View() == "" { - t.Error("expected view to render with empty data") + if cmd == nil { + t.Error("Should return tea.Quit command on error") } } -func TestSetsList_NoMatchingSeries(t *testing.T) { - original := getSetsData - defer func() { getSetsData = original }() +func TestSetsDataMsg_EmptyResult(t *testing.T) { + model, _ := SetsList("sv") - getSetsData = func(url string) ([]byte, error) { - json := `[ - {"series_id":"swsh","set_id":"swsh01","set_name":"Sword & Shield","official_card_count":202,"total_card_count":216,"logo":"","symbol":""} - ]` - return []byte(json), nil + // Simulate receiving empty data + msg := setsDataMsg{ + items: []list.Item{}, + setsIDMap: map[string]string{}, + seriesID: "sv", } - model, err := SetsList("sv") - if err != nil { - t.Fatalf("unexpected error: %v", err) + newModel, _ := model.Update(msg) + resultModel := newModel.(SetsModel) + + if resultModel.Loading { + t.Error("Loading should be false after receiving data") } - // No sets match "sv" series - if len(model.setsIDMap) != 0 { - t.Errorf("expected empty setsIDMap when no series match, got %d entries", len(model.setsIDMap)) + if len(resultModel.SetsIDMap) != 0 { + t.Errorf("expected empty SetsIDMap, got %d entries", len(resultModel.SetsIDMap)) } } diff --git a/cmd/types/types.go b/cmd/types/types.go index 918c1772..db0f4839 100644 --- a/cmd/types/types.go +++ b/cmd/types/types.go @@ -119,11 +119,11 @@ func createTypeSelectionTable() model { s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#FFCC00")). + BorderForeground(styling.YellowColor). BorderBottom(true) s.Selected = s.Selected. Foreground(lipgloss.Color("#000")). - Background(lipgloss.Color("#FFCC00")) + Background(styling.YellowColor) tbl.SetStyles(s) return model{table: tbl} diff --git a/cmd/types/types_test.go b/cmd/types/types_test.go index 995b90c3..52894ba1 100644 --- a/cmd/types/types_test.go +++ b/cmd/types/types_test.go @@ -74,11 +74,11 @@ func createTestModel() model { s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#FFCC00")). + BorderForeground(styling.YellowColor). BorderBottom(true) s.Selected = s.Selected. Foreground(lipgloss.Color("#000")). - Background(lipgloss.Color("#FFCC00")) + Background(styling.YellowColor) t.SetStyles(s) return model{table: t} diff --git a/connections/connection.go b/connections/connection.go index cbe9c287..3c35ffb8 100644 --- a/connections/connection.go +++ b/connections/connection.go @@ -102,3 +102,32 @@ func PokemonSpeciesApiCall(endpoint string, pokemonSpeciesName string, baseURL s func TypesApiCall(endpoint string, typesName string, baseURL string) (structs.TypesJSONStruct, string, error) { return FetchEndpoint[structs.TypesJSONStruct](endpoint, typesName, baseURL, "Type") } + +func CallTCGData(url string) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Add("apikey", "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") + req.Header.Add("Authorization", "Bearer sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j") + req.Header.Add("Content-Type", "application/json") + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("error making GET request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + return body, nil +} \ No newline at end of file diff --git a/connections/connection_test.go b/connections/connection_test.go index f2fdb824..8da96adc 100644 --- a/connections/connection_test.go +++ b/connections/connection_test.go @@ -329,3 +329,47 @@ func TestPokemonSpeciesApiCall(t *testing.T) { assert.Contains(t, err.Error(), "Perhaps a typo?", "Expected helpful suggestion in error message") }) } + +// testSupabaseKey is the publishable API key used in tests. +const testSupabaseKey = "sb_publishable_oondaaAIQC-wafhEiNgpSQ_reRiEp7j" + +func TestCallTCGData(t *testing.T) { + t.Run("sends correct headers and returns body", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Validate headers + if got := r.Header.Get("apikey"); got != testSupabaseKey { + t.Fatalf("missing or wrong apikey header: %q", got) + } + if got := r.Header.Get("Authorization"); got != "Bearer "+testSupabaseKey { + t.Fatalf("missing or wrong Authorization header: %q", got) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("missing or wrong Content-Type header: %q", got) + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + body, err := CallTCGData(srv.URL) + require.NoError(t, err) + assert.Equal(t, `{"ok":true}`, string(body)) + }) + + t.Run("returns error for non-200 status", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := CallTCGData(srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status code: 500") + }) + + t.Run("returns error for bad URL", func(t *testing.T) { + _, err := CallTCGData("http://%41:80/") // invalid URL host + require.Error(t, err) + }) +} diff --git a/flags/pokemonflagset.go b/flags/pokemonflagset.go index 27f86040..5c920a21 100644 --- a/flags/pokemonflagset.go +++ b/flags/pokemonflagset.go @@ -45,7 +45,7 @@ func header(header string) string { HeaderBold := lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#FFCC00")). + BorderForeground(styling.YellowColor). BorderTop(true). Bold(true). Render(header) diff --git a/go.mod b/go.mod index 0876fed9..956b178c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/digitalghost-dev/poke-cli -go 1.24.11 +go 1.24.12 require ( github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 @@ -12,6 +12,8 @@ require ( github.com/charmbracelet/x/exp/teatest v0.0.0-20251110210702-903592506081 github.com/charmbracelet/x/term v0.2.2 github.com/disintegration/imaging v1.6.2 + github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4 + github.com/schollz/closestmatch v2.1.0+incompatible github.com/stretchr/testify v1.11.1 golang.org/x/image v0.33.0 golang.org/x/text v0.31.0 @@ -47,7 +49,6 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/schollz/closestmatch v2.1.0+incompatible // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index 78e742dc..2ee4fadb 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ 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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4 h1:KGRb+vxMx5pGsfDjDSW2Th+b2OEflb0yC3s0daCmiYU= +github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4/go.mod h1:2vk7ATPVcI7uW4Sh6PrSQvtO+Czmq8509xcg/y8Osd0= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= diff --git a/nfpm.yaml b/nfpm.yaml index 25201c97..e2fded10 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -1,7 +1,7 @@ name: "poke-cli" arch: "arm64" platform: "linux" -version: "v1.8.7" +version: "v1.8.8" section: "default" version_schema: semver maintainer: "Christian S" diff --git a/styling/styling.go b/styling/styling.go index 33bd823f..ecc1db1a 100644 --- a/styling/styling.go +++ b/styling/styling.go @@ -14,21 +14,33 @@ import ( const ( HyphenHint = "Use a hyphen when typing a name with a space." + + // Brand colors - use these instead of hardcoding hex values + PrimaryYellow = "#FFCC00" // Main accent color for borders, highlights + LightYellow = "#FFDE00" // Used in dark mode adaptive colors + DarkYellow = "#E1AD01" // Used in light mode adaptive colors +) + +// Pre-defined lipgloss colors for convenience +var ( + YellowColor = lipgloss.Color(PrimaryYellow) + YellowAdaptive = lipgloss.AdaptiveColor{Light: DarkYellow, Dark: LightYellow} + YellowAdaptive2 = lipgloss.AdaptiveColor{Light: DarkYellow, Dark: PrimaryYellow} ) var ( Green = lipgloss.NewStyle().Foreground(lipgloss.Color("#38B000")) Red = lipgloss.NewStyle().Foreground(lipgloss.Color("#D00000")) Gray = lipgloss.Color("#777777") - Yellow = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#E1AD01", Dark: "#FFDE00"}) + Yellow = lipgloss.NewStyle().Foreground(YellowAdaptive) ColoredBullet = lipgloss.NewStyle(). SetString("•"). - Foreground(lipgloss.Color("#FFCC00")) - CheckboxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFCC00")) + Foreground(YellowColor) + CheckboxStyle = lipgloss.NewStyle().Foreground(YellowColor) KeyMenu = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) DocsLink = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#E1AD01", Dark: "#FFCC00"}). + Foreground(YellowAdaptive2). Render("\x1b]8;;https://docs.poke-cli.com\x1b\\docs.poke-cli.com\x1b]8;;\x1b\\") StyleBold = lipgloss.NewStyle().Bold(true) @@ -36,7 +48,7 @@ var ( StyleUnderline = lipgloss.NewStyle().Underline(true) HelpBorder = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FFCC00")) + BorderForeground(YellowColor) ErrorColor = lipgloss.NewStyle().Foreground(lipgloss.Color("#F2055C")) ErrorBorder = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). @@ -47,7 +59,7 @@ var ( BorderForeground(lipgloss.Color("#FF8C00")) TypesTableBorder = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#FFCC00")) + BorderForeground(YellowColor) ColorMap = map[string]string{ "normal": "#B7B7A9", "fire": "#FF4422", @@ -146,7 +158,7 @@ func (col Color) Hex() string { func FormTheme() *huh.Theme { var ( - yellow = lipgloss.Color("#FFDE00") + yellow = lipgloss.Color(LightYellow) blue = lipgloss.Color("#3B4CCA") red = lipgloss.Color("#D00000") black = lipgloss.Color("#000000") diff --git a/testdata/main_latest_flag.golden b/testdata/main_latest_flag.golden index cd926498..306c19b5 100644 --- a/testdata/main_latest_flag.golden +++ b/testdata/main_latest_flag.golden @@ -2,6 +2,6 @@ ┃ ┃ ┃ Latest available release ┃ ┃ on GitHub: ┃ -┃ • v1.8.6 ┃ +┃ • v1.8.7 ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛