Add compare and tui commands with shared sort/filter CLI wiring
- add pubcli compare command to rank nearby stores by filtered deal coverage, bogo count, aggregate score, and distance tie-breaks - support --count (1-10) for comparison breadth and emit structured JSON/text output with ranked entries - add robust distance token parsing to tolerate upstream distance string formatting differences - add pubcli tui interactive terminal browser with paging, deal detail drill-in, and explicit TTY validation for stdin/stdout - share deal-filter flag registration across root/tui/compare and add --sort support in root execution path - validate sort mode early and allow canonical aliases (end, expiry, expiration) while preserving explicit invalid-arg guidance - expand tolerant CLI normalization for new commands/flags and aliases (orderby, sortby, count, bare-flag rewrite for compare/tui) - update quick-start flag list and integration tests to cover compare help and normalization behavior
This commit is contained in:
@@ -18,13 +18,17 @@ var knownFlags = map[string]flagSpec{
|
|||||||
"department": {name: "department", requiresValue: true},
|
"department": {name: "department", requiresValue: true},
|
||||||
"bogo": {name: "bogo", requiresValue: false},
|
"bogo": {name: "bogo", requiresValue: false},
|
||||||
"query": {name: "query", requiresValue: true},
|
"query": {name: "query", requiresValue: true},
|
||||||
|
"sort": {name: "sort", requiresValue: true},
|
||||||
"limit": {name: "limit", requiresValue: true},
|
"limit": {name: "limit", requiresValue: true},
|
||||||
|
"count": {name: "count", requiresValue: true},
|
||||||
"help": {name: "help", requiresValue: false},
|
"help": {name: "help", requiresValue: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
var knownCommands = []string{
|
var knownCommands = []string{
|
||||||
"categories",
|
"categories",
|
||||||
"stores",
|
"stores",
|
||||||
|
"compare",
|
||||||
|
"tui",
|
||||||
"completion",
|
"completion",
|
||||||
"help",
|
"help",
|
||||||
}
|
}
|
||||||
@@ -36,6 +40,8 @@ var flagAliases = map[string]string{
|
|||||||
"storeno": "store",
|
"storeno": "store",
|
||||||
"dept": "department",
|
"dept": "department",
|
||||||
"search": "query",
|
"search": "query",
|
||||||
|
"orderby": "sort",
|
||||||
|
"sortby": "sort",
|
||||||
"max": "limit",
|
"max": "limit",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +162,7 @@ func bareFlagRewriteAllowed(command string) bool {
|
|||||||
// Some commands (for example `stores` and `categories`) are flag-only, so
|
// Some commands (for example `stores` and `categories`) are flag-only, so
|
||||||
// rewriting bare tokens like `zip` -> `--zip` is helpful there.
|
// rewriting bare tokens like `zip` -> `--zip` is helpful there.
|
||||||
switch command {
|
switch command {
|
||||||
case "stores", "categories":
|
case "stores", "categories", "compare", "tui":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ func TestNormalizeCLIArgs_RewritesTypoFlag(t *testing.T) {
|
|||||||
assert.NotEmpty(t, notes)
|
assert.NotEmpty(t, notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCLIArgs_RewritesSortAlias(t *testing.T) {
|
||||||
|
args, notes := normalizeCLIArgs([]string{"orderby=savings"})
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"--sort=savings"}, args)
|
||||||
|
assert.NotEmpty(t, notes)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeCLIArgs_RewritesCommandTypo(t *testing.T) {
|
func TestNormalizeCLIArgs_RewritesCommandTypo(t *testing.T) {
|
||||||
args, notes := normalizeCLIArgs([]string{"categoriess", "--zip", "33101"})
|
args, notes := normalizeCLIArgs([]string{"categoriess", "--zip", "33101"})
|
||||||
|
|
||||||
@@ -49,6 +56,13 @@ func TestNormalizeCLIArgs_RespectsDoubleDashBoundary(t *testing.T) {
|
|||||||
assert.Empty(t, notes)
|
assert.Empty(t, notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCLIArgs_RewritesBareFlagsForCompare(t *testing.T) {
|
||||||
|
args, notes := normalizeCLIArgs([]string{"compare", "zip", "33101"})
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"compare", "--zip", "33101"}, args)
|
||||||
|
assert.NotEmpty(t, notes)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeCLIArgs_LeavesKnownShorthandUntouched(t *testing.T) {
|
func TestNormalizeCLIArgs_LeavesKnownShorthandUntouched(t *testing.T) {
|
||||||
args, notes := normalizeCLIArgs([]string{"-z", "33101", "-n", "5"})
|
args, notes := normalizeCLIArgs([]string{"-z", "33101", "-n", "5"})
|
||||||
|
|
||||||
|
|||||||
198
cmd/compare.go
Normal file
198
cmd/compare.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/tayloree/publix-deals/internal/api"
|
||||||
|
"github.com/tayloree/publix-deals/internal/filter"
|
||||||
|
)
|
||||||
|
|
||||||
|
var flagCompareCount int
|
||||||
|
|
||||||
|
type compareStoreResult struct {
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
City string `json:"city"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Distance string `json:"distance"`
|
||||||
|
MatchedDeals int `json:"matchedDeals"`
|
||||||
|
BogoDeals int `json:"bogoDeals"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
TopDeal string `json:"topDeal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var compareCmd = &cobra.Command{
|
||||||
|
Use: "compare",
|
||||||
|
Short: "Compare nearby stores by filtered deal quality",
|
||||||
|
Example: ` pubcli compare --zip 33101
|
||||||
|
pubcli compare --zip 33101 --category produce --sort savings
|
||||||
|
pubcli compare --zip 33101 --bogo --json`,
|
||||||
|
RunE: runCompare,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(compareCmd)
|
||||||
|
|
||||||
|
registerDealFilterFlags(compareCmd.Flags())
|
||||||
|
compareCmd.Flags().IntVar(&flagCompareCount, "count", 5, "Number of nearby stores to compare (1-10)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCompare(cmd *cobra.Command, _ []string) error {
|
||||||
|
if err := validateSortMode(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flagZip == "" {
|
||||||
|
return invalidArgsError(
|
||||||
|
"--zip is required for compare",
|
||||||
|
"pubcli compare --zip 33101",
|
||||||
|
"pubcli compare --zip 33101 --category produce",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if flagCompareCount < 1 || flagCompareCount > 10 {
|
||||||
|
return invalidArgsError(
|
||||||
|
"--count must be between 1 and 10",
|
||||||
|
"pubcli compare --zip 33101 --count 5",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewClient()
|
||||||
|
stores, err := client.FetchStores(cmd.Context(), flagZip, flagCompareCount)
|
||||||
|
if err != nil {
|
||||||
|
return upstreamError("fetching stores", err)
|
||||||
|
}
|
||||||
|
if len(stores) == 0 {
|
||||||
|
return notFoundError(
|
||||||
|
fmt.Sprintf("no stores found near %s", flagZip),
|
||||||
|
"Try a nearby ZIP code.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]compareStoreResult, 0, len(stores))
|
||||||
|
errCount := 0
|
||||||
|
for _, store := range stores {
|
||||||
|
storeNumber := api.StoreNumber(store.Key)
|
||||||
|
resp, fetchErr := client.FetchSavings(cmd.Context(), storeNumber)
|
||||||
|
if fetchErr != nil {
|
||||||
|
errCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
items := filter.Apply(resp.Savings, filter.Options{
|
||||||
|
BOGO: flagBogo,
|
||||||
|
Category: flagCategory,
|
||||||
|
Department: flagDepartment,
|
||||||
|
Query: flagQuery,
|
||||||
|
Sort: flagSort,
|
||||||
|
Limit: flagLimit,
|
||||||
|
})
|
||||||
|
if len(items) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bogoDeals := 0
|
||||||
|
score := 0.0
|
||||||
|
for _, item := range items {
|
||||||
|
if filter.ContainsIgnoreCase(item.Categories, "bogo") {
|
||||||
|
bogoDeals++
|
||||||
|
}
|
||||||
|
score += filter.DealScore(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, compareStoreResult{
|
||||||
|
Number: storeNumber,
|
||||||
|
Name: store.Name,
|
||||||
|
City: store.City,
|
||||||
|
State: store.State,
|
||||||
|
Distance: strings.TrimSpace(store.Distance),
|
||||||
|
MatchedDeals: len(items),
|
||||||
|
BogoDeals: bogoDeals,
|
||||||
|
Score: score,
|
||||||
|
TopDeal: topDealTitle(items[0]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
if errCount == len(stores) {
|
||||||
|
return upstreamError("fetching deals", fmt.Errorf("all %d store lookups failed", len(stores)))
|
||||||
|
}
|
||||||
|
return notFoundError(
|
||||||
|
"no stores have deals matching your filters",
|
||||||
|
"Relax filters like --category/--department/--query.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(results, func(i, j int) bool {
|
||||||
|
if results[i].MatchedDeals != results[j].MatchedDeals {
|
||||||
|
return results[i].MatchedDeals > results[j].MatchedDeals
|
||||||
|
}
|
||||||
|
if results[i].Score != results[j].Score {
|
||||||
|
return results[i].Score > results[j].Score
|
||||||
|
}
|
||||||
|
return parseDistance(results[i].Distance) < parseDistance(results[j].Distance)
|
||||||
|
})
|
||||||
|
for i := range results {
|
||||||
|
results[i].Rank = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagJSON {
|
||||||
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "\nStore comparison near %s (%d matching store(s))\n\n", flagZip, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
fmt.Fprintf(
|
||||||
|
cmd.OutOrStdout(),
|
||||||
|
"%d. #%s %s (%s, %s)\n matches: %d | bogo: %d | score: %.1f | distance: %s mi\n top: %s\n\n",
|
||||||
|
r.Rank,
|
||||||
|
r.Number,
|
||||||
|
r.Name,
|
||||||
|
r.City,
|
||||||
|
r.State,
|
||||||
|
r.MatchedDeals,
|
||||||
|
r.BogoDeals,
|
||||||
|
r.Score,
|
||||||
|
emptyIf(r.Distance, "?"),
|
||||||
|
r.TopDeal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if errCount > 0 {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "note: skipped %d store(s) due to upstream fetch errors.\n", errCount)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func topDealTitle(item api.SavingItem) string {
|
||||||
|
if title := filter.CleanText(filter.Deref(item.Title)); title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
if desc := filter.CleanText(filter.Deref(item.Description)); desc != "" {
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
if item.ID != "" {
|
||||||
|
return "Deal " + item.ID
|
||||||
|
}
|
||||||
|
return "Untitled deal"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDistance(raw string) float64 {
|
||||||
|
for _, token := range strings.Fields(raw) {
|
||||||
|
clean := strings.Trim(token, ",")
|
||||||
|
if d, err := strconv.ParseFloat(clean, 64); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 999999
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyIf(value, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -240,12 +240,12 @@ func shouldAutoJSON(args []string, stdoutIsTTY bool) bool {
|
|||||||
|
|
||||||
// knownShorthands maps single-character shorthands to whether they require a value.
|
// knownShorthands maps single-character shorthands to whether they require a value.
|
||||||
var knownShorthands = map[byte]bool{
|
var knownShorthands = map[byte]bool{
|
||||||
's': true, // --store
|
's': true, // --store
|
||||||
'z': true, // --zip
|
'z': true, // --zip
|
||||||
'c': true, // --category
|
'c': true, // --category
|
||||||
'd': true, // --department
|
'd': true, // --department
|
||||||
'q': true, // --query
|
'q': true, // --query
|
||||||
'n': true, // --limit
|
'n': true, // --limit
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstCommand(args []string) string {
|
func firstCommand(args []string) string {
|
||||||
@@ -299,7 +299,7 @@ func printQuickStart(w io.Writer, asJSON bool) error {
|
|||||||
|
|
||||||
_, err := fmt.Fprintf(
|
_, err := fmt.Fprintf(
|
||||||
w,
|
w,
|
||||||
"%s\nusage: %s\nexamples:\n %s\n %s\n %s\nflags: --zip --store --json --bogo --category --department --query --limit\n",
|
"%s\nusage: %s\nexamples:\n %s\n %s\n %s\nflags: --zip --store --json --bogo --category --department --query --sort --limit\n",
|
||||||
help.Name,
|
help.Name,
|
||||||
help.Usage,
|
help.Usage,
|
||||||
help.Examples[0],
|
help.Examples[0],
|
||||||
|
|||||||
43
cmd/root.go
43
cmd/root.go
@@ -4,8 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
"github.com/tayloree/publix-deals/internal/api"
|
"github.com/tayloree/publix-deals/internal/api"
|
||||||
"github.com/tayloree/publix-deals/internal/display"
|
"github.com/tayloree/publix-deals/internal/display"
|
||||||
"github.com/tayloree/publix-deals/internal/filter"
|
"github.com/tayloree/publix-deals/internal/filter"
|
||||||
@@ -18,6 +20,7 @@ var (
|
|||||||
flagDepartment string
|
flagDepartment string
|
||||||
flagBogo bool
|
flagBogo bool
|
||||||
flagQuery string
|
flagQuery string
|
||||||
|
flagSort string
|
||||||
flagLimit int
|
flagLimit int
|
||||||
flagJSON bool
|
flagJSON bool
|
||||||
)
|
)
|
||||||
@@ -31,8 +34,10 @@ var rootCmd = &cobra.Command{
|
|||||||
"(for example: -zip 33101, zip=33101, --ziip 33101).",
|
"(for example: -zip 33101, zip=33101, --ziip 33101).",
|
||||||
Example: ` pubcli --zip 33101
|
Example: ` pubcli --zip 33101
|
||||||
pubcli --store 1425 --bogo
|
pubcli --store 1425 --bogo
|
||||||
|
pubcli --zip 33101 --sort savings
|
||||||
pubcli categories --zip 33101
|
pubcli categories --zip 33101
|
||||||
pubcli stores --zip 33101 --json`,
|
pubcli stores --zip 33101 --json
|
||||||
|
pubcli compare --zip 33101 --category produce`,
|
||||||
RunE: runDeals,
|
RunE: runDeals,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +50,7 @@ func init() {
|
|||||||
pf.StringVarP(&flagZip, "zip", "z", "", "Zip code to find nearby stores")
|
pf.StringVarP(&flagZip, "zip", "z", "", "Zip code to find nearby stores")
|
||||||
pf.BoolVar(&flagJSON, "json", false, "Output as JSON")
|
pf.BoolVar(&flagJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
f := rootCmd.Flags()
|
registerDealFilterFlags(rootCmd.Flags())
|
||||||
f.StringVarP(&flagCategory, "category", "c", "", "Filter by category (e.g., bogo, meat, produce)")
|
|
||||||
f.StringVarP(&flagDepartment, "department", "d", "", "Filter by department (e.g., Meat, Deli)")
|
|
||||||
f.BoolVar(&flagBogo, "bogo", false, "Show only BOGO deals")
|
|
||||||
f.StringVarP(&flagQuery, "query", "q", "", "Search deals by keyword in title/description")
|
|
||||||
f.IntVarP(&flagLimit, "limit", "n", 0, "Limit number of results (0 = all)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute runs the root command.
|
// Execute runs the root command.
|
||||||
@@ -112,10 +112,34 @@ func resetCLIState() {
|
|||||||
flagDepartment = ""
|
flagDepartment = ""
|
||||||
flagBogo = false
|
flagBogo = false
|
||||||
flagQuery = ""
|
flagQuery = ""
|
||||||
|
flagSort = ""
|
||||||
flagLimit = 0
|
flagLimit = 0
|
||||||
|
flagCompareCount = 5
|
||||||
flagJSON = false
|
flagJSON = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerDealFilterFlags(f *pflag.FlagSet) {
|
||||||
|
f.StringVarP(&flagCategory, "category", "c", "", "Filter by category (e.g., bogo, meat, produce)")
|
||||||
|
f.StringVarP(&flagDepartment, "department", "d", "", "Filter by department (e.g., Meat, Deli)")
|
||||||
|
f.BoolVar(&flagBogo, "bogo", false, "Show only BOGO deals")
|
||||||
|
f.StringVarP(&flagQuery, "query", "q", "", "Search deals by keyword in title/description")
|
||||||
|
f.StringVar(&flagSort, "sort", "", "Sort deals by relevance, savings, or ending")
|
||||||
|
f.IntVarP(&flagLimit, "limit", "n", 0, "Limit number of results (0 = all)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSortMode() error {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(flagSort)) {
|
||||||
|
case "", "relevance", "savings", "ending", "end", "expiry", "expiration":
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return invalidArgsError(
|
||||||
|
"invalid value for --sort (use relevance, savings, or ending)",
|
||||||
|
"pubcli --zip 33101 --sort savings",
|
||||||
|
"pubcli --zip 33101 --sort ending",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resolveStore(cmd *cobra.Command, client *api.Client) (string, error) {
|
func resolveStore(cmd *cobra.Command, client *api.Client) (string, error) {
|
||||||
if flagStore != "" {
|
if flagStore != "" {
|
||||||
return flagStore, nil
|
return flagStore, nil
|
||||||
@@ -147,6 +171,10 @@ func resolveStore(cmd *cobra.Command, client *api.Client) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runDeals(cmd *cobra.Command, _ []string) error {
|
func runDeals(cmd *cobra.Command, _ []string) error {
|
||||||
|
if err := validateSortMode(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
client := api.NewClient()
|
client := api.NewClient()
|
||||||
|
|
||||||
storeNumber, err := resolveStore(cmd, client)
|
storeNumber, err := resolveStore(cmd, client)
|
||||||
@@ -172,6 +200,7 @@ func runDeals(cmd *cobra.Command, _ []string) error {
|
|||||||
Category: flagCategory,
|
Category: flagCategory,
|
||||||
Department: flagDepartment,
|
Department: flagDepartment,
|
||||||
Query: flagQuery,
|
Query: flagQuery,
|
||||||
|
Sort: flagSort,
|
||||||
Limit: flagLimit,
|
Limit: flagLimit,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ func TestRunCLI_HelpStores(t *testing.T) {
|
|||||||
assert.Empty(t, stderr.String())
|
assert.Empty(t, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunCLI_HelpCompare(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := runCLI([]string{"help", "compare"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, code)
|
||||||
|
assert.Contains(t, stdout.String(), "pubcli compare [flags]")
|
||||||
|
assert.Empty(t, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunCLI_TolerantRewriteWithoutNetworkCall(t *testing.T) {
|
func TestRunCLI_TolerantRewriteWithoutNetworkCall(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|||||||
155
cmd/tui.go
Normal file
155
cmd/tui.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/tayloree/publix-deals/internal/api"
|
||||||
|
"github.com/tayloree/publix-deals/internal/display"
|
||||||
|
"github.com/tayloree/publix-deals/internal/filter"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tuiPageSize = 10
|
||||||
|
|
||||||
|
var tuiCmd = &cobra.Command{
|
||||||
|
Use: "tui",
|
||||||
|
Short: "Browse deals interactively in the terminal",
|
||||||
|
Example: ` pubcli tui --zip 33101
|
||||||
|
pubcli tui --store 1425 --category produce --sort ending`,
|
||||||
|
RunE: runTUI,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(tuiCmd)
|
||||||
|
registerDealFilterFlags(tuiCmd.Flags())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTUI(cmd *cobra.Command, _ []string) error {
|
||||||
|
if err := validateSortMode(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !flagJSON && !isInteractiveSession(cmd.InOrStdin(), cmd.OutOrStdout()) {
|
||||||
|
return invalidArgsError(
|
||||||
|
"`pubcli tui` requires an interactive terminal",
|
||||||
|
"Use `pubcli --zip 33101 --json` in pipelines.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewClient()
|
||||||
|
storeNumber, err := resolveStore(cmd, client)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.FetchSavings(cmd.Context(), storeNumber)
|
||||||
|
if err != nil {
|
||||||
|
return upstreamError("fetching deals", err)
|
||||||
|
}
|
||||||
|
if len(resp.Savings) == 0 {
|
||||||
|
return notFoundError(
|
||||||
|
fmt.Sprintf("no deals found for store #%s", storeNumber),
|
||||||
|
"Try another store with --store.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := filter.Apply(resp.Savings, filter.Options{
|
||||||
|
BOGO: flagBogo,
|
||||||
|
Category: flagCategory,
|
||||||
|
Department: flagDepartment,
|
||||||
|
Query: flagQuery,
|
||||||
|
Sort: flagSort,
|
||||||
|
Limit: flagLimit,
|
||||||
|
})
|
||||||
|
if len(items) == 0 {
|
||||||
|
return notFoundError(
|
||||||
|
"no deals match your filters",
|
||||||
|
"Relax filters like --category/--department/--query.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagJSON {
|
||||||
|
return display.PrintDealsJSON(cmd.OutOrStdout(), items)
|
||||||
|
}
|
||||||
|
|
||||||
|
return runTUILoop(cmd.OutOrStdout(), cmd.InOrStdin(), storeNumber, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInteractiveSession(stdin io.Reader, stdout io.Writer) bool {
|
||||||
|
inputFile, ok := stdin.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !term.IsTerminal(int(inputFile.Fd())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isTTY(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTUILoop(out io.Writer, in io.Reader, storeNumber string, items []api.SavingItem) error {
|
||||||
|
reader := bufio.NewReader(in)
|
||||||
|
page := 0
|
||||||
|
totalPages := (len(items)-1)/tuiPageSize + 1
|
||||||
|
|
||||||
|
for {
|
||||||
|
renderTUIPage(out, storeNumber, items, page, totalPages)
|
||||||
|
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmd := strings.TrimSpace(strings.ToLower(line))
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "q", "quit", "exit":
|
||||||
|
return nil
|
||||||
|
case "n", "next":
|
||||||
|
if page < totalPages-1 {
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
case "p", "prev", "previous":
|
||||||
|
if page > 0 {
|
||||||
|
page--
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
idx, convErr := strconv.Atoi(cmd)
|
||||||
|
if convErr != nil || idx < 1 || idx > len(items) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
renderDealDetail(out, items[idx-1], idx, len(items))
|
||||||
|
if _, err := reader.ReadString('\n'); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTUIPage(out io.Writer, storeNumber string, items []api.SavingItem, page, totalPages int) {
|
||||||
|
fmt.Fprint(out, "\033[H\033[2J")
|
||||||
|
fmt.Fprintf(out, "pubcli tui | store #%s | %d deals | page %d/%d\n\n", storeNumber, len(items), page+1, totalPages)
|
||||||
|
|
||||||
|
start := page * tuiPageSize
|
||||||
|
end := minInt(start+tuiPageSize, len(items))
|
||||||
|
for i := start; i < end; i++ {
|
||||||
|
item := items[i]
|
||||||
|
title := topDealTitle(item)
|
||||||
|
savings := filter.CleanText(filter.Deref(item.Savings))
|
||||||
|
if savings == "" {
|
||||||
|
savings = "-"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "%2d. %s [%s]\n", i+1, title, savings)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "\ncommands: number=details | n=next | p=prev | q=quit\n> ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDealDetail(out io.Writer, item api.SavingItem, index, total int) {
|
||||||
|
fmt.Fprint(out, "\033[H\033[2J")
|
||||||
|
fmt.Fprintf(out, "deal %d/%d\n\n", index, total)
|
||||||
|
display.PrintDeals(out, []api.SavingItem{item})
|
||||||
|
fmt.Fprint(out, "press Enter to return\n")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user