aboutsummaryrefslogtreecommitdiff
path: root/internal/i18n
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
commite14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch)
tree670ef0897839871a64d3a3bb2e17e242e7d6c385 /internal/i18n
downloadprognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz
prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
Diffstat (limited to 'internal/i18n')
-rw-r--r--internal/i18n/i18n.go196
-rw-r--r--internal/i18n/i18n_test.go88
2 files changed, 284 insertions, 0 deletions
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
new file mode 100644
index 0000000..f0526be
--- /dev/null
+++ b/internal/i18n/i18n.go
@@ -0,0 +1,196 @@
+// Package i18n holds the translations for everything prognosis writes itself:
+// column headers, condition names, section labels, weekday and month names, and
+// pollen species and bands.
+//
+// What is deliberately NOT translated: the body of an IMGW warning. IMGW
+// publishes those in Polish only, and rendering an invented English version of
+// an official warning would be worse than showing the original. In English mode
+// the surrounding labels are English and the warning text stays Polish.
+package i18n
+
+import (
+ "fmt"
+ "time"
+)
+
+// Lang is a supported display language.
+type Lang string
+
+const (
+ EN Lang = "en"
+ PL Lang = "pl"
+)
+
+// Catalog is the set of strings for one language.
+type Catalog struct {
+ lang Lang
+
+ // Table headers, keyed by column name.
+ headers map[string]string
+ // WMO weather code descriptions.
+ conditions map[int]string
+ // Section labels and short words.
+ words map[string]string
+ // Pollen taxa.
+ species map[string]string
+ // Qualitative pollen levels.
+ bands map[string]string
+
+ weekdays [7]string
+ months [12]string
+}
+
+var catalogs = map[Lang]*Catalog{EN: english(), PL: polish()}
+
+// For returns the catalogue for a language, falling back to English so a
+// mistyped language degrades to readable output rather than empty strings.
+func For(lang string) *Catalog {
+ if c, ok := catalogs[Lang(lang)]; ok {
+ return c
+ }
+ return catalogs[EN]
+}
+
+// Lang reports which language this catalogue is.
+func (c *Catalog) Lang() Lang { return c.lang }
+
+// Header is the column heading for a column name.
+func (c *Catalog) Header(column string) string {
+ if s, ok := c.headers[column]; ok {
+ return s
+ }
+ return column
+}
+
+// Condition describes a WMO weather code. Unknown codes are reported as such
+// rather than silently blank, so a new code in the API is visible.
+func (c *Catalog) Condition(code int) string {
+ if s, ok := c.conditions[code]; ok {
+ return s
+ }
+ return fmt.Sprintf("code %d", code)
+}
+
+// Word returns a label such as "day", "pollen" or "warnings".
+func (c *Catalog) Word(key string) string {
+ if s, ok := c.words[key]; ok {
+ return s
+ }
+ return key
+}
+
+// Species is the display name of a pollen taxon.
+func (c *Catalog) Species(name string) string {
+ if s, ok := c.species[name]; ok {
+ return s
+ }
+ return name
+}
+
+// Band is the display name of a qualitative pollen level.
+func (c *Catalog) Band(key string) string {
+ if s, ok := c.bands[key]; ok {
+ return s
+ }
+ return key
+}
+
+// Date formats a date as "Mon 10 Aug" in the catalogue's language. Go's time
+// package has no locale support, so the names come from the catalogue.
+func (c *Catalog) Date(t time.Time) string {
+ return fmt.Sprintf("%s %02d %s", c.weekdays[int(t.Weekday())], t.Day(), c.months[int(t.Month())-1])
+}
+
+func english() *Catalog {
+ return &Catalog{
+ lang: EN,
+ headers: map[string]string{
+ "hour": "hr", "icon": "", "temp": "temp", "feels": "feels",
+ "conditions": "conditions", "mm": "mm", "rain": "rain",
+ "wind": "wind", "gusts": "gusts", "dir": "dir",
+ "humidity": "hum", "dew": "dew", "uv": "uv",
+ "cloud": "cloud", "pressure": "hPa", "visibility": "vis",
+ },
+ conditions: map[int]string{
+ 0: "clear", 1: "mainly clear", 2: "part cloudy", 3: "overcast",
+ 45: "fog", 48: "rime fog",
+ 51: "lt drizzle", 53: "drizzle", 55: "hvy drizzle",
+ 56: "frz drizzle", 57: "frz drizzle",
+ 61: "lt rain", 63: "rain", 65: "hvy rain",
+ 66: "frz rain", 67: "frz rain",
+ 71: "lt snow", 73: "snow", 75: "hvy snow", 77: "snow grains",
+ 80: "lt showers", 81: "showers", 82: "hvy showers",
+ 85: "snow showers", 86: "hvy snow showers",
+ 95: "thunderstorm", 96: "storm + hail", 99: "storm + hail",
+ },
+ words: map[string]string{
+ "sun": "sun", "up": "up", "down": "down",
+ "day": "day", "dry": "dry", "pollen": "pollen",
+ "rainfall": "rain", "over": "over", "daylight": "daylight",
+ "of": "of", "max": "max", "level": "level",
+ "warnings": "warnings", "could_not_check": "could not check IMGW",
+ "poland_only": "IMGW covers Poland only",
+ "hours_available": "hours available, not",
+ "h_per_col": "h/col", "temp_row": "temp", "rain_row": "rain",
+ },
+ species: map[string]string{
+ "grass": "grass", "birch": "birch", "alder": "alder",
+ "mugwort": "mugwort", "ragweed": "ragweed", "olive": "olive",
+ },
+ bands: map[string]string{
+ "none": "none", "low": "low", "medium": "medium",
+ "high": "high", "very high": "very high",
+ },
+ weekdays: [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"},
+ months: [12]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"},
+ }
+}
+
+func polish() *Catalog {
+ return &Catalog{
+ lang: PL,
+ headers: map[string]string{
+ "hour": "godz", "icon": "", "temp": "temp", "feels": "odczuw",
+ "conditions": "warunki", "mm": "mm", "rain": "deszcz",
+ "wind": "wiatr", "gusts": "porywy", "dir": "kier",
+ "humidity": "wilg", "dew": "ros", "uv": "uv",
+ "cloud": "chmury", "pressure": "hPa", "visibility": "wid",
+ },
+ // Terminology follows IMGW's own vocabulary where it has one, so the
+ // table and a warning describe the same weather in the same words.
+ conditions: map[int]string{
+ 0: "bezchmurnie", 1: "gł. bezchmurnie", 2: "częśc. zachm.", 3: "zachmurzenie",
+ 45: "mgła", 48: "mgła osadz.",
+ 51: "słaba mżawka", 53: "mżawka", 55: "silna mżawka",
+ 56: "mżawka marzn.", 57: "mżawka marzn.",
+ 61: "słaby deszcz", 63: "deszcz", 65: "silny deszcz",
+ 66: "deszcz marzn.", 67: "deszcz marzn.",
+ 71: "słaby śnieg", 73: "śnieg", 75: "silny śnieg", 77: "ziarna śniegu",
+ 80: "słabe przelotne", 81: "przelotne", 82: "silne przelotne",
+ 85: "przelotny śnieg", 86: "silny przel. śnieg",
+ 95: "burza", 96: "burza z gradem", 99: "burza z gradem",
+ },
+ words: map[string]string{
+ "sun": "słońce", "up": "wsch", "down": "zach",
+ "day": "dzień", "dry": "sucho", "pollen": "pyłek",
+ "rainfall": "opad", "over": "przez", "daylight": "dnia",
+ "of": "z", "max": "maks", "level": "stopień",
+ "warnings": "ostrzeżenia", "could_not_check": "nie udało się sprawdzić IMGW",
+ "poland_only": "IMGW obejmuje tylko Polskę",
+ "hours_available": "godzin dostępnych, nie",
+ "h_per_col": "h/kol", "temp_row": "temp", "rain_row": "opad",
+ },
+ species: map[string]string{
+ "grass": "trawy", "birch": "brzoza", "alder": "olcha",
+ "mugwort": "bylica", "ragweed": "ambrozja", "olive": "oliwka",
+ },
+ bands: map[string]string{
+ "none": "brak", "low": "niskie", "medium": "średnie",
+ "high": "wysokie", "very high": "b. wysokie",
+ },
+ weekdays: [7]string{"nd", "pn", "wt", "śr", "cz", "pt", "sb"},
+ months: [12]string{"sty", "lut", "mar", "kwi", "maj", "cze",
+ "lip", "sie", "wrz", "paź", "lis", "gru"},
+ }
+}
diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go
new file mode 100644
index 0000000..2a6a617
--- /dev/null
+++ b/internal/i18n/i18n_test.go
@@ -0,0 +1,88 @@
+package i18n
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lukaszkasprzak/prognosis/internal/config"
+)
+
+func TestForFallsBackToEnglish(t *testing.T) {
+ if For("klingon").Lang() != EN {
+ t.Fatal("an unknown language must degrade to English, not to empty strings")
+ }
+ if For("pl").Lang() != PL {
+ t.Fatal("pl must resolve to the Polish catalogue")
+ }
+}
+
+// Every catalogue must cover every column, or a table in that language would
+// silently fall back to the internal column name.
+func TestEveryLanguageCoversEveryColumn(t *testing.T) {
+ for _, lang := range []Lang{EN, PL} {
+ c := For(string(lang))
+ for _, col := range config.ValidColumns() {
+ if _, ok := c.headers[col]; !ok {
+ t.Errorf("%s: no header for column %q", lang, col)
+ }
+ }
+ }
+}
+
+// The WMO codes must match across languages: a code described in English but
+// not Polish would print "code 71" to a Polish reader.
+func TestConditionCoverageMatchesAcrossLanguages(t *testing.T) {
+ en, pl := For("en"), For("pl")
+ for code := range en.conditions {
+ if _, ok := pl.conditions[code]; !ok {
+ t.Errorf("code %d described in English but not Polish", code)
+ }
+ }
+ for code := range pl.conditions {
+ if _, ok := en.conditions[code]; !ok {
+ t.Errorf("code %d described in Polish but not English", code)
+ }
+ }
+}
+
+func TestWordAndSpeciesCoverageMatches(t *testing.T) {
+ en, pl := For("en"), For("pl")
+ for k := range en.words {
+ if _, ok := pl.words[k]; !ok {
+ t.Errorf("word %q missing from Polish", k)
+ }
+ }
+ for _, s := range config.AllSpecies {
+ if _, ok := en.species[s]; !ok {
+ t.Errorf("species %q missing from English", s)
+ }
+ if _, ok := pl.species[s]; !ok {
+ t.Errorf("species %q missing from Polish", s)
+ }
+ }
+ for _, b := range []string{"none", "low", "medium", "high", "very high"} {
+ if en.Band(b) == b && b != "none" && b != "low" && b != "medium" && b != "high" && b != "very high" {
+ t.Errorf("band %q missing from English", b)
+ }
+ if pl.Band(b) == b {
+ t.Errorf("band %q not translated to Polish", b)
+ }
+ }
+}
+
+func TestUnknownCodeIsVisible(t *testing.T) {
+ got := For("en").Condition(4242)
+ if got != "code 4242" {
+ t.Fatalf("unknown codes must be visible, got %q", got)
+ }
+}
+
+func TestDate(t *testing.T) {
+ d := time.Date(2026, 8, 10, 13, 0, 0, 0, time.UTC) // a Monday
+ if got, want := For("en").Date(d), "Mon 10 Aug"; got != want {
+ t.Errorf("en date = %q, want %q", got, want)
+ }
+ if got, want := For("pl").Date(d), "pn 10 sie"; got != want {
+ t.Errorf("pl date = %q, want %q", got, want)
+ }
+}