diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/prognosis/main.go | 99 | ||||
| -rw-r--r-- | cmd/prognosis/out_test.go | 91 |
2 files changed, 187 insertions, 3 deletions
diff --git a/cmd/prognosis/main.go b/cmd/prognosis/main.go index 99d9918..6607c7c 100644 --- a/cmd/prognosis/main.go +++ b/cmd/prognosis/main.go @@ -8,8 +8,10 @@ import ( "fmt" "io" "os" + "path/filepath" "strconv" "strings" + "time" "github.com/lukaszkasprzak/prognosis/internal/cache" "github.com/lukaszkasprzak/prognosis/internal/config" @@ -26,6 +28,8 @@ type options struct { hours, days, pick *int noGraph, weather, noWarn, asciiOut, noColor *bool showCfg, showVer *bool + out, outLong *string + svg *bool } func defineFlags(fs *flag.FlagSet) *options { @@ -44,6 +48,9 @@ func defineFlags(fs *flag.FlagSet) *options { noColor: fs.Bool("no-color", false, "plain output"), pick: fs.Int("pick", 0, "choose the Nth place when the name is ambiguous"), showCfg: fs.Bool("config", false, "print the config file path and exit"), + svg: fs.Bool("svg", false, "produce an SVG meteogram instead of the table"), + out: fs.String("o", "", "write to this file or directory instead of stdout"), + outLong: fs.String("out", "", "same as -o"), showVer: fs.Bool("version", false, "print the version and exit"), } } @@ -201,7 +208,11 @@ func run() int { return 1 } - data, err := openmeteo.Forecast(geo.Lat, geo.Lon, cfg.Hours, cfg.Units, cfg.Fields()) + fields := cfg.Fields() + if *opt.svg { + fields = append(fields, render.SVGFields...) + } + data, err := openmeteo.Forecast(geo.Lat, geo.Lon, cfg.Hours, cfg.Units, fields) if err != nil { fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) return 1 @@ -236,8 +247,34 @@ func run() int { view.Warnings, view.WarnNote, view.WarnFailed = warnings(store, geo, cat) } - colour := shouldColour(cfg.Color) - fmt.Println(render.Render(view, cfg, terminalWidth(), colour)) + dest := *opt.out + if dest == "" { + dest = *opt.outLong + } + + var body, ext string + if *opt.svg { + body, ext = render.SVG(view, cfg), "svg" + } else { + // Colour is for a terminal. A file gets plain text, whatever the + // terminal would have shown. + colour := shouldColour(cfg.Color) && dest == "" + body, ext = render.Render(view, cfg, terminalWidth(), colour)+"\n", "txt" + } + + if dest == "" || dest == "-" { + fmt.Print(body) + return 0 + } + path, err := resolveOut(dest, geo.Label, rows0(view), ext) + if err == nil { + err = os.WriteFile(path, []byte(body), 0o644) + } + if err != nil { + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + return 1 + } + fmt.Fprintf(os.Stderr, "wrote %s\n", path) return 0 } @@ -455,6 +492,8 @@ flags: -ascii ASCII only, so an SMS stays in GSM-7 (160 chars, not 70) -no-color plain output -config print the config file path and exit + -svg produce an SVG meteogram instead of the table + -o, -out PATH write to PATH; a directory gets a generated filename -version print the version and exit `, openmeteo.MaxForecastDays-1, strings.Join(config.ValidColumns(), ", ")) } @@ -479,3 +518,57 @@ func mergeCustom(rows []openmeteo.Row, hourly map[string]map[string]float64, cfg } } } + +// rows0 is the first hour shown, used to date a generated filename. +func rows0(v render.View) time.Time { + if len(v.Rows) == 0 { + return time.Now() + } + return v.Rows[0].When +} + +// resolveOut turns -o into a path to write. +// +// An existing directory, or a path ending in a separator, gets a generated +// filename: writing "krakow-pl-2026-08-27.svg" into ~/photos/weather is more +// useful than refusing, and repeated runs on different days do not overwrite +// each other. Anything else is a filename, gaining the extension if it has none +// so "-o weather" does not produce an extensionless file no viewer will open. +func resolveOut(dest, label string, when time.Time, ext string) (string, error) { + if strings.HasSuffix(dest, string(os.PathSeparator)) { + if err := os.MkdirAll(dest, 0o755); err != nil { + return "", err + } + } + if fi, err := os.Stat(dest); err == nil && fi.IsDir() { + name := fmt.Sprintf("%s-%s.%s", slug(label), when.Format("2006-01-02"), ext) + return filepath.Join(dest, name), nil + } + if filepath.Ext(dest) == "" { + dest += "." + ext + } + return dest, nil +} + +// slug reduces a place label to something safe in a filename. +// +// Diacritics are transliterated rather than dropped, so "Zbylitowska Góra" +// becomes "zbylitowska-gora" and not "zbylitowska-g-ra", which is both ugly and +// awkward to glob. +func slug(s string) string { + var b strings.Builder + prevDash := false + for _, r := range strings.ToLower(render.Transliterate(s)) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + default: + if !prevDash && b.Len() > 0 { + b.WriteByte('-') + prevDash = true + } + } + } + return strings.Trim(b.String(), "-") +} diff --git a/cmd/prognosis/out_test.go b/cmd/prognosis/out_test.go new file mode 100644 index 0000000..71c1bae --- /dev/null +++ b/cmd/prognosis/out_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var when = time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + +// A directory gets a generated name: writing into ~/photos/weather is more +// useful than refusing, and two days do not overwrite each other. +func TestResolveOutIntoADirectory(t *testing.T) { + dir := t.TempDir() + got, err := resolveOut(dir, "Krakow, PL", when, "svg") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(dir, "krakow-pl-2026-08-27.svg") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + + // A different day must not collide with the first. + other, _ := resolveOut(dir, "Krakow, PL", when.AddDate(0, 0, 1), "svg") + if other == got { + t.Error("two days produced the same filename") + } +} + +func TestResolveOutCreatesATrailingSlashDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "photos", "weather") + string(os.PathSeparator) + got, err := resolveOut(dir, "Krakow, PL", when, "svg") + if err != nil { + t.Fatal(err) + } + if fi, err := os.Stat(filepath.Dir(got)); err != nil || !fi.IsDir() { + t.Fatalf("directory was not created: %v", err) + } +} + +// "-o weather" should not produce an extensionless file no viewer will open. +func TestResolveOutAddsTheExtensionToABareName(t *testing.T) { + base := filepath.Join(t.TempDir(), "weather") + got, err := resolveOut(base, "Krakow, PL", when, "svg") + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(got, ".svg") { + t.Fatalf("got %q, want a .svg suffix", got) + } +} + +// An explicit extension is respected, whatever the format's default. +func TestResolveOutKeepsAnExplicitExtension(t *testing.T) { + base := filepath.Join(t.TempDir(), "chart.png") + got, _ := resolveOut(base, "Krakow, PL", when, "svg") + if got != base { + t.Fatalf("got %q, want %q unchanged", got, base) + } +} + +func TestSlug(t *testing.T) { + for in, want := range map[string]string{ + "Krakow, PL": "krakow-pl", + "Tarnów, PL": "tarnow-pl", + "Zbylitowska Góra": "zbylitowska-gora", + "Gdańsk": "gdansk", + "Łódź": "lodz", + " spaced out ": "spaced-out", + "50.0617,19.9373": "50-0617-19-9373", + "!!!": "", + } { + if got := slug(in); got != want { + t.Errorf("slug(%q) = %q, want %q", in, got, want) + } + } +} + +// A slug must never contain a path separator, or -o would write outside the +// directory it was given. +func TestSlugCannotEscapeADirectory(t *testing.T) { + for _, nasty := range []string{"../../etc/passwd", "a/b", `c\d`} { + got := slug(nasty) + if strings.ContainsAny(got, `/\`) || strings.Contains(got, "..") { + t.Errorf("slug(%q) = %q, which can escape the directory", nasty, got) + } + } +} |
