aboutsummaryrefslogtreecommitdiff
path: root/cmd/prognosis/main.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
commite14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch)
tree670ef0897839871a64d3a3bb2e17e242e7d6c385 /cmd/prognosis/main.go
downloadprognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz
prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
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(), ", "))
+}