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] + "..." }