// Command prognosis prints an hour-by-hour forecast, with official IMGW // warnings for Polish locations. package main import ( "errors" "flag" "fmt" "io" "os" "path/filepath" "strconv" "strings" "time" "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" ) // options is every command line flag. Defined in one place so that run() and // the test asserting each one is documented cannot disagree about the set. type options struct { location, columns, icons, lang, pollen *string hours, days, pick *int noGraph, weather, noWarn, asciiOut, noColor *bool showCfg, showVer *bool out, outLong *string svg *bool } func defineFlags(fs *flag.FlagSet) *options { return &options{ location: fs.String("l", "", "place to query (default: location= in the config file)"), hours: fs.Int("n", 0, "hours ahead to show"), days: fs.Int("d", 0, "days ahead to show, 24h each"), columns: fs.String("columns", "", "comma-separated columns to display"), icons: fs.String("icons", "", "icon set: nerd, emoji or none"), lang: fs.String("lang", "", "display language: en or pl"), pollen: fs.String("pollen", "", "pollen species to show: a list, or all / none"), noGraph: fs.Bool("no-graph", false, "table only, no chart"), weather: fs.Bool("weather", false, "forecast only: no sun times, summary, pollen or chart"), noWarn: fs.Bool("no-warnings", false, "omit IMGW warnings"), asciiOut: fs.Bool("ascii", false, "ASCII only, so an SMS stays in GSM-7"), noColor: fs.Bool("no-color", false, "plain output"), pick: fs.Int("pick", 0, "choose the Nth place when the name is ambiguous"), showCfg: fs.Bool("config", false, "print the config file path and exit"), svg: fs.Bool("svg", false, "produce an SVG meteogram instead of the table"), out: fs.String("o", "", "write to this file or directory instead of stdout"), outLong: fs.String("out", "", "same as -o"), showVer: fs.Bool("version", false, "print the version and exit"), } } // 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() opt := defineFlags(flag.CommandLine) 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 *opt.showVer { fmt.Printf("prognosis %s\n", version) return 0 } cfgPath := config.Path() if *opt.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 *opt.columns != "" { cfg.Columns = splitList(*opt.columns) } if *opt.icons != "" { cfg.Icons = *opt.icons } if *opt.lang != "" { cfg.DisplayLang = *opt.lang } if *opt.pollen != "" { switch *opt.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(*opt.pollen), true } } if *opt.noGraph { cfg.Graph = false } if *opt.weather { // Meant for piping to someone else: the forecast and nothing else. cfg.Minimal = true cfg.Graph = false } if *opt.noWarn { cfg.Warnings = false } if *opt.asciiOut { cfg.ASCII = true } if cfg.ASCII { // Both glyph sets are non-ASCII by definition. cfg.Icons = "none" } if *opt.noColor { cfg.Color = "never" } switch { case *opt.days > 0 && *opt.hours > 0: fmt.Fprintln(os.Stderr, "prognosis: -n and -d cannot be combined") return 2 case *opt.days > 0: if *opt.days > openmeteo.MaxForecastDays-1 { fmt.Fprintf(os.Stderr, "prognosis: -d must be between 1 and %d\n", openmeteo.MaxForecastDays-1) return 2 } cfg.Hours = *opt.days * 24 case *opt.hours > 0: cfg.Hours = *opt.hours case *opt.days < 0 || *opt.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 *opt.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(*opt.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, *opt.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 } fields := cfg.Fields() if *opt.svg { fields = append(fields, render.SVGFields...) } data, err := openmeteo.Forecast(geo.Lat, geo.Lon, cfg.Hours, cfg.Units, 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) } dest := *opt.out if dest == "" { dest = *opt.outLong } var body, ext string if *opt.svg { body, ext = render.SVG(view, cfg), "svg" } else { // Colour is for a terminal. A file gets plain text, whatever the // terminal would have shown. colour := shouldColour(cfg.Color) && dest == "" body, ext = render.Render(view, cfg, terminalWidth(), colour)+"\n", "txt" } if dest == "" || dest == "-" { fmt.Print(body) return 0 } path, err := resolveOut(dest, geo.Label, rows0(view), ext) if err == nil { err = os.WriteFile(path, []byte(body), 0o644) } if err != nil { fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) return 1 } fmt.Fprintf(os.Stderr, "wrote %s\n", path) 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() { usageTo(os.Stderr) } func usageTo(w io.Writer) { fmt.Fprintf(w, `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 -svg produce an SVG meteogram instead of the table -o, -out PATH write to PATH; a directory gets a generated filename -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 } } } } // rows0 is the first hour shown, used to date a generated filename. func rows0(v render.View) time.Time { if len(v.Rows) == 0 { return time.Now() } return v.Rows[0].When } // resolveOut turns -o into a path to write. // // An existing directory, or a path ending in a separator, gets a generated // filename: writing "krakow-pl-2026-08-27.svg" into ~/photos/weather is more // useful than refusing, and repeated runs on different days do not overwrite // each other. Anything else is a filename, gaining the extension if it has none // so "-o weather" does not produce an extensionless file no viewer will open. func resolveOut(dest, label string, when time.Time, ext string) (string, error) { if strings.HasSuffix(dest, string(os.PathSeparator)) { if err := os.MkdirAll(dest, 0o755); err != nil { return "", err } } if fi, err := os.Stat(dest); err == nil && fi.IsDir() { name := fmt.Sprintf("%s-%s.%s", slug(label), when.Format("2006-01-02"), ext) return filepath.Join(dest, name), nil } if filepath.Ext(dest) == "" { dest += "." + ext } return dest, nil } // slug reduces a place label to something safe in a filename. // // Diacritics are transliterated rather than dropped, so "Zbylitowska Góra" // becomes "zbylitowska-gora" and not "zbylitowska-g-ra", which is both ugly and // awkward to glob. func slug(s string) string { var b strings.Builder prevDash := false for _, r := range strings.ToLower(render.Transliterate(s)) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) prevDash = false default: if !prevDash && b.Len() > 0 { b.WriteByte('-') prevDash = true } } } return strings.Trim(b.String(), "-") }