// Command prognosis prints an hour-by-hour forecast, with official IMGW // warnings for Polish locations. package main import ( "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" ) // version is stamped at build time: -ldflags "-X main.version=$(git describe)". // "dev" means someone built it straight from a working tree. var version = "dev" 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: location= in the config file)") 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") pollen = flag.String("pollen", "", "pollen species to show: a list, or all / none") 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") showVer = flag.Bool("version", false, "print the version 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:] } if *showVer { fmt.Printf("prognosis %s\n", version) return 0 } 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 *pollen != "" { switch *pollen { case "all": cfg.Pollen, cfg.PollenExplicit = append([]string(nil), config.AllSpecies...), false case "none": cfg.Pollen, cfg.PollenExplicit = nil, false default: cfg.Pollen, cfg.PollenExplicit = splitList(*pollen), true } } 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 == "" { fmt.Fprintf(os.Stderr, "prognosis: no location set. Put one in %s:\n\n location=Krakow\n\n"+ "or pass it for one run: prognosis -l Krakow\n", cfgPath) 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) } // Custom air-quality columns need a second request, made only when the // config actually declares one. if fields := cfg.AirFields(); len(fields) > 0 { hourly, err := openmeteo.AirHourly(geo.Lat, geo.Lon, cfg.Hours, fields) if err != nil { fmt.Fprintf(os.Stderr, "note: air-quality data unavailable: %v\n", err) } else { mergeCustom(data.Rows, hourly, cfg) } } 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 } 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: location= in the config file) -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 -pollen LIST pollen species to show: a list, or all / none -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 -version print the version and exit `, openmeteo.MaxForecastDays-1, strings.Join(config.ValidColumns(), ", ")) } // mergeCustom copies air-quality values onto the matching forecast hour. // // Custom values are stored under the column's own name, prefixed, so they can // never collide with an API field name already in the row. func mergeCustom(rows []openmeteo.Row, hourly map[string]map[string]float64, cfg config.Config) { for i, r := range rows { byField, ok := hourly[r.When.Format("2006-01-02T15:04")] if !ok { continue } for name, cc := range cfg.Custom { if cc.Source != "air" { continue } if v, ok := byField[cc.Field]; ok { rows[i].Vals[render.CustomKey(name)] = v } } } }