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 . --radar
|
||||
|
||||
# Interactive search (requires rofi)
|
||||
go run . -S
|
||||
go run . --interactive-search
|
||||
# Wayland interactive search (requires rofi)
|
||||
go run . --wayland --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.
|
||||
```
|
||||
|
||||
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 (
|
||||
locationCacheFile = "location.json"
|
||||
forecastCacheFile = "forecast.json"
|
||||
afdCacheFile = "afd.json"
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
@ -32,6 +33,14 @@ type ForecastCache struct {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
path := filepath.Join(c.dir, forecastCacheFile)
|
||||
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) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(filepath.Join(c.dir, afdCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
Properties struct {
|
||||
RadarStation string `json:"radarStation"`
|
||||
OfficeID string `json:"cwa"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
@ -27,6 +27,7 @@ type Location struct {
|
||||
Zipcode string
|
||||
DisplayName string
|
||||
RadarStation string
|
||||
OfficeID string
|
||||
}
|
||||
|
||||
func NewLocation() *Location {
|
||||
@ -44,6 +45,7 @@ func (l *Location) UnmarshalJSON(b []byte) error {
|
||||
Country string `json:"country"`
|
||||
Zipcode string `json:"zipcode"`
|
||||
RadarStation string `json:"RadarStation"`
|
||||
OfficeID string `json:"OfficeID"`
|
||||
Address struct {
|
||||
City string `json:"city"`
|
||||
Town string `json:"town"`
|
||||
@ -85,6 +87,7 @@ func (l *Location) UnmarshalJSON(b []byte) error {
|
||||
l.Zipcode = firstNonEmpty(aux.Address.Zipcode, aux.Zipcode)
|
||||
l.DisplayName = firstNonEmpty(aux.DisplayName, aux.City)
|
||||
l.RadarStation = aux.RadarStation
|
||||
l.OfficeID = aux.OfficeID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
101
main.go
101
main.go
@ -2,10 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -21,49 +19,14 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var (
|
||||
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()
|
||||
opts, err := parseArgs(os.Args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
temperatureU = strings.ToUpper(strings.TrimSpace(temperatureU))
|
||||
if temperatureU == "" {
|
||||
temperatureU = "F"
|
||||
}
|
||||
if temperatureU != "F" && temperatureU != "C" {
|
||||
return fmt.Errorf("invalid unit: %s", temperatureU)
|
||||
ua, err := buildUserAgent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cacheDir, err := cacheRoot()
|
||||
@ -74,24 +37,24 @@ func run() error {
|
||||
cache := NewCache(cacheDir)
|
||||
ctx := context.Background()
|
||||
|
||||
if iconTest {
|
||||
if opts.iconTest {
|
||||
printIconTest()
|
||||
return nil
|
||||
}
|
||||
|
||||
if interactiveSearchFlag {
|
||||
if opts.wayland && opts.searchSet {
|
||||
if err := requireRofi(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if radarMode {
|
||||
if opts.radarMode {
|
||||
if err := requireMPV(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if clearCache {
|
||||
if opts.clearCache {
|
||||
if err := cache.Clear(); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -99,14 +62,25 @@ func run() error {
|
||||
}
|
||||
|
||||
var loc Location
|
||||
if interactiveSearchFlag {
|
||||
if opts.wayland && opts.searchSet {
|
||||
if opts.searchValue == "" {
|
||||
var err error
|
||||
loc, err = interactiveSearch(ctx, cache, ua)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if search != "" {
|
||||
results, err := SearchLocations(ctx, search, ua)
|
||||
} else {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@ -114,7 +88,7 @@ func run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cache.SaveLocation(search, loc); err != nil {
|
||||
if err := cache.SaveLocation(opts.searchValue, loc); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
@ -128,40 +102,51 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
if radarMode {
|
||||
return runRadar(ctx, cache, loc, search, ua)
|
||||
if opts.radarMode {
|
||||
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)
|
||||
var period ForecastPeriod
|
||||
if refresh {
|
||||
if opts.refresh {
|
||||
var err error
|
||||
period, err = client.FetchForecast(ctx, loc, temperatureU)
|
||||
period, err = client.FetchForecast(ctx, loc, opts.temperatureU)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cache.SaveForecast(loc, temperatureU, period); err != nil {
|
||||
if err := cache.SaveForecast(loc, opts.temperatureU, period); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
var fromCache bool
|
||||
period, fromCache, err = cache.LoadForecast(loc, temperatureU, defaultForecastTTL)
|
||||
period, fromCache, err = cache.LoadForecast(loc, opts.temperatureU, defaultForecastTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fromCache {
|
||||
period, err = client.FetchForecast(ctx, loc, temperatureU)
|
||||
period, err = client.FetchForecast(ctx, loc, opts.temperatureU)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cache.SaveForecast(loc, temperatureU, period); err != nil {
|
||||
if err := cache.SaveForecast(loc, opts.temperatureU, period); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
line := FormatOutput(loc, period, includeDesc, prettyOutput)
|
||||
line := FormatOutput(loc, period, opts.includeDesc, opts.prettyOutput)
|
||||
fmt.Println(line)
|
||||
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
|
||||
}
|
||||
|
||||
options := make([]string, 0, len(recent)+1)
|
||||
for _, loc := range recent {
|
||||
options = append(options, loc.LabelFull())
|
||||
}
|
||||
|
||||
selection, ok, err := rofiSelect("Location", options)
|
||||
selection, ok, err := rofiPrompt("Search")
|
||||
if err != nil {
|
||||
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