adding interactive flag to allow for interactive search and recent location caching
This commit is contained in:
parent
524d30c797
commit
61d7127d51
@ -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.
|
||||
```
|
||||
|
||||
|
||||
51
cache.go
51
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
|
||||
|
||||
20
cache_recent_test.go
Normal file
20
cache_recent_test.go
Normal 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
33
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
|
||||
|
||||
160
rofi.go
Normal file
160
rofi.go
Normal 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
26
rofi_match_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user