710 lines
16 KiB
Go
710 lines
16 KiB
Go
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] + "..."
|
|
}
|