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 . --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.
```

View File

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

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

33
main.go
View File

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

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