added area forecast discussion and reworked 'interactive' mode using
This commit is contained in:
parent
6eda2df111
commit
8cc4372bbd
12
README.md
12
README.md
@ -44,9 +44,15 @@ go run . --icon-test
|
|||||||
go run . -R
|
go run . -R
|
||||||
go run . --radar
|
go run . --radar
|
||||||
|
|
||||||
# Interactive search (requires rofi)
|
# Wayland interactive search (requires rofi)
|
||||||
go run . -S
|
go run . --wayland --search
|
||||||
go run . --interactive-search
|
|
||||||
|
# Area forecast discussion (AFD) text
|
||||||
|
go run . -D
|
||||||
|
go run . --discussion
|
||||||
|
|
||||||
|
# AFD in scratchpad (requires st + nvim)
|
||||||
|
go run . --wayland -D
|
||||||
|
|
||||||
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.
|
||||||
```
|
```
|
||||||
|
|||||||
207
afd.go
Normal file
207
afd.go
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runDiscussion(ctx context.Context, cache *Cache, loc Location, searchQuery, userAgent string) (string, error) {
|
||||||
|
client := NewNWSClient(userAgent)
|
||||||
|
if loc.OfficeID == "" {
|
||||||
|
office, err := client.FetchOfficeID(ctx, loc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
loc.OfficeID = office
|
||||||
|
if err := cache.UpdateLocation(loc); err != nil {
|
||||||
|
if searchQuery == "" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := cache.SaveLocation(searchQuery, loc); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cached, ok, err := cache.LoadAFD()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
productID, productURL, err := client.FetchLatestAFDMeta(ctx, loc.OfficeID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok && cached.OfficeID == loc.OfficeID && cached.ProductID == productID {
|
||||||
|
cached.CheckedAt = time.Now().UTC()
|
||||||
|
if err := cache.SaveAFD(cached); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return cached.Text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text, issuanceTime, err := client.FetchAFDText(ctx, productURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := AFDCache{
|
||||||
|
OfficeID: loc.OfficeID,
|
||||||
|
ProductID: productID,
|
||||||
|
Text: text,
|
||||||
|
IssuanceTime: issuanceTime,
|
||||||
|
CheckedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if err := cache.SaveAFD(payload); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NWSClient) FetchLatestAFDMeta(ctx context.Context, officeID string) (string, string, error) {
|
||||||
|
officeID = strings.TrimSpace(strings.ToUpper(officeID))
|
||||||
|
if officeID == "" {
|
||||||
|
return "", "", fmt.Errorf("office id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.weather.gov/products/types/AFD/locations/%s", officeID)
|
||||||
|
var resp struct {
|
||||||
|
Graph []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ref string `json:"@id"`
|
||||||
|
} `json:"@graph"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, url, &resp); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if len(resp.Graph) == 0 {
|
||||||
|
return "", "", fmt.Errorf("no AFD products for %s", officeID)
|
||||||
|
}
|
||||||
|
productID := strings.TrimSpace(resp.Graph[0].ID)
|
||||||
|
productURL := strings.TrimSpace(resp.Graph[0].Ref)
|
||||||
|
if productID == "" || productURL == "" {
|
||||||
|
return "", "", fmt.Errorf("invalid AFD metadata")
|
||||||
|
}
|
||||||
|
return productID, productURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NWSClient) FetchAFDText(ctx context.Context, url string) (string, string, error) {
|
||||||
|
var resp struct {
|
||||||
|
ProductText string `json:"productText"`
|
||||||
|
IssuanceTime string `json:"issuanceTime"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, url, &resp); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
text := sanitizeAFDText(resp.ProductText)
|
||||||
|
if text == "" {
|
||||||
|
return "", "", fmt.Errorf("AFD product text missing")
|
||||||
|
}
|
||||||
|
return text, strings.TrimSpace(resp.IssuanceTime), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeAFDText(text string) string {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||||
|
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
|
||||||
|
lines = lines[1:]
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.Contains(strings.ToLower(line), "area forecast discussion") {
|
||||||
|
lines = lines[i:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
|
||||||
|
lines = lines[1:]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAFDMarkdown(text string) string {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||||
|
out := make([]string, 0, len(lines))
|
||||||
|
titleDone := false
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "&&" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !titleDone && strings.EqualFold(trimmed, "Area Forecast Discussion") {
|
||||||
|
out = append(out, "# Area Forecast Discussion")
|
||||||
|
titleDone = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if header, rest := splitKeyMessage(trimmed); header != "" {
|
||||||
|
out = append(out, "## "+header)
|
||||||
|
if rest != "" {
|
||||||
|
out = append(out, rest)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isAFDHeaderLine(trimmed) {
|
||||||
|
title := afdHeaderToTitle(trimmed)
|
||||||
|
out = append(out, "# "+title)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAFDHeaderLine(line string) bool {
|
||||||
|
if !strings.HasPrefix(line, ".") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasSuffix(line, "...") || strings.HasSuffix(line, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func afdHeaderToTitle(line string) string {
|
||||||
|
trimmed := strings.Trim(line, ". ")
|
||||||
|
parts := strings.Fields(trimmed)
|
||||||
|
for i, part := range parts {
|
||||||
|
subparts := strings.Split(part, "/")
|
||||||
|
for j, sub := range subparts {
|
||||||
|
subparts[j] = titleWord(sub)
|
||||||
|
}
|
||||||
|
parts[i] = strings.Join(subparts, "/")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleWord(word string) string {
|
||||||
|
if word == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(word)
|
||||||
|
r := []rune(lower)
|
||||||
|
r[0] = []rune(strings.ToUpper(string(r[0])))[0]
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitKeyMessage(line string) (string, string) {
|
||||||
|
lower := strings.ToLower(line)
|
||||||
|
if !strings.HasPrefix(lower, "key message") {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "...", 2)
|
||||||
|
header := strings.TrimSpace(parts[0])
|
||||||
|
rest := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
rest = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
fields := strings.Fields(header)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
fields[0] = "Key"
|
||||||
|
fields[1] = "Message"
|
||||||
|
return strings.Join(fields, " "), rest
|
||||||
|
}
|
||||||
25
afd_cache_test.go
Normal file
25
afd_cache_test.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAFDCacheRoundTrip(t *testing.T) {
|
||||||
|
cache := NewCache(t.TempDir())
|
||||||
|
payload := AFDCache{
|
||||||
|
OfficeID: "BOX",
|
||||||
|
ProductID: "ABC",
|
||||||
|
Text: "Discussion",
|
||||||
|
}
|
||||||
|
if err := cache.SaveAFD(payload); err != nil {
|
||||||
|
t.Fatalf("save afd failed: %v", err)
|
||||||
|
}
|
||||||
|
loaded, ok, err := cache.LoadAFD()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load afd failed: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected afd cache")
|
||||||
|
}
|
||||||
|
if loaded.OfficeID != payload.OfficeID || loaded.ProductID != payload.ProductID || loaded.Text != payload.Text {
|
||||||
|
t.Fatalf("afd cache mismatch: %+v", loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
afd_format_test.go
Normal file
21
afd_format_test.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSanitizeAFDText(t *testing.T) {
|
||||||
|
input := "000\nFXUS61 KBOX 010000\nArea Forecast Discussion\n\nBody line"
|
||||||
|
out := sanitizeAFDText(input)
|
||||||
|
expected := "Area Forecast Discussion\n\nBody line"
|
||||||
|
if out != expected {
|
||||||
|
t.Fatalf("unexpected sanitize output: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeAFDTextSubstring(t *testing.T) {
|
||||||
|
input := "000\nFXUS61 KBOX 010000\n...Area Forecast Discussion...\n\nBody line"
|
||||||
|
out := sanitizeAFDText(input)
|
||||||
|
expected := "...Area Forecast Discussion...\n\nBody line"
|
||||||
|
if out != expected {
|
||||||
|
t.Fatalf("unexpected sanitize output: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
afd_markdown_test.go
Normal file
21
afd_markdown_test.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFormatAFDMarkdown(t *testing.T) {
|
||||||
|
input := "Area Forecast Discussion\n\n.SHORT TERM /Tonight/...\nText\n&&\n.LONG TERM /Friday/...\nMore"
|
||||||
|
out := formatAFDMarkdown(input)
|
||||||
|
expected := "# Area Forecast Discussion\n\n# Short Term /Tonight/\nText\n# Long Term /Friday/\nMore"
|
||||||
|
if out != expected {
|
||||||
|
t.Fatalf("unexpected markdown output:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatAFDMarkdownKeyMessage(t *testing.T) {
|
||||||
|
input := "Area Forecast Discussion\nKey Message 1...Heavy rain possible\nDetails"
|
||||||
|
out := formatAFDMarkdown(input)
|
||||||
|
expected := "# Area Forecast Discussion\n## Key Message 1\nHeavy rain possible\nDetails"
|
||||||
|
if out != expected {
|
||||||
|
t.Fatalf("unexpected markdown output:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
cache.go
31
cache.go
@ -12,6 +12,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
locationCacheFile = "location.json"
|
locationCacheFile = "location.json"
|
||||||
forecastCacheFile = "forecast.json"
|
forecastCacheFile = "forecast.json"
|
||||||
|
afdCacheFile = "afd.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
@ -32,6 +33,14 @@ type ForecastCache struct {
|
|||||||
FetchedAt time.Time `json:"fetched_at"`
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AFDCache struct {
|
||||||
|
OfficeID string `json:"office_id"`
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
IssuanceTime string `json:"issuance_time"`
|
||||||
|
CheckedAt time.Time `json:"checked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewCache(dir string) *Cache {
|
func NewCache(dir string) *Cache {
|
||||||
return &Cache{dir: dir}
|
return &Cache{dir: dir}
|
||||||
}
|
}
|
||||||
@ -109,6 +118,25 @@ func (c *Cache) SaveForecast(loc Location, unit string, period ForecastPeriod) e
|
|||||||
return writeJSON(filepath.Join(c.dir, forecastCacheFile), payload)
|
return writeJSON(filepath.Join(c.dir, forecastCacheFile), payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Cache) LoadAFD() (AFDCache, bool, error) {
|
||||||
|
path := filepath.Join(c.dir, afdCacheFile)
|
||||||
|
var payload AFDCache
|
||||||
|
if err := readJSON(path, &payload); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return AFDCache{}, false, nil
|
||||||
|
}
|
||||||
|
return AFDCache{}, false, err
|
||||||
|
}
|
||||||
|
return payload, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) SaveAFD(afd AFDCache) error {
|
||||||
|
if err := c.ensureDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeJSON(filepath.Join(c.dir, afdCacheFile), afd)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Cache) LoadForecast(loc Location, unit string, ttl time.Duration) (ForecastPeriod, bool, error) {
|
func (c *Cache) LoadForecast(loc Location, unit string, ttl time.Duration) (ForecastPeriod, bool, error) {
|
||||||
path := filepath.Join(c.dir, forecastCacheFile)
|
path := filepath.Join(c.dir, forecastCacheFile)
|
||||||
var payload ForecastCache
|
var payload ForecastCache
|
||||||
@ -159,6 +187,9 @@ func (c *Cache) ClearForecastAndRadar() error {
|
|||||||
if err := os.Remove(filepath.Join(c.dir, "radar.gif")); err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err := os.Remove(filepath.Join(c.dir, "radar.gif")); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := os.Remove(filepath.Join(c.dir, afdCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
123
flags.go
Normal file
123
flags.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
searchValue string
|
||||||
|
searchSet bool
|
||||||
|
includeDesc bool
|
||||||
|
prettyOutput bool
|
||||||
|
temperatureU string
|
||||||
|
clearCache bool
|
||||||
|
refresh bool
|
||||||
|
iconTest bool
|
||||||
|
radarMode bool
|
||||||
|
discussion bool
|
||||||
|
wayland bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArgs(args []string) (options, error) {
|
||||||
|
opts := options{temperatureU: "F"}
|
||||||
|
|
||||||
|
nextValue := func(i *int) (string, error) {
|
||||||
|
if *i+1 >= len(args) {
|
||||||
|
return "", fmt.Errorf("missing value for %s", args[*i])
|
||||||
|
}
|
||||||
|
*i = *i + 1
|
||||||
|
return args[*i], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
if !strings.HasPrefix(arg, "-") {
|
||||||
|
return opts, fmt.Errorf("unexpected argument: %s", arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(arg, "--") {
|
||||||
|
key, val, hasVal := strings.Cut(strings.TrimPrefix(arg, "--"), "=")
|
||||||
|
switch key {
|
||||||
|
case "search":
|
||||||
|
opts.searchSet = true
|
||||||
|
if hasVal {
|
||||||
|
opts.searchValue = val
|
||||||
|
} else if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||||
|
val, _ = nextValue(&i)
|
||||||
|
opts.searchValue = val
|
||||||
|
}
|
||||||
|
case "desc":
|
||||||
|
opts.includeDesc = true
|
||||||
|
case "pretty":
|
||||||
|
opts.prettyOutput = true
|
||||||
|
case "units":
|
||||||
|
if hasVal {
|
||||||
|
opts.temperatureU = val
|
||||||
|
} else {
|
||||||
|
val, err := nextValue(&i)
|
||||||
|
if err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
opts.temperatureU = val
|
||||||
|
}
|
||||||
|
case "clear-cache":
|
||||||
|
opts.clearCache = true
|
||||||
|
case "refresh":
|
||||||
|
opts.refresh = true
|
||||||
|
case "icon-test":
|
||||||
|
opts.iconTest = true
|
||||||
|
case "radar":
|
||||||
|
opts.radarMode = true
|
||||||
|
case "discussion":
|
||||||
|
opts.discussion = true
|
||||||
|
case "wayland":
|
||||||
|
opts.wayland = true
|
||||||
|
default:
|
||||||
|
return opts, fmt.Errorf("unknown flag: --%s", key)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch arg {
|
||||||
|
case "-s":
|
||||||
|
opts.searchSet = true
|
||||||
|
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||||
|
val, _ := nextValue(&i)
|
||||||
|
opts.searchValue = val
|
||||||
|
}
|
||||||
|
case "-d":
|
||||||
|
opts.includeDesc = true
|
||||||
|
case "-p":
|
||||||
|
opts.prettyOutput = true
|
||||||
|
case "-u":
|
||||||
|
val, err := nextValue(&i)
|
||||||
|
if err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
opts.temperatureU = val
|
||||||
|
case "-c":
|
||||||
|
opts.clearCache = true
|
||||||
|
case "-r":
|
||||||
|
opts.refresh = true
|
||||||
|
case "-i":
|
||||||
|
opts.iconTest = true
|
||||||
|
case "-R":
|
||||||
|
opts.radarMode = true
|
||||||
|
case "-D":
|
||||||
|
opts.discussion = true
|
||||||
|
default:
|
||||||
|
return opts, fmt.Errorf("unknown flag: %s", arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.temperatureU = strings.ToUpper(strings.TrimSpace(opts.temperatureU))
|
||||||
|
if opts.temperatureU == "" {
|
||||||
|
opts.temperatureU = "F"
|
||||||
|
}
|
||||||
|
if opts.temperatureU != "F" && opts.temperatureU != "C" {
|
||||||
|
return opts, fmt.Errorf("invalid unit: %s", opts.temperatureU)
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
36
flags_test.go
Normal file
36
flags_test.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseArgsSearchWayland(t *testing.T) {
|
||||||
|
opts, err := parseArgs([]string{"--wayland", "--search"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !opts.searchSet || !opts.wayland {
|
||||||
|
t.Fatalf("expected search+wayland set")
|
||||||
|
}
|
||||||
|
if opts.searchValue != "" {
|
||||||
|
t.Fatalf("expected empty search value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArgsSearchRequiresValueWithoutWayland(t *testing.T) {
|
||||||
|
opts, err := parseArgs([]string{"--search"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse error: %v", err)
|
||||||
|
}
|
||||||
|
if !opts.searchSet || opts.searchValue != "" {
|
||||||
|
t.Fatalf("expected search set with empty value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArgsUnits(t *testing.T) {
|
||||||
|
opts, err := parseArgs([]string{"--units", "C"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if opts.temperatureU != "C" {
|
||||||
|
t.Fatalf("expected units C, got %s", opts.temperatureU)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
forecast.go
18
forecast.go
@ -90,6 +90,7 @@ func (c *NWSClient) FetchRadarStation(ctx context.Context, loc Location) (string
|
|||||||
var pointsResp struct {
|
var pointsResp struct {
|
||||||
Properties struct {
|
Properties struct {
|
||||||
RadarStation string `json:"radarStation"`
|
RadarStation string `json:"radarStation"`
|
||||||
|
OfficeID string `json:"cwa"`
|
||||||
} `json:"properties"`
|
} `json:"properties"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil {
|
if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil {
|
||||||
@ -102,6 +103,23 @@ func (c *NWSClient) FetchRadarStation(ctx context.Context, loc Location) (string
|
|||||||
return station, nil
|
return station, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *NWSClient) FetchOfficeID(ctx context.Context, loc Location) (string, error) {
|
||||||
|
pointsURL := fmt.Sprintf("https://api.weather.gov/points/%.4f,%.4f", loc.Lat, loc.Lon)
|
||||||
|
var pointsResp struct {
|
||||||
|
Properties struct {
|
||||||
|
OfficeID string `json:"cwa"`
|
||||||
|
} `json:"properties"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
office := strings.TrimSpace(pointsResp.Properties.OfficeID)
|
||||||
|
if office == "" {
|
||||||
|
return "", fmt.Errorf("missing office id from NWS points")
|
||||||
|
}
|
||||||
|
return office, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *NWSClient) getJSON(ctx context.Context, url string, target any) error {
|
func (c *NWSClient) getJSON(ctx context.Context, url string, target any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -27,6 +27,7 @@ type Location struct {
|
|||||||
Zipcode string
|
Zipcode string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
RadarStation string
|
RadarStation string
|
||||||
|
OfficeID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLocation() *Location {
|
func NewLocation() *Location {
|
||||||
@ -44,6 +45,7 @@ func (l *Location) UnmarshalJSON(b []byte) error {
|
|||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
Zipcode string `json:"zipcode"`
|
Zipcode string `json:"zipcode"`
|
||||||
RadarStation string `json:"RadarStation"`
|
RadarStation string `json:"RadarStation"`
|
||||||
|
OfficeID string `json:"OfficeID"`
|
||||||
Address struct {
|
Address struct {
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Town string `json:"town"`
|
Town string `json:"town"`
|
||||||
@ -85,6 +87,7 @@ func (l *Location) UnmarshalJSON(b []byte) error {
|
|||||||
l.Zipcode = firstNonEmpty(aux.Address.Zipcode, aux.Zipcode)
|
l.Zipcode = firstNonEmpty(aux.Address.Zipcode, aux.Zipcode)
|
||||||
l.DisplayName = firstNonEmpty(aux.DisplayName, aux.City)
|
l.DisplayName = firstNonEmpty(aux.DisplayName, aux.City)
|
||||||
l.RadarStation = aux.RadarStation
|
l.RadarStation = aux.RadarStation
|
||||||
|
l.OfficeID = aux.OfficeID
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
101
main.go
101
main.go
@ -2,10 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,49 +19,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
var (
|
opts, err := parseArgs(os.Args[1:])
|
||||||
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\")")
|
|
||||||
flag.StringVar(&search, "search", "", "Search location (e.g. \"Somerville, MA\")")
|
|
||||||
flag.BoolVar(&includeDesc, "d", false, "Include forecast description text")
|
|
||||||
flag.BoolVar(&includeDesc, "desc", false, "Include forecast description text")
|
|
||||||
flag.BoolVar(&prettyOutput, "pretty", false, "Enable NerdFont icons")
|
|
||||||
flag.BoolVar(&prettyOutput, "p", false, "Enable NerdFont icons")
|
|
||||||
flag.StringVar(&temperatureU, "u", "F", "Temperature unit (F or C)")
|
|
||||||
flag.StringVar(&temperatureU, "units", "F", "Temperature unit (F or C)")
|
|
||||||
flag.BoolVar(&clearCache, "clear-cache", false, "Clear cached location and forecast data")
|
|
||||||
flag.BoolVar(&clearCache, "c", false, "Clear cached location and forecast data")
|
|
||||||
flag.BoolVar(&refresh, "refresh", false, "Force refresh forecast data")
|
|
||||||
flag.BoolVar(&refresh, "r", false, "Force refresh forecast data")
|
|
||||||
flag.BoolVar(&iconTest, "icon-test", 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, "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()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
temperatureU = strings.ToUpper(strings.TrimSpace(temperatureU))
|
ua, err := buildUserAgent()
|
||||||
if temperatureU == "" {
|
if err != nil {
|
||||||
temperatureU = "F"
|
return err
|
||||||
}
|
|
||||||
if temperatureU != "F" && temperatureU != "C" {
|
|
||||||
return fmt.Errorf("invalid unit: %s", temperatureU)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir, err := cacheRoot()
|
cacheDir, err := cacheRoot()
|
||||||
@ -74,24 +37,24 @@ func run() error {
|
|||||||
cache := NewCache(cacheDir)
|
cache := NewCache(cacheDir)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if iconTest {
|
if opts.iconTest {
|
||||||
printIconTest()
|
printIconTest()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if interactiveSearchFlag {
|
if opts.wayland && opts.searchSet {
|
||||||
if err := requireRofi(); err != nil {
|
if err := requireRofi(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if radarMode {
|
if opts.radarMode {
|
||||||
if err := requireMPV(); err != nil {
|
if err := requireMPV(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if clearCache {
|
if opts.clearCache {
|
||||||
if err := cache.Clear(); err != nil {
|
if err := cache.Clear(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -99,14 +62,25 @@ func run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var loc Location
|
var loc Location
|
||||||
if interactiveSearchFlag {
|
if opts.wayland && opts.searchSet {
|
||||||
|
if opts.searchValue == "" {
|
||||||
var err error
|
var err error
|
||||||
loc, err = interactiveSearch(ctx, cache, ua)
|
loc, err = interactiveSearch(ctx, cache, ua)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if search != "" {
|
} else {
|
||||||
results, err := SearchLocations(ctx, search, ua)
|
var err error
|
||||||
|
loc, err = interactiveSearch(ctx, cache, ua)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if opts.searchSet {
|
||||||
|
if opts.searchValue == "" {
|
||||||
|
return fmt.Errorf("--search requires a value unless --wayland is set")
|
||||||
|
}
|
||||||
|
results, err := SearchLocations(ctx, opts.searchValue, ua)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -114,7 +88,7 @@ func run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := cache.SaveLocation(search, loc); err != nil {
|
if err := cache.SaveLocation(opts.searchValue, loc); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -128,40 +102,51 @@ func run() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if radarMode {
|
if opts.radarMode {
|
||||||
return runRadar(ctx, cache, loc, search, ua)
|
return runRadar(ctx, cache, loc, opts.searchValue, ua)
|
||||||
|
}
|
||||||
|
if opts.discussion {
|
||||||
|
text, err := runDiscussion(ctx, cache, loc, opts.searchValue, ua)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if opts.wayland {
|
||||||
|
return runDiscussionWayland(ctx, formatAFDMarkdown(text))
|
||||||
|
}
|
||||||
|
fmt.Println(text)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewNWSClient(ua)
|
client := NewNWSClient(ua)
|
||||||
var period ForecastPeriod
|
var period ForecastPeriod
|
||||||
if refresh {
|
if opts.refresh {
|
||||||
var err error
|
var err error
|
||||||
period, err = client.FetchForecast(ctx, loc, temperatureU)
|
period, err = client.FetchForecast(ctx, loc, opts.temperatureU)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := cache.SaveForecast(loc, temperatureU, period); err != nil {
|
if err := cache.SaveForecast(loc, opts.temperatureU, period); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var err error
|
var err error
|
||||||
var fromCache bool
|
var fromCache bool
|
||||||
period, fromCache, err = cache.LoadForecast(loc, temperatureU, defaultForecastTTL)
|
period, fromCache, err = cache.LoadForecast(loc, opts.temperatureU, defaultForecastTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !fromCache {
|
if !fromCache {
|
||||||
period, err = client.FetchForecast(ctx, loc, temperatureU)
|
period, err = client.FetchForecast(ctx, loc, opts.temperatureU)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := cache.SaveForecast(loc, temperatureU, period); err != nil {
|
if err := cache.SaveForecast(loc, opts.temperatureU, period); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
line := FormatOutput(loc, period, includeDesc, prettyOutput)
|
line := FormatOutput(loc, period, opts.includeDesc, opts.prettyOutput)
|
||||||
fmt.Println(line)
|
fmt.Println(line)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
7
rofi.go
7
rofi.go
@ -56,12 +56,7 @@ func interactiveSearch(ctx context.Context, cache *Cache, userAgent string) (Loc
|
|||||||
return Location{}, err
|
return Location{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
options := make([]string, 0, len(recent)+1)
|
selection, ok, err := rofiPrompt("Search")
|
||||||
for _, loc := range recent {
|
|
||||||
options = append(options, loc.LabelFull())
|
|
||||||
}
|
|
||||||
|
|
||||||
selection, ok, err := rofiSelect("Location", options)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Location{}, err
|
return Location{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
85
wayland.go
Normal file
85
wayland.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func requireST() error {
|
||||||
|
if _, err := exec.LookPath("st"); err != nil {
|
||||||
|
return fmt.Errorf("st is required for wayland discussion")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireNvim() error {
|
||||||
|
if _, err := exec.LookPath("nvim"); err != nil {
|
||||||
|
return fmt.Errorf("nvim is required for wayland discussion")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDiscussionWayland(ctx context.Context, text string) error {
|
||||||
|
if err := requireST(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := requireNvim(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("swaymsg"); err != nil {
|
||||||
|
return fmt.Errorf("swaymsg is required for wayland discussion")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath, cleanup, err := makeAFDTempFile(text)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "st", "-n", "scratch", "-e", "nvim", tmpPath)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
_ = exec.CommandContext(ctx, "swaymsg", "[instance=\"scratch\"]", "move", "to", "scratchpad").Run()
|
||||||
|
_ = exec.CommandContext(ctx, "swaymsg", "[instance=\"scratch\"]", "scratchpad", "show").Run()
|
||||||
|
|
||||||
|
err = cmd.Wait()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAFDTempFile(text string) (string, func(), error) {
|
||||||
|
base := os.Getenv("XDG_CACHE_HOME")
|
||||||
|
if base == "" {
|
||||||
|
base = os.TempDir()
|
||||||
|
}
|
||||||
|
dir := filepath.Join(base, "weather")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
file, err := os.CreateTemp(dir, "afd-*.md")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
path := file.Name()
|
||||||
|
if _, err := file.WriteString(text); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
cleanup := func() {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
return path, cleanup, nil
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user