added area forecast discussion and reworked 'interactive' mode using

This commit is contained in:
spinach 2026-01-29 13:21:15 -05:00
parent 6eda2df111
commit 8cc4372bbd
13 changed files with 627 additions and 71 deletions

View File

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

View File

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

View File

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

View File

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

109
main.go
View File

@ -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 {
var err error if opts.searchValue == "" {
loc, err = interactiveSearch(ctx, cache, ua) var err error
if err != nil { loc, err = interactiveSearch(ctx, cache, ua)
return err if err != nil {
return err
}
} else {
var err error
loc, err = interactiveSearch(ctx, cache, ua)
if err != nil {
return err
}
} }
} else if search != "" { } else if opts.searchSet {
results, err := SearchLocations(ctx, search, ua) 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
} }

View File

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