adding interactive flag to allow for interactive search and recent location caching

This commit is contained in:
spinach 2026-01-28 13:17:54 -05:00
parent 524d30c797
commit 61d7127d51
6 changed files with 278 additions and 16 deletions

View File

@ -44,6 +44,10 @@ go run . --icon-test
go run . -R go run . -R
go run . --radar 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. Radar mode caches the GIF on disk (10 minute TTL) under the cache directory.
``` ```

View File

@ -21,6 +21,7 @@ type Cache struct {
type LocationCache struct { type LocationCache struct {
Query string `json:"query"` Query string `json:"query"`
Location Location `json:"location"` Location Location `json:"location"`
Recent []Location `json:"recent"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@ -39,11 +40,16 @@ func (c *Cache) SaveLocation(query string, loc Location) error {
if err := c.ensureDir(); err != nil { if err := c.ensureDir(); err != nil {
return err return err
} }
payload := LocationCache{ payload := LocationCache{}
Query: query, if err := readJSON(filepath.Join(c.dir, locationCacheFile), &payload); err != nil {
Location: loc, if !errors.Is(err, os.ErrNotExist) {
UpdatedAt: time.Now().UTC(), 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 { if err := writeJSON(filepath.Join(c.dir, locationCacheFile), payload); err != nil {
return err return err
} }
@ -62,6 +68,18 @@ func (c *Cache) LoadLocation() (Location, bool, error) {
return payload.Location, true, nil 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 { func (c *Cache) UpdateLocation(loc Location) error {
path := filepath.Join(c.dir, locationCacheFile) path := filepath.Join(c.dir, locationCacheFile)
var payload LocationCache var payload LocationCache
@ -74,6 +92,7 @@ func (c *Cache) UpdateLocation(loc Location) error {
payload.Location = loc payload.Location = loc
payload.UpdatedAt = time.Now().UTC() payload.UpdatedAt = time.Now().UTC()
payload.Recent = addRecentLocation(payload.Recent, loc, 10)
return writeJSON(path, payload) return writeJSON(path, payload)
} }
@ -163,6 +182,24 @@ func sameLocation(a, b Location) bool {
return a.Lat == b.Lat && a.Lon == b.Lon 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) { func cacheRoot() (string, error) {
if override := os.Getenv("WEATHER_CACHE_DIR"); override != "" { if override := os.Getenv("WEATHER_CACHE_DIR"); override != "" {
return override, nil return override, nil

20
cache_recent_test.go Normal file
View File

@ -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")
}
}

17
main.go
View File

@ -30,6 +30,7 @@ func run() error {
refresh bool refresh bool
iconTest bool iconTest bool
radarMode bool radarMode bool
interactiveSearchFlag bool
) )
flag.StringVar(&search, "s", "", "Search location (e.g. \"Somerville, MA\")") 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(&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, "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(&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() flag.Parse()
ua, err := buildUserAgent() ua, err := buildUserAgent()
@ -76,6 +79,12 @@ func run() error {
return nil return nil
} }
if interactiveSearchFlag {
if err := requireRofi(); err != nil {
return err
}
}
if radarMode { if radarMode {
if err := requireMPV(); err != nil { if err := requireMPV(); err != nil {
return err return err
@ -90,7 +99,13 @@ func run() error {
} }
var loc Location 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) results, err := SearchLocations(ctx, search, ua)
if err != nil { if err != nil {
return err return err

160
rofi.go Normal file
View File

@ -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
}

26
rofi_match_test.go Normal file
View File

@ -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))
}
}