Compare commits
5 Commits
b7c6c9e26a
...
8cc4372bbd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc4372bbd | ||
|
|
6eda2df111 | ||
|
|
61d7127d51 | ||
|
|
524d30c797 | ||
|
|
430a51b659 |
63
AGENTS.md
Normal file
63
AGENTS.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
# Guidance for AI coding agents (Codex/ChatGPT) working in this repository.
|
||||||
|
# Follow these instructions as higher priority than general preferences.
|
||||||
|
|
||||||
|
## Operating principles
|
||||||
|
- Optimize for correctness, safety, and maintainability over cleverness.
|
||||||
|
- Keep changes small and reviewable. Prefer multiple small commits/PRs over one large refactor.
|
||||||
|
- Preserve existing style, architecture, and public APIs unless explicitly asked to change them.
|
||||||
|
|
||||||
|
## Dependencies (STRICT)
|
||||||
|
- Do NOT add new external dependencies (runtime or dev) without explicit approval.
|
||||||
|
- This includes package manager installs, new requirements, new npm/pip packages, new system packages, new containers/images.
|
||||||
|
- Prefer using the standard library or existing dependencies already in the repo.
|
||||||
|
- If you believe a new dependency is truly necessary:
|
||||||
|
1) explain why,
|
||||||
|
2) propose at least one no-new-deps alternative,
|
||||||
|
3) estimate blast radius and maintenance risk,
|
||||||
|
4) STOP and ask for approval before adding it.
|
||||||
|
|
||||||
|
## Tests and verification (REQUIRED)
|
||||||
|
- For any bug fix: add/adjust a unit test that reproduces the issue and prevents regressions.
|
||||||
|
- For any new feature: add unit tests for the “happy path” and at least 1–2 edge cases.
|
||||||
|
- Prefer unit tests over integration tests unless the change is inherently integration-level.
|
||||||
|
- Run the smallest relevant test command(s) after changes:
|
||||||
|
- First: targeted tests for the changed modules.
|
||||||
|
- Then (if time/CI expectations): full unit test suite.
|
||||||
|
- If tests cannot be run in the current environment, clearly state what you would run and why.
|
||||||
|
|
||||||
|
## Commands and environment safety
|
||||||
|
- Before running commands that could modify the environment or take a long time (e.g., installs, migrations, DB changes),
|
||||||
|
explain what you intend to run and ask for approval.
|
||||||
|
- Avoid destructive operations (deleting files, dropping DBs, resetting environments) unless explicitly requested.
|
||||||
|
- Never print, log, or exfiltrate secrets. If you detect a likely secret, redact it and point it out.
|
||||||
|
|
||||||
|
## Implementation approach
|
||||||
|
- Start by understanding existing patterns:
|
||||||
|
- find similar code paths,
|
||||||
|
- follow established naming and folder conventions,
|
||||||
|
- reuse existing utilities/helpers.
|
||||||
|
- When making non-trivial changes:
|
||||||
|
1) propose a short plan (3–7 bullets),
|
||||||
|
2) list the files you expect to touch,
|
||||||
|
3) call out risks/unknowns,
|
||||||
|
4) then proceed.
|
||||||
|
|
||||||
|
## Coding standards
|
||||||
|
- Prefer clear, boring code over abstractions.
|
||||||
|
- Add types/annotations where the repo uses them.
|
||||||
|
- Add docstrings/comments only where they clarify intent, invariants, or tricky logic.
|
||||||
|
- Handle errors explicitly; don’t swallow exceptions.
|
||||||
|
|
||||||
|
## Documentation and changelog
|
||||||
|
- Update relevant docs when behavior changes (README, module docs, inline docs).
|
||||||
|
- Summarize changes at the end:
|
||||||
|
- What changed (bullets),
|
||||||
|
- Why,
|
||||||
|
- How to test (exact commands),
|
||||||
|
- Any follow-ups/todos.
|
||||||
|
|
||||||
|
## When uncertain
|
||||||
|
- Ask a direct question or present two options with tradeoffs.
|
||||||
|
- Do not make breaking changes based on guesswork.
|
||||||
|
|
||||||
102
README.md
Normal file
102
README.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# weather
|
||||||
|
|
||||||
|
Small CLI for waybar-style weather output using free APIs:
|
||||||
|
|
||||||
|
- **Location search:** OpenStreetMap Nominatim
|
||||||
|
- **Forecast:** NWS `points` → `forecastHourly`
|
||||||
|
- **Output:** single line with NerdFont icon
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set location by search (uses fzf if multiple matches)
|
||||||
|
go run . -s "Somerville, MA"
|
||||||
|
go run . --search "Somerville, MA"
|
||||||
|
|
||||||
|
# Subsequent runs use cached location
|
||||||
|
go run .
|
||||||
|
|
||||||
|
# Include short forecast description
|
||||||
|
go run . -d
|
||||||
|
go run . --desc
|
||||||
|
|
||||||
|
# Enable NerdFont icons
|
||||||
|
go run . -p
|
||||||
|
go run . --pretty
|
||||||
|
|
||||||
|
# Use Celsius
|
||||||
|
go run . -u C
|
||||||
|
go run . --units C
|
||||||
|
|
||||||
|
# Force forecast refresh (bypass cache)
|
||||||
|
go run . -r
|
||||||
|
go run . --refresh
|
||||||
|
|
||||||
|
# Clear cached location and forecast
|
||||||
|
go run . -c
|
||||||
|
go run . --clear-cache
|
||||||
|
|
||||||
|
# Show all icons used by the app
|
||||||
|
go run . -i
|
||||||
|
go run . --icon-test
|
||||||
|
|
||||||
|
# Display radar loop for the current location (requires mpv)
|
||||||
|
go run . -R
|
||||||
|
go run . --radar
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `go run main.go` only compiles that file. Use `go run .` to include the whole package.
|
||||||
|
|
||||||
|
Output format (default, text only):
|
||||||
|
|
||||||
|
```
|
||||||
|
<CONDITION> <TEMP>°F <POP%> in <CITY, ST>
|
||||||
|
```
|
||||||
|
|
||||||
|
If `-d` is set, a title-cased short forecast description is prepended.
|
||||||
|
|
||||||
|
Output format (with `--pretty`):
|
||||||
|
|
||||||
|
```
|
||||||
|
<COND_GLYPH> <TEMP>°F <POP_GLYPH> <POP%> in <CITY, ST>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
- **Location** is cached forever until you run with `-s` again.
|
||||||
|
- **Forecast** is cached for **10 minutes** to avoid API hammering.
|
||||||
|
- Use `--clear-cache` to remove both caches.
|
||||||
|
- Use `--refresh` to bypass the forecast cache for one run.
|
||||||
|
|
||||||
|
Cache location is stored in:
|
||||||
|
|
||||||
|
- `$WEATHER_CACHE_DIR` if set
|
||||||
|
- Otherwise `$XDG_CACHE_HOME/weather`
|
||||||
|
- Otherwise `~/.cache/weather`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- If Nominatim returns multiple matches, `fzf` is required for selection.
|
||||||
|
- If `fzf` is not installed and multiple matches are returned, the command exits with an error.
|
||||||
|
- User-Agent is automatically generated and includes hostname + repo/contact.
|
||||||
|
|
||||||
|
## Waybar example
|
||||||
|
|
||||||
|
```json
|
||||||
|
"custom/weather": {
|
||||||
|
"exec": "weather -p",
|
||||||
|
"interval": 60
|
||||||
|
}
|
||||||
|
```
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
246
cache.go
Normal file
246
cache.go
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
locationCacheFile = "location.json"
|
||||||
|
forecastCacheFile = "forecast.json"
|
||||||
|
afdCacheFile = "afd.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocationCache struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Location Location `json:"location"`
|
||||||
|
Recent []Location `json:"recent"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForecastCache struct {
|
||||||
|
Location Location `json:"location"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Period ForecastPeriod `json:"period"`
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) SaveLocation(query string, loc Location) error {
|
||||||
|
if err := c.ensureDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := LocationCache{}
|
||||||
|
if err := readJSON(filepath.Join(c.dir, locationCacheFile), &payload); err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload.Query = query
|
||||||
|
payload.Location = loc
|
||||||
|
payload.UpdatedAt = time.Now().UTC()
|
||||||
|
payload.Recent = addRecentLocation(payload.Recent, loc, 10)
|
||||||
|
if err := writeJSON(filepath.Join(c.dir, locationCacheFile), payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.ClearForecastAndRadar()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) LoadLocation() (Location, bool, error) {
|
||||||
|
path := filepath.Join(c.dir, locationCacheFile)
|
||||||
|
var payload LocationCache
|
||||||
|
if err := readJSON(path, &payload); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return Location{}, false, nil
|
||||||
|
}
|
||||||
|
return Location{}, false, err
|
||||||
|
}
|
||||||
|
return payload.Location, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) LoadRecentLocations() ([]Location, error) {
|
||||||
|
path := filepath.Join(c.dir, locationCacheFile)
|
||||||
|
var payload LocationCache
|
||||||
|
if err := readJSON(path, &payload); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return payload.Recent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) UpdateLocation(loc Location) error {
|
||||||
|
path := filepath.Join(c.dir, locationCacheFile)
|
||||||
|
var payload LocationCache
|
||||||
|
if err := readJSON(path, &payload); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("no cached location found to update")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.Location = loc
|
||||||
|
payload.UpdatedAt = time.Now().UTC()
|
||||||
|
payload.Recent = addRecentLocation(payload.Recent, loc, 10)
|
||||||
|
return writeJSON(path, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) SaveForecast(loc Location, unit string, period ForecastPeriod) error {
|
||||||
|
if err := c.ensureDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := ForecastCache{
|
||||||
|
Location: loc,
|
||||||
|
Unit: unit,
|
||||||
|
Period: period,
|
||||||
|
FetchedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if err := readJSON(path, &payload); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return ForecastPeriod{}, false, nil
|
||||||
|
}
|
||||||
|
return ForecastPeriod{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Unit != unit {
|
||||||
|
return ForecastPeriod{}, false, nil
|
||||||
|
}
|
||||||
|
if !sameLocation(payload.Location, loc) {
|
||||||
|
return ForecastPeriod{}, false, nil
|
||||||
|
}
|
||||||
|
if time.Since(payload.FetchedAt) > ttl {
|
||||||
|
return ForecastPeriod{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.Period, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) ensureDir() error {
|
||||||
|
if c.dir == "" {
|
||||||
|
return fmt.Errorf("cache directory is empty")
|
||||||
|
}
|
||||||
|
return os.MkdirAll(c.dir, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Clear() error {
|
||||||
|
if c.dir == "" {
|
||||||
|
return fmt.Errorf("cache directory is empty")
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(c.dir, locationCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.ClearForecastAndRadar()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) ClearForecastAndRadar() error {
|
||||||
|
if c.dir == "" {
|
||||||
|
return fmt.Errorf("cache directory is empty")
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(c.dir, forecastCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSON(path string, target any) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(path string, value any) error {
|
||||||
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameLocation(a, b Location) bool {
|
||||||
|
return a.Lat == b.Lat && a.Lon == b.Lon
|
||||||
|
}
|
||||||
|
|
||||||
|
func addRecentLocation(recent []Location, loc Location, limit int) []Location {
|
||||||
|
if limit <= 0 {
|
||||||
|
return recent
|
||||||
|
}
|
||||||
|
trimmed := make([]Location, 0, len(recent)+1)
|
||||||
|
trimmed = append(trimmed, loc)
|
||||||
|
for _, item := range recent {
|
||||||
|
if sameLocation(item, loc) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trimmed = append(trimmed, item)
|
||||||
|
if len(trimmed) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheRoot() (string, error) {
|
||||||
|
if override := os.Getenv("WEATHER_CACHE_DIR"); override != "" {
|
||||||
|
return override, nil
|
||||||
|
}
|
||||||
|
if base := os.Getenv("XDG_CACHE_HOME"); base != "" {
|
||||||
|
return filepath.Join(base, "weather"), nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".cache", "weather"), nil
|
||||||
|
}
|
||||||
20
cache_recent_test.go
Normal file
20
cache_recent_test.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAddRecentLocationLRU(t *testing.T) {
|
||||||
|
locA := Location{Lat: 1, Lon: 1, City: "A"}
|
||||||
|
locB := Location{Lat: 2, Lon: 2, City: "B"}
|
||||||
|
locC := Location{Lat: 3, Lon: 3, City: "C"}
|
||||||
|
|
||||||
|
recent := []Location{locA, locB}
|
||||||
|
recent = addRecentLocation(recent, locC, 3)
|
||||||
|
if len(recent) != 3 || !sameLocation(recent[0], locC) {
|
||||||
|
t.Fatalf("expected new location at front")
|
||||||
|
}
|
||||||
|
|
||||||
|
recent = addRecentLocation(recent, locB, 3)
|
||||||
|
if len(recent) != 3 || !sameLocation(recent[0], locB) {
|
||||||
|
t.Fatalf("expected B to move to front")
|
||||||
|
}
|
||||||
|
}
|
||||||
135
cache_test.go
Normal file
135
cache_test.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCacheLocationRoundTrip(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("WEATHER_CACHE_DIR", tmp)
|
||||||
|
|
||||||
|
cache := NewCache(tmp)
|
||||||
|
loc := Location{Lat: 1.23, Lon: 4.56, City: "Testville", State: "TS"}
|
||||||
|
if err := cache.SaveLocation("Testville", loc); err != nil {
|
||||||
|
t.Fatalf("save location failed: %v", err)
|
||||||
|
}
|
||||||
|
loaded, ok, err := cache.LoadLocation()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location failed: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected cached location")
|
||||||
|
}
|
||||||
|
if loaded.City != loc.City || loaded.Lat != loc.Lat || loaded.Lon != loc.Lon {
|
||||||
|
t.Fatalf("loaded location mismatch: %+v", loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheUpdateLocation(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
cache := NewCache(tmp)
|
||||||
|
loc := Location{Lat: 1.23, Lon: 4.56, City: "Testville", State: "TS"}
|
||||||
|
if err := cache.SaveLocation("Testville", loc); err != nil {
|
||||||
|
t.Fatalf("save location failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loc.RadarStation = "KBOX"
|
||||||
|
if err := cache.UpdateLocation(loc); err != nil {
|
||||||
|
t.Fatalf("update location failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, ok, err := cache.LoadLocation()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location failed: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected cached location")
|
||||||
|
}
|
||||||
|
if loaded.RadarStation != "KBOX" {
|
||||||
|
t.Fatalf("expected radar station to persist, got %q", loaded.RadarStation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveLocationClearsForecastAndRadar(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
cache := NewCache(tmp)
|
||||||
|
|
||||||
|
forecastPath := filepath.Join(tmp, forecastCacheFile)
|
||||||
|
radarPath := filepath.Join(tmp, "radar.gif")
|
||||||
|
|
||||||
|
if err := os.WriteFile(forecastPath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write forecast: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(radarPath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write radar: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := Location{Lat: 1.23, Lon: 4.56, City: "Testville", State: "TS"}
|
||||||
|
if err := cache.SaveLocation("Testville", loc); err != nil {
|
||||||
|
t.Fatalf("save location failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(forecastPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected forecast cache removed, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(radarPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected radar cache removed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheForecastExpiry(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
cache := NewCache(tmp)
|
||||||
|
loc := Location{Lat: 1.23, Lon: 4.56}
|
||||||
|
period := ForecastPeriod{Temp: 70, Unit: "F"}
|
||||||
|
|
||||||
|
if err := cache.SaveForecast(loc, "F", period); err != nil {
|
||||||
|
t.Fatalf("save forecast failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(tmp, forecastCacheFile)
|
||||||
|
var payload ForecastCache
|
||||||
|
if err := readJSON(path, &payload); err != nil {
|
||||||
|
t.Fatalf("read forecast cache failed: %v", err)
|
||||||
|
}
|
||||||
|
payload.FetchedAt = time.Now().Add(-2 * time.Hour)
|
||||||
|
if err := writeJSON(path, payload); err != nil {
|
||||||
|
t.Fatalf("write forecast cache failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok, err := cache.LoadForecast(loc, "F", 10*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load forecast failed: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected expired forecast to be ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheClear(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
cache := NewCache(tmp)
|
||||||
|
loc := Location{Lat: 1.23, Lon: 4.56}
|
||||||
|
period := ForecastPeriod{Temp: 70, Unit: "F"}
|
||||||
|
|
||||||
|
if err := cache.SaveLocation("Testville", loc); err != nil {
|
||||||
|
t.Fatalf("save location failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := cache.SaveForecast(loc, "F", period); err != nil {
|
||||||
|
t.Fatalf("save forecast failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cache.Clear(); err != nil {
|
||||||
|
t.Fatalf("clear cache failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(tmp, locationCacheFile)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected location cache removed, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(tmp, forecastCacheFile)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected forecast cache removed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
305
forecast.go
Normal file
305
forecast.go
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ForecastPeriod struct {
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
IsDaytime bool `json:"is_daytime"`
|
||||||
|
Temp int `json:"temperature"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Pop *int `json:"pop,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NWSClient struct {
|
||||||
|
client *http.Client
|
||||||
|
userAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNWSClient(userAgent string) *NWSClient {
|
||||||
|
return &NWSClient{
|
||||||
|
client: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
userAgent: userAgent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NWSClient) FetchForecast(ctx context.Context, loc Location, unit string) (ForecastPeriod, error) {
|
||||||
|
pointsURL := fmt.Sprintf("https://api.weather.gov/points/%.4f,%.4f", loc.Lat, loc.Lon)
|
||||||
|
var pointsResp struct {
|
||||||
|
Properties struct {
|
||||||
|
ForecastHourly string `json:"forecastHourly"`
|
||||||
|
} `json:"properties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil {
|
||||||
|
return ForecastPeriod{}, err
|
||||||
|
}
|
||||||
|
if pointsResp.Properties.ForecastHourly == "" {
|
||||||
|
return ForecastPeriod{}, fmt.Errorf("missing forecast hourly URL from NWS")
|
||||||
|
}
|
||||||
|
|
||||||
|
var forecastResp struct {
|
||||||
|
Properties struct {
|
||||||
|
Periods []struct {
|
||||||
|
StartTime time.Time `json:"startTime"`
|
||||||
|
IsDaytime bool `json:"isDaytime"`
|
||||||
|
Temp int `json:"temperature"`
|
||||||
|
Unit string `json:"temperatureUnit"`
|
||||||
|
Summary string `json:"shortForecast"`
|
||||||
|
Pop struct {
|
||||||
|
Value *int `json:"value"`
|
||||||
|
} `json:"probabilityOfPrecipitation"`
|
||||||
|
} `json:"periods"`
|
||||||
|
} `json:"properties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, pointsResp.Properties.ForecastHourly, &forecastResp); err != nil {
|
||||||
|
return ForecastPeriod{}, err
|
||||||
|
}
|
||||||
|
if len(forecastResp.Properties.Periods) == 0 {
|
||||||
|
return ForecastPeriod{}, fmt.Errorf("no forecast periods returned by NWS")
|
||||||
|
}
|
||||||
|
|
||||||
|
period := forecastResp.Properties.Periods[0]
|
||||||
|
finalUnit := period.Unit
|
||||||
|
finalTemp := period.Temp
|
||||||
|
if unit == "C" && strings.EqualFold(period.Unit, "F") {
|
||||||
|
finalTemp = int((float64(period.Temp) - 32) * 5.0 / 9.0)
|
||||||
|
finalUnit = "C"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ForecastPeriod{
|
||||||
|
StartTime: period.StartTime,
|
||||||
|
IsDaytime: period.IsDaytime,
|
||||||
|
Temp: finalTemp,
|
||||||
|
Unit: finalUnit,
|
||||||
|
Summary: period.Summary,
|
||||||
|
Pop: period.Pop.Value,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NWSClient) FetchRadarStation(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 {
|
||||||
|
RadarStation string `json:"radarStation"`
|
||||||
|
OfficeID string `json:"cwa"`
|
||||||
|
} `json:"properties"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
station := strings.TrimSpace(pointsResp.Properties.RadarStation)
|
||||||
|
if station == "" {
|
||||||
|
return "", fmt.Errorf("missing radar station from NWS points")
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
|
req.Header.Set("Accept", "application/geo+json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode > 299 {
|
||||||
|
return fmt.Errorf("request failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(resp.Body)
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatOutput(loc Location, period ForecastPeriod, includeDesc, pretty bool) string {
|
||||||
|
conditionText := titleCase(period.Summary)
|
||||||
|
temp := formatTemperature(period, pretty)
|
||||||
|
popText, popIcon := formatPop(period, pretty)
|
||||||
|
|
||||||
|
if pretty {
|
||||||
|
conditionIcon := iconForForecast(period.Summary, period.IsDaytime)
|
||||||
|
condition := conditionIcon
|
||||||
|
if includeDesc {
|
||||||
|
condition = fmt.Sprintf("%s %s", conditionIcon, conditionText)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := []string{
|
||||||
|
condition,
|
||||||
|
temp,
|
||||||
|
popIcon + popText,
|
||||||
|
"in",
|
||||||
|
loc.Label(),
|
||||||
|
}
|
||||||
|
return joinParts(parts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
condition := conditionText
|
||||||
|
if includeDesc {
|
||||||
|
condition = fmt.Sprintf("%s", conditionText)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := []string{
|
||||||
|
condition,
|
||||||
|
temp,
|
||||||
|
popText,
|
||||||
|
"in",
|
||||||
|
loc.Label(),
|
||||||
|
}
|
||||||
|
return joinParts(parts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleCase(input string) string {
|
||||||
|
parts := strings.Fields(strings.ToLower(input))
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "and" || part == "or" || part == "the" || part == "a" || part == "an" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func iconForForecast(summary string, isDay bool) string {
|
||||||
|
text := strings.ToLower(summary)
|
||||||
|
|
||||||
|
if strings.Contains(text, "thunder") {
|
||||||
|
return "\ue31d"
|
||||||
|
}
|
||||||
|
if isPrecip(text) {
|
||||||
|
if strings.Contains(text, "snow") || strings.Contains(text, "sleet") || strings.Contains(text, "flurr") {
|
||||||
|
return "\ue31a"
|
||||||
|
}
|
||||||
|
return "\ue318"
|
||||||
|
}
|
||||||
|
if isMostlySunny(text) {
|
||||||
|
if isDay {
|
||||||
|
return "\ue30c"
|
||||||
|
}
|
||||||
|
return "\ue379"
|
||||||
|
}
|
||||||
|
if isPartlySunny(text) {
|
||||||
|
if isDay {
|
||||||
|
return "\ue30c"
|
||||||
|
}
|
||||||
|
return "\ue379"
|
||||||
|
}
|
||||||
|
if isClear(text) {
|
||||||
|
if isDay {
|
||||||
|
return "\ue30d"
|
||||||
|
}
|
||||||
|
return "\ue32b"
|
||||||
|
}
|
||||||
|
if isCloudy(text) {
|
||||||
|
return "\ue312"
|
||||||
|
}
|
||||||
|
if isDay {
|
||||||
|
return "\ue30d"
|
||||||
|
}
|
||||||
|
return "\ue32b"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPop(period ForecastPeriod, pretty bool) (string, string) {
|
||||||
|
popValue := 0
|
||||||
|
if period.Pop != nil {
|
||||||
|
popValue = *period.Pop
|
||||||
|
}
|
||||||
|
|
||||||
|
if pretty {
|
||||||
|
icon := precipitationIcon(period.Summary, period.IsDaytime)
|
||||||
|
return fmt.Sprintf(" %d%%", popValue), icon
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d%%", popValue), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func precipitationIcon(summary string, isDay bool) string {
|
||||||
|
text := strings.ToLower(summary)
|
||||||
|
if strings.Contains(text, "snow") || strings.Contains(text, "sleet") || strings.Contains(text, "flurr") {
|
||||||
|
return "\uf2dc"
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "rain") || strings.Contains(text, "shower") || strings.Contains(text, "drizzle") {
|
||||||
|
return "\ue371"
|
||||||
|
}
|
||||||
|
return "\ue371"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrecip(text string) bool {
|
||||||
|
return strings.Contains(text, "rain") ||
|
||||||
|
strings.Contains(text, "shower") ||
|
||||||
|
strings.Contains(text, "drizzle") ||
|
||||||
|
strings.Contains(text, "snow") ||
|
||||||
|
strings.Contains(text, "sleet") ||
|
||||||
|
strings.Contains(text, "flurr") ||
|
||||||
|
strings.Contains(text, "ice") ||
|
||||||
|
strings.Contains(text, "hail")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isClear(text string) bool {
|
||||||
|
return strings.Contains(text, "clear") || strings.Contains(text, "sunny")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMostlySunny(text string) bool {
|
||||||
|
return strings.Contains(text, "mostly sunny") || strings.Contains(text, "mostly clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPartlySunny(text string) bool {
|
||||||
|
return strings.Contains(text, "partly sunny") || strings.Contains(text, "partly cloudy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCloudy(text string) bool {
|
||||||
|
return strings.Contains(text, "cloudy") || strings.Contains(text, "overcast")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTemperature(period ForecastPeriod, pretty bool) string {
|
||||||
|
unit := strings.ToUpper(strings.TrimSpace(period.Unit))
|
||||||
|
if unit == "" {
|
||||||
|
unit = "F"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d°%s", period.Temp, unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinParts(parts ...string) string {
|
||||||
|
trimmed := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if strings.TrimSpace(part) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trimmed = append(trimmed, part)
|
||||||
|
}
|
||||||
|
return strings.Join(trimmed, " ")
|
||||||
|
}
|
||||||
80
forecast_test.go
Normal file
80
forecast_test.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIconForForecast(t *testing.T) {
|
||||||
|
icon := iconForForecast("Thunderstorms", true)
|
||||||
|
if icon != "\ue31d" {
|
||||||
|
t.Fatalf("expected thunderstorm icon, got %q", icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
icon = iconForForecast("Partly Cloudy", false)
|
||||||
|
if icon != "\ue379" {
|
||||||
|
t.Fatalf("expected partly cloudy night icon, got %q", icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
icon = iconForForecast("Mostly Sunny", true)
|
||||||
|
if icon != "\ue30c" {
|
||||||
|
t.Fatalf("expected mostly sunny icon, got %q", icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
icon = iconForForecast("Overcast", true)
|
||||||
|
if icon != "\ue312" {
|
||||||
|
t.Fatalf("expected cloudy icon, got %q", icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
icon = iconForForecast("Rain", true)
|
||||||
|
if icon != "\ue318" {
|
||||||
|
t.Fatalf("expected rain icon, got %q", icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
icon = iconForForecast("Snow", true)
|
||||||
|
if icon != "\ue31a" {
|
||||||
|
t.Fatalf("expected snow icon, got %q", icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatOutputPopAlways(t *testing.T) {
|
||||||
|
pop := 20
|
||||||
|
loc := Location{City: "Testville", State: "TS"}
|
||||||
|
period := ForecastPeriod{
|
||||||
|
Temp: 70,
|
||||||
|
Unit: "F",
|
||||||
|
Summary: "Clear",
|
||||||
|
Pop: &pop,
|
||||||
|
}
|
||||||
|
|
||||||
|
out := FormatOutput(loc, period, false, false)
|
||||||
|
if !strings.Contains(out, "20%") {
|
||||||
|
t.Fatalf("expected pop to be shown above threshold, got %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "\ue371") || strings.Contains(out, "\uf2dc") {
|
||||||
|
t.Fatalf("expected no pop icon in plain mode, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatOutputPopIcon(t *testing.T) {
|
||||||
|
pop := 60
|
||||||
|
loc := Location{City: "Testville", State: "TS"}
|
||||||
|
period := ForecastPeriod{
|
||||||
|
Temp: 30,
|
||||||
|
Unit: "F",
|
||||||
|
Summary: "Snow",
|
||||||
|
Pop: &pop,
|
||||||
|
IsDaytime: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
out := FormatOutput(loc, period, false, true)
|
||||||
|
if !strings.Contains(out, "\uf2dc 60%") {
|
||||||
|
t.Fatalf("expected snow pop icon before pop, got %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "\ue31a 30°F") {
|
||||||
|
t.Fatalf("expected precip icon before temperature, got %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "\uf2dc 60% in") {
|
||||||
|
t.Fatalf("expected pop to precede location, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
icons.go
Normal file
33
icons.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func printIconTest() {
|
||||||
|
printIconTestTo(os.Stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printIconTestTo(w io.Writer) {
|
||||||
|
entries := []struct {
|
||||||
|
Label string
|
||||||
|
Icon string
|
||||||
|
}{
|
||||||
|
{Label: "Clear/Sunny (Day)", Icon: "\ue30d"},
|
||||||
|
{Label: "Clear/Sunny (Night)", Icon: "\ue32b"},
|
||||||
|
{Label: "Partly/Mostly Sunny (Day)", Icon: "\ue30c"},
|
||||||
|
{Label: "Partly/Mostly Sunny (Night)", Icon: "\ue379"},
|
||||||
|
{Label: "Cloudy/Overcast", Icon: "\ue312"},
|
||||||
|
{Label: "Rain", Icon: "\ue318"},
|
||||||
|
{Label: "Snow", Icon: "\ue31a"},
|
||||||
|
{Label: "Thunderstorm", Icon: "\ue31d"},
|
||||||
|
{Label: "Rain Percent Chance", Icon: "\ue371"},
|
||||||
|
{Label: "Snow Percent Chance", Icon: "\uf2dc"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\n", entry.Icon, entry.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
icons_test.go
Normal file
22
icons_test.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrintIconTest(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
printIconTestTo(&buf)
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "Clear/Sunny") {
|
||||||
|
t.Fatalf("expected clear/sunny entry in icon test output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Rain Percent Chance") {
|
||||||
|
t.Fatalf("expected rain percent chance entry in icon test output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Snow Percent Chance") {
|
||||||
|
t.Fatalf("expected snow percent chance entry in icon test output")
|
||||||
|
}
|
||||||
|
}
|
||||||
218
location.go
218
location.go
@ -1,10 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -18,8 +25,9 @@ type Location struct {
|
|||||||
State string
|
State string
|
||||||
Country string
|
Country string
|
||||||
Zipcode string
|
Zipcode string
|
||||||
// NWS specific location data
|
DisplayName string
|
||||||
// Station string
|
RadarStation string
|
||||||
|
OfficeID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLocation() *Location {
|
func NewLocation() *Location {
|
||||||
@ -29,10 +37,21 @@ func NewLocation() *Location {
|
|||||||
func (l *Location) UnmarshalJSON(b []byte) error {
|
func (l *Location) UnmarshalJSON(b []byte) error {
|
||||||
|
|
||||||
var aux struct {
|
var aux struct {
|
||||||
Lat float64 `json:"lat,string"`
|
Lat json.RawMessage `json:"lat"`
|
||||||
Lon float64 `json:"lon,string"`
|
Lon json.RawMessage `json:"lon"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
City string `json:"city"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Zipcode string `json:"zipcode"`
|
||||||
|
RadarStation string `json:"RadarStation"`
|
||||||
|
OfficeID string `json:"OfficeID"`
|
||||||
Address struct {
|
Address struct {
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
|
Town string `json:"town"`
|
||||||
|
Village string `json:"village"`
|
||||||
|
Hamlet string `json:"hamlet"`
|
||||||
|
County string `json:"county"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
Zipcode string `json:"postcode"`
|
Zipcode string `json:"postcode"`
|
||||||
@ -43,29 +62,48 @@ func (l *Location) UnmarshalJSON(b []byte) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lat, err := parseJSONFloat(aux.Lat)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lon, err := parseJSONFloat(aux.Lon)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// copy over values
|
// copy over values
|
||||||
l.Lat = aux.Lat
|
l.Lat = lat
|
||||||
l.Lon = aux.Lon
|
l.Lon = lon
|
||||||
l.City = aux.Address.City
|
l.City = firstNonEmpty(
|
||||||
l.State = aux.Address.State
|
aux.Address.City,
|
||||||
l.Country = aux.Address.Country
|
aux.Address.Town,
|
||||||
l.Zipcode = aux.Address.Zipcode
|
aux.Address.Village,
|
||||||
|
aux.Address.Hamlet,
|
||||||
|
aux.Address.County,
|
||||||
|
aux.City,
|
||||||
|
)
|
||||||
|
l.State = firstNonEmpty(aux.Address.State, aux.State)
|
||||||
|
l.Country = firstNonEmpty(aux.Address.Country, aux.Country)
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchLocations(query string) ([]Location, error) {
|
func SearchLocations(ctx context.Context, query, userAgent string) ([]Location, error) {
|
||||||
// url to perform location queries against
|
queryURL := fmt.Sprintf(
|
||||||
//locations := []Location{}
|
"https://nominatim.openstreetmap.org/search?q=%s&format=jsonv2&addressdetails=1&limit=10",
|
||||||
queryURL := fmt.Sprintf("https://nominatim.openstreetmap.org/search?q=%s&format=jsonv2&addressdetails=1", query)
|
url.QueryEscape(query),
|
||||||
|
)
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, queryURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, queryURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []Location{}, err
|
return []Location{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent",
|
req.Header.Set("User-Agent", userAgent)
|
||||||
"weather/0.1 (https://git.keegandeppe.com/kdeppe/weather; contact=19keegandeppe@gmail.com)")
|
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 15 * time.Second,
|
Timeout: 15 * time.Second,
|
||||||
@ -78,6 +116,10 @@ func SearchLocations(query string) ([]Location, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode > 299 {
|
||||||
|
return []Location{}, fmt.Errorf("request failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []Location{}, err
|
return []Location{}, err
|
||||||
@ -86,14 +128,9 @@ func SearchLocations(query string) ([]Location, error) {
|
|||||||
var locations []Location
|
var locations []Location
|
||||||
|
|
||||||
if err := json.Unmarshal(b, &locations); err != nil {
|
if err := json.Unmarshal(b, &locations); err != nil {
|
||||||
panic(err)
|
return []Location{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// l := NewLocation()
|
|
||||||
// l.UnmarshalJSON(b)
|
|
||||||
|
|
||||||
fmt.Printf("%+v\n", locations)
|
|
||||||
|
|
||||||
return locations, nil
|
return locations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,3 +220,138 @@ func (l *Location) String() string {
|
|||||||
|
|
||||||
return fmt.Sprintf("%s, %s %s (%f, %f)", l.City, l.State, l.Zipcode, l.Lat, l.Lon)
|
return fmt.Sprintf("%s, %s %s (%f, %f)", l.City, l.State, l.Zipcode, l.Lat, l.Lon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Location) Label() string {
|
||||||
|
return l.label(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Location) LabelFull() string {
|
||||||
|
return l.label(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Location) label(abbrevUS bool) string {
|
||||||
|
city := strings.TrimSpace(l.City)
|
||||||
|
state := strings.TrimSpace(l.State)
|
||||||
|
country := strings.TrimSpace(l.Country)
|
||||||
|
|
||||||
|
if abbrevUS && (country == "" || strings.EqualFold(country, "United States") || strings.EqualFold(country, "United States of America")) {
|
||||||
|
if abbr := usStateAbbreviation(state); abbr != "" {
|
||||||
|
state = abbr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if city != "" && state != "" {
|
||||||
|
return fmt.Sprintf("%s, %s", city, state)
|
||||||
|
}
|
||||||
|
if city != "" {
|
||||||
|
return city
|
||||||
|
}
|
||||||
|
if l.DisplayName != "" {
|
||||||
|
return l.DisplayName
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprintf("%s %s", city, l.State))
|
||||||
|
}
|
||||||
|
|
||||||
|
func SelectLocation(results []Location) (Location, error) {
|
||||||
|
switch len(results) {
|
||||||
|
case 0:
|
||||||
|
return Location{}, fmt.Errorf("no locations found")
|
||||||
|
case 1:
|
||||||
|
return results[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasTTY(os.Stdin, os.Stdout) {
|
||||||
|
return Location{}, fmt.Errorf("multiple locations found; fzf requires a TTY, refine search")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("fzf"); err != nil {
|
||||||
|
return Location{}, fmt.Errorf("multiple locations found; install fzf or refine search")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, 0, len(results))
|
||||||
|
for i, loc := range results {
|
||||||
|
label := loc.Label()
|
||||||
|
if loc.Country != "" && !strings.Contains(label, loc.Country) {
|
||||||
|
label = fmt.Sprintf("%s, %s", label, loc.Country)
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%d\t%s (%.4f, %.4f)", i+1, label, loc.Lat, loc.Lon))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("fzf", "--prompt", "Select location> ", "--height", "40%", "--reverse")
|
||||||
|
cmd.Stdin = strings.NewReader(strings.Join(lines, "\n"))
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
|
||||||
|
return Location{}, fmt.Errorf("location selection cancelled")
|
||||||
|
}
|
||||||
|
return Location{}, fmt.Errorf("fzf selection failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
selection := strings.TrimSpace(out.String())
|
||||||
|
if selection == "" {
|
||||||
|
return Location{}, fmt.Errorf("no location selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(selection, "\t", 2)
|
||||||
|
idx, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
if err != nil || idx < 1 || idx > len(results) {
|
||||||
|
return Location{}, fmt.Errorf("invalid selection")
|
||||||
|
}
|
||||||
|
|
||||||
|
return results[idx-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJSONFloat(raw json.RawMessage) (float64, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var asFloat float64
|
||||||
|
if err := json.Unmarshal(raw, &asFloat); err == nil {
|
||||||
|
return asFloat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var asString string
|
||||||
|
if err := json.Unmarshal(raw, &asString); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.ParseFloat(asString, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func usStateAbbreviation(state string) string {
|
||||||
|
state = strings.TrimSpace(state)
|
||||||
|
if state == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if abbr, ok := usStateMap[state]; ok {
|
||||||
|
return abbr
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTTY(in, out *os.File) bool {
|
||||||
|
inStat, err := in.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
outStat, err := out.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (inStat.Mode()&os.ModeCharDevice) != 0 && (outStat.Mode()&os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|||||||
63
location_test.go
Normal file
63
location_test.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocationUnmarshalCityFallback(t *testing.T) {
|
||||||
|
payload := []byte(`{
|
||||||
|
"lat": "42.1234",
|
||||||
|
"lon": "-71.5678",
|
||||||
|
"display_name": "Somerville, Massachusetts, USA",
|
||||||
|
"address": {
|
||||||
|
"town": "Somerville",
|
||||||
|
"state": "Massachusetts",
|
||||||
|
"country": "United States",
|
||||||
|
"postcode": "02143"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var loc Location
|
||||||
|
if err := json.Unmarshal(payload, &loc); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if loc.City != "Somerville" {
|
||||||
|
t.Fatalf("expected city fallback to town, got %q", loc.City)
|
||||||
|
}
|
||||||
|
if loc.State != "Massachusetts" {
|
||||||
|
t.Fatalf("expected state, got %q", loc.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocationLabel(t *testing.T) {
|
||||||
|
loc := Location{City: "Somerville", State: "Massachusetts", Country: "United States"}
|
||||||
|
if got := loc.Label(); got != "Somerville, MA" {
|
||||||
|
t.Fatalf("unexpected label: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
loc = Location{DisplayName: "Somerville, Massachusetts, USA"}
|
||||||
|
if got := loc.Label(); got != loc.DisplayName {
|
||||||
|
t.Fatalf("unexpected label: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUSStateAbbreviationFallback(t *testing.T) {
|
||||||
|
loc := Location{City: "Toronto", State: "Ontario", Country: "Canada"}
|
||||||
|
if got := loc.Label(); got != "Toronto, Ontario" {
|
||||||
|
t.Fatalf("unexpected non-us label: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasTTYFalse(t *testing.T) {
|
||||||
|
f, err := os.CreateTemp(t.TempDir(), "tty-check-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if hasTTY(f, f) {
|
||||||
|
t.Fatalf("expected non-tty for temp file")
|
||||||
|
}
|
||||||
|
}
|
||||||
219
main.go
219
main.go
@ -1,89 +1,152 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultForecastTTL = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
SearchLocations("Somerville")
|
fmt.Fprintln(os.Stderr, err.Error())
|
||||||
|
os.Exit(1)
|
||||||
return
|
|
||||||
w := NewWeather()
|
|
||||||
|
|
||||||
// if err := w.getLatest(); err != nil {
|
|
||||||
// panic(err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
fmt.Println(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
type HourForecast struct {
|
|
||||||
StartTime time.Time
|
|
||||||
EndTime time.Time
|
|
||||||
Temperature float64
|
|
||||||
TemperatureUnit string
|
|
||||||
ProbabilityOfPercipitation float64
|
|
||||||
WindSpeed float64
|
|
||||||
WindDirection string
|
|
||||||
Icon string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Weather struct {
|
|
||||||
*Location
|
|
||||||
Forecast []*HourForecast
|
|
||||||
ForecastURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWeather() *Weather {
|
|
||||||
l := NewLocation()
|
|
||||||
// try to set location
|
|
||||||
// if err := l.getCoords(); err != nil {
|
|
||||||
// panic(err)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if err := l.getStation(); err != nil {
|
|
||||||
// panic(err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
return &Weather{
|
|
||||||
Location: l,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// func (w *Weather) getLatest() error {
|
func run() error {
|
||||||
// url := fmt.Sprintf("https://api.weather.gov/stations/%s/observations/latest", w.Location.Station)
|
opts, err := parseArgs(os.Args[1:])
|
||||||
//
|
if err != nil {
|
||||||
// res, err := http.Get(url)
|
return err
|
||||||
//
|
}
|
||||||
// if err != nil {
|
|
||||||
// return err
|
ua, err := buildUserAgent()
|
||||||
// }
|
if err != nil {
|
||||||
//
|
return err
|
||||||
// body, err := io.ReadAll(res.Body)
|
}
|
||||||
// res.Body.Close()
|
|
||||||
// if res.StatusCode > 299 {
|
cacheDir, err := cacheRoot()
|
||||||
// errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode)
|
if err != nil {
|
||||||
// return errors.New(errMsg)
|
return err
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// if err != nil {
|
cache := NewCache(cacheDir)
|
||||||
// return err
|
ctx := context.Background()
|
||||||
// }
|
|
||||||
//
|
if opts.iconTest {
|
||||||
// temp := gjson.Get(string(body), "properties.temperature.value")
|
printIconTest()
|
||||||
// humidity := gjson.Get(string(body), "properties.relativeHumidity.value")
|
return nil
|
||||||
// cond := gjson.Get(string(body), "properties.textDescription")
|
}
|
||||||
//
|
|
||||||
// // convert to Farenheit
|
if opts.wayland && opts.searchSet {
|
||||||
// w.Temperature = temp.Float()*(9.0/5) + 32
|
if err := requireRofi(); err != nil {
|
||||||
// w.Humidity = humidity.Float()
|
return err
|
||||||
// w.Conditions = cond.String()
|
}
|
||||||
//
|
}
|
||||||
// return nil
|
|
||||||
// }
|
if opts.radarMode {
|
||||||
//
|
if err := requireMPV(); err != nil {
|
||||||
// func (w *Weather) String() string {
|
return err
|
||||||
// return fmt.Sprintf("%s %.1f deg %.1f%% RH in %s, %s", w.Conditions, w.Temperature, w.Humidity, w.Location.City, w.Location.State)
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
|
if opts.clearCache {
|
||||||
|
if err := cache.Clear(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var loc Location
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
loc, err = SelectLocation(results)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cache.SaveLocation(opts.searchValue, loc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var ok bool
|
||||||
|
loc, ok, err = cache.LoadLocation()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no cached location found; use -s to set a location")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 opts.refresh {
|
||||||
|
var err error
|
||||||
|
period, err = client.FetchForecast(ctx, loc, opts.temperatureU)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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, opts.temperatureU, defaultForecastTTL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !fromCache {
|
||||||
|
period, err = client.FetchForecast(ctx, loc, opts.temperatureU)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cache.SaveForecast(loc, opts.temperatureU, period); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
line := FormatOutput(loc, period, opts.includeDesc, opts.prettyOutput)
|
||||||
|
fmt.Println(line)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
126
radar.go
Normal file
126
radar.go
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const radarCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
func requireMPV() error {
|
||||||
|
if _, err := exec.LookPath("mpv"); err != nil {
|
||||||
|
return fmt.Errorf("mpv is required for radar mode")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRadar(ctx context.Context, cache *Cache, loc Location, searchQuery string, userAgent string) error {
|
||||||
|
station := strings.TrimSpace(loc.RadarStation)
|
||||||
|
if station == "" {
|
||||||
|
client := NewNWSClient(userAgent)
|
||||||
|
var err error
|
||||||
|
station, err = client.FetchRadarStation(ctx, loc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
loc.RadarStation = station
|
||||||
|
if err := cache.UpdateLocation(loc); err != nil {
|
||||||
|
if searchQuery == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cache.SaveLocation(searchQuery, loc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := radarURL(station)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
radarPath, err := cacheRadarGIF(ctx, cache, url, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "mpv", "--fs", "--loop-file", radarPath)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func radarURL(station string) (string, error) {
|
||||||
|
station = strings.TrimSpace(station)
|
||||||
|
if station == "" {
|
||||||
|
return "", fmt.Errorf("radar station not set")
|
||||||
|
}
|
||||||
|
station = strings.ToUpper(station)
|
||||||
|
return fmt.Sprintf("https://radar.weather.gov/ridge/standard/%s_loop.gif", station), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheRadarGIF(ctx context.Context, cache *Cache, url, userAgent string) (string, error) {
|
||||||
|
if err := cache.ensureDir(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := filepath.Join(cache.dir, "radar.gif")
|
||||||
|
if !shouldRefreshFile(path, radarCacheTTL) {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode > 299 {
|
||||||
|
return "", fmt.Errorf("radar request failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := path + ".tmp"
|
||||||
|
tmp, err := os.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, copyErr := io.Copy(tmp, resp.Body)
|
||||||
|
closeErr := tmp.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", copyErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRefreshFile(path string, ttl time.Duration) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Since(info.ModTime()) > ttl
|
||||||
|
}
|
||||||
32
radar_cache_test.go
Normal file
32
radar_cache_test.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldRefreshFile(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "radar.gif")
|
||||||
|
|
||||||
|
if !shouldRefreshFile(path, time.Minute) {
|
||||||
|
t.Fatalf("expected refresh when file missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write temp file: %v", err)
|
||||||
|
}
|
||||||
|
if shouldRefreshFile(path, time.Minute) {
|
||||||
|
t.Fatalf("expected no refresh for fresh file")
|
||||||
|
}
|
||||||
|
|
||||||
|
old := time.Now().Add(-2 * time.Hour)
|
||||||
|
if err := os.Chtimes(path, old, old); err != nil {
|
||||||
|
t.Fatalf("chtimes: %v", err)
|
||||||
|
}
|
||||||
|
if !shouldRefreshFile(path, time.Minute) {
|
||||||
|
t.Fatalf("expected refresh for stale file")
|
||||||
|
}
|
||||||
|
}
|
||||||
14
radar_test.go
Normal file
14
radar_test.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRadarURL(t *testing.T) {
|
||||||
|
url, err := radarURL("kbox")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("radar url error: %v", err)
|
||||||
|
}
|
||||||
|
expected := "https://radar.weather.gov/ridge/standard/KBOX_loop.gif"
|
||||||
|
if url != expected {
|
||||||
|
t.Fatalf("unexpected radar url: %s", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
158
rofi.go
Normal file
158
rofi.go
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func requireRofi() error {
|
||||||
|
if _, err := exec.LookPath("rofi"); err != nil {
|
||||||
|
return fmt.Errorf("rofi is required for interactive search")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rofiPrompt(prompt string) (string, bool, error) {
|
||||||
|
cmd := exec.Command("rofi", "-dmenu", "-p", prompt)
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, fmt.Errorf("rofi prompt failed: %w", err)
|
||||||
|
}
|
||||||
|
selection := strings.TrimSpace(out.String())
|
||||||
|
if selection == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return selection, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rofiSelect(prompt string, options []string) (string, bool, error) {
|
||||||
|
cmd := exec.Command("rofi", "-dmenu", "-p", prompt)
|
||||||
|
cmd.Stdin = strings.NewReader(strings.Join(options, "\n"))
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, fmt.Errorf("rofi selection failed: %w", err)
|
||||||
|
}
|
||||||
|
selection := strings.TrimSpace(out.String())
|
||||||
|
if selection == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return selection, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func interactiveSearch(ctx context.Context, cache *Cache, userAgent string) (Location, error) {
|
||||||
|
recent, err := cache.LoadRecentLocations()
|
||||||
|
if err != nil {
|
||||||
|
return Location{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
selection, ok, err := rofiPrompt("Search")
|
||||||
|
if err != nil {
|
||||||
|
return Location{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return Location{}, fmt.Errorf("interactive search cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := selection
|
||||||
|
matches := matchRecentLocations(recent, query)
|
||||||
|
if len(matches) > 0 {
|
||||||
|
choiceOptions := make([]string, 0, len(matches)+1)
|
||||||
|
for _, loc := range matches {
|
||||||
|
choiceOptions = append(choiceOptions, fmt.Sprintf("Use saved %s", loc.LabelFull()))
|
||||||
|
}
|
||||||
|
searchOption := fmt.Sprintf("Search for %s", query)
|
||||||
|
choiceOptions = append(choiceOptions, searchOption)
|
||||||
|
|
||||||
|
choice, ok, err := rofiSelect("Use saved or search", choiceOptions)
|
||||||
|
if err != nil {
|
||||||
|
return Location{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return Location{}, fmt.Errorf("interactive search cancelled")
|
||||||
|
}
|
||||||
|
if choice != searchOption {
|
||||||
|
for _, loc := range matches {
|
||||||
|
if choice == fmt.Sprintf("Use saved %s", loc.LabelFull()) {
|
||||||
|
if err := cache.SaveLocation(loc.LabelFull(), loc); err != nil {
|
||||||
|
return Location{}, err
|
||||||
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Searching for location...")
|
||||||
|
results, err := SearchLocations(ctx, query, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
return Location{}, err
|
||||||
|
}
|
||||||
|
loc, err := SelectLocationRofi(results)
|
||||||
|
if err != nil {
|
||||||
|
return Location{}, err
|
||||||
|
}
|
||||||
|
if err := cache.SaveLocation(query, loc); err != nil {
|
||||||
|
return Location{}, err
|
||||||
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SelectLocationRofi(results []Location) (Location, error) {
|
||||||
|
switch len(results) {
|
||||||
|
case 0:
|
||||||
|
return Location{}, fmt.Errorf("no locations found")
|
||||||
|
case 1:
|
||||||
|
return results[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
options := make([]string, 0, len(results))
|
||||||
|
for _, loc := range results {
|
||||||
|
label := loc.LabelFull()
|
||||||
|
if loc.Country != "" && !strings.Contains(label, loc.Country) {
|
||||||
|
label = fmt.Sprintf("%s, %s", label, loc.Country)
|
||||||
|
}
|
||||||
|
options = append(options, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
selection, ok, err := rofiSelect("Select", options)
|
||||||
|
if err != nil {
|
||||||
|
return Location{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return Location{}, fmt.Errorf("interactive selection cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, option := range options {
|
||||||
|
if option == selection {
|
||||||
|
return results[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Location{}, fmt.Errorf("invalid selection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchRecentLocations(recent []Location, query string) []Location {
|
||||||
|
query = strings.TrimSpace(strings.ToLower(query))
|
||||||
|
if query == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
matches := make([]Location, 0, len(recent))
|
||||||
|
for _, loc := range recent {
|
||||||
|
label := strings.ToLower(loc.LabelFull())
|
||||||
|
if strings.Contains(label, query) {
|
||||||
|
matches = append(matches, loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
26
rofi_match_test.go
Normal file
26
rofi_match_test.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMatchRecentLocations(t *testing.T) {
|
||||||
|
recent := []Location{
|
||||||
|
{City: "Boston", State: "MA"},
|
||||||
|
{City: "Somerville", State: "MA"},
|
||||||
|
{City: "Somerville", State: "NJ"},
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := matchRecentLocations(recent, "somer")
|
||||||
|
if len(matches) != 2 {
|
||||||
|
t.Fatalf("expected 2 matches, got %d", len(matches))
|
||||||
|
}
|
||||||
|
|
||||||
|
matches = matchRecentLocations(recent, "boston")
|
||||||
|
if len(matches) != 1 {
|
||||||
|
t.Fatalf("expected 1 match, got %d", len(matches))
|
||||||
|
}
|
||||||
|
|
||||||
|
matches = matchRecentLocations(recent, "")
|
||||||
|
if len(matches) != 0 {
|
||||||
|
t.Fatalf("expected 0 matches for empty query, got %d", len(matches))
|
||||||
|
}
|
||||||
|
}
|
||||||
55
us_states.go
Normal file
55
us_states.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
var usStateMap = map[string]string{
|
||||||
|
"Alabama": "AL",
|
||||||
|
"Alaska": "AK",
|
||||||
|
"Arizona": "AZ",
|
||||||
|
"Arkansas": "AR",
|
||||||
|
"California": "CA",
|
||||||
|
"Colorado": "CO",
|
||||||
|
"Connecticut": "CT",
|
||||||
|
"Delaware": "DE",
|
||||||
|
"Florida": "FL",
|
||||||
|
"Georgia": "GA",
|
||||||
|
"Hawaii": "HI",
|
||||||
|
"Idaho": "ID",
|
||||||
|
"Illinois": "IL",
|
||||||
|
"Indiana": "IN",
|
||||||
|
"Iowa": "IA",
|
||||||
|
"Kansas": "KS",
|
||||||
|
"Kentucky": "KY",
|
||||||
|
"Louisiana": "LA",
|
||||||
|
"Maine": "ME",
|
||||||
|
"Maryland": "MD",
|
||||||
|
"Massachusetts": "MA",
|
||||||
|
"Michigan": "MI",
|
||||||
|
"Minnesota": "MN",
|
||||||
|
"Mississippi": "MS",
|
||||||
|
"Missouri": "MO",
|
||||||
|
"Montana": "MT",
|
||||||
|
"Nebraska": "NE",
|
||||||
|
"Nevada": "NV",
|
||||||
|
"New Hampshire": "NH",
|
||||||
|
"New Jersey": "NJ",
|
||||||
|
"New Mexico": "NM",
|
||||||
|
"New York": "NY",
|
||||||
|
"North Carolina": "NC",
|
||||||
|
"North Dakota": "ND",
|
||||||
|
"Ohio": "OH",
|
||||||
|
"Oklahoma": "OK",
|
||||||
|
"Oregon": "OR",
|
||||||
|
"Pennsylvania": "PA",
|
||||||
|
"Rhode Island": "RI",
|
||||||
|
"South Carolina": "SC",
|
||||||
|
"South Dakota": "SD",
|
||||||
|
"Tennessee": "TN",
|
||||||
|
"Texas": "TX",
|
||||||
|
"Utah": "UT",
|
||||||
|
"Vermont": "VT",
|
||||||
|
"Virginia": "VA",
|
||||||
|
"Washington": "WA",
|
||||||
|
"West Virginia": "WV",
|
||||||
|
"Wisconsin": "WI",
|
||||||
|
"Wyoming": "WY",
|
||||||
|
"District of Columbia": "DC",
|
||||||
|
}
|
||||||
23
useragent.go
Normal file
23
useragent.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
appName = "weather"
|
||||||
|
appVersion = "0.1"
|
||||||
|
appRepo = "https://git.keegandeppe.com/kdeppe/weather"
|
||||||
|
appContact = "contact=19keegandeppe@gmail.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildUserAgent() (string, error) {
|
||||||
|
host, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
host = strings.ReplaceAll(host, " ", "-")
|
||||||
|
return fmt.Sprintf("%s/%s (%s; %s; host=%s)", appName, appVersion, appRepo, appContact, host), nil
|
||||||
|
}
|
||||||
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