diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-25 15:42:55 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-25 15:42:55 +0200 |
| commit | 92e1106b91f836339a957312a1ea4449c55114c7 (patch) | |
| tree | ee3726c567054e3f3a4eec692dcf5fb49099318b | |
| parent | e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (diff) | |
| download | prognosis-6b5c3acaf88783cf2eebd5f036731771a808758a.tar.gz prognosis-6b5c3acaf88783cf2eebd5f036731771a808758a.zip | |
Stand alone, document, and let the config show anythingv0.1.1
prognosis no longer reads ~/.wegorc. Falling back to another program's
configuration made it useless without wego installed, and hid the fact that
it has no way to know where you are: location= is now required in its own
config, and the error says so and shows how to set it.
Custom columns. Any field the two Open-Meteo APIs expose can be displayed by
declaring it -- column.birch = air:birch_pollen -- and then naming it in
columns=. The source is explicit because forecast and air-quality are separate
services with separate fields; an air column costs one extra request, made only
when one is declared. Air values merge onto forecast hours by timestamp rather
than array index, since nothing guarantees the two endpoints start at the same
hour and merging by position would shift a column by an hour unnoticed. A value
the API withholds renders blank, not zero: for an allergen those are different
claims.
Pollen selection no longer privileges grass. A species named in pollen= is
shown even at zero, because you named it for a reason; pollen=all shows only
what is present, or the line is six zeroes. Grass had been special-cased, which
forced it on someone allergic to birch while hiding theirs.
Temperature colours are compared in Celsius whatever the display units. In
imperial, 85F -- a mild 29C -- was rendering in the red that means IMGW would
issue a heat warning.
A man page, checked by make lint and installed by make install. The Makefile
gains PREFIX/DESTDIR for packaging, a version stamped into the binary, a
release target that refuses to tag a dirty tree or a version with no changelog
entry, cross-compilation for six platforms, and a pre-push hook.
Also: humidity in the default columns, -weather for output meant for someone
else, -ascii so an SMS stays in GSM-7 rather than dropping to 70-character
UCS-2 segments, and -pollen and -version.
| -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 |
