// Package config reads prognosis' KEY=VALUE configuration file. // // The format is one KEY=VALUE per line, '#' starts a comment, and values are // never quoted. Parsing it here rather than pulling in a config library keeps // the binary dependency-free. package config import ( "bufio" "fmt" "os" "path/filepath" "sort" "strconv" "strings" ) // Columns available for the hourly table, in the order they are documented. // The value is the Open-Meteo hourly field the column needs, or "" when the // column is derived from data already fetched. var columnFields = map[string]string{ "hour": "", "icon": "weather_code", "temp": "temperature_2m", "feels": "apparent_temperature", "conditions": "weather_code", "mm": "precipitation", "rain": "precipitation_probability", "wind": "wind_speed_10m", "gusts": "wind_gusts_10m", "dir": "wind_direction_10m", "humidity": "relative_humidity_2m", "dew": "dew_point_2m", "uv": "uv_index", "cloud": "cloud_cover", "pressure": "pressure_msl", "visibility": "visibility", } // Sources a custom column can draw from. They are separate Open-Meteo APIs with // different field sets, so a column has to say which one it means. var validSources = map[string]bool{"forecast": true, "air": true} // CustomColumn is a column defined in the config rather than built in, so a user // can display a field prognosis never anticipated -- an allergen, soil // temperature, anything the two APIs expose. type CustomColumn struct { Source string // "forecast" or "air" Field string // the API field name, verbatim Label string // header text; defaults to the column name Width int // 0 means derive it from the label Decimals int // digits after the point Suffix string // appended to the value, e.g. "°" or "%" } var ( validIcons = map[string]bool{"nerd": true, "emoji": true, "none": true} validColors = map[string]bool{"auto": true, "always": true, "never": true} validUnits = map[string]bool{"metric": true, "imperial": true, "si": true} validLangs = map[string]bool{"en": true, "pl": true} ) // AllSpecies is every pollen taxon Open-Meteo reports for Europe. var AllSpecies = []string{"grass", "birch", "alder", "mugwort", "ragweed", "olive"} // Config is the fully resolved settings for one run. type Config struct { Location string Hours int Units string Columns []string Icons string Graph bool GraphHeight int Warnings bool Pollen []string Color string DisplayLang string // Minimal strips everything that is not the forecast itself: sun times, the // day summary and pollen. Set by -weather, for output meant to be piped to // someone who did not ask for pollen counts. Flag only -- there is no config // key, because it describes one invocation rather than a preference. Minimal bool // PollenExplicit records that the user named the species rather than asking // for "all". A named species is shown even at zero -- you asked for it, so // its absence is information -- while "all" shows only what is present, or // the line would be six zeros of noise. PollenExplicit bool // Custom holds columns declared in the config, keyed by the short name used // in Columns. Custom map[string]CustomColumn // ASCII restricts output to ASCII so an SMS stays in GSM-7 (160 characters // per segment) instead of UCS-2 (70). One degree sign costs more than half // the message. ASCII bool } // Default returns the built-in configuration, used when no file exists and as // the base every file and flag overrides. func Default() Config { return Config{ Hours: 12, Units: "metric", Columns: []string{"hour", "temp", "feels", "conditions", "humidity", "mm", "rain"}, Icons: "nerd", Graph: true, GraphHeight: 5, Warnings: true, Pollen: append([]string(nil), AllSpecies...), Color: "auto", DisplayLang: "en", Custom: map[string]CustomColumn{}, } } // Path is the default location of the config file. func Path() string { if dir, err := os.UserConfigDir(); err == nil { return filepath.Join(dir, "prognosis", "config") } return filepath.Join(os.Getenv("HOME"), ".config", "prognosis", "config") } // ValidColumns lists every column name, sorted, for error messages and docs. func ValidColumns() []string { names := make([]string, 0, len(columnFields)) for k := range columnFields { names = append(names, k) } sort.Strings(names) return names } // Fields returns the forecast-API hourly fields the selected columns need. // Only what is displayed is requested, so a narrow table costs a small response. func (c Config) Fields() []string { return c.fieldsFor("forecast") } // AirFields returns the air-quality-API hourly fields custom columns need. It is // empty unless the config declares one, so the extra request is only made when // something actually needs it. func (c Config) AirFields() []string { return c.fieldsFor("air") } func (c Config) fieldsFor(source string) []string { seen := map[string]bool{} var out []string for _, col := range c.Columns { var f string if cc, ok := c.Custom[col]; ok { if cc.Source != source { continue } f = cc.Field } else if source == "forecast" { f = columnFields[col] } if f == "" || seen[f] { continue } seen[f] = true out = append(out, f) } sort.Strings(out) return out } // Has reports whether a column is selected. func (c Config) Has(column string) bool { for _, col := range c.Columns { if col == column { return true } } return false } // Validate rejects unusable settings, naming the offending value and listing // what would have been accepted. A silently blank column is worse than an error. func (c Config) Validate() error { for name, cc := range c.Custom { if _, clash := columnFields[name]; clash { return fmt.Errorf("column.%s: %q is a built-in column; pick another name", name, name) } if cc.Source == "" || cc.Field == "" { return fmt.Errorf("%q has label/width/decimals but no column.%s = source:field", name, name) } } for _, col := range c.Columns { if _, ok := columnFields[col]; ok { continue } if _, ok := c.Custom[col]; ok { continue } return fmt.Errorf("unknown column %q; valid: %s (or declare it: column.%s = air:FIELD)", col, strings.Join(ValidColumns(), ", "), col) } if !validIcons[c.Icons] { return fmt.Errorf("unknown icons %q; valid: emoji, nerd, none", c.Icons) } if !validColors[c.Color] { return fmt.Errorf("unknown color %q; valid: auto, always, never", c.Color) } if !validUnits[c.Units] { return fmt.Errorf("unknown units %q; valid: metric, imperial, si", c.Units) } if !validLangs[c.DisplayLang] { return fmt.Errorf("unknown display_lang %q; valid: en, pl", c.DisplayLang) } if c.Hours < 1 { return fmt.Errorf("hours must be at least 1, got %d", c.Hours) } if c.GraphHeight < 2 { return fmt.Errorf("graph_height must be at least 2, got %d", c.GraphHeight) } for _, s := range c.Pollen { if !contains(AllSpecies, s) { return fmt.Errorf("unknown pollen species %q; valid: %s, all, none", s, strings.Join(AllSpecies, ", ")) } } return nil } func contains(list []string, want string) bool { for _, s := range list { if s == want { return true } } return false } // Load reads a config file over the defaults. A missing file is not an error: // the defaults stand, and the caller may write them out. func Load(path string) (Config, error) { cfg := Default() f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return cfg, nil } return cfg, err } defer f.Close() sc := bufio.NewScanner(f) for line := 1; sc.Scan(); line++ { text := strings.TrimSpace(sc.Text()) if text == "" || strings.HasPrefix(text, "#") { continue } // Trailing comments are allowed so the generated file can annotate keys. if i := strings.Index(text, "#"); i >= 0 { text = strings.TrimSpace(text[:i]) } key, value, ok := strings.Cut(text, "=") if !ok { return cfg, fmt.Errorf("%s:%d: expected KEY=VALUE, got %q", path, line, text) } if err := cfg.set(strings.TrimSpace(key), strings.TrimSpace(value)); err != nil { return cfg, fmt.Errorf("%s:%d: %w", path, line, err) } } return cfg, sc.Err() } // customKey splits "label.birch" into ("label", "birch"). func customKey(key string) (attr, name string, ok bool) { attr, name, ok = strings.Cut(key, ".") if !ok || name == "" { return "", "", false } switch attr { case "column", "label", "width", "decimals", "suffix": return attr, name, true } return "", "", false } func (c *Config) setCustom(attr, name, value string) error { if c.Custom == nil { c.Custom = map[string]CustomColumn{} } cc := c.Custom[name] switch attr { case "column": src, field, ok := strings.Cut(value, ":") if !ok { return fmt.Errorf("column.%s: expected source:field, got %q; sources: air, forecast", name, value) } src, field = strings.TrimSpace(src), strings.TrimSpace(field) if !validSources[src] { return fmt.Errorf("column.%s: unknown source %q; valid: air, forecast", name, src) } if field == "" { return fmt.Errorf("column.%s: no field given after %q:", name, src) } cc.Source, cc.Field = src, field case "label": cc.Label = value case "suffix": cc.Suffix = value case "width": n, err := strconv.Atoi(value) if err != nil || n < 1 { return fmt.Errorf("width.%s: %q is not a positive number", name, value) } cc.Width = n case "decimals": n, err := strconv.Atoi(value) if err != nil || n < 0 || n > 6 { return fmt.Errorf("decimals.%s: %q is not a number between 0 and 6", name, value) } cc.Decimals = n } c.Custom[name] = cc return nil } func (c *Config) set(key, value string) error { if attr, name, ok := customKey(key); ok { return c.setCustom(attr, name, value) } switch key { case "location": c.Location = value case "units": c.Units = value case "icons": c.Icons = value case "color": c.Color = value case "display_lang": c.DisplayLang = value case "ascii": b, err := parseBool(value) if err != nil { return fmt.Errorf("ascii: %w", err) } c.ASCII = b case "columns": c.Columns = splitList(value) case "pollen": switch value { case "all": c.Pollen, c.PollenExplicit = append([]string(nil), AllSpecies...), false case "none": c.Pollen, c.PollenExplicit = nil, false default: c.Pollen, c.PollenExplicit = splitList(value), true } case "hours": n, err := strconv.Atoi(value) if err != nil { return fmt.Errorf("hours: %q is not a number", value) } c.Hours = n case "graph_height": n, err := strconv.Atoi(value) if err != nil { return fmt.Errorf("graph_height: %q is not a number", value) } c.GraphHeight = n case "graph": b, err := parseBool(value) if err != nil { return fmt.Errorf("graph: %w", err) } c.Graph = b case "warnings": b, err := parseBool(value) if err != nil { return fmt.Errorf("warnings: %w", err) } c.Warnings = b default: return fmt.Errorf("unknown key %q", key) } return nil } func parseBool(v string) (bool, error) { switch strings.ToLower(v) { case "true", "yes", "on", "1": return true, nil case "false", "no", "off", "0": return false, nil } return false, fmt.Errorf("%q is not true or false", v) } func splitList(v string) []string { var out []string for _, part := range strings.Split(v, ",") { if p := strings.TrimSpace(part); p != "" { out = append(out, p) } } return out } const template = `# prognosis configuration # # One KEY=VALUE per line. '#' starts a comment. Values are not quoted. # Command line flags override everything here. # Place to query: a name, or "lat,lon". Required -- prognosis has no other way # to know where you are, and will not guess. location=%s # Default span in hours. -n and -d override it. hours=%d # metric (C, km/h, mm) | imperial (F, mph, inch) | si (C, m/s, mm) units=%s # Columns, in order. Available: # %s columns=%s # Weather glyph set for the "icon" column: nerd | emoji | none. # nerd is single-width and monochrome, so it follows the terminal palette. # emoji are colour glyphs from a fallback font and are not all one cell wide. icons=%s # Temperature chart under the table. graph=%t graph_height=%d # Official IMGW warnings for your powiat (Poland only). warnings=%t # Which allergens to report, or whether to report any at all. # none no pollen line # all every species that has a reading # birch,mugwort exactly these, always -- even at zero, because a species # you name is one you react to pollen=%s # Restrict output to ASCII: no degree sign, no diacritics, no block drawing. # For SMS, where one non-ASCII character cuts the segment from 160 to 70 chars. ascii=%t # auto (colour when stdout is a terminal) | always | never color=%s # Columns prognosis does not ship with. Declare a short name against a source # and a field, then put the name in columns= above. "forecast" is the weather # API, "air" the air-quality one that carries the allergens; an air column costs # one extra request, made only when you declare one. See prognosis(1). # # column.birch = air:birch_pollen # column.soil = forecast:soil_temperature_0cm # label.birch = birch # header; defaults to the name # width.birch = 6 # defaults to fit the label # decimals.birch = 1 # digits after the point, default 0 # suffix.soil = C # appended to the value # Language for everything prognosis writes itself -- headers, condition names, # labels, dates, pollen species: en | pl. IMGW publishes its warning text in # Polish only, so that text stays Polish whatever this is set to. display_lang=%s ` // WriteDefault writes a commented configuration file, creating parent // directories. The generated file documents every key, so the config is // discoverable without the README. func WriteDefault(path string, c Config) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } pollen := "none" if len(c.Pollen) > 0 { pollen = strings.Join(c.Pollen, ",") } body := fmt.Sprintf(template, c.Location, c.Hours, c.Units, strings.Join(ValidColumns(), ", "), strings.Join(c.Columns, ","), c.Icons, c.Graph, c.GraphHeight, c.Warnings, pollen, c.ASCII, c.Color, c.DisplayLang) return os.WriteFile(path, []byte(body), 0o644) }