diff options
| -rw-r--r-- | CHANGELOG.md | 51 | ||||
| -rw-r--r-- | Makefile | 89 | ||||
| -rw-r--r-- | README.md | 50 | ||||
| -rw-r--r-- | cmd/prognosis/args_test.go | 2 | ||||
| -rw-r--r-- | cmd/prognosis/main.go | 94 | ||||
| -rw-r--r-- | internal/config/config.go | 162 | ||||
| -rw-r--r-- | internal/config/config_test.go | 123 | ||||
| -rw-r--r-- | internal/openmeteo/openmeteo.go | 49 | ||||
| -rw-r--r-- | internal/render/render.go | 8 | ||||
| -rw-r--r-- | internal/render/render_test.go | 122 | ||||
| -rw-r--r-- | internal/render/table.go | 66 | ||||
| -rw-r--r-- | man/prognosis.1 | 276 | ||||
| -rwxr-xr-x | scripts/hooks/pre-push | 7 |
13 files changed, 1011 insertions, 88 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c797f5e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +Terse, newest first. Versions are git tags. + +## 0.1.1 — 2026-08-25 + +### Added +- Man page, `man/prognosis.1`, installed by `make install`. +- **Custom columns.** Any field the Open-Meteo forecast or air-quality APIs + expose can be displayed by declaring it in the config: + `column.birch = air:birch_pollen`, then `columns=hour,temp,birch`. Optional + `label.`, `width.`, `decimals.` and `suffix.` keys. An air-quality column + costs one extra request, made only when one is declared. +- `humidity` column added to the default column set. +- `-version`, and the version is stamped into the binary at build time. +- `-weather` (forecast only), `-no-warnings`, `-ascii` (GSM-7 safe output for + SMS). +- `make lint` checks the man page renders without groff warnings; `make ci` + now includes it. `make release`, `make install-hooks`, and cross-compilation + for six platforms. + +### Changed +- **Pollen selection no longer privileges grass.** A species named in `pollen=` + is shown even at zero; `pollen=all` shows only what is present. Previously + grass was special-cased and always displayed, which forced it on someone + allergic to birch while hiding theirs. `-pollen` sets it for one run. +- **`~/.wegorc` is no longer read.** prognosis is standalone: set `location=` + in its own config, or pass `-l`. Previously it fell back to wego's config, + which made it useless without wego installed. +- `make install` installs the man page too, and honours `PREFIX` and `DESTDIR` + for packaging. +- Temperature colours are compared in Celsius whatever the display units, so + `units=imperial` no longer reports 85F as the "IMGW would warn" red. + +### Fixed +- Pollen values the API withholds are absent rather than zero, so a location + outside Europe no longer reports a confident `grass 0.0 none`. +- Flags after a positional argument are parsed rather than swallowed into the + place name. + +## 0.1.0 — 2026-08-14 + +Initial release. Go implementation replacing an earlier Python one. + +- Hourly table, temperature chart, day summary, sun times, pollen with bands + sourced from Polish clinical thresholds. +- Official IMGW warnings filtered to your powiat via GUGiK, with the failure + case ("could not check") kept distinct from "none in force". +- Temperature colours anchored to IMGW's own warning criteria. +- Configurable columns, `en`/`pl` display languages, three icon sets. +- No API key, no third-party Go modules. @@ -1,31 +1,45 @@ .POSIX: -DESTDIR=$(HOME) -PREFIX=/.local -INSTALL_PATH=$(DESTDIR)$(PREFIX)/bin -BIN=prognosis -DIST=dist -.PHONY: help build install uninstall test vet fmt ci cross clean +# Standard GNU-ish install variables, so a packager can redirect everything: +# make install PREFIX=/usr DESTDIR=/tmp/pkg +DESTDIR ?= +PREFIX ?= $(HOME)/.local +BINDIR := $(DESTDIR)$(PREFIX)/bin +MANDIR := $(DESTDIR)$(PREFIX)/share/man/man1 -help: ## show this help - @grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[1m%-16s\033[0m %s\n", $$1, $$2}' +BIN := prognosis +DIST := dist +MODULE := github.com/lukaszkasprzak/prognosis + +# Version comes from git when there is a tag, "dev" otherwise, and is stamped +# into the binary so -version reports something meaningful. +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +GOFLAGS := -trimpath +LDFLAGS := -s -w -X main.version=$(VERSION) + +PLATFORMS := linux/amd64 linux/arm64 android/arm64 darwin/amd64 darwin/arm64 freebsd/amd64 -build: ## build the Go binary into ./$(BIN) - go build -trimpath -ldflags "-s -w" -o $(BIN) ./cmd/prognosis +.PHONY: help build install uninstall test vet fmt lint ci cross release install-hooks clean -install: build ## build and install the Go binary - mkdir -p $(INSTALL_PATH) - # rm first: the target may be a symlink into this repo (see `link`), and cp - # follows symlinks -- it would write the binary over bin/prognosis itself. - rm -f $(INSTALL_PATH)/$(BIN) - cp $(BIN) $(INSTALL_PATH)/$(BIN) - chmod 755 $(INSTALL_PATH)/$(BIN) - @echo "Installed. Config: $$($(INSTALL_PATH)/$(BIN) -config)" +help: ## show this help + @grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[1m%-14s\033[0m %s\n", $$1, $$2}' + @echo + @echo " version: $(VERSION) prefix: $(PREFIX)" +build: ## build ./$(BIN) + go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/prognosis +install: build ## install the binary and man page + mkdir -p $(BINDIR) $(MANDIR) + # rm first: the target may be a symlink, and cp follows symlinks. + rm -f $(BINDIR)/$(BIN) + install -m 755 $(BIN) $(BINDIR)/$(BIN) + install -m 644 man/prognosis.1 $(MANDIR)/prognosis.1 + @echo "installed $(BINDIR)/$(BIN) and $(MANDIR)/prognosis.1" -uninstall: ## remove the installed binary - rm -f $(INSTALL_PATH)/$(BIN) +uninstall: ## remove the installed binary and man page + rm -f $(BINDIR)/$(BIN) $(MANDIR)/prognosis.1 test: ## run the tests go test ./... @@ -36,21 +50,42 @@ vet: ## go vet fmt: ## gofmt the tree gofmt -w . -ci: ## pre-push gate: gofmt clean, vet, tests, no third-party deps +lint: ## check the man page renders without warnings + @out=$$(man --warnings -l man/prognosis.1 2>&1 >/dev/null); \ + test -z "$$out" || { echo "man page warnings:"; echo "$$out"; exit 1; } + @echo "man page ok" + +ci: ## the gate: gofmt, vet, tests, man page, and no third-party deps @test -z "$$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; } go vet ./... go test ./... - @deps=$$(go list -deps ./... | grep -E '^[a-z0-9-]+\.[a-z]+/' | grep -v '^github.com/lukaszkasprzak/prognosis' || true); \ + @$(MAKE) --no-print-directory lint + @deps=$$(go list -deps ./... | grep -E '^[a-z0-9-]+\.[a-z]+/' | grep -v '^$(MODULE)' || true); \ test -z "$$deps" || { echo "third-party dependencies crept in:"; echo "$$deps"; exit 1; } @echo "ci ok" -cross: ## cross-compile into $(DIST)/ -- android/arm64 is the phone - mkdir -p $(DIST) - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(BIN)-linux-amd64 ./cmd/prognosis - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(BIN)-linux-arm64 ./cmd/prognosis - CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(BIN)-android-arm64 ./cmd/prognosis +cross: ## cross-compile every platform into $(DIST)/ + @mkdir -p $(DIST) + @for p in $(PLATFORMS); do \ + os=$${p%/*}; arch=$${p#*/}; \ + echo " $$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \ + go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(DIST)/$(BIN)-$$os-$$arch ./cmd/prognosis || exit 1; \ + done @ls -la $(DIST) +release: ## tag a release: make release VERSION=0.2.0 (add its CHANGELOG entry first) + @test "$(VERSION)" != "dev" || { echo "give a version: make release VERSION=0.2.0"; exit 1; } + @grep -q "^## $(VERSION)" CHANGELOG.md || \ + { echo "CHANGELOG.md has no '## $(VERSION)' entry; write it first"; exit 1; } + @test -z "$$(git status --porcelain)" || { echo "working tree is dirty"; exit 1; } + @$(MAKE) --no-print-directory ci + git tag -a v$(VERSION) -m "prognosis v$(VERSION)" + @echo "tagged v$(VERSION); push it with: git push --tags" + +install-hooks: ## install the pre-push hook that runs make ci + install -m 755 scripts/hooks/pre-push .git/hooks/pre-push + @echo "installed .git/hooks/pre-push" clean: ## remove build artifacts rm -rf $(BIN) $(DIST) @@ -38,7 +38,11 @@ Krakow, PL Mon 10 Aug 05:15 up 20:01 down GMT+2 make build # build ./prognosis make install # build and install into ~/.local/bin make cross # dist/ binaries for linux-amd64, linux-arm64, android-arm64 - make ci # gofmt, vet, tests, and the no-dependencies check + make ci # gofmt, vet, tests, man page, no-dependencies check + make install-hooks # pre-push hook that runs make ci + +Packagers: `make install PREFIX=/usr DESTDIR=/tmp/pkg` installs the binary and +`man/prognosis.1`. Needs `~/.local/bin` on `PATH`. For the phone, copy `dist/prognosis-android-arm64` across — no interpreter, no shebang, nothing to install. @@ -62,6 +66,43 @@ blank column. glyphs are single-width and monochrome, so they follow the terminal palette; emoji are colour glyphs from a fallback font and are not all one cell wide. +### Pollen + +`pollen=` decides whether the allergen line appears at all and which species it +carries: + + pollen=none # no pollen line + pollen=all # every species that has a reading + pollen=birch,mugwort # exactly these, always — even at zero + +A species you name is shown even when it reads zero: you named it because you +react to it, so "none today" is the answer you wanted. With `all` nobody chose, +so absent species are dropped rather than printing a line of zeroes. `-pollen` +does the same for one run. + +### Custom columns + +Any field the two Open-Meteo APIs expose can be displayed, whether or not +prognosis ships with it. Declare a short name, then use it: + + columns=hour,temp,birch,soil,conditions + + column.birch = air:birch_pollen + column.soil = forecast:soil_temperature_0cm + + label.birch = birch + decimals.birch = 1 + suffix.soil = ° + +`forecast` is the weather API, `air` the air-quality one that carries the +allergens — they are separate services with separate fields, which is why the +source is explicit. An `air` column costs one extra request, made only when one +is declared. + +`label.`, `width.`, `decimals.` and `suffix.` are optional. A custom name may +not shadow a built-in column, and a value the API does not supply renders blank +rather than as zero: for an allergen, "no data" and "none" are different claims. + `display_lang=` is `en` or `pl`, covering everything prognosis writes itself — headers, condition names, labels, dates, pollen species and bands. **IMGW publishes its warning text in Polish only**, so that text stays Polish in either @@ -78,8 +119,9 @@ than the original. prognosis --no-graph # table only prognosis --no-color # plain text -Location comes from `location=` in `~/.wegorc`, so wego and prognosis never -disagree about where you are. `-l` overrides it for one run. +Location comes from `location=` in the config file; `-l` overrides it for one +run. prognosis is standalone — it reads no other program's configuration and +will not guess where you are. Quote a name that contains a comma or a space. `-l Wiry, PL` is two arguments once the shell has finished with it, and prognosis refuses it rather than @@ -176,7 +218,7 @@ as bare numbers rather than banded on a guess. - **Open-Meteo hourly arrays start at 00:00 local.** Slicing from the front reports this morning, not the hours ahead. See `openmeteo.WindowStart`. - **The table does not shrink to fit.** Column widths are fixed; the default set - needs 34 columns. `TestTableMinimumWidthIsKnown` pins that figure. Use + needs 40 columns. `TestTableMinimumWidthIsKnown` pins that figure. Use `columns=` for a narrow terminal. - **GUGiK's default search radius is 100 m**, which finds nothing in the mountains or deep countryside — indistinguishable from being abroad. The diff --git a/cmd/prognosis/args_test.go b/cmd/prognosis/args_test.go index 4a85a2d..32a4208 100644 --- a/cmd/prognosis/args_test.go +++ b/cmd/prognosis/args_test.go @@ -41,7 +41,7 @@ func TestPositionalsJoinIntoThePlace(t *testing.T) { func TestNoPlaceAnywhereIsNotAnError(t *testing.T) { got, err := placeFromArgs("", nil) if err != nil { - t.Fatalf("unexpected error %v: the config and ~/.wegorc are consulted next", err) + t.Fatalf("unexpected error %v: location= in the config is consulted next", err) } if got != "" { t.Errorf("got %q, want empty", got) diff --git a/cmd/prognosis/main.go b/cmd/prognosis/main.go index 6e29c8e..3075563 100644 --- a/cmd/prognosis/main.go +++ b/cmd/prognosis/main.go @@ -3,7 +3,6 @@ package main import ( - "bufio" "errors" "flag" "fmt" @@ -19,7 +18,9 @@ import ( "github.com/lukaszkasprzak/prognosis/internal/render" ) -const wegorc = ".wegorc" +// 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()) } @@ -28,12 +29,13 @@ func run() int { configureResolver() var ( - location = flag.String("l", "", "place to query (default: config, then ~/.wegorc)") + 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") @@ -41,6 +43,7 @@ func run() int { 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 @@ -59,6 +62,11 @@ func run() int { rest = flag.Args()[1:] } + if *showVer { + fmt.Printf("prognosis %s\n", version) + return 0 + } + cfgPath := config.Path() if *showCfg { fmt.Println(cfgPath) @@ -87,6 +95,16 @@ func run() int { 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 } @@ -147,10 +165,9 @@ func run() int { 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) + 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 } @@ -180,6 +197,17 @@ func run() int { 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, @@ -350,33 +378,6 @@ func parseCoords(s string) (float64, float64, bool) { 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": @@ -424,18 +425,41 @@ func usage() { usage: prognosis [flags] [place] flags: - -l PLACE place to query (default: config, then ~/.wegorc) + -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 + } + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 51d8c27..b2b96cf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,8 @@ // Package config reads prognosis' KEY=VALUE configuration file. // -// The format is deliberately the same shape as wego's ~/.wegorc: one KEY=VALUE -// per line, '#' starts a comment, values are never quoted. Parsing it here -// rather than pulling in a config library keeps the binary dependency-free. +// 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 ( @@ -37,6 +37,22 @@ var columnFields = map[string]string{ "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} @@ -67,6 +83,16 @@ type Config struct { // 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. @@ -79,7 +105,7 @@ func Default() Config { return Config{ Hours: 12, Units: "metric", - Columns: []string{"hour", "temp", "feels", "conditions", "mm", "rain"}, + Columns: []string{"hour", "temp", "feels", "conditions", "humidity", "mm", "rain"}, Icons: "nerd", Graph: true, GraphHeight: 5, @@ -87,6 +113,7 @@ func Default() Config { Pollen: append([]string(nil), AllSpecies...), Color: "auto", DisplayLang: "en", + Custom: map[string]CustomColumn{}, } } @@ -108,13 +135,32 @@ func ValidColumns() []string { return names } -// Fields returns the Open-Meteo hourly fields the selected columns need. +// 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 { - f := columnFields[col] + 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 } @@ -138,11 +184,24 @@ func (c Config) Has(column string) bool { // 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 { - return fmt.Errorf("unknown column %q; valid: %s", - col, strings.Join(ValidColumns(), ", ")) + 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) @@ -214,7 +273,64 @@ func Load(path string) (Config, error) { 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 @@ -237,11 +353,11 @@ func (c *Config) set(key, value string) error { case "pollen": switch value { case "all": - c.Pollen = append([]string(nil), AllSpecies...) + c.Pollen, c.PollenExplicit = append([]string(nil), AllSpecies...), false case "none": - c.Pollen = nil + c.Pollen, c.PollenExplicit = nil, false default: - c.Pollen = splitList(value) + c.Pollen, c.PollenExplicit = splitList(value), true } case "hours": n, err := strconv.Atoi(value) @@ -298,8 +414,8 @@ 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. When empty, location= from ~/.wegorc is used, so prognosis -# and wego never disagree about where you are. +# 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. @@ -324,7 +440,11 @@ graph_height=%d # Official IMGW warnings for your powiat (Poland only). warnings=%t -# Pollen species to report, or "all" / "none". +# 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. @@ -334,6 +454,18 @@ 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. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 06c0234..46a8635 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -156,3 +156,126 @@ func TestWriteDefaultRoundTrips(t *testing.T) { t.Fatalf("the file we generate must validate: %v", err) } } + +func TestCustomColumnDeclaration(t *testing.T) { + cfg, err := Load(write(t, `columns=hour,temp,birch +column.birch = air:birch_pollen +label.birch = brzoza +width.birch = 7 +decimals.birch = 2 +suffix.birch = g +`)) + if err != nil { + t.Fatal(err) + } + cc, ok := cfg.Custom["birch"] + if !ok { + t.Fatal("birch was not declared") + } + if cc.Source != "air" || cc.Field != "birch_pollen" { + t.Errorf("source/field = %q/%q", cc.Source, cc.Field) + } + if cc.Label != "brzoza" || cc.Width != 7 || cc.Decimals != 2 || cc.Suffix != "g" { + t.Errorf("attributes not parsed: %+v", cc) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("a complete declaration must validate: %v", err) + } +} + +// Only the fields a selected column needs, split by which API serves them. +func TestCustomColumnsSplitFieldsByApi(t *testing.T) { + cfg, err := Load(write(t, `columns=hour,temp,birch,soil +column.birch = air:birch_pollen +column.soil = forecast:soil_temperature_0cm +`)) + if err != nil { + t.Fatal(err) + } + fc := strings.Join(cfg.Fields(), ",") + if !strings.Contains(fc, "soil_temperature_0cm") || !strings.Contains(fc, "temperature_2m") { + t.Errorf("forecast fields = %q", fc) + } + if strings.Contains(fc, "birch_pollen") { + t.Errorf("an air field must not be asked of the forecast API: %q", fc) + } + if air := strings.Join(cfg.AirFields(), ","); air != "birch_pollen" { + t.Errorf("air fields = %q, want birch_pollen", air) + } +} + +// No custom air column means no second request at all. +func TestNoAirFieldsWhenNoneDeclared(t *testing.T) { + if got := Default().AirFields(); len(got) != 0 { + t.Fatalf("AirFields() = %v, want empty", got) + } +} + +func TestCustomColumnErrors(t *testing.T) { + for name, body := range map[string]string{ + "no source": "column.x = birch_pollen\ncolumns=hour,x\n", + "unknown source": "column.x = weather:birch_pollen\ncolumns=hour,x\n", + "empty field": "column.x = air:\ncolumns=hour,x\n", + "bad width": "column.x = air:f\nwidth.x = wide\n", + "bad decimals": "column.x = air:f\ndecimals.x = 9\n", + } { + t.Run(name, func(t *testing.T) { + if _, err := Load(write(t, body)); err == nil { + t.Fatalf("expected an error for %q", body) + } + }) + } +} + +// Attributes without a declaration are a typo, not a silent no-op. +func TestAttributesWithoutDeclarationAreRejected(t *testing.T) { + cfg, err := Load(write(t, "label.birch = brzoza\n")) + if err != nil { + t.Fatal(err) + } + if err := cfg.Validate(); err == nil { + t.Fatal("label.birch without column.birch must be an error") + } +} + +// Shadowing a built-in would make which column you get depend on lookup order. +func TestCustomColumnCannotShadowABuiltIn(t *testing.T) { + cfg, err := Load(write(t, "column.temp = air:birch_pollen\n")) + if err != nil { + t.Fatal(err) + } + err = cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "built-in") { + t.Fatalf("expected a built-in clash error, got %v", err) + } +} + +// An undeclared column name should say how to declare it. +func TestUnknownColumnSuggestsDeclaringIt(t *testing.T) { + cfg := Default() + cfg.Columns = []string{"hour", "birch"} + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "column.birch") { + t.Fatalf("error should show how to declare it, got %v", err) + } +} + +func TestPollenExplicitTracksWhoChose(t *testing.T) { + cases := map[string]bool{ + "pollen=grass,birch\n": true, + "pollen=all\n": false, + "pollen=none\n": false, + } + for body, want := range cases { + cfg, err := Load(write(t, body)) + if err != nil { + t.Fatal(err) + } + if cfg.PollenExplicit != want { + t.Errorf("%q gave PollenExplicit=%v, want %v", body, cfg.PollenExplicit, want) + } + } + if Default().PollenExplicit { + t.Error("the default is not an explicit choice") + } +} diff --git a/internal/openmeteo/openmeteo.go b/internal/openmeteo/openmeteo.go index b5220f4..b1596e0 100644 --- a/internal/openmeteo/openmeteo.go +++ b/internal/openmeteo/openmeteo.go @@ -383,3 +383,52 @@ func presentSlice(v any) []bool { } return out } + +// AirHourly fetches per-hour values for air-quality fields, keyed by the local +// timestamp the API reports ("2006-01-02T15:04") and then by field. +// +// Keyed by time rather than by index because this is a different endpoint from +// the forecast: nothing guarantees the two arrays start at the same hour, and +// merging by position would silently shift a column by an hour. +// +// A field the API withholds is absent rather than zero, so a column shows blank +// instead of a confident wrong number. +func AirHourly(lat, lon float64, hours int, fields []string) (map[string]map[string]float64, error) { + out := map[string]map[string]float64{} + if len(fields) == 0 { + return out, nil + } + days := hours/24 + 2 + if days > MaxAirDays { + days = MaxAirDays + } + var r struct { + Hourly map[string]any `json:"hourly"` + } + err := get(airURL, url.Values{ + "latitude": {strconv.FormatFloat(lat, 'f', 4, 64)}, + "longitude": {strconv.FormatFloat(lon, 'f', 4, 64)}, + "hourly": {strings.Join(fields, ",")}, + "forecast_days": {strconv.Itoa(days)}, + "timezone": {"auto"}, + }, &r) + if err != nil { + return nil, err + } + + times := stringSlice(r.Hourly["time"]) + for _, f := range fields { + vals := floatSlice(r.Hourly[f]) + have := presentSlice(r.Hourly[f]) + for i, t := range times { + if i >= len(vals) || (i < len(have) && !have[i]) { + continue + } + if out[t] == nil { + out[t] = map[string]float64{} + } + out[t][f] = vals[i] + } + } + return out, nil +} diff --git a/internal/render/render.go b/internal/render/render.go index 4417763..7df9b66 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -209,9 +209,11 @@ func (x ctx) header(v View) []string { var bits []string for _, s := range sortedByValue(v.Pollen) { band := PollenBand(s, v.Pollen[s]) - // Skip taxa that are simply absent, but never hide grass: it is the - // one someone may be allergic to and its absence is information. - if band == "none" && s != "grass" { + // A species the user named is always shown, even at zero: they named + // it because they react to it, and "none today" is what they wanted + // to know. With pollen=all nobody chose, so absent taxa are dropped + // rather than printing a line of zeroes. + if band == "none" && !x.cfg.PollenExplicit { continue } text := fmt.Sprintf("%s %.1f", x.cat.Species(s), v.Pollen[s]) diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 484a45b..d06fc7b 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -27,7 +27,7 @@ func row(hour int, temp float64, code int, mm, pop float64) openmeteo.Row { func testConfig() config.Config { c := config.Default() - c.Columns = []string{"hour", "temp", "feels", "conditions", "mm", "rain"} + c.Columns = []string{"hour", "temp", "feels", "conditions", "humidity", "mm", "rain"} c.Graph = false c.Icons = "none" c.DisplayLang = "en" @@ -255,7 +255,7 @@ func TestTableMinimumWidthIsKnown(t *testing.T) { widest = w } } - const documented = 34 + const documented = 40 if widest != documented { t.Fatalf("the default table now needs %d columns, not the documented %d; "+ "update the README if this is intended", widest, documented) @@ -285,3 +285,121 @@ func TestChartAxisLabelsAreWholeOrAbsent(t *testing.T) { } } } + +func customCfg() config.Config { + c := testConfig() + c.Columns = []string{"hour", "temp", "birch", "soil"} + c.Custom = map[string]config.CustomColumn{ + "birch": {Source: "air", Field: "birch_pollen", Label: "brzoza", Decimals: 1}, + "soil": {Source: "forecast", Field: "soil_temperature_0cm", Suffix: "°", Decimals: 0}, + } + return c +} + +func TestCustomColumnsRender(t *testing.T) { + r := row(12, 25, 3, 0, 0) + r.Vals[CustomKey("birch")] = 12.34 + r.Vals["soil_temperature_0cm"] = 21.6 + + out := Render(view(r), customCfg(), 80, false) + if !strings.Contains(out, "brzoza") { + t.Errorf("the declared label must be the header:\n%s", out) + } + if !strings.Contains(out, "12.3") { + t.Errorf("decimals=1 should give 12.3:\n%s", out) + } + if !strings.Contains(out, "22°") { + t.Errorf("decimals=0 with a suffix should give 22°:\n%s", out) + } +} + +// "No data" and "zero" are different claims, and for an allergen the difference +// matters. +func TestCustomColumnBlankWhenTheApiGaveNothing(t *testing.T) { + r := row(12, 25, 3, 0, 0) // no custom values set at all + out := Render(view(r), customCfg(), 80, false) + if strings.Contains(out, "0.0") { + t.Errorf("a missing value must render blank, not as zero:\n%s", out) + } + if !strings.Contains(out, "brzoza") { + t.Errorf("the column should still be present:\n%s", out) + } +} + +// A custom column named after a built-in API field must not read that field's +// value; the prefix is what keeps them apart. +func TestCustomKeyDoesNotCollideWithApiFields(t *testing.T) { + if CustomKey("temperature_2m") == "temperature_2m" { + t.Fatal("custom values must be stored under a distinct key") + } + r := row(12, 25, 3, 0, 0) // temperature_2m = 25 + cfg := testConfig() + cfg.Columns = []string{"hour", "mine"} + cfg.Custom = map[string]config.CustomColumn{ + "mine": {Source: "air", Field: "temperature_2m", Decimals: 0}, + } + if out := Render(view(r), cfg, 80, false); strings.Contains(out, "25") { + t.Errorf("the custom column picked up the built-in field's value:\n%s", out) + } +} + +func TestCustomColumnWidthFromLabelWhenUnset(t *testing.T) { + cfg := testConfig() + cfg.Columns = []string{"hour", "verylongname"} + cfg.Custom = map[string]config.CustomColumn{ + "verylongname": {Source: "air", Field: "f", Label: "verylongname"}, + } + r := row(12, 25, 3, 0, 0) + r.Vals[CustomKey("verylongname")] = 1 + out := Render(view(r), cfg, 80, false) + for _, l := range strings.Split(out, "\n") { + if strings.Contains(l, "verylongname") && DisplayWidth(l) < 12 { + t.Errorf("header was truncated: %q", l) + } + } +} + +// A species the user named is shown even at zero: they named it because they +// react to it, and "none today" is the answer they wanted. +func TestNamedPollenSpeciesShownEvenAtZero(t *testing.T) { + cfg := testConfig() + cfg.Pollen = []string{"birch"} + cfg.PollenExplicit = true + v := view(row(12, 25, 3, 0, 0)) + v.Pollen = map[string]float64{"birch": 0} + + out := Render(v, cfg, 80, false) + if !strings.Contains(out, "birch") { + t.Errorf("a named species must appear even at zero:\n%s", out) + } +} + +// With pollen=all nobody chose, so a line of six zeroes is noise. +func TestPollenAllHidesAbsentSpecies(t *testing.T) { + cfg := testConfig() + cfg.Pollen = []string{"grass", "birch"} + cfg.PollenExplicit = false + v := view(row(12, 25, 3, 0, 0)) + v.Pollen = map[string]float64{"grass": 12, "birch": 0} + + out := Render(v, cfg, 80, false) + if !strings.Contains(out, "grass") { + t.Errorf("a present species must be shown:\n%s", out) + } + if strings.Contains(out, "birch") { + t.Errorf("an absent species must be dropped when nobody named it:\n%s", out) + } +} + +// No species is privileged. Grass used to be special-cased, which forced it on +// someone allergic to birch while hiding theirs. +func TestNoSpeciesIsPrivileged(t *testing.T) { + cfg := testConfig() + cfg.PollenExplicit = false + v := view(row(12, 25, 3, 0, 0)) + v.Pollen = map[string]float64{"grass": 0, "birch": 0} + + if out := Render(v, cfg, 80, false); strings.Contains(out, "grass") { + t.Errorf("grass at zero must be dropped like any other species:\n%s", out) + } +} diff --git a/internal/render/table.go b/internal/render/table.go index 3ade296..3219e9d 100644 --- a/internal/render/table.go +++ b/internal/render/table.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/lukaszkasprzak/prognosis/internal/config" "github.com/lukaszkasprzak/prognosis/internal/openmeteo" ) @@ -14,8 +15,34 @@ type cell struct { left bool } +// custom returns the config's definition of a column, if it has one. +func (x ctx) custom(name string) (config.CustomColumn, bool) { + cc, ok := x.cfg.Custom[name] + return cc, ok +} + +// customLabel is the header for a custom column: what the user asked for, or +// the column's own name. +func customLabel(name string, cc config.CustomColumn) string { + if cc.Label != "" { + return cc.Label + } + return name +} + // colWidth is the reserved display width per column. func (x ctx) colWidth(name string) int { + if cc, ok := x.custom(name); ok { + if cc.Width > 0 { + return cc.Width + } + // Wide enough for the header, and for a value of a few digits. + w := DisplayWidth(customLabel(name, cc)) + if w < 5 { + w = 5 + } + return w + } switch name { case "hour": // Three, not two: the Python leaves a double space after the hour. @@ -39,6 +66,9 @@ func (x ctx) colWidth(name string) int { } func (x ctx) leftAligned(name string) bool { + if _, ok := x.custom(name); ok { + return false // custom columns are numeric + } switch name { case "hour", "icon", "temp", "feels", "conditions": return true @@ -86,7 +116,12 @@ func (x ctx) table(v View) []string { headers := map[string]cell{} for _, name := range x.visible() { - headers[name] = cell{text: x.cat.Header(name), style: ""} + text := x.cat.Header(name) + if cc, ok := x.custom(name); ok { + // A user-declared label is not ours to translate. + text = customLabel(name, cc) + } + headers[name] = cell{text: text, style: ""} } out = append(out, x.c(Underline, x.rowPlain(headers))) @@ -129,6 +164,10 @@ func (x ctx) cells(r openmeteo.Row, isNow bool, prevCode *int) map[string]cell { temp, hasTemp := r.Val("temperature_2m") for _, name := range x.visible() { + if cc, ok := x.custom(name); ok { + out[name] = x.customCell(r, name, cc) + continue + } switch name { case "hour": style := Reset @@ -238,3 +277,28 @@ func abs(f float64) float64 { } return f } + +// CustomKey is where a custom column's value lives in a Row. +// +// Prefixed so a column called "temp" or "visibility" can never shadow the API +// field of the same name that a built-in column reads. +func CustomKey(name string) string { return "x:" + name } + +// customCell formats one user-declared column. +// +// A value the API did not supply renders blank rather than as zero: for an +// allergen or a soil reading, "no data" and "none" are different claims. +func (x ctx) customCell(r openmeteo.Row, name string, cc config.CustomColumn) cell { + key := cc.Field + if cc.Source == "air" { + key = CustomKey(name) + } + v, ok := r.Val(key) + if !ok { + return cell{text: "", style: Dim} + } + return cell{ + text: fmt.Sprintf("%.*f%s", cc.Decimals, v, cc.Suffix), + style: Dim, + } +} diff --git a/man/prognosis.1 b/man/prognosis.1 new file mode 100644 index 0000000..8c28b60 --- /dev/null +++ b/man/prognosis.1 @@ -0,0 +1,276 @@ +.TH PROGNOSIS 1 "2026-08-21" "prognosis" "User Commands" +.SH NAME +prognosis \- hour-by-hour terminal forecast with official Polish warnings +.SH SYNOPSIS +.B prognosis +.RI [ options ] +.RI [ place ] +.SH DESCRIPTION +.B prognosis +prints an hourly weather table, a temperature chart, and \(em for locations in +Poland \(em the meteorological warnings IMGW has issued for that powiat. +.PP +It needs no API key and has no third-party dependencies. Which columns appear, +in what order, and in which language is set by the configuration file; columns +the program does not ship with can be declared there too, so any field the +underlying APIs expose can be displayed. See +.B CUSTOM COLUMNS . +.PP +Output is pipe-safe: colour is switched off when standard output is not a +terminal, notes and diagnostics go to standard error, and +.B \-ascii +restricts output to ASCII for onward transmission by SMS. +.SH OPTIONS +.TP +.BI \-l " PLACE" +Place to query: a name, or +.IR lat , lon . +Overrides +.B location +in the configuration file for one run. +.TP +.BI \-n " N" +Show +.I N +hours ahead. +.TP +.BI \-d " N" +Show +.I N +days ahead, of 24 hours each. Mutually exclusive with +.BR \-n . +.TP +.BI \-pick " N" +Choose the +.IR N th +candidate for an ambiguous place name, and remember it. Re-resolves rather than +reading the cache, which is how a wrongly cached name is corrected. +.TP +.BI \-columns " LIST" +Comma-separated columns to display, overriding the configuration file. +.TP +.BI \-icons " SET" +Glyph set for the +.B icon +column: +.BR nerd , +.BR emoji , +or +.BR none . +.TP +.BI \-pollen " LIST" +Allergens to show for one run: a comma-separated list, or +.B all +or +.BR none . +.TP +.BI \-lang " LANG" +Display language: +.B en +or +.BR pl . +.TP +.B \-weather +Forecast only: omit sun times, the day summary, pollen and the chart. +.TP +.B \-no\-warnings +Omit IMGW warnings. +.TP +.B \-no\-graph +Table only, no chart. +.TP +.B \-no\-color +Plain output, no escape sequences. +.TP +.B \-ascii +Restrict output to ASCII. Intended for SMS, where a single non-ASCII character +forces the message from GSM\-7 (160 characters per segment) into UCS\-2 (70). +The degree sign becomes the unit letter and Polish diacritics are transliterated. +.TP +.B \-config +Print the path of the configuration file and exit. +.TP +.B \-version +Print the version and exit. +.SH CONFIGURATION +The configuration file is +.I $XDG_CONFIG_HOME/prognosis/config +(by default +.IR ~/.config/prognosis/config ). +It is written with commented defaults on first run. The format is one +.I KEY=VALUE +per line; +.B # +begins a comment; values are not quoted. +.PP +Command line flags override the file, and the file overrides the built-in +defaults. +.TP +.B location +Place to query. Required: prognosis has no other way to know where you are and +will not guess. +.TP +.B hours +Default span in hours. +.TP +.B units +.BR metric ", " imperial " or " si . +Passed to the provider, so rounding is theirs. Warning thresholds are always +compared in Celsius, whatever the display units. +.TP +.B columns +Columns to display, in order. +.TP +.B icons +.BR nerd ", " emoji " or " none . +.TP +.B graph ", " graph_height +Whether to draw the temperature chart, and over how many rows. +.TP +.B warnings +Whether to check IMGW for warnings. +.TP +.B pollen +Which allergens to report, or whether to report any: +.B none +omits the line entirely, +.B all +shows every species that has a reading, and a comma-separated list shows exactly +those species \(em even at zero, since a species you named is one you react to +and "none today" is the answer you wanted. With +.B all +nobody chose, so absent species are dropped rather than printing a line of +zeroes. Species: +.BR grass ", " birch ", " alder ", " mugwort ", " ragweed ", " olive . +.TP +.B color +.BR auto ", " always " or " never . +.TP +.B display_lang +.BR en " or " pl . +Covers everything prognosis writes itself. IMGW publishes its warning text in +Polish only, so that text remains Polish in either language. +.TP +.B ascii +Restrict output to ASCII, as +.BR \-ascii . +.SH COLUMNS +Built-in columns: +.BR hour ", " icon ", " temp ", " feels ", " conditions ", " mm ", " rain ", " +.BR wind ", " gusts ", " dir ", " humidity ", " dew ", " uv ", " cloud ", " +.BR pressure ", " visibility . +.PP +Only the fields the selected columns need are requested, so a narrow table +costs a smaller response. An unknown column name is an error at startup naming +the offender, never a silently blank column. +.PP +.B mm +and +.B rain +are hidden automatically when the window is dry and no hour reaches a 20% +chance of precipitation, and the day summary says +.I dry +instead. +.SH CUSTOM COLUMNS +Any field the two Open-Meteo APIs expose can be displayed, whether or not +prognosis knows about it. Declare a short name, then use it in +.BR columns . +.PP +.in +4n +.EX +columns=hour,temp,birch,soil,conditions + +column.birch = air:birch_pollen +column.soil = forecast:soil_temperature_0cm + +label.soil = soil +suffix.soil = \(de +decimals.soil = 0 +label.birch = birch +decimals.birch = 1 +.EE +.in +.PP +.B column.\fINAME\fB = \fISOURCE\fB:\fIFIELD\fR +is the declaration. +.I SOURCE +is +.B forecast +(the weather API) or +.B air +(the air-quality API, which carries the allergens); they are separate services +with separate field sets, which is why the source must be given. An +.B air +column costs one extra request, made only when such a column is declared. +.PP +The remaining keys are optional: +.B label.\fINAME\fR +sets the header (default: the name), +.B width.\fINAME\fR +the column width (default: derived from the label), +.B decimals.\fINAME\fR +the digits after the point (default: 0), and +.B suffix.\fINAME\fR +a string appended to each value. +.PP +A custom name may not shadow a built-in column. A value the API does not supply +renders blank rather than as zero: for an allergen, "no data" and "none" are +different claims. +.SH WARNINGS +IMGW publishes every warning in Poland, each tagged with the TERYT codes of the +powiats it covers. The coordinates are resolved to that code through GUGiK, so +warnings are filtered to your area rather than the whole country. +.PP +Four states are kept deliberately distinct, because silence must never be +mistaken for an all-clear: +.TP +warnings printed +In force for your powiat. +.TP +nothing printed +Checked; none in force. +.TP +.I warnings: could not check IMGW +The check itself failed. +.TP +.I warnings: IMGW covers Poland only +The location is outside Poland. +.SH FILES +.TP +.I ~/.config/prognosis/config +Configuration. +.TP +.I ~/.cache/prognosis/cache.json +Cached geocoding and TERYT lookups, one entry per line. Disposable: an entry +that will not parse is skipped and the rest kept; a file that will not parse at +all is moved aside to +.I cache.json.bad +rather than overwritten. +.SH EXIT STATUS +.TP +.B 0 +Success. +.TP +.B 1 +The forecast could not be fetched. +.TP +.B 2 +Usage error: bad flags, an invalid configuration, no location set, or an +ambiguous place name. +.SH EXAMPLES +.TP +.B prognosis +The next twelve hours where you live. +.TP +.B prognosis \-l krakow \-d 3 +Three days for another place. +.TP +.B prognosis \-weather \-ascii \-no\-warnings \-n 6 +A short, ASCII-only forecast suitable for sending by SMS. +.SH SEE ALSO +.BR wego (1) +.PP +Data from Open-Meteo (https://open-meteo.com/), GUGiK +(https://services.gugik.gov.pl/) and IMGW (https://danepubliczne.imgw.pl/). +.SH AUTHOR +Lukasz Kasprzak. diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push new file mode 100755 index 0000000..83b2043 --- /dev/null +++ b/scripts/hooks/pre-push @@ -0,0 +1,7 @@ +#!/bin/sh +# Refuse to push anything that would not survive `make ci`. +# +# git never clones hooks, so on a fresh checkout this must be installed with +# `make install-hooks`. +set -e +exec make ci |
