Files
bargain-hardware/internal/tui/model.go
T

710 lines
16 KiB
Go
Raw Normal View History

2026-03-11 09:01:45 +00:00
package tui
import (
"context"
"fmt"
"os/exec"
"sort"
"strconv"
"strings"
"bargain-scraper/internal/models"
"bargain-scraper/internal/scraper"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#1E40AF")).Padding(0, 1)
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#64748B"))
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B82F6")).Padding(0, 1)
normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E2E8F0")).Padding(0, 1)
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")).Bold(true)
)
type SortOption struct {
Name string
Key string
Order string
}
type Model struct {
products []models.StorageProduct
filteredProducts []models.StorageProduct
categories []models.Category
selectedCategory int
selectedProduct int
searchQuery string
isScraping bool
progress scraper.ScrapeProgress
err error
ctx context.Context
cancel context.CancelFunc
view string
sortBy SortOption
sortMenuIndex int
ScrapeCallback func(models.Category)
ExportCallback func(string) error
lastExportError string
}
var sortOptions = []SortOption{
{Name: "Price (Low to High)", Key: "price", Order: "asc"},
{Name: "Price (High to Low)", Key: "price", Order: "desc"},
{Name: "Capacity (Small to Large)", Key: "capacity", Order: "asc"},
{Name: "Capacity (Large to Small)", Key: "capacity", Order: "desc"},
{Name: "Stock Status (In Stock First)", Key: "stock", Order: "desc"},
{Name: "Name (A-Z)", Key: "name", Order: "asc"},
{Name: "Name (Z-A)", Key: "name", Order: "desc"},
}
func New() *Model {
ctx, cancel := context.WithCancel(context.Background())
return &Model{
products: []models.StorageProduct{},
filteredProducts: []models.StorageProduct{},
categories: models.StorageCategories,
selectedCategory: 0,
selectedProduct: 0,
searchQuery: "",
isScraping: false,
view: "categories",
ctx: ctx,
cancel: cancel,
}
}
func (m *Model) Init() tea.Cmd {
return nil
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKeyPress(msg)
case scraper.ScrapeProgress:
m.progress = msg
return m, nil
case ScrapingDoneMsg:
m.isScraping = false
if m.view == "products" {
return m, nil
}
m.view = "products"
return m, nil
default:
return m, nil
}
}
type ScrapingDoneMsg struct{}
func (m *Model) applySort() {
if len(m.filteredProducts) == 0 {
return
}
sorted := make([]models.StorageProduct, len(m.filteredProducts))
copy(sorted, m.filteredProducts)
switch m.sortBy.Key {
case "price":
sort.Slice(sorted, func(i, j int) bool {
priceI := extractPriceValue(sorted[i].Price)
priceJ := extractPriceValue(sorted[j].Price)
if m.sortBy.Order == "asc" {
return priceI < priceJ
}
return priceI > priceJ
})
case "capacity":
sort.Slice(sorted, func(i, j int) bool {
capI := extractCapacityValue(sorted[i].Capacity)
capJ := extractCapacityValue(sorted[j].Capacity)
if m.sortBy.Order == "asc" {
return capI < capJ
}
return capI > capJ
})
case "stock":
sort.Slice(sorted, func(i, j int) bool {
stockI := sorted[i].StockStatus
stockJ := sorted[j].StockStatus
inStockI := strings.Contains(strings.ToLower(stockI), "in stock")
inStockJ := strings.Contains(strings.ToLower(stockJ), "in stock")
if inStockI && !inStockJ {
return true
}
if !inStockI && inStockJ {
return false
}
return sorted[i].Name < sorted[j].Name
})
case "name":
sort.Slice(sorted, func(i, j int) bool {
if m.sortBy.Order == "asc" {
return sorted[i].Name < sorted[j].Name
}
return sorted[i].Name > sorted[j].Name
})
}
m.filteredProducts = sorted
}
func extractPriceValue(priceStr string) float64 {
priceStr = strings.ReplaceAll(priceStr, "£", "")
priceStr = strings.ReplaceAll(priceStr, ",", "")
parts := strings.Split(priceStr, " ")
if len(parts) > 0 {
priceStr = parts[0]
}
price, _ := strconv.ParseFloat(priceStr, 64)
return price
}
func extractCapacityValue(capStr string) float64 {
capStr = strings.ToUpper(capStr)
multiplier := 1.0
if strings.Contains(capStr, "TB") {
multiplier = 1000.0
}
capStr = strings.ReplaceAll(capStr, "TB", "")
capStr = strings.ReplaceAll(capStr, "GB", "")
capStr = strings.ReplaceAll(capStr, " ", "")
cap, _ := strconv.ParseFloat(capStr, 64)
return cap * multiplier
}
func (m *Model) SetSort(option SortOption) {
m.sortBy = option
m.applySort()
}
func (m *Model) View() string {
if m.err != nil {
return m.renderError()
}
if m.isScraping {
return m.renderProgress()
}
switch m.view {
case "categories":
return m.renderCategories()
case "products":
return m.renderProducts()
case "export":
return m.renderExport()
default:
return m.renderCategories()
}
}
func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.isScraping {
if msg.String() == "esc" {
m.CancelScraping()
m.isScraping = false
}
return m, nil
}
switch m.view {
case "categories":
return m.handleCategoriesKeyPress(msg)
case "products":
return m.handleProductsKeyPress(msg)
case "export":
return m.handleExportKeyPress(msg)
case "sort":
return m.handleSortMenuKeyPress(msg)
}
return m, nil
}
func (m *Model) handleCategoriesKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.selectedCategory > 0 {
m.selectedCategory--
}
case "down", "j":
if m.selectedCategory < len(m.categories)-1 {
m.selectedCategory++
}
case "enter":
m.view = "products"
m.isScraping = true
if m.ScrapeCallback != nil {
go m.ScrapeCallback(m.categories[m.selectedCategory])
}
case "esc":
return m, tea.Quit
}
return m, nil
}
func (m *Model) handleProductsKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.selectedProduct > 0 {
m.selectedProduct--
}
case "down", "j":
if m.selectedProduct < len(m.filteredProducts)-1 {
m.selectedProduct++
}
case "enter":
if len(m.filteredProducts) > 0 && m.selectedProduct < len(m.filteredProducts) {
url := m.filteredProducts[m.selectedProduct].URL
if url != "" {
go func() {
cmd := exec.Command("xdg-open", url)
cmd.Start()
}()
}
}
case "/":
m.searchQuery = ""
case "r":
m.isScraping = true
m.view = "products"
case "e":
m.view = "export"
case "o":
m.sortMenuIndex = 0
m.view = "sort"
case "p":
m.SetSort(SortOption{Name: "Price (Low to High)", Key: "price", Order: "asc"})
case "P":
m.SetSort(SortOption{Name: "Price (High to Low)", Key: "price", Order: "desc"})
case "c":
m.SetSort(SortOption{Name: "Capacity (Small to Large)", Key: "capacity", Order: "asc"})
case "C":
m.SetSort(SortOption{Name: "Capacity (Large to Small)", Key: "capacity", Order: "desc"})
case "s":
m.SetSort(SortOption{Name: "Stock Status (In Stock First)", Key: "stock", Order: "desc"})
case "esc":
m.view = "categories"
m.searchQuery = ""
default:
if len(msg.String()) == 1 {
m.searchQuery += msg.String()
m.applyFilter()
}
}
return m, nil
}
func (m *Model) handleExportKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.ExportCallback == nil {
m.view = "products"
return m, nil
}
var format string
switch msg.String() {
case "j", "J":
format = "json"
case "c", "C":
format = "csv"
case "p", "P":
format = "print"
case "esc":
m.view = "products"
return m, nil
default:
return m, nil
}
err := m.ExportCallback(format)
if err != nil {
m.lastExportError = err.Error()
} else {
m.lastExportError = ""
}
m.view = "products"
return m, nil
}
func (m *Model) handleSortMenuKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.sortMenuIndex > 0 {
m.sortMenuIndex--
}
case "down", "j":
if m.sortMenuIndex < len(sortOptions) {
m.sortMenuIndex++
}
case "enter":
if m.sortMenuIndex >= 0 && m.sortMenuIndex < len(sortOptions) {
m.SetSort(sortOptions[m.sortMenuIndex])
}
m.view = "products"
case "esc":
m.view = "products"
}
return m, nil
}
func (m *Model) GetProducts() []models.StorageProduct {
return m.products
}
func (m *Model) GetFilteredProducts() []models.StorageProduct {
return m.filteredProducts
}
func (m *Model) GetCategories() []models.Category {
return m.categories
}
func (m *Model) GetSelectedCategory() int {
return m.selectedCategory
}
func (m *Model) GetSelectedProduct() int {
return m.selectedProduct
}
func (m *Model) GetSearchQuery() string {
return m.searchQuery
}
func (m *Model) IsScraping() bool {
return m.isScraping
}
func (m *Model) GetProgress() scraper.ScrapeProgress {
return m.progress
}
func (m *Model) GetError() error {
return m.err
}
func (m *Model) GetView() string {
return m.view
}
func (m *Model) SetProducts(products []models.StorageProduct) {
m.products = products
m.filteredProducts = products
m.selectedProduct = 0
}
func (m *Model) SetScraping(isScraping bool) {
m.isScraping = isScraping
}
func (m *Model) SetProgress(progress scraper.ScrapeProgress) {
m.progress = progress
}
func (m *Model) SetError(err error) {
m.err = err
}
func (m *Model) SetView(view string) {
m.view = view
}
func (m *Model) SetSelectedCategory(index int) {
if index >= 0 && index < len(m.categories) {
m.selectedCategory = index
}
}
func (m *Model) SetSelectedProduct(index int) {
if index >= 0 && index < len(m.filteredProducts) {
m.selectedProduct = index
}
}
func (m *Model) SetSearchQuery(query string) {
m.searchQuery = query
m.applyFilter()
}
func (m *Model) applyFilter() {
if m.searchQuery == "" {
m.filteredProducts = m.products
} else {
query := toLower(m.searchQuery)
var filtered []models.StorageProduct
for _, p := range m.products {
if contains(toLower(p.Name), query) ||
contains(toLower(p.Capacity), query) ||
contains(toLower(p.Brand), query) ||
contains(toLower(p.Interface), query) ||
contains(toLower(p.FormFactor), query) ||
contains(toLower(p.PartNumber), query) {
filtered = append(filtered, p)
}
}
m.filteredProducts = filtered
}
m.selectedProduct = 0
}
func toLower(s string) string {
result := make([]rune, len(s))
for i, r := range s {
if r >= 'A' && r <= 'Z' {
result[i] = r + 32
} else {
result[i] = r
}
}
return string(result)
}
func contains(s, substr string) bool {
if len(substr) == 0 {
return true
}
if len(s) < len(substr) {
return false
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func (m *Model) FilterByCategory(categoryIndex int) {
if categoryIndex < 0 || categoryIndex >= len(m.categories) {
return
}
category := m.categories[categoryIndex]
m.selectedCategory = categoryIndex
if category.Name == "All Categories" {
m.filteredProducts = m.products
} else {
var filtered []models.StorageProduct
for _, p := range m.products {
if matchesCategory(p, category) {
filtered = append(filtered, p)
}
}
m.filteredProducts = filtered
}
m.selectedProduct = 0
}
func matchesCategory(p models.StorageProduct, category models.Category) bool {
catLower := toLower(category.Name)
if contains(catLower, "3.5") && p.FormFactor == "3.5\"" {
return true
}
if contains(catLower, "2.5") && p.FormFactor == "2.5\"" {
return true
}
if contains(catLower, "nvme") && contains(toLower(p.Interface), "nvme") {
return true
}
if contains(catLower, "sata") && contains(toLower(p.Interface), "sata") {
return true
}
if contains(catLower, "sas") && contains(toLower(p.Interface), "sas") {
return true
}
if contains(catLower, "ssd") && (p.Interface == "SATA" || p.Interface == "SAS" || p.Interface == "NVMe") {
return true
}
if contains(catLower, "hdd") && (p.Interface == "SATA" || p.Interface == "SAS") && p.Capacity != "" {
return true
}
return false
}
func (m *Model) GetContext() context.Context {
return m.ctx
}
func (m *Model) CancelScraping() {
if m.cancel != nil {
m.cancel()
}
}
func (m *Model) renderCategories() string {
var result string
result += headerStyle.Width(60).Render(" Select Storage Category ")
result += "\n\n"
result += subtleStyle.Render("Use arrow keys to navigate, Enter to select, Esc to go back\n")
result += "\n"
for i, cat := range m.categories {
if i == m.selectedCategory {
result += selectedStyle.Width(58).Render(" " + cat.Name)
} else {
result += normalStyle.Width(58).Render(" " + cat.Name)
}
result += "\n"
}
result += "\n"
result += subtleStyle.Render(fmt.Sprintf("Categories: %d | Press Enter to scrape selected category", len(m.categories)))
return result
}
func (m *Model) renderProducts() string {
var result string
title := fmt.Sprintf(" Storage Products - %s ", m.categories[m.selectedCategory].Name)
result += headerStyle.Width(80).Render(title)
result += "\n\n"
if len(m.products) == 0 {
result += subtleStyle.Render("No products scraped yet. Select a category and press Enter to scrape.\n")
result += "\n"
result += subtleStyle.Render("Press Esc to go back to category selection.\n")
return result
}
result += subtleStyle.Render(fmt.Sprintf("Search: %s | ", m.searchQuery))
result += fmt.Sprintf("Showing: %d/%d products", len(m.filteredProducts), len(m.products))
result += "\n\n"
result += m.renderProductTable()
result += "\n\n"
result += subtleStyle.Render("Controls: ↑↓ Navigate | / Search | E Export | Esc Back | R Rescrape")
return result
}
func (m *Model) renderProductTable() string {
if len(m.filteredProducts) == 0 {
return " No products found matching your criteria."
}
var result string
header := " # Name Capacity Interface FormFactor Price \n"
result += headerStyle.Render(header)
start := m.selectedProduct - 5
if start < 0 {
start = 0
}
end := start + 15
if end > len(m.filteredProducts) {
end = len(m.filteredProducts)
}
for i := start; i < end; i++ {
p := m.filteredProducts[i]
row := fmt.Sprintf(" %-4d %-35s %-10s %-8s %-10s %-12s\n",
i+1,
truncate(p.Name, 35),
p.Capacity,
p.Interface,
p.FormFactor,
truncate(p.Price, 12))
if i == m.selectedProduct {
result += selectedStyle.Width(79).Render(row)
} else {
result += normalStyle.Width(79).Render(row)
}
}
return result
}
func (m *Model) renderProgress() string {
var result string
result += headerStyle.Width(60).Render(" Scraping Progress ")
result += "\n\n"
result += fmt.Sprintf(" Category: %s\n\n", m.progress.Category)
result += fmt.Sprintf(" Status: %s\n\n", m.progress.Status)
if m.progress.Total > 0 {
percent := float64(m.progress.Current) / float64(m.progress.Total) * 100
barWidth := 40
filled := int(float64(barWidth) * percent / 100)
bar := "["
for i := 0; i < barWidth; i++ {
if i < filled {
bar += "="
} else if i == filled {
bar += ">"
} else {
bar += " "
}
}
bar += "]"
result += fmt.Sprintf(" Progress: %s %d/%d (%.1f%%)\n\n", bar, m.progress.Current, m.progress.Total, percent)
}
if m.progress.ProductName != "" {
result += fmt.Sprintf(" Current: %s\n", truncate(m.progress.ProductName, 50))
}
result += "\n\n"
result += subtleStyle.Render("Press Esc to cancel...")
return result
}
func (m *Model) renderError() string {
var result string
result += headerStyle.Width(60).Render(" Error ")
result += "\n\n"
result += errorStyle.Render(fmt.Sprintf(" %v\n", m.err))
result += "\n\n"
result += subtleStyle.Render("Press any key to continue...")
return result
}
func (m *Model) renderExport() string {
var result string
result += headerStyle.Width(60).Render(" Export Data ")
result += "\n\n"
result += fmt.Sprintf(" Products: %d\n", len(m.products))
result += fmt.Sprintf(" Filtered: %d\n\n", len(m.filteredProducts))
result += " Export Options:\n"
result += " [J] Export to JSON\n"
result += " [C] Export to CSV\n"
result += " [P] Print to console\n"
result += " [Esc] Go back\n"
result += "\n\n"
result += subtleStyle.Render("Select an option...")
return result
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}