diff options
Diffstat (limited to 'cmd/prognosis/main.go')
| -rw-r--r-- | cmd/prognosis/main.go | 441 |
1 files changed, 441 insertions, 0 deletions
diff --git a/cmd/prognosis/main.go b/cmd/prognosis/main.go new file mode 100644 index 0000000..6e29c8e --- /dev/null +++ b/cmd/prognosis/main.go @@ -0,0 +1,441 @@ +// Command prognosis prints an hour-by-hour forecast, with official IMGW +// warnings for Polish locations. +package main + +import ( + "bufio" + "errors" + "flag" + "fmt" + "os" + "strconv" + "strings" + + "github.com/lukaszkasprzak/prognosis/internal/cache" + "github.com/lukaszkasprzak/prognosis/internal/config" + "github.com/lukaszkasprzak/prognosis/internal/i18n" + "github.com/lukaszkasprzak/prognosis/internal/imgw" + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" + "github.com/lukaszkasprzak/prognosis/internal/render" +) + +const wegorc = ".wegorc" + +func main() { os.Exit(run()) } + +func run() int { + // Android has no /etc/resolv.conf; without this every lookup fails. + configureResolver() + + var ( + location = flag.String("l", "", "place to query (default: config, then ~/.wegorc)") + hours = flag.Int("n", 0, "hours ahead to show") + days = flag.Int("d", 0, "days ahead to show, 24h each") + columns = flag.String("columns", "", "comma-separated columns to display") + icons = flag.String("icons", "", "icon set: nerd, emoji or none") + lang = flag.String("lang", "", "display language: en or pl") + noGraph = flag.Bool("no-graph", false, "table only, no chart") + weather = flag.Bool("weather", false, "forecast only: no sun times, summary, pollen or chart") + noWarn = flag.Bool("no-warnings", false, "omit IMGW warnings") + asciiOut = flag.Bool("ascii", false, "ASCII only, so an SMS stays in GSM-7") + noColor = flag.Bool("no-color", false, "plain output") + pick = flag.Int("pick", 0, "choose the Nth place when the name is ambiguous") + showCfg = flag.Bool("config", false, "print the config file path and exit") + ) + flag.Usage = usage + // Go's flag package stops at the first non-flag argument, so + // "prognosis 52.52,13.40 -n 3" would swallow the flags into the place name. + // Re-parse around each positional, which is what argparse does. + var positional []string + rest := os.Args[1:] + for { + if err := flag.CommandLine.Parse(rest); err != nil { + return 2 + } + if flag.NArg() == 0 { + break + } + positional = append(positional, flag.Arg(0)) + rest = flag.Args()[1:] + } + + cfgPath := config.Path() + if *showCfg { + fmt.Println(cfgPath) + return 0 + } + + cfg, err := config.Load(cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + return 2 + } + // Write the defaults out on first run, so the file documents itself. + if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) { + if err := config.WriteDefault(cfgPath, cfg); err == nil { + fmt.Fprintf(os.Stderr, "note: wrote default config to %s\n", cfgPath) + } + } + + // Flags override the file. The place is settled later, by placeFromArgs. + if *columns != "" { + cfg.Columns = splitList(*columns) + } + if *icons != "" { + cfg.Icons = *icons + } + if *lang != "" { + cfg.DisplayLang = *lang + } + if *noGraph { + cfg.Graph = false + } + if *weather { + // Meant for piping to someone else: the forecast and nothing else. + cfg.Minimal = true + cfg.Graph = false + } + if *noWarn { + cfg.Warnings = false + } + if *asciiOut { + cfg.ASCII = true + } + if cfg.ASCII { + // Both glyph sets are non-ASCII by definition. + cfg.Icons = "none" + } + if *noColor { + cfg.Color = "never" + } + switch { + case *days > 0 && *hours > 0: + fmt.Fprintln(os.Stderr, "prognosis: -n and -d cannot be combined") + return 2 + case *days > 0: + if *days > openmeteo.MaxForecastDays-1 { + fmt.Fprintf(os.Stderr, "prognosis: -d must be between 1 and %d\n", openmeteo.MaxForecastDays-1) + return 2 + } + cfg.Hours = *days * 24 + case *hours > 0: + cfg.Hours = *hours + case *days < 0 || *hours < 0: + fmt.Fprintln(os.Stderr, "prognosis: hours and days must be positive") + return 2 + } + + // -icons only chooses what the icon column draws. Without that column it + // changes nothing, which looks like the flag being ignored. + if *icons != "" && !cfg.Has("icon") { + fmt.Fprintf(os.Stderr, + "note: -icons has no effect: %q is not in columns (add it: -columns %s)\n", + "icon", strings.Join(append([]string{"hour", "icon"}, cfg.Columns[1:]...), ",")) + } + + if err := cfg.Validate(); err != nil { + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + return 2 + } + + place, err := placeFromArgs(*location, positional) + if err != nil { + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + return 2 + } + if place != "" { + cfg.Location = place + } + if cfg.Location == "" { + cfg.Location = locationFromWegorc() + } + if cfg.Location == "" { + fmt.Fprintf(os.Stderr, "prognosis: no location set in %s and none in ~/%s\n", cfgPath, wegorc) + return 2 + } + + store := cache.New(cache.DefaultPath()) + geo, err := resolve(store, cfg.Location, *pick) + if err != nil { + var amb *ambiguousError + if errors.As(err, &amb) { + fmt.Fprintf(os.Stderr, "prognosis: %s\n", ambiguousListing(amb)) + return 2 + } + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + var pe *pickError + if errors.As(err, &pe) { + return 2 + } + return 1 + } + + data, err := openmeteo.Forecast(geo.Lat, geo.Lon, cfg.Hours, cfg.Units, cfg.Fields()) + if err != nil { + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + return 1 + } + cat := i18n.For(cfg.DisplayLang) + if len(data.Rows) < cfg.Hours { + fmt.Fprintf(os.Stderr, "note: %d %s %d\n", len(data.Rows), cat.Word("hours_available"), cfg.Hours) + } + + view := render.View{ + Label: geo.Label, TZ: data.TZ, Rows: data.Rows, + Sun: data.Sun, Daily: data.Daily, + } + if len(cfg.Pollen) > 0 { + // Pollen is a nicety: a failure here must not cost the forecast. + if peaks, err := openmeteo.Pollen(geo.Lat, geo.Lon, cfg.Hours, cfg.Pollen); err == nil { + view.Pollen = peaks + } + } + if cfg.Warnings { + view.Warnings, view.WarnNote, view.WarnFailed = warnings(store, geo, cat) + } + + colour := shouldColour(cfg.Color) + fmt.Println(render.Render(view, cfg, terminalWidth(), colour)) + return 0 +} + +// warnings resolves the powiat and fetches warnings for it. +// +// The three outcomes are deliberately distinct: a list, a note explaining why +// there can be none, or failed. Silence must never be read as all-clear. +func warnings(store *cache.Cache, geo cache.Geo, cat *i18n.Catalog) ([]imgw.Warning, string, bool) { + if geo.Country != "" && geo.Country != "PL" { + return nil, cat.Word("poland_only"), false + } + // A bare "lat,lon" carries no country, so ask GUGiK where the point is + // rather than inferring from a code we do not have. + key := fmt.Sprintf("%.4f,%.4f", geo.Lat, geo.Lon) + code, cached := store.Teryt(key) + if !cached { + var status imgw.Status + var err error + code, status, err = imgw.Powiat(geo.Lat, geo.Lon) + if err != nil || status == imgw.StatusError { + return nil, "", true + } + _ = store.PutTeryt(key, code) // "" records "not in Poland" + } + if code == "" { + return nil, cat.Word("poland_only"), false + } + live, err := imgw.Warnings(code) + if err != nil { + return nil, "", true + } + return live, "", false +} + +// placeFromArgs settles where the place name comes from: -l, or bare words, but +// never both. A leftover positional next to -l means the shell split an unquoted +// name, and accepting it silently discards half of what was typed. +func placeFromArgs(location string, positional []string) (string, error) { + if location != "" { + if len(positional) > 0 { + return "", fmt.Errorf("unexpected argument %q (did you mean -l %q?)", + strings.Join(positional, " "), + strings.Join(append([]string{location}, positional...), " ")) + } + return location, nil + } + // A positional argument is accepted as the place, as the Python version did. + return strings.Join(positional, " "), nil +} + +// geocode is a variable so tests can resolve a place without the network. +var geocode = openmeteo.Geocode + +// ambiguousError reports a name that matched several places with none chosen. +// Picking the first match silently is how a request for Wiry in Poland comes +// back as a forecast for Vyry in Ukraine, so resolve refuses and hands the +// candidates back for the caller to list. +type ambiguousError struct { + place string + candidates []openmeteo.Candidate +} + +func (e *ambiguousError) Error() string { + return fmt.Sprintf("%q is ambiguous - nothing fetched", e.place) +} + +// pickError reports a -pick outside the candidate list. It is a bad flag value, +// not a runtime failure, so it exits 2 like every other one. +type pickError struct { + place string + pick int + n int +} + +func (e *pickError) Error() string { + return fmt.Sprintf("-pick %d, but %q matched %d place(s)", e.pick, e.place, e.n) +} + +// ambiguousListing renders the candidates as a numbered list. It carries the +// region, because that is the only thing telling two places of one name apart, +// and the coordinates, because they are what a caller falls back to when it +// wants a place in a script rather than typing -pick every time. +func ambiguousListing(e *ambiguousError) string { + var b strings.Builder + fmt.Fprintf(&b, "%v.\n", e) + regions := make([]string, len(e.candidates)) + labelW, regionW := 0, 0 + for i, c := range e.candidates { + regions[i] = c.Admin1 + if regions[i] == "" { + regions[i] = "?" + } + // fmt pads by runes, so measure by runes or the diacritics misalign. + if n := len([]rune(c.Geo.Label)); n > labelW { + labelW = n + } + if n := len([]rune(regions[i])); n > regionW { + regionW = n + } + } + for i, c := range e.candidates { + fmt.Fprintf(&b, " %d %-*s %-*s %.4f,%.4f\n", + i+1, labelW, c.Geo.Label, regionW, regions[i], c.Geo.Lat, c.Geo.Lon) + } + fmt.Fprint(&b, "Re-run with -pick N, or give coordinates as the place.") + return b.String() +} + +// resolve turns a place name into coordinates. pick is 1-based and selects one +// of the geocoder's candidates; 0 means none was requested. +func resolve(store *cache.Cache, place string, pick int) (cache.Geo, error) { + if lat, lon, ok := parseCoords(place); ok { + // Coordinates carry no country code; callers must not assume one. + return cache.Geo{Lat: lat, Lon: lon, Label: place}, nil + } + // An explicit pick must re-resolve rather than read the cache: the entry + // sitting there is usually the wrong guess being corrected. + if pick == 0 { + if g, ok := store.Geo(place); ok { + return g, nil + } + } + cands, err := geocode(place) + if err != nil { + return cache.Geo{}, err + } + switch { + case pick != 0: + if pick < 1 || pick > len(cands) { + return cache.Geo{}, &pickError{place: place, pick: pick, n: len(cands)} + } + case len(cands) > 1: + return cache.Geo{}, &ambiguousError{place: place, candidates: cands} + default: + pick = 1 + } + g := cands[pick-1].Geo + _ = store.PutGeo(place, g) + return g, nil +} + +func parseCoords(s string) (float64, float64, bool) { + lat, lon, ok := strings.Cut(s, ",") + if !ok { + return 0, 0, false + } + a, err1 := strconv.ParseFloat(strings.TrimSpace(lat), 64) + b, err2 := strconv.ParseFloat(strings.TrimSpace(lon), 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return a, b, true +} + +// locationFromWegorc reads location= from wego's config, so the two tools never +// disagree about where you are. +func locationFromWegorc() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + f, err := os.Open(home + "/" + wegorc) + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if k, v, ok := strings.Cut(line, "="); ok && strings.TrimSpace(k) == "location" { + if v = strings.TrimSpace(v); v != "" { + return v + } + } + } + return "" +} + +func shouldColour(mode string) bool { + switch mode { + case "never": + return false + case "always": + return true + } + // Stdlib isatty: a terminal is a character device, a pipe or file is not. + fi, err := os.Stdout.Stat() + return err == nil && fi.Mode()&os.ModeCharDevice != 0 && os.Getenv("TERM") != "dumb" +} + +func terminalWidth() int { + w := 80 + if env := os.Getenv("COLUMNS"); env != "" { + if n, err := strconv.Atoi(env); err == nil && n > 0 { + w = n + } + } else if n, ok := termCols(); ok { + w = n + } + w -= 4 + if w < 32 { + w = 32 + } + if w > 96 { + w = 96 + } + return w +} + +func splitList(v string) []string { + var out []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func usage() { + fmt.Fprintf(os.Stderr, `prognosis - hour-by-hour forecast, with IMGW warnings for Poland + +usage: prognosis [flags] [place] + +flags: + -l PLACE place to query (default: config, then ~/.wegorc) + -pick N choose the Nth place when the name matches several + -n N hours ahead to show + -d N days ahead to show, 24h each (max %d) + -columns LIST comma-separated columns; valid: %s + -icons SET nerd, emoji or none + -lang LANG en or pl + -no-graph table only + -weather forecast only: no sun times, summary, pollen or chart + -no-warnings omit IMGW warnings + -ascii ASCII only, so an SMS stays in GSM-7 (160 chars, not 70) + -no-color plain output + -config print the config file path and exit +`, openmeteo.MaxForecastDays-1, strings.Join(config.ValidColumns(), ", ")) +} |
