summaryrefslogtreecommitdiff
path: root/cmd/prognosis/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/prognosis/main.go')
-rw-r--r--cmd/prognosis/main.go441
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(), ", "))
+}