From 8cc4372bbd7834cf115dd55bdfa979359b42572e Mon Sep 17 00:00:00 2001 From: spinach Date: Thu, 29 Jan 2026 13:21:15 -0500 Subject: [PATCH] added area forecast discussion and reworked 'interactive' mode using --- README.md | 12 ++- afd.go | 207 +++++++++++++++++++++++++++++++++++++++++++ afd_cache_test.go | 25 ++++++ afd_format_test.go | 21 +++++ afd_markdown_test.go | 21 +++++ cache.go | 31 +++++++ flags.go | 123 +++++++++++++++++++++++++ flags_test.go | 36 ++++++++ forecast.go | 18 ++++ location.go | 3 + main.go | 109 ++++++++++------------- rofi.go | 7 +- wayland.go | 85 ++++++++++++++++++ 13 files changed, 627 insertions(+), 71 deletions(-) create mode 100644 afd.go create mode 100644 afd_cache_test.go create mode 100644 afd_format_test.go create mode 100644 afd_markdown_test.go create mode 100644 flags.go create mode 100644 flags_test.go create mode 100644 wayland.go diff --git a/README.md b/README.md index bbd7f0d..afd8246 100644 --- a/README.md +++ b/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. ``` diff --git a/afd.go b/afd.go new file mode 100644 index 0000000..12431e7 --- /dev/null +++ b/afd.go @@ -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 +} diff --git a/afd_cache_test.go b/afd_cache_test.go new file mode 100644 index 0000000..1a29aa8 --- /dev/null +++ b/afd_cache_test.go @@ -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) + } +} diff --git a/afd_format_test.go b/afd_format_test.go new file mode 100644 index 0000000..ec3322f --- /dev/null +++ b/afd_format_test.go @@ -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) + } +} diff --git a/afd_markdown_test.go b/afd_markdown_test.go new file mode 100644 index 0000000..787c619 --- /dev/null +++ b/afd_markdown_test.go @@ -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) + } +} diff --git a/cache.go b/cache.go index b52ecc6..1ec0614 100644 --- a/cache.go +++ b/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 } diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..1cdbc06 --- /dev/null +++ b/flags.go @@ -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 +} diff --git a/flags_test.go b/flags_test.go new file mode 100644 index 0000000..1c732f7 --- /dev/null +++ b/flags_test.go @@ -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) + } +} diff --git a/forecast.go b/forecast.go index ffa59ec..82e1156 100644 --- a/forecast.go +++ b/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 { diff --git a/location.go b/location.go index 16dc004..ab37ded 100644 --- a/location.go +++ b/location.go @@ -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 } diff --git a/main.go b/main.go index 0d60553..9da76f5 100644 --- a/main.go +++ b/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 { - var err error - loc, err = interactiveSearch(ctx, cache, ua) - if err != nil { - return err + if opts.wayland && opts.searchSet { + if opts.searchValue == "" { + var err error + loc, err = interactiveSearch(ctx, cache, ua) + if err != nil { + return err + } + } else { + var err error + loc, err = interactiveSearch(ctx, cache, ua) + if err != nil { + return err + } } - } else if search != "" { - results, err := SearchLocations(ctx, search, ua) + } 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 } diff --git a/rofi.go b/rofi.go index 3773ebd..58933d4 100644 --- a/rofi.go +++ b/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 } diff --git a/wayland.go b/wayland.go new file mode 100644 index 0000000..433c96f --- /dev/null +++ b/wayland.go @@ -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 +}