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 . -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.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
45
cache.go
45
cache.go
@ -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
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
17
main.go
17
main.go
@ -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
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