diff --git a/README.md b/README.md index e3749ec..bbd7f0d 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ go run . --icon-test go run . -R go run . --radar +# Interactive search (requires rofi) +go run . -S +go run . --interactive-search + Radar mode caches the GIF on disk (10 minute TTL) under the cache directory. ``` diff --git a/cache.go b/cache.go index 3bebf32..b52ecc6 100644 --- a/cache.go +++ b/cache.go @@ -19,9 +19,10 @@ type Cache struct { } type LocationCache struct { - Query string `json:"query"` - Location Location `json:"location"` - UpdatedAt time.Time `json:"updated_at"` + Query string `json:"query"` + Location Location `json:"location"` + Recent []Location `json:"recent"` + UpdatedAt time.Time `json:"updated_at"` } type ForecastCache struct { @@ -39,11 +40,16 @@ func (c *Cache) SaveLocation(query string, loc Location) error { if err := c.ensureDir(); err != nil { return err } - payload := LocationCache{ - Query: query, - Location: loc, - UpdatedAt: time.Now().UTC(), + payload := LocationCache{} + if err := readJSON(filepath.Join(c.dir, locationCacheFile), &payload); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } } + payload.Query = query + payload.Location = loc + payload.UpdatedAt = time.Now().UTC() + payload.Recent = addRecentLocation(payload.Recent, loc, 10) if err := writeJSON(filepath.Join(c.dir, locationCacheFile), payload); err != nil { return err } @@ -62,6 +68,18 @@ func (c *Cache) LoadLocation() (Location, bool, error) { return payload.Location, true, nil } +func (c *Cache) LoadRecentLocations() ([]Location, error) { + path := filepath.Join(c.dir, locationCacheFile) + var payload LocationCache + if err := readJSON(path, &payload); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + return payload.Recent, nil +} + func (c *Cache) UpdateLocation(loc Location) error { path := filepath.Join(c.dir, locationCacheFile) var payload LocationCache @@ -74,6 +92,7 @@ func (c *Cache) UpdateLocation(loc Location) error { payload.Location = loc payload.UpdatedAt = time.Now().UTC() + payload.Recent = addRecentLocation(payload.Recent, loc, 10) return writeJSON(path, payload) } @@ -163,6 +182,24 @@ func sameLocation(a, b Location) bool { return a.Lat == b.Lat && a.Lon == b.Lon } +func addRecentLocation(recent []Location, loc Location, limit int) []Location { + if limit <= 0 { + return recent + } + trimmed := make([]Location, 0, len(recent)+1) + trimmed = append(trimmed, loc) + for _, item := range recent { + if sameLocation(item, loc) { + continue + } + trimmed = append(trimmed, item) + if len(trimmed) >= limit { + break + } + } + return trimmed +} + func cacheRoot() (string, error) { if override := os.Getenv("WEATHER_CACHE_DIR"); override != "" { return override, nil diff --git a/cache_recent_test.go b/cache_recent_test.go new file mode 100644 index 0000000..a577490 --- /dev/null +++ b/cache_recent_test.go @@ -0,0 +1,20 @@ +package main + +import "testing" + +func TestAddRecentLocationLRU(t *testing.T) { + locA := Location{Lat: 1, Lon: 1, City: "A"} + locB := Location{Lat: 2, Lon: 2, City: "B"} + locC := Location{Lat: 3, Lon: 3, City: "C"} + + recent := []Location{locA, locB} + recent = addRecentLocation(recent, locC, 3) + if len(recent) != 3 || !sameLocation(recent[0], locC) { + t.Fatalf("expected new location at front") + } + + recent = addRecentLocation(recent, locB, 3) + if len(recent) != 3 || !sameLocation(recent[0], locB) { + t.Fatalf("expected B to move to front") + } +} diff --git a/main.go b/main.go index a5e6d50..0d60553 100644 --- a/main.go +++ b/main.go @@ -22,14 +22,15 @@ func main() { func run() error { var ( - search string - includeDesc bool - prettyOutput bool - temperatureU string - clearCache bool - refresh bool - iconTest bool - radarMode bool + search string + includeDesc bool + prettyOutput bool + temperatureU string + clearCache bool + refresh bool + iconTest bool + radarMode bool + interactiveSearchFlag bool ) flag.StringVar(&search, "s", "", "Search location (e.g. \"Somerville, MA\")") @@ -48,6 +49,8 @@ func run() error { flag.BoolVar(&iconTest, "i", false, "Print all icons used by the program") flag.BoolVar(&radarMode, "radar", false, "Display radar loop for current location via mpv") flag.BoolVar(&radarMode, "R", false, "Display radar loop for current location via mpv") + flag.BoolVar(&interactiveSearchFlag, "interactive-search", false, "Use rofi for interactive location search") + flag.BoolVar(&interactiveSearchFlag, "S", false, "Use rofi for interactive location search") flag.Parse() ua, err := buildUserAgent() @@ -76,6 +79,12 @@ func run() error { return nil } + if interactiveSearchFlag { + if err := requireRofi(); err != nil { + return err + } + } + if radarMode { if err := requireMPV(); err != nil { return err @@ -90,7 +99,13 @@ func run() error { } var loc Location - if search != "" { + if interactiveSearchFlag { + var err error + loc, err = interactiveSearch(ctx, cache, ua) + if err != nil { + return err + } + } else if search != "" { results, err := SearchLocations(ctx, search, ua) if err != nil { return err diff --git a/rofi.go b/rofi.go new file mode 100644 index 0000000..d3604da --- /dev/null +++ b/rofi.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +func requireRofi() error { + if _, err := exec.LookPath("rofi"); err != nil { + return fmt.Errorf("rofi is required for interactive search") + } + return nil +} + +func rofiPrompt(prompt string) (string, bool, error) { + cmd := exec.Command("rofi", "-dmenu", "-p", prompt) + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return "", false, nil + } + return "", false, fmt.Errorf("rofi prompt failed: %w", err) + } + selection := strings.TrimSpace(out.String()) + if selection == "" { + return "", false, nil + } + return selection, true, nil +} + +func rofiSelect(prompt string, options []string) (string, bool, error) { + cmd := exec.Command("rofi", "-dmenu", "-p", prompt) + cmd.Stdin = strings.NewReader(strings.Join(options, "\n")) + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return "", false, nil + } + return "", false, fmt.Errorf("rofi selection failed: %w", err) + } + selection := strings.TrimSpace(out.String()) + if selection == "" { + return "", false, nil + } + return selection, true, nil +} + +func interactiveSearch(ctx context.Context, cache *Cache, userAgent string) (Location, error) { + recent, err := cache.LoadRecentLocations() + if err != nil { + return Location{}, err + } + + options := make([]string, 0, len(recent)+1) + for _, loc := range recent { + options = append(options, loc.Label()) + } + + selection, ok, err := rofiSelect("Location", options) + if err != nil { + return Location{}, err + } + if !ok { + return Location{}, fmt.Errorf("interactive search cancelled") + } + + query := selection + matches := matchRecentLocations(recent, query) + if len(matches) > 0 { + choiceOptions := make([]string, 0, len(matches)+1) + for _, loc := range matches { + choiceOptions = append(choiceOptions, fmt.Sprintf("Use saved %s", loc.Label())) + } + searchOption := fmt.Sprintf("Search for %s", query) + choiceOptions = append(choiceOptions, searchOption) + + choice, ok, err := rofiSelect("Use saved or search", choiceOptions) + if err != nil { + return Location{}, err + } + if !ok { + return Location{}, fmt.Errorf("interactive search cancelled") + } + if choice != searchOption { + for _, loc := range matches { + if choice == fmt.Sprintf("Use saved %s", loc.Label()) { + return loc, nil + } + } + } + } + + fmt.Println("Searching for location...") + results, err := SearchLocations(ctx, query, userAgent) + if err != nil { + return Location{}, err + } + loc, err := SelectLocationRofi(results) + if err != nil { + return Location{}, err + } + if err := cache.SaveLocation(query, loc); err != nil { + return Location{}, err + } + return loc, nil +} + +func SelectLocationRofi(results []Location) (Location, error) { + switch len(results) { + case 0: + return Location{}, fmt.Errorf("no locations found") + case 1: + return results[0], nil + } + + options := make([]string, 0, len(results)) + for _, loc := range results { + label := loc.Label() + if loc.Country != "" && !strings.Contains(label, loc.Country) { + label = fmt.Sprintf("%s, %s", label, loc.Country) + } + options = append(options, label) + } + + selection, ok, err := rofiSelect("Select", options) + if err != nil { + return Location{}, err + } + if !ok { + return Location{}, fmt.Errorf("interactive selection cancelled") + } + + for i, option := range options { + if option == selection { + return results[i], nil + } + } + + return Location{}, fmt.Errorf("invalid selection") +} + +func matchRecentLocations(recent []Location, query string) []Location { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return nil + } + matches := make([]Location, 0, len(recent)) + for _, loc := range recent { + label := strings.ToLower(loc.Label()) + if strings.Contains(label, query) { + matches = append(matches, loc) + } + } + return matches +} diff --git a/rofi_match_test.go b/rofi_match_test.go new file mode 100644 index 0000000..51dd3df --- /dev/null +++ b/rofi_match_test.go @@ -0,0 +1,26 @@ +package main + +import "testing" + +func TestMatchRecentLocations(t *testing.T) { + recent := []Location{ + {City: "Boston", State: "MA"}, + {City: "Somerville", State: "MA"}, + {City: "Somerville", State: "NJ"}, + } + + matches := matchRecentLocations(recent, "somer") + if len(matches) != 2 { + t.Fatalf("expected 2 matches, got %d", len(matches)) + } + + matches = matchRecentLocations(recent, "boston") + if len(matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(matches)) + } + + matches = matchRecentLocations(recent, "") + if len(matches) != 0 { + t.Fatalf("expected 0 matches for empty query, got %d", len(matches)) + } +}