This commit is contained in:
Igor Chubin 2024-01-03 17:00:06 +01:00
commit dd87ab5076
63 changed files with 4401 additions and 1104 deletions

30
.golangci.yaml Normal file
View file

@ -0,0 +1,30 @@
run:
skip-dirs:
- pkg/curlator
linters:
enable-all: true
disable:
- wsl
- wrapcheck
- varnamelen
- gci
- exhaustivestruct
- exhaustruct
- gomnd
- gofmt
# to be fixed:
- ireturn
- gosec
- noctx
- interfacer
# deprecated:
- scopelint
- deadcode
- varcheck
- maligned
- ifshort
- nosnakecase
- structcheck
- golint

View file

@ -13,7 +13,7 @@ RUN go get -u github.com/mattn/go-colorable && \
cd /app && CGO_ENABLED=0 go build . cd /app && CGO_ENABLED=0 go build .
# Application stage # Application stage
FROM alpine:3 FROM alpine:3.16
WORKDIR /app WORKDIR /app

9
Makefile Normal file
View file

@ -0,0 +1,9 @@
srv: srv.go internal/*/*.go internal/*/*/*.go
go build -o srv -ldflags '-w -linkmode external -extldflags "-static"' ./
#go build -o srv ./
go-test:
go test ./...
lint:
golangci-lint run ./...

View file

@ -11,7 +11,7 @@ intended to demonstrate the power of the console-oriented services,
You can see it running here: [wttr.in](https://wttr.in). You can see it running here: [wttr.in](https://wttr.in).
[Documentation](https://wttr.in/:help) | [Usage](https://github.com/chubin/wttr.in#usage) | [One-line output](https://github.com/chubin/wttr.in#one-line-output) | [Data-rich output format](https://github.com/chubin/wttr.in#data-rich-output-format-v2) | [Map view](https://github.com/chubin/wttr.in#map-view-v3) | [Output formats](https://github.com/chubin/wttr.in#different-output-formats) | [Moon phases](https://github.com/chubin/wttr.in#moon-phases) | [Internationalization](https://github.com/chubin/wttr.in#internationalization-and-localization) | [Windows issues](https://github.com/chubin/wttr.in#windows-users) | [Installation](https://github.com/chubin/wttr.in#installation) [Documentation](https://wttr.in/:help) | [Usage](https://github.com/chubin/wttr.in#usage) | [One-line output](https://github.com/chubin/wttr.in#one-line-output) | [Data-rich output format](https://github.com/chubin/wttr.in#data-rich-output-format-v2) | [Map view](https://github.com/chubin/wttr.in#map-view-v3) | [Output formats](https://github.com/chubin/wttr.in#different-output-formats) | [Moon phases](https://github.com/chubin/wttr.in#moon-phases) | [Internationalization](https://github.com/chubin/wttr.in#internationalization-and-localization) | [Installation](https://github.com/chubin/wttr.in#installation)
## Usage ## Usage
@ -21,18 +21,15 @@ You can access the service from a shell or from a Web browser like this:
Weather for City: Paris, France Weather for City: Paris, France
\ / Clear \ / Clear
.-. 10 11 °C .-. 10 11 °C
― ( ) ― ↑ 11 km/h ― ( ) ― ↑ 11 km/h
`- 10 km `- 10 km
/ \ 0.0 mm / \ 0.0 mm
Here is an actual weather report for your location (it's live!): Here is an example weather report:
![Weather Report](https://wttr.in/San-Francisco.png?) ![Weather Report](San_Francisco.png)
(It's not your actual location - GitHub's CDN hides your real IP address with its own IP address,
but it's still a live weather report in your language.)
Or in PowerShell: Or in PowerShell:
@ -78,7 +75,6 @@ To get detailed information online, you can access the [/:help](https://wttr.in/
$ curl wttr.in/:help $ curl wttr.in/:help
### Weather Units ### Weather Units
By default the USCS units are used for the queries from the USA and the metric system for the rest of the world. By default the USCS units are used for the queries from the USA and the metric system for the rest of the world.
@ -112,6 +108,10 @@ To force plain text, which disables colors:
$ curl wttr.in/?T $ curl wttr.in/?T
To restrict output to glyphs available in standard console fonts (e.g. Consolas and Lucida Console):
$ curl wttr.in/?d
The PNG format can be forced by adding `.png` to the end of the query: The PNG format can be forced by adding `.png` to the end of the query:
$ wget wttr.in/Paris.png $ wget wttr.in/Paris.png
@ -227,17 +227,19 @@ set -g status-right "$WEATHER ..."
``` ```
![wttr.in in tmux status bar](https://wttr.in/files/example-tmux-status-line.png) ![wttr.in in tmux status bar](https://wttr.in/files/example-tmux-status-line.png)
### Weechat ### WeeChat
To embed in to an IRC ([Weechat](https://github.com/weechat/weechat)) client's existing status bar: To embed in to an IRC ([WeeChat](https://github.com/weechat/weechat)) client's existing status bar:
``` ```
/alias add wttr /exec -pipe "/set plugins.var.python.text_item.wttr all" url:wttr.in/Montreal?format=%l:+%c+%f+%h+%p+%P+%m+%w+%S+%s /alias add wttr /exec -pipe "/mute /set plugins.var.wttr" url:wttr.in/Montreal?format=%l:+%c+%f+%h+%p+%P+%m+%w+%S+%s;/wait 3 /item refresh wttr
/trigger add wttr timer 60000;0;0 "" "" "/wttr" /trigger add wttr timer 60000;0;0 "" "" "/wttr"
/eval /set weechat.bar.status.items ${weechat.bar.status.items},wttr /item add wttr "" "${plugins.var.wttr}"
/eval /set weechat.bar.status.items ${weechat.bar.status.items},spacer,wttr
/eval /set weechat.startup.command_after_plugins ${weechat.startup.command_after_plugins};/wttr /eval /set weechat.startup.command_after_plugins ${weechat.startup.command_after_plugins};/wttr
/wttr
``` ```
![wttr.in in weechat status bar](https://i.imgur.com/IyvbxjL.png) ![wttr.in in WeeChat status bar](https://i.imgur.com/XkYiRU7.png)
### conky ### conky
@ -245,12 +247,12 @@ To embed in to an IRC ([Weechat](https://github.com/weechat/weechat)) client's e
Conky usage example: Conky usage example:
``` ```
${texeci 1800 curl wttr.in/kyiv_0pq_lang=uk.png ${texeci 1800 curl wttr.in/kyiv_0pq_lang=uk.png
| convert - -transparent black $HOME/.config/conky/out.png} | convert - -transparent black $HOME/.config/conky/out.png}
${image $HOME/.config/conky/out.png -p 0,0} ${image $HOME/.config/conky/out.png -p 0,0}
``` ```
![wttr.in in weechat status bar](https://user-images.githubusercontent.com/3875145/172178453-9e9ed9e3-9815-426a-9a21-afdd6e279fc8.png) ![wttr.in in conky](https://user-images.githubusercontent.com/3875145/172178453-9e9ed9e3-9815-426a-9a21-afdd6e279fc8.png)
### Emojis support ### Emojis support
@ -443,7 +445,7 @@ Most of these values are self-explanatory, aside from `weatherCode`. The `weathe
### Prometheus Metrics Output ### Prometheus Metrics Output
The [Prometheus](https://github.com/prometheus/prometheus) Metrics format is a feature providing access to *wttr.in* data through an easy-to-parse format for monitoring systems, without requiring the user to create a complex script to reinterpret wttr.in's graphical output. The [Prometheus](https://github.com/prometheus/prometheus) Metrics format is a feature providing access to *wttr.in* data through an easy-to-parse format for monitoring systems, without requiring the user to create a complex script to reinterpret wttr.in's graphical output.
To fetch information in Prometheus format, use the following syntax: To fetch information in Prometheus format, use the following syntax:
@ -549,19 +551,6 @@ in your language.
![Queries to wttr.in in various languages](https://pbs.twimg.com/media/C7hShiDXQAES6z1.jpg) ![Queries to wttr.in in various languages](https://pbs.twimg.com/media/C7hShiDXQAES6z1.jpg)
## Windows Users
There are currently two Windows related issues that prevent the examples found on this page from working exactly as expected out of the box. Until Microsoft fixes the issues, there are a few workarounds. To circumvent both issues you may use a shell such as `bash` on the [Windows Subsystem for Linux (WSL)](https://docs.microsoft.com/en-us/windows/wsl/install-win10) or read on for alternative solutions.
### Garbage characters in the output
There is a limitation of the current Win32 version of `curl`. Until the [Win32 curl issue](https://github.com/chubin/wttr.in/issues/18#issuecomment-474145551) is resolved and rolled out in a future Windows release, it is recommended that you use Powershells `Invoke-Web-Request` command instead:
- `(Invoke-WebRequest https://wttr.in).Content`
### Missing or double wide diagonal wind direction characters
The second issue is regarding the width of the diagonal arrow glyphs that some Windows Terminal Applications such as the default `conhost.exe` use. At the time of writing this, `ConEmu.exe`, `ConEmu64.exe` and Terminal Applications built on top of ConEmu such as Cmder (`cmder.exe`) use these double-wide glyphs by default. The result is the same with all of these programs, either a missing character for certain wind directions or a broken table in the output or both. Some third-party Terminal Applications have addressed the wind direction glyph issue but that fix depends on the font and the Terminal Application you are using.
One way to display the diagonal wind direction glyphs in your Terminal Application is to use [Windows Terminal](https://www.microsoft.com/en-us/p/windows-terminal-preview/9n0dx20hk701?activetab=pivot:overviewtab) which is currently available in the [Microsoft Store](https://www.microsoft.com/en-us/p/windows-terminal-preview/9n0dx20hk701?activetab=pivot:overviewtab). Windows Terminal is currently a preview release and will be rolled out as the default Terminal Application in an upcoming release. If your output is still skewed after using Windows Terminal then try maximizing the terminal window.
Another way you can display the diagonal wind direction is to swap out the problematic characters with forward and backward slashes as shown [here](https://github.com/chubin/wttr.in/issues/18#issuecomment-405640892).
## Installation ## Installation
To install the application: To install the application:
@ -581,9 +570,9 @@ wttr.in has the following external dependencies:
* [wego](https://github.com/schachmat/wego), weather client for terminal * [wego](https://github.com/schachmat/wego), weather client for terminal
After you install [golang](https://golang.org/doc/install), install `wego`: After you install [golang](https://golang.org/doc/install), install `wego`:
```bash
$ go get -u github.com/schachmat/wego go install github.com/schachmat/wego@latest
$ go install github.com/schachmat/wego ```
### Install Python dependencies ### Install Python dependencies
@ -605,13 +594,15 @@ You can install most of them using `pip`.
Some python package use LLVM, so install it first: Some python package use LLVM, so install it first:
$ apt-get install llvm-7 llvm-7-dev ```bash
apt-get install llvm-7 llvm-7-dev
```
If `virtualenv` is used: If `virtualenv` is used:
```bash
$ virtualenv -p python3 ve virtualenv -p python3 ve
$ ve/bin/pip3 install -r requirements.txt ve/bin/pip3 install -r requirements.txt
$ ve/bin/python3 bin/srv.py ve/bin/python3 bin/srv.py
```
Also, you need to install the geoip2 database. Also, you need to install the geoip2 database.
You can use a free database GeoLite2 that can be downloaded from (http://dev.maxmind.com/geoip/geoip2/geolite2/). You can use a free database GeoLite2 that can be downloaded from (http://dev.maxmind.com/geoip/geoip2/geolite2/).

BIN
San_Francisco.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View file

@ -1,79 +0,0 @@
package main
import (
"log"
"net/http"
"sync"
"time"
"github.com/robfig/cron"
)
var peakRequest30 sync.Map
var peakRequest60 sync.Map
func initPeakHandling() {
c := cron.New()
// cronTime := fmt.Sprintf("%d,%d * * * *", 30-prefetchInterval/60, 60-prefetchInterval/60)
c.AddFunc("24 * * * *", prefetchPeakRequests30)
c.AddFunc("54 * * * *", prefetchPeakRequests60)
c.Start()
}
func savePeakRequest(cacheDigest string, r *http.Request) {
_, min, _ := time.Now().Clock()
if min == 30 {
peakRequest30.Store(cacheDigest, *r)
} else if min == 0 {
peakRequest60.Store(cacheDigest, *r)
}
}
func prefetchRequest(r *http.Request) {
processRequest(r)
}
func syncMapLen(sm *sync.Map) int {
count := 0
f := func(key, value interface{}) bool {
// Not really certain about this part, don't know for sure
// if this is a good check for an entry's existence
if key == "" {
return false
}
count++
return true
}
sm.Range(f)
return count
}
func prefetchPeakRequests(peakRequestMap *sync.Map) {
peakRequestLen := syncMapLen(peakRequestMap)
log.Printf("PREFETCH: Prefetching %d requests\n", peakRequestLen)
if peakRequestLen == 0 {
return
}
sleepBetweenRequests := time.Duration(prefetchInterval*1000/peakRequestLen) * time.Millisecond
peakRequestMap.Range(func(key interface{}, value interface{}) bool {
go func(r http.Request) {
prefetchRequest(&r)
}(value.(http.Request))
peakRequestMap.Delete(key)
time.Sleep(sleepBetweenRequests)
return true
})
}
func prefetchPeakRequests30() {
prefetchPeakRequests(&peakRequest30)
}
func prefetchPeakRequests60() {
prefetchPeakRequests(&peakRequest60)
}

View file

@ -1,199 +0,0 @@
package main
import (
"fmt"
"io/ioutil"
"log"
"math/rand"
"net"
"net/http"
"strings"
"time"
)
func processRequest(r *http.Request) responseWithHeader {
var response responseWithHeader
if response, ok := redirectInsecure(r); ok {
return *response
}
if dontCache(r) {
return get(r)
}
cacheDigest := getCacheDigest(r)
foundInCache := false
savePeakRequest(cacheDigest, r)
cacheBody, ok := lruCache.Get(cacheDigest)
if ok {
cacheEntry := cacheBody.(responseWithHeader)
// if after all attempts we still have no answer,
// we try to make the query on our own
for attempts := 0; attempts < 300; attempts++ {
if !ok || !cacheEntry.InProgress {
break
}
time.Sleep(30 * time.Millisecond)
cacheBody, ok = lruCache.Get(cacheDigest)
cacheEntry = cacheBody.(responseWithHeader)
}
if cacheEntry.InProgress {
log.Printf("TIMEOUT: %s\n", cacheDigest)
}
if ok && !cacheEntry.InProgress && cacheEntry.Expires.After(time.Now()) {
response = cacheEntry
foundInCache = true
}
}
if !foundInCache {
lruCache.Add(cacheDigest, responseWithHeader{InProgress: true})
response = get(r)
if response.StatusCode == 200 || response.StatusCode == 304 || response.StatusCode == 404 {
lruCache.Add(cacheDigest, response)
} else {
log.Printf("REMOVE: %d response for %s from cache\n", response.StatusCode, cacheDigest)
lruCache.Remove(cacheDigest)
}
}
return response
}
func get(req *http.Request) responseWithHeader {
client := &http.Client{}
queryURL := fmt.Sprintf("http://%s%s", req.Host, req.RequestURI)
proxyReq, err := http.NewRequest(req.Method, queryURL, req.Body)
if err != nil {
log.Printf("Request: %s\n", err)
}
// proxyReq.Header.Set("Host", req.Host)
// proxyReq.Header.Set("X-Forwarded-For", req.RemoteAddr)
for header, values := range req.Header {
for _, value := range values {
proxyReq.Header.Add(header, value)
}
}
res, err := client.Do(proxyReq)
if err != nil {
panic(err)
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Println(err)
}
return responseWithHeader{
InProgress: false,
Expires: time.Now().Add(time.Duration(randInt(1000, 1500)) * time.Second),
Body: body,
Header: res.Header,
StatusCode: res.StatusCode,
}
}
// implementation of the cache.get_signature of original wttr.in
func getCacheDigest(req *http.Request) string {
userAgent := req.Header.Get("User-Agent")
queryHost := req.Host
queryString := req.RequestURI
clientIPAddress := readUserIP(req)
lang := req.Header.Get("Accept-Language")
return fmt.Sprintf("%s:%s%s:%s:%s", userAgent, queryHost, queryString, clientIPAddress, lang)
}
// return true if request should not be cached
func dontCache(req *http.Request) bool {
// dont cache cyclic requests
loc := strings.Split(req.RequestURI, "?")[0]
return strings.Contains(loc, ":")
}
// redirectInsecure returns redirection response, and bool value, if redirection was needed,
// if the query comes from a browser, and it is insecure.
//
// Insecure queries are marked by the frontend web server
// with X-Forwarded-Proto header:
//
// proxy_set_header X-Forwarded-Proto $scheme;
//
//
func redirectInsecure(req *http.Request) (*responseWithHeader, bool) {
if isPlainTextAgent(req.Header.Get("User-Agent")) {
return nil, false
}
if strings.ToLower(req.Header.Get("X-Forwarded-Proto")) == "https" {
return nil, false
}
target := "https://" + req.Host + req.URL.Path
if len(req.URL.RawQuery) > 0 {
target += "?" + req.URL.RawQuery
}
body := []byte(fmt.Sprintf(`<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="%s">here</A>.
</BODY></HTML>
`, target))
return &responseWithHeader{
InProgress: false,
Expires: time.Now().Add(time.Duration(randInt(1000, 1500)) * time.Second),
Body: body,
Header: http.Header{"Location": []string{target}},
StatusCode: 301,
}, true
}
// isPlainTextAgent returns true if userAgent is a plain-text agent
func isPlainTextAgent(userAgent string) bool {
userAgentLower := strings.ToLower(userAgent)
for _, signature := range plainTextAgents {
if strings.Contains(userAgentLower, signature) {
return true
}
}
return false
}
func readUserIP(r *http.Request) string {
IPAddress := r.Header.Get("X-Real-Ip")
if IPAddress == "" {
IPAddress = r.Header.Get("X-Forwarded-For")
}
if IPAddress == "" {
IPAddress = r.RemoteAddr
var err error
IPAddress, _, err = net.SplitHostPort(IPAddress)
if err != nil {
log.Printf("ERROR: userip: %q is not IP:port\n", IPAddress)
}
}
return IPAddress
}
func randInt(min int, max int) int {
return min + rand.Intn(max-min)
}

View file

@ -1,87 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"time"
lru "github.com/hashicorp/golang-lru"
)
const serverPort = 8083
const uplinkSrvAddr = "127.0.0.1:9002"
const uplinkTimeout = 30
const prefetchInterval = 300
const lruCacheSize = 12800
// plainTextAgents contains signatures of the plain-text agents
var plainTextAgents = []string{
"curl",
"httpie",
"lwp-request",
"wget",
"python-httpx",
"python-requests",
"openbsd ftp",
"powershell",
"fetch",
"aiohttp",
"http_get",
"xh",
}
var lruCache *lru.Cache
type responseWithHeader struct {
InProgress bool // true if the request is being processed
Expires time.Time // expiration time of the cache entry
Body []byte
Header http.Header
StatusCode int // e.g. 200
}
func init() {
var err error
lruCache, err = lru.New(lruCacheSize)
if err != nil {
panic(err)
}
dialer := &net.Dialer{
Timeout: uplinkTimeout * time.Second,
KeepAlive: uplinkTimeout * time.Second,
DualStack: true,
}
http.DefaultTransport.(*http.Transport).DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, uplinkSrvAddr)
}
initPeakHandling()
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// printStat()
response := processRequest(r)
copyHeader(w.Header(), response.Header)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(response.StatusCode)
w.Write(response.Body)
})
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", serverPort), nil))
}

View file

@ -1,40 +0,0 @@
package main
// import (
// "log"
// "sync"
// "time"
// )
//
// type safeCounter struct {
// v map[int]int
// mux sync.Mutex
// }
//
// func (c *safeCounter) inc(key int) {
// c.mux.Lock()
// c.v[key]++
// c.mux.Unlock()
// }
//
// // func (c *safeCounter) val(key int) int {
// // c.mux.Lock()
// // defer c.mux.Unlock()
// // return c.v[key]
// // }
// //
// // func (c *safeCounter) reset(key int) int {
// // c.mux.Lock()
// // defer c.mux.Unlock()
// // result := c.v[key]
// // c.v[key] = 0
// // return result
// // }
//
// var queriesPerMinute safeCounter
//
// func printStat() {
// _, min, _ := time.Now().Clock()
// queriesPerMinute.inc(min)
// log.Printf("Processed %d requests\n", min)
// }

26
go.mod Normal file
View file

@ -0,0 +1,26 @@
module github.com/chubin/wttr.in
go 1.16
require (
github.com/alecthomas/kong v0.7.1 // indirect
github.com/denisenkom/go-mssqldb v0.0.0-20200910202707-1e08a3fab204 // indirect
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect
github.com/hashicorp/golang-lru v0.6.0
github.com/itchyny/gojq v0.12.11 // indirect
github.com/klauspost/lctime v0.1.0 // indirect
github.com/lib/pq v1.8.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mattn/go-sqlite3 v1.14.16 // indirect
github.com/robfig/cron v1.2.0
github.com/samonzeweb/godb v1.0.8 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/smartystreets/assertions v1.2.0 // indirect
github.com/smartystreets/goconvey v1.6.4 // indirect
github.com/stretchr/testify v1.8.1 // indirect
github.com/zsefvlol/timezonemapper v1.0.0 // indirect
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

80
go.sum Normal file
View file

@ -0,0 +1,80 @@
github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA=
github.com/alecthomas/kong v0.7.1 h1:azoTh0IOfwlAX3qN9sHWTxACE2oV8Bg2gAwBsMwDQY4=
github.com/alecthomas/kong v0.7.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U=
github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20200910202707-1e08a3fab204/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4=
github.com/hashicorp/golang-lru v0.6.0/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/itchyny/gojq v0.12.11 h1:YhLueoHhHiN4mkfM+3AyJV6EPcCxKZsOnYf+aVSwaQw=
github.com/itchyny/gojq v0.12.11/go.mod h1:o3FT8Gkbg/geT4pLI0tF3hvip5F3Y/uskjRz9OYa38g=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/lctime v0.1.0 h1:nINsuFc860M9cyYhT6vfg6U1USh7kiVBj/s/2b04U70=
github.com/klauspost/lctime v0.1.0/go.mod h1:OwdMhr8tbQvusAsnilqkkgDQqivWlqyg0w5cfXkLiDk=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/samonzeweb/godb v1.0.8 h1:WRn6nq0FChYOzh+w8SgpXHUkEhL7W6ZqkCf5Ninx7Uc=
github.com/samonzeweb/godb v1.0.8/go.mod h1:LNDt3CakfBwpRY4AD0y/QPTbj+jB6O17tSxQES0p47o=
github.com/samonzeweb/godb v1.0.15 h1:HyNb8o1w109as9KWE8ih1YIBe8jC4luJ22f1XNacW38=
github.com/samonzeweb/godb v1.0.15/go.mod h1:SxCHqyireDXNrZApknS9lGUEutA43x9eJF632ecbK5Q=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/zsefvlol/timezonemapper v1.0.0 h1:HXqkOzf01gXYh2nDQcDSROikFgMaximnhE8BY9SyF6E=
github.com/zsefvlol/timezonemapper v1.0.0/go.mod h1:cVUCOLEmc/VvOMusEhpd2G/UBtadL26ZVz2syODXDoQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

179
internal/config/config.go Normal file
View file

@ -0,0 +1,179 @@
package config
import (
"log"
"os"
"gopkg.in/yaml.v3"
"github.com/chubin/wttr.in/internal/types"
"github.com/chubin/wttr.in/internal/util"
)
// Config of the program.
type Config struct {
Cache
Geo
Logging
Server
Uplink
}
// Logging configuration.
type Logging struct {
// AccessLog path.
AccessLog string `yaml:"accessLog,omitempty"`
// ErrorsLog path.
ErrorsLog string `yaml:"errorsLog,omitempty"`
// Interval between access log flushes, in seconds.
Interval int `yaml:"interval,omitempty"`
}
// Server configuration.
type Server struct {
// PortHTTP is port where HTTP server must listen.
// If 0, HTTP is disabled.
PortHTTP int `yaml:"portHttp,omitempty"`
// PortHTTP is port where the HTTPS server must listen.
// If 0, HTTPS is disabled.
PortHTTPS int `yaml:"portHttps,omitempty"`
// TLSCertFile contains path to cert file for TLS Server.
TLSCertFile string `yaml:"tlsCertFile,omitempty"`
// TLSCertFile contains path to key file for TLS Server.
TLSKeyFile string `yaml:"tlsKeyFile,omitempty"`
}
// Uplink configuration.
type Uplink struct {
// Address contains address of the uplink server in form IP:PORT.
Address string `yaml:"address,omitempty"`
// Timeout for upstream queries.
Timeout int `yaml:"timeout,omitempty"`
// PrefetchInterval contains time (in milliseconds) indicating,
// how long the prefetch procedure should take.
PrefetchInterval int `yaml:"prefetchInterval,omitempty"`
}
// Cache configuration.
type Cache struct {
// Size of the main cache.
Size int `yaml:"size,omitempty"`
}
// Geo contains geolocation configuration.
type Geo struct {
// IPCache contains the path to the IP Geodata cache.
IPCache string `yaml:"ipCache,omitempty"`
// IPCacheDB contains the path to the SQLite DB with the IP Geodata cache.
IPCacheDB string `yaml:"ipCacheDb,omitempty"`
IPCacheType types.CacheType `yaml:"ipCacheType,omitempty"`
// LocationCache contains the path to the Location Geodata cache.
LocationCache string `yaml:"locationCache,omitempty"`
// LocationCacheDB contains the path to the SQLite DB with the Location Geodata cache.
LocationCacheDB string `yaml:"locationCacheDb,omitempty"`
LocationCacheType types.CacheType `yaml:"locationCacheType,omitempty"`
Nominatim []Nominatim
}
type Nominatim struct {
Name string
// Type describes the type of the location service.
// Supported types: iq.
Type string
URL string
Token string
}
// Default contains the default configuration.
func Default() *Config {
return &Config{
Cache{
Size: 12800,
},
Geo{
IPCache: "/wttr.in/cache/ip2l",
IPCacheDB: "/wttr.in/cache/geoip.db",
IPCacheType: types.CacheTypeDB,
LocationCache: "/wttr.in/cache/loc",
LocationCacheDB: "/wttr.in/cache/geoloc.db",
LocationCacheType: types.CacheTypeDB,
Nominatim: []Nominatim{
{
Name: "locationiq",
Type: "iq",
URL: "https://eu1.locationiq.com/v1/search",
Token: os.Getenv("NOMINATIM_LOCATIONIQ"),
},
{
Name: "opencage",
Type: "opencage",
URL: "https://api.opencagedata.com/geocode/v1/json",
Token: os.Getenv("NOMINATIM_OPENCAGE"),
},
},
},
Logging{
AccessLog: "/wttr.in/log/access.log",
ErrorsLog: "/wttr.in/log/errors.log",
Interval: 300,
},
Server{
PortHTTP: 8083,
PortHTTPS: 8084,
TLSCertFile: "/wttr.in/etc/fullchain.pem",
TLSKeyFile: "/wttr.in/etc/privkey.pem",
},
Uplink{
Address: "127.0.0.1:9002",
Timeout: 30,
PrefetchInterval: 300,
},
}
}
// Load config from file.
func Load(filename string) (*Config, error) {
var (
config Config
data []byte
err error
)
data, err = os.ReadFile(filename)
if err != nil {
return nil, err
}
err = util.YamlUnmarshalStrict(data, &config)
if err != nil {
return nil, err
}
return &config, nil
}
func (c *Config) Dump() []byte {
data, err := yaml.Marshal(c)
if err != nil {
// should never happen.
log.Fatalln("config.Dump():", err)
}
return data
}

774
internal/fmt/png/colors.go Normal file
View file

@ -0,0 +1,774 @@
package main
// Source: https://www.ditig.com/downloads/256-colors.json
var ansiColorsDB = [][3]float64{
{
0, 0, 0,
},
{
128, 0, 0,
},
{
0, 128, 0,
},
{
128, 128, 0,
},
{
0, 0, 128,
},
{
128, 0, 128,
},
{
0, 128, 128,
},
{
192, 192, 192,
},
{
128, 128, 128,
},
{
255, 0, 0,
},
{
0, 255, 0,
},
{
255, 255, 0,
},
{
0, 0, 255,
},
{
255, 0, 255,
},
{
0, 255, 255,
},
{
255, 255, 255,
},
{
0, 0, 0,
},
{
0, 0, 95,
},
{
0, 0, 135,
},
{
0, 0, 175,
},
{
0, 0, 215,
},
{
0, 0, 255,
},
{
0, 95, 0,
},
{
0, 95, 95,
},
{
0, 95, 135,
},
{
0, 95, 175,
},
{
0, 95, 215,
},
{
0, 95, 255,
},
{
0, 135, 0,
},
{
0, 135, 95,
},
{
0, 135, 135,
},
{
0, 135, 175,
},
{
0, 135, 215,
},
{
0, 135, 255,
},
{
0, 175, 0,
},
{
0, 175, 95,
},
{
0, 175, 135,
},
{
0, 175, 175,
},
{
0, 175, 215,
},
{
0, 175, 255,
},
{
0, 215, 0,
},
{
0, 215, 95,
},
{
0, 215, 135,
},
{
0, 215, 175,
},
{
0, 215, 215,
},
{
0, 215, 255,
},
{
0, 255, 0,
},
{
0, 255, 95,
},
{
0, 255, 135,
},
{
0, 255, 175,
},
{
0, 255, 215,
},
{
0, 255, 255,
},
{
95, 0, 0,
},
{
95, 0, 95,
},
{
95, 0, 135,
},
{
95, 0, 175,
},
{
95, 0, 215,
},
{
95, 0, 255,
},
{
95, 95, 0,
},
{
95, 95, 95,
},
{
95, 95, 135,
},
{
95, 95, 175,
},
{
95, 95, 215,
},
{
95, 95, 255,
},
{
95, 135, 0,
},
{
95, 135, 95,
},
{
95, 135, 135,
},
{
95, 135, 175,
},
{
95, 135, 215,
},
{
95, 135, 255,
},
{
95, 175, 0,
},
{
95, 175, 95,
},
{
95, 175, 135,
},
{
95, 175, 175,
},
{
95, 175, 215,
},
{
95, 175, 255,
},
{
95, 215, 0,
},
{
95, 215, 95,
},
{
95, 215, 135,
},
{
95, 215, 175,
},
{
95, 215, 215,
},
{
95, 215, 255,
},
{
95, 255, 0,
},
{
95, 255, 95,
},
{
95, 255, 135,
},
{
95, 255, 175,
},
{
95, 255, 215,
},
{
95, 255, 255,
},
{
135, 0, 0,
},
{
135, 0, 95,
},
{
135, 0, 135,
},
{
135, 0, 175,
},
{
135, 0, 215,
},
{
135, 0, 255,
},
{
135, 95, 0,
},
{
135, 95, 95,
},
{
135, 95, 135,
},
{
135, 95, 175,
},
{
135, 95, 215,
},
{
135, 95, 255,
},
{
135, 135, 0,
},
{
135, 135, 95,
},
{
135, 135, 135,
},
{
135, 135, 175,
},
{
135, 135, 215,
},
{
135, 135, 255,
},
{
135, 175, 0,
},
{
135, 175, 95,
},
{
135, 175, 135,
},
{
135, 175, 175,
},
{
135, 175, 215,
},
{
135, 175, 255,
},
{
135, 215, 0,
},
{
135, 215, 95,
},
{
135, 215, 135,
},
{
135, 215, 175,
},
{
135, 215, 215,
},
{
135, 215, 255,
},
{
135, 255, 0,
},
{
135, 255, 95,
},
{
135, 255, 135,
},
{
135, 255, 175,
},
{
135, 255, 215,
},
{
135, 255, 255,
},
{
175, 0, 0,
},
{
175, 0, 95,
},
{
175, 0, 135,
},
{
175, 0, 175,
},
{
175, 0, 215,
},
{
175, 0, 255,
},
{
175, 95, 0,
},
{
175, 95, 95,
},
{
175, 95, 135,
},
{
175, 95, 175,
},
{
175, 95, 215,
},
{
175, 95, 255,
},
{
175, 135, 0,
},
{
175, 135, 95,
},
{
175, 135, 135,
},
{
175, 135, 175,
},
{
175, 135, 215,
},
{
175, 135, 255,
},
{
175, 175, 0,
},
{
175, 175, 95,
},
{
175, 175, 135,
},
{
175, 175, 175,
},
{
175, 175, 215,
},
{
175, 175, 255,
},
{
175, 215, 0,
},
{
175, 215, 95,
},
{
175, 215, 135,
},
{
175, 215, 175,
},
{
175, 215, 215,
},
{
175, 215, 255,
},
{
175, 255, 0,
},
{
175, 255, 95,
},
{
175, 255, 135,
},
{
175, 255, 175,
},
{
175, 255, 215,
},
{
175, 255, 255,
},
{
215, 0, 0,
},
{
215, 0, 95,
},
{
215, 0, 135,
},
{
215, 0, 175,
},
{
215, 0, 215,
},
{
215, 0, 255,
},
{
215, 95, 0,
},
{
215, 95, 95,
},
{
215, 95, 135,
},
{
215, 95, 175,
},
{
215, 95, 215,
},
{
215, 95, 255,
},
{
215, 135, 0,
},
{
215, 135, 95,
},
{
215, 135, 135,
},
{
215, 135, 175,
},
{
215, 135, 215,
},
{
215, 135, 255,
},
{
215, 175, 0,
},
{
215, 175, 95,
},
{
215, 175, 135,
},
{
215, 175, 175,
},
{
215, 175, 215,
},
{
215, 175, 255,
},
{
215, 215, 0,
},
{
215, 215, 95,
},
{
215, 215, 135,
},
{
215, 215, 175,
},
{
215, 215, 215,
},
{
215, 215, 255,
},
{
215, 255, 0,
},
{
215, 255, 95,
},
{
215, 255, 135,
},
{
215, 255, 175,
},
{
215, 255, 215,
},
{
215, 255, 255,
},
{
255, 0, 0,
},
{
255, 0, 95,
},
{
255, 0, 135,
},
{
255, 0, 175,
},
{
255, 0, 215,
},
{
255, 0, 255,
},
{
255, 95, 0,
},
{
255, 95, 95,
},
{
255, 95, 135,
},
{
255, 95, 175,
},
{
255, 95, 215,
},
{
255, 95, 255,
},
{
255, 135, 0,
},
{
255, 135, 95,
},
{
255, 135, 135,
},
{
255, 135, 175,
},
{
255, 135, 215,
},
{
255, 135, 255,
},
{
255, 175, 0,
},
{
255, 175, 95,
},
{
255, 175, 135,
},
{
255, 175, 175,
},
{
255, 175, 215,
},
{
255, 175, 255,
},
{
255, 215, 0,
},
{
255, 215, 95,
},
{
255, 215, 135,
},
{
255, 215, 175,
},
{
255, 215, 215,
},
{
255, 215, 255,
},
{
255, 255, 0,
},
{
255, 255, 95,
},
{
255, 255, 135,
},
{
255, 255, 175,
},
{
255, 255, 215,
},
{
255, 255, 255,
},
{
8, 8, 8,
},
{
18, 18, 18,
},
{
28, 28, 28,
},
{
38, 38, 38,
},
{
48, 48, 48,
},
{
58, 58, 58,
},
{
68, 68, 68,
},
{
78, 78, 78,
},
{
88, 88, 88,
},
{
98, 98, 98,
},
{
108, 108, 108,
},
{
118, 118, 118,
},
{
128, 128, 128,
},
{
138, 138, 138,
},
{
148, 148, 148,
},
{
158, 158, 158,
},
{
168, 168, 168,
},
{
178, 178, 178,
},
{
188, 188, 188,
},
{
198, 198, 198,
},
{
208, 208, 208,
},
{
218, 218, 218,
},
{
228, 228, 228,
},
{
238, 238, 238,
},
}

10
internal/fmt/png/go.mod Normal file
View file

@ -0,0 +1,10 @@
module example.com/m/v2
go 1.20
require (
github.com/chubin/vt10x v0.0.0-20231112153020-ef4f56837bf1 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
golang.org/x/image v0.14.0 // indirect
)

8
internal/fmt/png/go.sum Normal file
View file

@ -0,0 +1,8 @@
github.com/chubin/vt10x v0.0.0-20231112153020-ef4f56837bf1 h1:CHg5BTAJZmCjBaAAQrD92s248JHH3JTsLlaC6QBJo/Y=
github.com/chubin/vt10x v0.0.0-20231112153020-ef4f56837bf1/go.mod h1:mQssL2gI1LTqWgbffl6DESqe6QkAF67ujBdzSe4bWkU=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=

224
internal/fmt/png/png.go Normal file
View file

@ -0,0 +1,224 @@
package main
import (
"fmt"
"log"
"os"
"strings"
"github.com/chubin/vt10x"
"github.com/fogleman/gg"
)
func StringSliceToRuneSlice(s string) [][]rune {
strings := strings.Split(s, "\n")
result := make([][]rune, len(strings))
i := 0
for _, str := range strings {
if len(str) == 0 {
continue
}
result[i] = []rune(str)
i++
}
return result
}
func maxRowLength(rows [][]rune) int {
maxLen := 0
for _, row := range rows {
if len(row) > maxLen {
maxLen = len(row)
}
}
return maxLen
}
func GeneratePng() {
runes := StringSliceToRuneSlice(`
Weather report: Hochstadt an der Aisch, Germany
\ / Partly cloudy
_ /"".-. +5(2) °C
\_( ). 9 km/h
/(___(__) 10 km
0.0 mm
Sat 11 Nov
Noon Night
_'/"".-. Patchy rain po _'/"".-. Patchy rain po
,\_( ). +6(3) °C ,\_( ). +5(2) °C
/(___(__) 22-29 km/h /(___(__) 14-20 km/h
10 km 10 km
0.1 mm | 86% 0.0 mm | 89%
Sun 12 Nov
Noon Night
\ / Partly cloudy .-. Light drizzle
_ /"".-. +8(7) °C ( ). +5(2) °C
\_( ). 7-8 km/h (___(__) 13-18 km/h
/(___(__) 10 km 2 km
0.0 mm | 0% 0.3 mm | 76%
`)
// Dimensions of each rune in pixels
runeWidth := 8
runeHeight := 14
// Compute the width and height of the final image
imageWidth := runeWidth * maxRowLength(runes)
imageHeight := runeHeight * len(runes)
// Create a new context with the computed dimensions
dc := gg.NewContext(imageWidth, imageHeight)
// fontPath := "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
// fontPath := "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"
fontPath := "/usr/share/fonts/truetype/lexi/LexiGulim.ttf"
err := dc.LoadFontFace(fontPath, 13)
if err != nil {
log.Fatal(err)
}
// Loop through each rune in the array and draw it on the context
for i, row := range runes {
for j, char := range row {
// Compute the x and y coordinates for drawing the current rune
x := float64(j*runeWidth + runeWidth/2)
y := float64(i*runeHeight + runeHeight/2)
// Set the appropriate color for the current rune
if char == '#' {
dc.SetRGB(0, 0, 0) // Black
} else if char == '@' {
dc.SetRGB(1, 0, 0) // Red
} else {
dc.SetRGB(1, 1, 1) // White
}
character := string(char)
// if char == ' ' {
// character = fmt.Sprint(j % 10)
// }
dc.DrawRectangle(x, y, x+float64(runeWidth), y+float64(runeHeight))
dc.Fill()
// Draw a rectangle with the rune's dimensions and color
dc.DrawString(character, x, y) // Draw the character centered on the canvas
// dc.DrawStringAnchored(character, x, y, 0.5, 0.5) // Draw the character centered on the canvas
}
}
// Save the image to a PNG file
err = dc.SavePNG("output.png")
if err != nil {
fmt.Println("Error saving PNG:", err)
return
}
fmt.Println("PNG generated successfully")
}
func GeneratePngFromANSI(input []byte, outputFile string) error {
// Dimensions of each rune in pixels
runeWidth := 8
runeHeight := 14
fontSize := 13.0
// fontPath := "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
fontPath := "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"
imageCols := 80
imageRows := 25
// Compute the width and height of the final image
imageWidth := runeWidth * imageCols
imageHeight := runeHeight * imageRows
// Create terminal and feed it with input.
term := vt10x.New(vt10x.WithSize(imageCols, imageRows))
_, err := term.Write([]byte("\033[20h"))
if err != nil {
return fmt.Errorf("virtual terminal write error: %w", err)
}
_, err = term.Write(input)
if err != nil {
return fmt.Errorf("virtual terminal write error: %w", err)
}
// Create a new context with the computed dimensions
dc := gg.NewContext(imageWidth, imageHeight)
err = dc.LoadFontFace(fontPath, fontSize) // Set font size to 96
if err != nil {
return fmt.Errorf("error loading font: %w", err)
}
// Loop through each rune in the array and draw it on the context
for i := 0; i < imageRows; i++ {
for j := 0; j < imageCols; j++ {
// Compute the x and y coordinates for drawing the current rune
x := float64(j * runeWidth)
y := float64(i * runeHeight)
cell := term.Cell(j, i)
character := string(cell.Char)
dc.DrawRectangle(x, y, float64(runeWidth), float64(runeHeight))
bg := colorANSItoRGB(cell.BG)
dc.SetRGB(bg[0], bg[1], bg[2])
dc.Fill()
fg := colorANSItoRGB(cell.FG)
dc.SetRGB(fg[0], fg[1], fg[2])
// Draw a rectangle with the rune's dimensions and color
dc.DrawString(character, x, y+float64(runeHeight)-3) // Draw the character centered on the canvas
// dc.DrawStringAnchored(character, x, y, 0.5, 0.5) // Draw the character centered on the canvas
}
}
// Save the image to a PNG file
err = dc.SavePNG(outputFile)
if err != nil {
return fmt.Errorf("error saving png: %w", err)
}
return nil
}
func colorANSItoRGB(colorANSI vt10x.Color) [3]float64 {
defaultBG := vt10x.Color(0)
defaultFG := vt10x.Color(8)
if colorANSI == vt10x.DefaultFG {
colorANSI = defaultFG
}
if colorANSI == vt10x.DefaultBG {
colorANSI = defaultBG
}
if colorANSI > 255 {
return [3]float64{127, 127, 127}
}
return ansiColorsDB[colorANSI]
}
func main() {
data, err := os.ReadFile("zh-text.txt")
if err != nil {
log.Fatalln(err)
}
err = GeneratePngFromANSI(data, "output.png")
if err != nil {
log.Fatalln(err)
}
}

View file

@ -0,0 +1,88 @@
package ip
import (
"fmt"
"log"
"path/filepath"
"github.com/samonzeweb/godb"
"github.com/samonzeweb/godb/adapters/sqlite"
"github.com/chubin/wttr.in/internal/util"
)
//nolint:cyclop
func (c *Cache) ConvertCache() error {
dbfile := c.config.Geo.IPCacheDB
err := util.RemoveFileIfExists(dbfile)
if err != nil {
return err
}
db, err := godb.Open(sqlite.Adapter, dbfile)
if err != nil {
return err
}
err = createTable(db, "Address")
if err != nil {
return err
}
log.Println("listing cache entries...")
files, err := filepath.Glob(filepath.Join(c.config.Geo.IPCache, "*"))
if err != nil {
return err
}
log.Printf("going to convert %d entries\n", len(files))
block := []Address{}
for i, file := range files {
ip := filepath.Base(file)
loc, err := c.Read(ip)
if err != nil {
log.Println("invalid entry for", ip)
continue
}
block = append(block, *loc)
if i%1000 != 0 || i == 0 {
continue
}
err = db.BulkInsert(&block).Do()
if err != nil {
return err
}
block = []Address{}
log.Println("converted", i+1, "entries")
}
// inserting the rest.
err = db.BulkInsert(&block).Do()
if err != nil {
return err
}
log.Println("converted", len(files), "entries")
return nil
}
func createTable(db *godb.DB, tableName string) error {
createTable := fmt.Sprintf(
`create table %s (
name text not null primary key,
fullName text not null,
lat text not null,
long text not null);
`, tableName)
_, err := db.CurrentDB().Exec(createTable)
return err
}

244
internal/geo/ip/ip.go Normal file
View file

@ -0,0 +1,244 @@
package ip
import (
"fmt"
"log"
"net/http"
"os"
"path"
"regexp"
"strconv"
"strings"
"github.com/chubin/wttr.in/internal/config"
"github.com/chubin/wttr.in/internal/routing"
"github.com/chubin/wttr.in/internal/types"
"github.com/chubin/wttr.in/internal/util"
"github.com/samonzeweb/godb"
"github.com/samonzeweb/godb/adapters/sqlite"
)
// Address information.
type Address struct {
IP string `db:"ip,key"`
CountryCode string `db:"countryCode"`
Country string `db:"country"`
Region string `db:"region"`
City string `db:"city"`
Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"`
}
func (l *Address) String() string {
if l.Latitude == -1000 {
return fmt.Sprintf(
"%s;%s;%s;%s",
l.CountryCode, l.Country, l.Region, l.City)
}
return fmt.Sprintf(
"%s;%s;%s;%s;%v;%v",
l.CountryCode, l.Country, l.Region, l.City, l.Latitude, l.Longitude)
}
// Cache provides access to the IP Geodata cache.
type Cache struct {
config *config.Config
db *godb.DB
}
// NewCache returns new cache reader for the specified config.
func NewCache(config *config.Config) (*Cache, error) {
db, err := godb.Open(sqlite.Adapter, config.Geo.IPCacheDB)
if err != nil {
return nil, err
}
// Needed for "upsert" implementation in Put()
db.UseErrorParser()
return &Cache{
config: config,
db: db,
}, nil
}
// Read returns location information from the cache, if found,
// or types.ErrNotFound if not found. If the entry is found, but its format
// is invalid, types.ErrInvalidCacheEntry is returned.
//
// Format:
//
// [CountryCode];Country;Region;City;[Latitude];[Longitude]
//
// Example:
//
// DE;Germany;Free and Hanseatic City of Hamburg;Hamburg;53.5736;9.9782
//
func (c *Cache) Read(addr string) (*Address, error) {
if c.config.Geo.IPCacheType == types.CacheTypeDB {
return c.readFromCacheDB(addr)
}
return c.readFromCacheFile(addr)
}
func (c *Cache) readFromCacheFile(addr string) (*Address, error) {
bytes, err := os.ReadFile(c.cacheFile(addr))
if err != nil {
return nil, types.ErrNotFound
}
return NewAddressFromString(addr, string(bytes))
}
func (c *Cache) readFromCacheDB(addr string) (*Address, error) {
result := Address{}
err := c.db.Select(&result).
Where("IP = ?", addr).
Do()
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Cache) Put(addr string, loc *Address) error {
if c.config.Geo.IPCacheType == types.CacheTypeDB {
return c.putToCacheDB(loc)
}
return c.putToCacheFile(addr, loc)
}
func (c *Cache) putToCacheDB(loc *Address) error {
err := c.db.Insert(loc).Do()
// it should work like this:
//
// target := dberror.UniqueConstraint{}
// if errors.As(err, &target) {
//
// See: https://github.com/samonzeweb/godb/pull/23
//
// But for some reason it does not work,
// so the dirty hack is used:
if strings.Contains(fmt.Sprint(err), "UNIQUE constraint failed") {
return c.db.Update(loc).Do()
}
return err
}
func (c *Cache) putToCacheFile(addr string, loc fmt.Stringer) error {
return os.WriteFile(c.cacheFile(addr), []byte(loc.String()), 0o600)
}
// cacheFile returns path to the cache entry for addr.
func (c *Cache) cacheFile(addr string) string {
return path.Join(c.config.Geo.IPCache, addr)
}
// NewAddressFromString parses the location cache entry s,
// and return location, or error, if the cache entry is invalid.
func NewAddressFromString(addr, s string) (*Address, error) {
var (
lat float64 = -1000
long float64 = -1000
err error
)
parts := strings.Split(s, ";")
if len(parts) < 4 {
return nil, types.ErrInvalidCacheEntry
}
if len(parts) >= 6 {
lat, err = strconv.ParseFloat(parts[4], 64)
if err != nil {
return nil, types.ErrInvalidCacheEntry
}
long, err = strconv.ParseFloat(parts[5], 64)
if err != nil {
return nil, types.ErrInvalidCacheEntry
}
}
return &Address{
IP: addr,
CountryCode: parts[0],
Country: parts[1],
Region: parts[2],
City: parts[3],
Latitude: lat,
Longitude: long,
}, nil
}
// Response provides routing interface to the geo cache.
//
// Temporary workaround to switch IP addresses handling to the Go server.
// Handles two queries:
//
// - /:geo-ip-put?ip=IP&value=VALUE
// - /:geo-ip-get?ip=IP
//
//nolint:cyclop
func (c *Cache) Response(r *http.Request) *routing.Cadre {
var (
respERR = &routing.Cadre{Body: []byte("ERR")}
respOK = &routing.Cadre{Body: []byte("OK")}
)
if ip := util.ReadUserIP(r); ip != "127.0.0.1" {
log.Printf("geoIP access from %s rejected\n", ip)
return nil
}
if r.URL.Path == "/:geo-ip-put" {
ip := r.URL.Query().Get("ip")
value := r.URL.Query().Get("value")
if !validIP4(ip) || value == "" {
log.Printf("invalid geoIP put query: ip='%s' value='%s'\n", ip, value)
return respERR
}
location, err := NewAddressFromString(ip, value)
if err != nil {
return respERR
}
err = c.Put(ip, location)
if err != nil {
return respERR
}
return respOK
}
if r.URL.Path == "/:geo-ip-get" {
ip := r.URL.Query().Get("ip")
if !validIP4(ip) {
return respERR
}
result, err := c.Read(ip)
if result == nil || err != nil {
return respERR
}
return &routing.Cadre{Body: []byte(result.String())}
}
return nil
}
func validIP4(ipAddress string) bool {
re := regexp.MustCompile(
`^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$`)
return re.MatchString(strings.Trim(ipAddress, " "))
}

View file

@ -0,0 +1,84 @@
package ip_test
import (
"testing"
"github.com/stretchr/testify/require"
. "github.com/chubin/wttr.in/internal/geo/ip"
"github.com/chubin/wttr.in/internal/types"
)
//nolint:funlen
func TestParseCacheEntry(t *testing.T) {
t.Parallel()
tests := []struct {
addr string
input string
expected Address
err error
}{
{
"1.2.3.4",
"DE;Germany;Free and Hanseatic City of Hamburg;Hamburg;53.5736;9.9782",
Address{
IP: "1.2.3.4",
CountryCode: "DE",
Country: "Germany",
Region: "Free and Hanseatic City of Hamburg",
City: "Hamburg",
Latitude: 53.5736,
Longitude: 9.9782,
},
nil,
},
{
"1.2.3.4",
"ES;Spain;Madrid, Comunidad de;Madrid;40.4165;-3.70256;28223;Orange Espagne SA;orange.es",
Address{
IP: "1.2.3.4",
CountryCode: "ES",
Country: "Spain",
Region: "Madrid, Comunidad de",
City: "Madrid",
Latitude: 40.4165,
Longitude: -3.70256,
},
nil,
},
{
"1.2.3.4",
"US;United States of America;California;Mountain View",
Address{
IP: "1.2.3.4",
CountryCode: "US",
Country: "United States of America",
Region: "California",
City: "Mountain View",
Latitude: -1000,
Longitude: -1000,
},
nil,
},
// Invalid entries
{
"1.2.3.4",
"DE;Germany;Free and Hanseatic City of Hamburg;Hamburg;53.5736;XXX",
Address{},
types.ErrInvalidCacheEntry,
},
}
for _, tt := range tests {
result, err := NewAddressFromString(tt.addr, tt.input)
if tt.err == nil {
require.NoError(t, err)
require.Equal(t, *result, tt.expected)
} else {
require.ErrorIs(t, err, tt.err)
}
}
}

View file

@ -0,0 +1,218 @@
package location
import (
"encoding/json"
"errors"
"fmt"
"os"
"path"
"strconv"
"strings"
"github.com/samonzeweb/godb"
"github.com/samonzeweb/godb/adapters/sqlite"
log "github.com/sirupsen/logrus"
"github.com/zsefvlol/timezonemapper"
"github.com/chubin/wttr.in/internal/config"
"github.com/chubin/wttr.in/internal/types"
)
// Cache is an implemenation of DB/file-based cache.
//
// At the moment, it is an implementation for the location cache,
// but it should be generalized to cache everything.
type Cache struct {
config *config.Config
db *godb.DB
searcher *Searcher
indexField string
filesCacheDir string
}
// NewCache returns new cache reader for the specified config.
func NewCache(config *config.Config) (*Cache, error) {
var (
db *godb.DB
err error
)
if config.Geo.LocationCacheType == types.CacheTypeDB {
log.Debugln("using db for location cache")
db, err = godb.Open(sqlite.Adapter, config.Geo.LocationCacheDB)
if err != nil {
return nil, err
}
log.Debugln("db file:", config.Geo.LocationCacheDB)
// Needed for "upsert" implementation in Put()
db.UseErrorParser()
}
return &Cache{
config: config,
db: db,
indexField: "name",
filesCacheDir: config.Geo.LocationCache,
searcher: NewSearcher(config),
}, nil
}
// Resolve returns location information for specified location.
// If the information is found in the cache, it is returned.
// If it is not found, the external service is queried,
// and the result is stored in the cache.
func (c *Cache) Resolve(location string) (*Location, error) {
location = normalizeLocationName(location)
loc, err := c.Read(location)
if !errors.Is(err, types.ErrNotFound) {
return loc, err
}
log.Debugln("geo/location: not found in cache:", location)
loc, err = c.searcher.Search(location)
if err != nil {
return nil, err
}
loc.Name = location
loc.Timezone = latLngToTimezoneString(loc.Lat, loc.Lon)
err = c.Put(location, loc)
if err != nil {
return nil, err
}
return loc, nil
}
// Read returns location information from the cache, if found,
// or types.ErrNotFound if not found. If the entry is found, but its format
// is invalid, types.ErrInvalidCacheEntry is returned.
func (c *Cache) Read(addr string) (*Location, error) {
if c.config.Geo.LocationCacheType == types.CacheTypeFiles {
return c.readFromCacheFile(addr)
}
return c.readFromCacheDB(addr)
}
func (c *Cache) readFromCacheFile(name string) (*Location, error) {
var (
fileLoc = struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Timezone string `json:"timezone"`
Address string `json:"address"`
}{}
location Location
)
bytes, err := os.ReadFile(c.cacheFile(name))
if err != nil {
return nil, types.ErrNotFound
}
err = json.Unmarshal(bytes, &fileLoc)
if err != nil {
return nil, err
}
// normalize name
name = strings.TrimSpace(
strings.TrimRight(
strings.TrimLeft(name, `"`), `"`))
timezone := fileLoc.Timezone
if timezone == "" {
timezone = timezonemapper.LatLngToTimezoneString(fileLoc.Latitude, fileLoc.Longitude)
}
location = Location{
Name: name,
Lat: fmt.Sprint(fileLoc.Latitude),
Lon: fmt.Sprint(fileLoc.Longitude),
Timezone: timezone,
Fullname: fileLoc.Address,
}
return &location, nil
}
func (c *Cache) readFromCacheDB(addr string) (*Location, error) {
result := Location{}
err := c.db.Select(&result).
Where(c.indexField+" = ?", addr).
Do()
if strings.Contains(fmt.Sprint(err), "no rows in result set") {
return nil, types.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("readFromCacheDB: %w", err)
}
return &result, nil
}
func (c *Cache) Put(addr string, loc *Location) error {
log.Infoln("geo/location: storing in cache:", loc)
if c.config.Geo.IPCacheType == types.CacheTypeDB {
return c.putToCacheDB(loc)
}
return c.putToCacheFile(addr, loc)
}
func (c *Cache) putToCacheDB(loc *Location) error {
err := c.db.Insert(loc).Do()
if strings.Contains(fmt.Sprint(err), "UNIQUE constraint failed") {
return c.db.Update(loc).Do()
}
return err
}
func (c *Cache) putToCacheFile(addr string, loc fmt.Stringer) error {
return os.WriteFile(c.cacheFile(addr), []byte(loc.String()), 0o600)
}
// cacheFile returns path to the cache entry for addr.
func (c *Cache) cacheFile(item string) string {
return path.Join(c.filesCacheDir, item)
}
// normalizeLocationName converts name into the standard location form
// with the following steps:
// - remove excessive spaces,
// - remove quotes,
// - convert to lover case.
func normalizeLocationName(name string) string {
name = strings.ReplaceAll(name, `"`, " ")
name = strings.ReplaceAll(name, `'`, " ")
name = strings.TrimSpace(name)
name = strings.Join(strings.Fields(name), " ")
return strings.ToLower(name)
}
// latLngToTimezoneString returns timezone for lat, lon,
// or an empty string if they are invalid.
func latLngToTimezoneString(lat, lon string) string {
latFloat, err := strconv.ParseFloat(lat, 64)
if err != nil {
log.Errorln("geoloc: latLngToTimezoneString:", err)
return ""
}
lonFloat, err := strconv.ParseFloat(lon, 64)
if err != nil {
log.Errorln("geoloc: latLngToTimezoneString:", err)
return ""
}
return timezonemapper.LatLngToTimezoneString(latFloat, lonFloat)
}

View file

@ -0,0 +1,145 @@
package location
import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/samonzeweb/godb"
"github.com/samonzeweb/godb/adapters/sqlite"
)
// ConvertCache converts files-based cache into the DB-based cache.
// If reset is true, the DB cache is created from scratch.
//
//nolint:funlen,cyclop
func (c *Cache) ConvertCache(reset bool) error {
var (
dbfile = c.config.Geo.LocationCacheDB
tableName = "Location"
cacheFiles = c.filesCacheDir
known = map[string]bool{}
)
if reset {
err := removeDBIfExists(dbfile)
if err != nil {
return err
}
}
db, err := godb.Open(sqlite.Adapter, dbfile)
if err != nil {
return err
}
if reset {
err = createTable(db, tableName)
if err != nil {
return err
}
}
log.Println("listing cache entries...")
files, err := filepath.Glob(filepath.Join(cacheFiles, "*"))
if err != nil {
return err
}
log.Printf("going to convert %d entries\n", len(files))
block := []Location{}
for i, file := range files {
ip := filepath.Base(file)
loc, err := c.Read(ip)
if err != nil {
log.Println("invalid entry for", ip)
continue
}
// Skip too long location names.
if len(loc.Name) > 25 {
continue
}
// Skip duplicates.
if known[loc.Name] {
log.Println("skipping", loc.Name)
continue
}
singleLocation := Location{}
err = db.Select(&singleLocation).
Where("name = ?", loc.Name).
Do()
if !errors.Is(err, sql.ErrNoRows) {
log.Println("found in db:", loc.Name)
continue
}
known[loc.Name] = true
// Skip some invalid names.
if strings.Contains(loc.Name, "\n") {
continue
}
block = append(block, *loc)
if i%1000 != 0 || i == 0 {
continue
}
log.Println("going to insert new entries")
err = db.BulkInsert(&block).Do()
if err != nil {
return err
}
block = []Location{}
log.Println("converted", i+1, "entries")
}
// inserting the rest.
err = db.BulkInsert(&block).Do()
if err != nil {
return err
}
log.Println("converted", len(files), "entries")
return nil
}
func createTable(db *godb.DB, tableName string) error {
createTable := fmt.Sprintf(
`create table %s (
name text not null primary key,
displayName text not null,
lat text not null,
lon text not null,
timezone text not null);
`, tableName)
_, err := db.CurrentDB().Exec(createTable)
return err
}
func removeDBIfExists(filename string) error {
_, err := os.Stat(filename)
if err != nil {
if !os.IsNotExist(err) {
return err
}
// no db file
return nil
}
return os.Remove(filename)
}

View file

@ -0,0 +1,25 @@
package location
import (
"encoding/json"
"log"
)
type Location struct {
Name string `db:"name,key" json:"name"`
Lat string `db:"lat" json:"latitude"`
Lon string `db:"lon" json:"longitude"`
Timezone string `db:"timezone" json:"timezone"`
Fullname string `db:"displayName" json:"address"`
}
// String returns string representation of location.
func (l *Location) String() string {
bytes, err := json.Marshal(l)
if err != nil {
// should never happen
log.Fatalln(err)
}
return string(bytes)
}

View file

@ -0,0 +1,77 @@
package location
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/chubin/wttr.in/internal/types"
log "github.com/sirupsen/logrus"
)
type Nominatim struct {
name string
url string
token string
typ string
}
type locationQuerier interface {
Query(*Nominatim, string) (*Location, error)
}
func NewNominatim(name, typ, url, token string) *Nominatim {
return &Nominatim{
name: name,
url: url,
token: token,
typ: typ,
}
}
func (n *Nominatim) Query(location string) (*Location, error) {
var data locationQuerier
switch n.typ {
case "iq":
data = &locationIQ{}
case "opencage":
data = &locationOpenCage{}
default:
return nil, fmt.Errorf("%s: %w", n.name, types.ErrUnknownLocationService)
}
return data.Query(n, location)
}
func makeQuery(url string, result interface{}) error {
var errResponse struct {
Error string
}
log.Debugln("nominatim:", url)
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
err = json.Unmarshal(body, &errResponse)
if err == nil && errResponse.Error != "" {
return fmt.Errorf("%w: %s", types.ErrUpstream, errResponse.Error)
}
log.Debugln("nominatim: response: ", string(body))
err = json.Unmarshal(body, &result)
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,39 @@
package location
import (
"fmt"
"net/url"
"github.com/chubin/wttr.in/internal/types"
)
type locationIQ []struct {
Name string `db:"name,key"`
Lat string `db:"lat"`
Lon string `db:"lon"`
//nolint:tagliatelle
Fullname string `db:"displayName" json:"display_name"`
}
func (data *locationIQ) Query(n *Nominatim, location string) (*Location, error) {
url := fmt.Sprintf(
"%s?q=%s&format=json&language=native&limit=1&key=%s",
n.url, url.QueryEscape(location), n.token)
err := makeQuery(url, data)
if err != nil {
return nil, fmt.Errorf("%s: %w", n.name, err)
}
if len(*data) != 1 {
return nil, fmt.Errorf("%w: %s: invalid response", types.ErrUpstream, n.name)
}
nl := &(*data)[0]
return &Location{
Lat: nl.Lat,
Lon: nl.Lon,
Fullname: nl.Fullname,
}, nil
}

View file

@ -0,0 +1,42 @@
package location
import (
"fmt"
"net/url"
"github.com/chubin/wttr.in/internal/types"
)
type locationOpenCage struct {
Results []struct {
Name string `db:"name,key"`
Geometry struct {
Lat float64 `db:"lat"`
Lng float64 `db:"lng"`
}
Fullname string `json:"formatted"`
} `json:"results"`
}
func (data *locationOpenCage) Query(n *Nominatim, location string) (*Location, error) {
url := fmt.Sprintf(
"%s?q=%s&language=native&limit=1&key=%s",
n.url, url.QueryEscape(location), n.token)
err := makeQuery(url, data)
if err != nil {
return nil, fmt.Errorf("%s: %w", n.name, err)
}
if len(data.Results) != 1 {
return nil, fmt.Errorf("%w: %s: invalid response", types.ErrUpstream, n.name)
}
nl := data.Results[0]
return &Location{
Lat: fmt.Sprint(nl.Geometry.Lat),
Lon: fmt.Sprint(nl.Geometry.Lng),
Fullname: nl.Fullname,
}, nil
}

View file

@ -0,0 +1,44 @@
package location
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/chubin/wttr.in/internal/routing"
)
// Response provides routing interface to the geo cache.
func (c *Cache) Response(r *http.Request) *routing.Cadre {
var (
locationName = r.URL.Query().Get("location")
loc *Location
bytes []byte
err error
)
if locationName == "" {
return errorResponse("location is not specified")
}
loc, err = c.Resolve(locationName)
if err != nil {
log.Println("geo/location error:", locationName)
return errorResponse(fmt.Sprint(err))
}
bytes, err = json.Marshal(loc)
if err != nil {
return errorResponse(fmt.Sprint(err))
}
return &routing.Cadre{Body: bytes}
}
func errorResponse(s string) *routing.Cadre {
return &routing.Cadre{Body: []byte(
fmt.Sprintf(`{"error": %q}`, s),
)}
}

View file

@ -0,0 +1,42 @@
package location
import "github.com/chubin/wttr.in/internal/config"
type Provider interface {
Query(location string) (*Location, error)
}
type Searcher struct {
providers []Provider
}
// NewSearcher returns a new Searcher for the specified config.
func NewSearcher(config *config.Config) *Searcher {
providers := []Provider{}
for _, p := range config.Geo.Nominatim {
providers = append(providers, NewNominatim(p.Name, p.Type, p.URL, p.Token))
}
return &Searcher{
providers: providers,
}
}
// Search makes queries through all known providers,
// and returns response, as soon as it is not nil.
// If all responses were nil, the last response is returned.
func (s *Searcher) Search(location string) (*Location, error) {
var (
err error
result *Location
)
for _, p := range s.providers {
result, err = p.Query(location)
if result != nil && err == nil {
return result, nil
}
}
return result, err
}

119
internal/logging/logging.go Normal file
View file

@ -0,0 +1,119 @@
package logging
import (
"fmt"
"net/http"
"os"
"sync"
"time"
"github.com/chubin/wttr.in/internal/util"
)
// Logging request.
//
// RequestLogger logs all incoming HTTP requests.
type RequestLogger struct {
buf map[logEntry]int
filename string
m sync.Mutex
period time.Duration
lastFlush time.Time
}
type logEntry struct {
Proto string
IP string
URI string
UserAgent string
}
// NewRequestLogger returns a new RequestLogger for the specified log file.
// Flush logging entries after period of time.
//
// If filename is empty, no log will be written, and all logging entries
// will be silently dropped.
func NewRequestLogger(filename string, period time.Duration) *RequestLogger {
return &RequestLogger{
buf: map[logEntry]int{},
filename: filename,
m: sync.Mutex{},
period: period,
}
}
// Log logs information about a HTTP request.
func (rl *RequestLogger) Log(r *http.Request) error {
le := logEntry{
Proto: "http",
IP: util.ReadUserIP(r),
URI: r.RequestURI,
UserAgent: r.Header.Get("User-Agent"),
}
if r.TLS != nil {
le.Proto = "https"
}
rl.m.Lock()
rl.buf[le]++
rl.m.Unlock()
if time.Since(rl.lastFlush) > rl.period {
return rl.flush()
}
return nil
}
// flush stores log data to disk, and flushes the buffer.
func (rl *RequestLogger) flush() error {
rl.m.Lock()
defer rl.m.Unlock()
// It is possible, that while waiting the mutex,
// the buffer was already flushed.
if time.Since(rl.lastFlush) <= rl.period {
return nil
}
if rl.filename != "" {
// Generate log output.
output := ""
for k, hitsNumber := range rl.buf {
output += fmt.Sprintf("%s %3d %s\n", time.Now().Format(time.RFC3339), hitsNumber, k.String())
}
// Open log file.
//nolint:nosnakecase
f, err := os.OpenFile(rl.filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
if err != nil {
return err
}
defer f.Close()
// Save output to log file.
_, err = f.Write([]byte(output))
if err != nil {
return err
}
}
// Flush buffer.
rl.buf = map[logEntry]int{}
rl.lastFlush = time.Now()
return nil
}
// String returns string representation of logEntry.
func (e *logEntry) String() string {
return fmt.Sprintf(
"%s %s %s %s",
e.Proto,
e.IP,
e.URI,
e.UserAgent,
)
}

View file

@ -0,0 +1,84 @@
package logging
import (
"os"
"strings"
"sync"
)
// LogSuppressor provides io.Writer interface for logging
// with lines suppression. For usage with log.Logger.
type LogSuppressor struct {
filename string
suppress []string
linePrefix string
logFile *os.File
m sync.Mutex
}
// NewLogSuppressor creates a new LogSuppressor for specified
// filename and lines to be suppressed.
//
// If filename is empty, log entries will be printed to stderr.
func NewLogSuppressor(filename string, suppress []string, linePrefix string) *LogSuppressor {
return &LogSuppressor{
filename: filename,
suppress: suppress,
linePrefix: linePrefix,
}
}
// Open opens log file.
func (ls *LogSuppressor) Open() error {
var err error
if ls.filename == "" {
return nil
}
//nolint:nosnakecase
ls.logFile, err = os.OpenFile(ls.filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
return err
}
// Close closes log file.
func (ls *LogSuppressor) Close() error {
if ls.filename == "" {
return nil
}
return ls.logFile.Close()
}
// Write writes p to log, and returns number f bytes written.
// Implements io.Writer interface.
func (ls *LogSuppressor) Write(p []byte) (int, error) {
var output string
if ls.filename == "" {
return os.Stdin.Write(p)
}
ls.m.Lock()
defer ls.m.Unlock()
lines := strings.Split(string(p), ls.linePrefix)
for _, line := range lines {
if (func(line string) bool {
for _, suppress := range ls.suppress {
if strings.Contains(line, suppress) {
return true
}
}
return false
})(line) {
continue
}
output += line
}
return ls.logFile.Write([]byte(output))
}

View file

@ -0,0 +1,98 @@
package processor
import (
"log"
"net/http"
"sync"
"time"
"github.com/robfig/cron"
)
func (rp *RequestProcessor) startPeakHandling() error {
var err error
c := cron.New()
// cronTime := fmt.Sprintf("%d,%d * * * *", 30-prefetchInterval/60, 60-prefetchInterval/60)
err = c.AddFunc(
"24 * * * *",
func() { rp.prefetchPeakRequests(&rp.peakRequest30) },
)
if err != nil {
return err
}
err = c.AddFunc(
"54 * * * *",
func() { rp.prefetchPeakRequests(&rp.peakRequest60) },
)
if err != nil {
return err
}
c.Start()
return nil
}
// registerPeakRequest registers requests coming in the peak time.
// Such requests can be prefetched afterwards just before the peak time comes.
func (rp *RequestProcessor) savePeakRequest(cacheDigest string, r *http.Request) {
if _, min, _ := time.Now().Clock(); min == 30 {
rp.peakRequest30.Store(cacheDigest, *r)
} else if min == 0 {
rp.peakRequest60.Store(cacheDigest, *r)
}
}
func (rp *RequestProcessor) prefetchRequest(r *http.Request) error {
_, err := rp.ProcessRequest(r)
return err
}
func syncMapLen(sm *sync.Map) int {
count := 0
f := func(key, value interface{}) bool {
// Not really certain about this part, don't know for sure
// if this is a good check for an entry's existence
if key == "" {
return false
}
count++
return true
}
sm.Range(f)
return count
}
func (rp *RequestProcessor) prefetchPeakRequests(peakRequestMap *sync.Map) {
peakRequestLen := syncMapLen(peakRequestMap)
if peakRequestLen == 0 {
return
}
log.Printf("PREFETCH: Prefetching %d requests\n", peakRequestLen)
sleepBetweenRequests := time.Duration(rp.config.Uplink.PrefetchInterval*1000/peakRequestLen) * time.Millisecond
peakRequestMap.Range(func(key interface{}, value interface{}) bool {
req, ok := value.(http.Request)
if !ok {
log.Println("missing value for:", key)
return true
}
go func(r http.Request) {
err := rp.prefetchRequest(&r)
if err != nil {
log.Println("prefetch request:", err)
}
}(req)
peakRequestMap.Delete(key)
time.Sleep(sleepBetweenRequests)
return true
})
}

View file

@ -0,0 +1,381 @@
package processor
import (
"context"
"fmt"
"io/ioutil"
"log"
"math/rand"
"net"
"net/http"
"strings"
"sync"
"time"
lru "github.com/hashicorp/golang-lru"
"github.com/chubin/wttr.in/internal/config"
geoip "github.com/chubin/wttr.in/internal/geo/ip"
geoloc "github.com/chubin/wttr.in/internal/geo/location"
"github.com/chubin/wttr.in/internal/routing"
"github.com/chubin/wttr.in/internal/stats"
"github.com/chubin/wttr.in/internal/util"
)
// plainTextAgents contains signatures of the plain-text agents.
func plainTextAgents() []string {
return []string{
"curl",
"httpie",
"lwp-request",
"wget",
"python-httpx",
"python-requests",
"openbsd ftp",
"powershell",
"fetch",
"aiohttp",
"http_get",
"xh",
"nushell",
}
}
type ResponseWithHeader struct {
InProgress bool // true if the request is being processed
Expires time.Time // expiration time of the cache entry
Body []byte
Header http.Header
StatusCode int // e.g. 200
}
// RequestProcessor handles incoming requests.
type RequestProcessor struct {
peakRequest30 sync.Map
peakRequest60 sync.Map
lruCache *lru.Cache
stats *stats.Stats
router routing.Router
upstreamTransport *http.Transport
config *config.Config
geoIPCache *geoip.Cache
geoLocation *geoloc.Cache
}
// NewRequestProcessor returns new RequestProcessor.
func NewRequestProcessor(config *config.Config) (*RequestProcessor, error) {
lruCache, err := lru.New(config.Cache.Size)
if err != nil {
return nil, err
}
dialer := &net.Dialer{
Timeout: time.Duration(config.Uplink.Timeout) * time.Second,
KeepAlive: time.Duration(config.Uplink.Timeout) * time.Second,
DualStack: true,
}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, config.Uplink.Address)
},
}
geoCache, err := geoip.NewCache(config)
if err != nil {
return nil, err
}
geoLocation, err := geoloc.NewCache(config)
if err != nil {
return nil, err
}
rp := &RequestProcessor{
lruCache: lruCache,
stats: stats.New(),
upstreamTransport: transport,
config: config,
geoIPCache: geoCache,
geoLocation: geoLocation,
}
// Initialize routes.
rp.router.AddPath("/:stats", rp.stats)
rp.router.AddPath("/:geo-ip-get", rp.geoIPCache)
rp.router.AddPath("/:geo-ip-put", rp.geoIPCache)
rp.router.AddPath("/:geo-location", rp.geoLocation)
return rp, nil
}
// Start starts async request processor jobs, such as peak handling.
func (rp *RequestProcessor) Start() error {
return rp.startPeakHandling()
}
func (rp *RequestProcessor) ProcessRequest(r *http.Request) (*ResponseWithHeader, error) {
var (
response *ResponseWithHeader
ip = util.ReadUserIP(r)
)
if ip != "127.0.0.1" {
rp.stats.Inc("total")
}
// Main routing logic.
if rh := rp.router.Route(r); rh != nil {
result := rh.Response(r)
if result != nil {
return fromCadre(result), nil
}
}
if resp, ok := redirectInsecure(r); ok {
rp.stats.Inc("redirects")
return resp, nil
}
if dontCache(r) {
rp.stats.Inc("uncached")
return get(r, rp.upstreamTransport)
}
// processing cached request
cacheDigest := getCacheDigest(r)
rp.savePeakRequest(cacheDigest, r)
response = rp.processRequestFromCache(r)
if response != nil {
return response, nil
}
return rp.processUncachedRequest(r)
}
// processRequestFromCache processes requests using the cache.
// If no entry in cache found, nil is returned.
func (rp *RequestProcessor) processRequestFromCache(r *http.Request) *ResponseWithHeader {
var (
cacheEntry ResponseWithHeader
cacheDigest = getCacheDigest(r)
ok bool
)
cacheBody, _ := rp.lruCache.Get(cacheDigest)
cacheEntry, ok = cacheBody.(ResponseWithHeader)
if !ok {
return nil
}
// if after all attempts we still have no answer,
// we try to make the query on our own
for attempts := 0; attempts < 300; attempts++ {
if !ok || !cacheEntry.InProgress {
break
}
time.Sleep(30 * time.Millisecond)
cacheBody, _ = rp.lruCache.Get(cacheDigest)
v, ok := cacheBody.(ResponseWithHeader)
if ok {
cacheEntry = v
}
}
if cacheEntry.InProgress {
log.Printf("TIMEOUT: %s\n", cacheDigest)
}
if ok && !cacheEntry.InProgress && cacheEntry.Expires.After(time.Now()) {
rp.stats.Inc("cache1")
return &cacheEntry
}
return nil
}
// processUncachedRequest processes requests that were not found in the cache.
func (rp *RequestProcessor) processUncachedRequest(r *http.Request) (*ResponseWithHeader, error) {
var (
cacheDigest = getCacheDigest(r)
ip = util.ReadUserIP(r)
response *ResponseWithHeader
err error
)
// Response was not found in cache.
// Starting real handling.
format := r.URL.Query().Get("format")
if len(format) != 0 {
rp.stats.Inc("format")
if format == "j1" {
rp.stats.Inc("format=j1")
}
}
// Count, how many IP addresses are known.
_, err = rp.geoIPCache.Read(ip)
if err == nil {
rp.stats.Inc("geoip")
}
// Indicate, that the request is being handled.
rp.lruCache.Add(cacheDigest, ResponseWithHeader{InProgress: true})
response, err = get(r, rp.upstreamTransport)
if err != nil {
return nil, err
}
if response.StatusCode == 200 || response.StatusCode == 304 || response.StatusCode == 404 {
rp.lruCache.Add(cacheDigest, *response)
} else {
log.Printf("REMOVE: %d response for %s from cache\n", response.StatusCode, cacheDigest)
rp.lruCache.Remove(cacheDigest)
}
return response, nil
}
func get(req *http.Request, transport *http.Transport) (*ResponseWithHeader, error) {
client := &http.Client{
Transport: transport,
}
queryURL := fmt.Sprintf("http://%s%s", req.Host, req.RequestURI)
proxyReq, err := http.NewRequest(req.Method, queryURL, req.Body)
if err != nil {
return nil, err
}
// proxyReq.Header.Set("Host", req.Host)
// proxyReq.Header.Set("X-Forwarded-For", req.RemoteAddr)
for header, values := range req.Header {
for _, value := range values {
proxyReq.Header.Add(header, value)
}
}
if proxyReq.Header.Get("X-Forwarded-For") == "" {
proxyReq.Header.Set("X-Forwarded-For", ipFromAddr(req.RemoteAddr))
}
res, err := client.Do(proxyReq)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
return &ResponseWithHeader{
InProgress: false,
Expires: time.Now().Add(time.Duration(randInt(1000, 1500)) * time.Second),
Body: body,
Header: res.Header,
StatusCode: res.StatusCode,
}, nil
}
// getCacheDigest is an implementation of the cache.get_signature of original wttr.in.
func getCacheDigest(req *http.Request) string {
userAgent := req.Header.Get("User-Agent")
queryHost := req.Host
queryString := req.RequestURI
clientIPAddress := util.ReadUserIP(req)
lang := req.Header.Get("Accept-Language")
return fmt.Sprintf("%s:%s%s:%s:%s", userAgent, queryHost, queryString, clientIPAddress, lang)
}
// dontCache returns true if req should not be cached.
func dontCache(req *http.Request) bool {
// dont cache cyclic requests
loc := strings.Split(req.RequestURI, "?")[0]
return strings.Contains(loc, ":")
}
// redirectInsecure returns redirection response, and bool value, if redirection was needed,
// if the query comes from a browser, and it is insecure.
//
// Insecure queries are marked by the frontend web server
// with X-Forwarded-Proto header:
// `proxy_set_header X-Forwarded-Proto $scheme;`.
func redirectInsecure(req *http.Request) (*ResponseWithHeader, bool) {
if isPlainTextAgent(req.Header.Get("User-Agent")) {
return nil, false
}
if req.TLS != nil || strings.ToLower(req.Header.Get("X-Forwarded-Proto")) == "https" {
return nil, false
}
target := "https://" + req.Host + req.URL.Path
if len(req.URL.RawQuery) > 0 {
target += "?" + req.URL.RawQuery
}
body := []byte(fmt.Sprintf(`<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="%s">here</A>.
</BODY></HTML>
`, target))
return &ResponseWithHeader{
InProgress: false,
Expires: time.Now().Add(time.Duration(randInt(1000, 1500)) * time.Second),
Body: body,
Header: http.Header{"Location": []string{target}},
StatusCode: 301,
}, true
}
// isPlainTextAgent returns true if userAgent is a plain-text agent.
func isPlainTextAgent(userAgent string) bool {
userAgentLower := strings.ToLower(userAgent)
for _, signature := range plainTextAgents() {
if strings.Contains(userAgentLower, signature) {
return true
}
}
return false
}
func randInt(min int, max int) int {
return min + rand.Intn(max-min)
}
// ipFromAddr returns IP address from a ADDR:PORT pair.
func ipFromAddr(s string) string {
pos := strings.LastIndex(s, ":")
if pos == -1 {
return s
}
return s[:pos]
}
// fromCadre converts Cadre into a responseWithHeader.
func fromCadre(cadre *routing.Cadre) *ResponseWithHeader {
return &ResponseWithHeader{
Body: cadre.Body,
Expires: cadre.Expires,
StatusCode: 200,
InProgress: false,
}
}

View file

@ -0,0 +1,72 @@
package routing
import (
"net/http"
"time"
)
// CadreFormat specifies how the shot data is formatted.
type CadreFormat int
const (
// CadreFormatANSI represents Terminal ANSI format.
CadreFormatANSI = iota
// CadreFormatHTML represents HTML.
CadreFormatHTML
// CadreFormatPNG represents PNG.
CadreFormatPNG
)
// Cadre contains result of a query execution.
type Cadre struct {
// Body contains the data of Cadre, formatted as Format.
Body []byte
// Format of the shot.
Format CadreFormat
// Expires contains the time of the Cadre expiration,
// or 0 if it does not expire.
Expires time.Time
}
// Handler can handle queries and return views.
type Handler interface {
Response(*http.Request) *Cadre
}
type routeFunc func(*http.Request) bool
type route struct {
routeFunc
Handler
}
// Router keeps a routing table, and finds queries handlers, based on its rules.
type Router struct {
rt []route
}
// Route returns a query handler based on its content.
func (r *Router) Route(req *http.Request) Handler {
for _, re := range r.rt {
if re.routeFunc(req) {
return re.Handler
}
}
return nil
}
// AddPath adds route for a static path.
func (r *Router) AddPath(path string, handler Handler) {
r.rt = append(r.rt, route{routePath(path), handler})
}
func routePath(path string) routeFunc {
return routeFunc(func(req *http.Request) bool {
return req.URL.Path == path
})
}

89
internal/stats/stats.go Normal file
View file

@ -0,0 +1,89 @@
package stats
import (
"bytes"
"fmt"
"net/http"
"sync"
"time"
"github.com/chubin/wttr.in/internal/routing"
)
// Stats holds processed requests statistics.
type Stats struct {
m sync.Mutex
v map[string]int
startTime time.Time
}
// New returns new Stats.
func New() *Stats {
return &Stats{
v: map[string]int{},
startTime: time.Now(),
}
}
// Inc key by one.
func (c *Stats) Inc(key string) {
c.m.Lock()
c.v[key]++
c.m.Unlock()
}
// Get current key counter value.
func (c *Stats) Get(key string) int {
c.m.Lock()
defer c.m.Unlock()
return c.v[key]
}
// Reset key counter.
func (c *Stats) Reset(key string) int {
c.m.Lock()
defer c.m.Unlock()
result := c.v[key]
c.v[key] = 0
return result
}
// Show returns current statistics formatted as []byte.
func (c *Stats) Show() []byte {
var b bytes.Buffer
c.m.Lock()
defer c.m.Unlock()
uptime := time.Since(c.startTime) / time.Second
fmt.Fprintf(&b, "%-20s: %v\n", "Running since", c.startTime.Format(time.RFC3339))
fmt.Fprintf(&b, "%-20s: %d\n", "Uptime (min)", uptime/60)
fmt.Fprintf(&b, "%-20s: %d\n", "Total queries", c.v["total"])
if uptime != 0 {
fmt.Fprintf(&b, "%-20s: %d\n", "Throughput (QpM)", c.v["total"]*60/int(uptime))
}
fmt.Fprintf(&b, "%-20s: %d\n", "Cache L1 queries", c.v["cache1"])
if c.v["total"] != 0 {
fmt.Fprintf(&b, "%-20s: %d\n", "Cache L1 queries (%)", (100*c.v["cache1"])/c.v["total"])
}
fmt.Fprintf(&b, "%-20s: %d\n", "Upstream queries", c.v["total"]-c.v["cache1"])
fmt.Fprintf(&b, "%-20s: %d\n", "Queries with format", c.v["format"])
fmt.Fprintf(&b, "%-20s: %d\n", "Queries with format=j1", c.v["format=j1"])
fmt.Fprintf(&b, "%-20s: %d\n", "Queries with known IP", c.v["geoip"])
return b.Bytes()
}
func (c *Stats) Response(*http.Request) *routing.Cadre {
return &routing.Cadre{
Body: c.Show(),
}
}

14
internal/types/errors.go Normal file
View file

@ -0,0 +1,14 @@
package types
import "errors"
var (
ErrNotFound = errors.New("cache entry not found")
ErrInvalidCacheEntry = errors.New("invalid cache entry format")
ErrUpstream = errors.New("upstream error")
// ErrNoServersConfigured means that there are no servers to run.
ErrNoServersConfigured = errors.New("no servers configured")
ErrUnknownLocationService = errors.New("unknown location service")
)

8
internal/types/types.go Normal file
View file

@ -0,0 +1,8 @@
package types
type CacheType string
const (
CacheTypeDB = "db"
CacheTypeFiles = "files"
)

18
internal/util/files.go Normal file
View file

@ -0,0 +1,18 @@
package util
import "os"
// RemoveFileIfExists removes filename if exists, or does nothing if the file
// is not there. Returns an error, if it occurred during deletion.
func RemoveFileIfExists(filename string) error {
_, err := os.Stat(filename)
if err != nil {
if !os.IsNotExist(err) {
return err
}
// no db file
return nil
}
return os.Remove(filename)
}

26
internal/util/http.go Normal file
View file

@ -0,0 +1,26 @@
package util
import (
"log"
"net"
"net/http"
)
// ReadUserIP returns IP address of the client from http.Request,
// taking into account the HTTP headers.
func ReadUserIP(r *http.Request) string {
IPAddress := r.Header.Get("X-Real-Ip")
if IPAddress == "" {
IPAddress = r.Header.Get("X-Forwarded-For")
}
if IPAddress == "" {
IPAddress = r.RemoteAddr
var err error
IPAddress, _, err = net.SplitHostPort(IPAddress)
if err != nil {
log.Printf("ERROR: userip: %q is not IP:port\n", IPAddress)
}
}
return IPAddress
}

15
internal/util/yaml.go Normal file
View file

@ -0,0 +1,15 @@
package util
import (
"bytes"
"gopkg.in/yaml.v3"
)
// YamlUnmarshalStrict unmarshals YAML data with an error when unknown fields are present.
func YamlUnmarshalStrict(in []byte, out interface{}) error {
dec := yaml.NewDecoder(bytes.NewReader(in))
dec.KnownFields(true)
return dec.Decode(out)
}

View file

@ -1,4 +1,5 @@
package main //nolint:forbidigo,funlen,nestif,goerr113,gocognit,cyclop
package v1
import ( import (
"bytes" "bytes"
@ -6,7 +7,6 @@ import (
"flag" "flag"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -14,6 +14,7 @@ import (
"strings" "strings"
) )
//nolint:tagliatelle
type cond struct { type cond struct {
ChanceOfRain string `json:"chanceofrain"` ChanceOfRain string `json:"chanceofrain"`
FeelsLikeC int `json:",string"` FeelsLikeC int `json:",string"`
@ -49,6 +50,7 @@ type loc struct {
Type string `json:"type"` Type string `json:"type"`
} }
//nolint:tagliatelle
type resp struct { type resp struct {
Data struct { Data struct {
Cur []cond `json:"current_condition"` Cur []cond `json:"current_condition"`
@ -58,65 +60,78 @@ type resp struct {
} `json:"data"` } `json:"data"`
} }
func getDataFromAPI() (ret resp) { func (g *global) getDataFromAPI() (*resp, error) {
var params []string var (
ret resp
params []string
)
if len(config.APIKey) == 0 { if len(g.config.APIKey) == 0 {
log.Fatal("No API key specified. Setup instructions are in the README.") return nil, fmt.Errorf("no API key specified. Setup instructions are in the README")
} }
params = append(params, "key="+config.APIKey) params = append(params, "key="+g.config.APIKey)
// non-flag shortcut arguments will overwrite possible flag arguments // non-flag shortcut arguments will overwrite possible flag arguments
for _, arg := range flag.Args() { for _, arg := range flag.Args() {
if v, err := strconv.Atoi(arg); err == nil && len(arg) == 1 { if v, err := strconv.Atoi(arg); err == nil && len(arg) == 1 {
config.Numdays = v g.config.Numdays = v
} else { } else {
config.City = arg g.config.City = arg
} }
} }
if len(config.City) > 0 { if len(g.config.City) > 0 {
params = append(params, "q="+url.QueryEscape(config.City)) params = append(params, "q="+url.QueryEscape(g.config.City))
} }
params = append(params, "format=json", "num_of_days="+strconv.Itoa(config.Numdays), "tp=3") params = append(params, "format=json", "num_of_days="+strconv.Itoa(g.config.Numdays), "tp=3")
if config.Lang != "" { if g.config.Lang != "" {
params = append(params, "lang="+config.Lang) params = append(params, "lang="+g.config.Lang)
} }
if debug { if g.debug {
fmt.Fprintln(os.Stderr, params) fmt.Fprintln(os.Stderr, params)
} }
res, err := http.Get(wuri + strings.Join(params, "&")) res, err := http.Get(wuri + strings.Join(params, "&"))
if err != nil { if err != nil {
log.Fatal(err) return nil, err
} }
defer res.Body.Close() defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body) body, err := ioutil.ReadAll(res.Body)
if err != nil { if err != nil {
log.Fatal(err) return nil, err
} }
if debug { if g.debug {
var out bytes.Buffer var out bytes.Buffer
json.Indent(&out, body, "", " ")
out.WriteTo(os.Stderr) err := json.Indent(&out, body, "", " ")
if err != nil {
return nil, err
}
_, err = out.WriteTo(os.Stderr)
if err != nil {
return nil, err
}
fmt.Print("\n\n") fmt.Print("\n\n")
} }
if config.Lang == "" { if g.config.Lang == "" {
if err = json.Unmarshal(body, &ret); err != nil { if err = json.Unmarshal(body, &ret); err != nil {
log.Println(err) return nil, err
} }
} else { } else {
if err = unmarshalLang(body, &ret); err != nil { if err = g.unmarshalLang(body, &ret); err != nil {
log.Println(err) return nil, err
} }
} }
return
return &ret, nil
} }
func unmarshalLang(body []byte, r *resp) error { func (g *global) unmarshalLang(body []byte, r *resp) error {
var rv map[string]interface{} var rv map[string]interface{}
if err := json.Unmarshal(body, &rv); err != nil { if err := json.Unmarshal(body, &rv); err != nil {
return err return err
@ -128,7 +143,7 @@ func unmarshalLang(body []byte, r *resp) error {
if !ok { if !ok {
continue continue
} }
langs, ok := cc["lang_"+config.Lang].([]interface{}) langs, ok := cc["lang_"+g.config.Lang].([]interface{})
if !ok || len(langs) == 0 { if !ok || len(langs) == 0 {
continue continue
} }
@ -151,7 +166,7 @@ func unmarshalLang(body []byte, r *resp) error {
if !ok { if !ok {
continue continue
} }
langs, ok := h["lang_"+config.Lang].([]interface{}) langs, ok := h["lang_"+g.config.Lang].([]interface{})
if !ok || len(langs) == 0 { if !ok || len(langs) == 0 {
continue continue
} }
@ -172,5 +187,6 @@ func unmarshalLang(body []byte, r *resp) error {
if err := json.NewDecoder(&buf).Decode(r); err != nil { if err := json.NewDecoder(&buf).Decode(r); err != nil {
return err return err
} }
return nil return nil
} }

172
internal/view/v1/cmd.go Normal file
View file

@ -0,0 +1,172 @@
// This code represents wttr.in view v1.
// It is based on wego (github.com/schachmat/wego) from which it diverged back in 2016.
//nolint:forbidigo,funlen,gocognit,cyclop
package v1
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io/ioutil"
"log"
"os"
"os/user"
"path"
"regexp"
"strings"
"github.com/mattn/go-colorable"
"github.com/mattn/go-runewidth"
)
type Configuration struct {
APIKey string
City string
Numdays int
Imperial bool
WindUnit bool
Inverse bool
Lang string
Narrow bool
LocationName string
WindMS bool
RightToLeft bool
}
type global struct {
ansiEsc *regexp.Regexp
config Configuration
configpath string
debug bool
}
const (
wuri = "http://127.0.0.1:5001/premium/v1/weather.ashx?"
suri = "http://127.0.0.1:5001/premium/v1/search.ashx?"
slotcount = 4
)
func (g *global) configload() error {
b, err := ioutil.ReadFile(g.configpath)
if err == nil {
return json.Unmarshal(b, &g.config)
}
return err
}
func (g *global) configsave() error {
j, err := json.MarshalIndent(g.config, "", "\t")
if err == nil {
return ioutil.WriteFile(g.configpath, j, 0o600)
}
return err
}
func (g *global) init() {
flag.IntVar(&g.config.Numdays, "days", 3, "Number of days of weather forecast to be displayed")
flag.StringVar(&g.config.Lang, "lang", "en", "Language of the report")
flag.StringVar(&g.config.City, "city", "New York", "City to be queried")
flag.BoolVar(&g.debug, "debug", false, "Print out raw json response for debugging purposes")
flag.BoolVar(&g.config.Imperial, "imperial", false, "Use imperial units")
flag.BoolVar(&g.config.Inverse, "inverse", false, "Use inverted colors")
flag.BoolVar(&g.config.Narrow, "narrow", false, "Narrow output (two columns)")
flag.StringVar(&g.config.LocationName, "location_name", "", "Location name (used in the caption)")
flag.BoolVar(&g.config.WindMS, "wind_in_ms", false, "Show wind speed in m/s")
flag.BoolVar(&g.config.RightToLeft, "right_to_left", false, "Right to left script")
g.configpath = os.Getenv("WEGORC")
if g.configpath == "" {
usr, err := user.Current()
if err != nil {
log.Fatalf("%v\nYou can set the environment variable WEGORC to point to your config file as a workaround.", err)
}
g.configpath = path.Join(usr.HomeDir, ".wegorc")
}
g.config.APIKey = ""
g.config.Imperial = false
g.config.Lang = "en"
err := g.configload()
var pathError *os.PathError
if errors.Is(err, pathError) {
log.Printf("No config file found. Creating %s ...", g.configpath)
if err2 := g.configsave(); err2 != nil {
log.Fatal(err2)
}
} else if err != nil {
log.Fatalf("could not parse %v: %v", g.configpath, err)
}
g.ansiEsc = regexp.MustCompile("\033.*?m")
}
func Cmd() error {
g := global{}
g.init()
flag.Parse()
r, err := g.getDataFromAPI()
if err != nil {
return err
}
if r.Data.Req == nil || len(r.Data.Req) < 1 {
if r.Data.Err != nil && len(r.Data.Err) >= 1 {
log.Fatal(r.Data.Err[0].Msg)
}
log.Fatal("Malformed response.")
}
locationName := r.Data.Req[0].Query
if g.config.LocationName != "" {
locationName = g.config.LocationName
}
if g.config.Lang == "he" || g.config.Lang == "ar" || g.config.Lang == "fa" {
g.config.RightToLeft = true
}
if caption, ok := localizedCaption()[g.config.Lang]; !ok {
fmt.Printf("Weather report: %s\n\n", locationName)
} else {
if g.config.RightToLeft {
caption = locationName + " " + caption
space := strings.Repeat(" ", 125-runewidth.StringWidth(caption))
fmt.Printf("%s%s\n\n", space, caption)
} else {
fmt.Printf("%s %s\n\n", caption, locationName)
}
}
stdout := colorable.NewColorableStdout()
if r.Data.Cur == nil || len(r.Data.Cur) < 1 {
log.Fatal("No weather data available.")
}
out := g.formatCond(make([]string, 5), r.Data.Cur[0], true)
for _, val := range out {
if g.config.RightToLeft {
fmt.Fprint(stdout, strings.Repeat(" ", 94))
} else {
fmt.Fprint(stdout, " ")
}
fmt.Fprintln(stdout, val)
}
if g.config.Numdays == 0 {
return nil
}
if r.Data.Weather == nil {
log.Fatal("No detailed weather forecast available.")
}
for _, d := range r.Data.Weather {
lines, err := g.printDay(d)
if err != nil {
return err
}
for _, val := range lines {
fmt.Fprintln(stdout, val)
}
}
return nil
}

View file

@ -1,4 +1,5 @@
package main //nolint:funlen,nestif,cyclop,gocognit,gocyclo
package v1
import ( import (
"fmt" "fmt"
@ -8,8 +9,8 @@ import (
"github.com/mattn/go-runewidth" "github.com/mattn/go-runewidth"
) )
var ( func windDir() map[string]string {
windDir = map[string]string{ return map[string]string{
"N": "\033[1m↓\033[0m", "N": "\033[1m↓\033[0m",
"NNE": "\033[1m↓\033[0m", "NNE": "\033[1m↓\033[0m",
"NE": "\033[1m↙\033[0m", "NE": "\033[1m↙\033[0m",
@ -27,13 +28,14 @@ var (
"NW": "\033[1m↘\033[0m", "NW": "\033[1m↘\033[0m",
"NNW": "\033[1m↘\033[0m", "NNW": "\033[1m↘\033[0m",
} }
) }
func formatTemp(c cond) string { func (g *global) formatTemp(c cond) string {
color := func(temp int, explicitPlus bool) string { color := func(temp int, explicitPlus bool) string {
var col = 0 var col int
if !config.Inverse { //nolint:dupl
// Extemely cold temperature must be shown with violet if !g.config.Inverse {
// Extremely cold temperature must be shown with violet
// because dark blue is too dark // because dark blue is too dark
col = 165 col = 165
switch temp { switch temp {
@ -127,12 +129,13 @@ func formatTemp(c cond) string {
} }
} }
} }
if config.Imperial { if g.config.Imperial {
temp = (temp*18 + 320) / 10 temp = (temp*18 + 320) / 10
} }
if explicitPlus { if explicitPlus {
return fmt.Sprintf("\033[38;5;%03dm+%d\033[0m", col, temp) return fmt.Sprintf("\033[38;5;%03dm+%d\033[0m", col, temp)
} }
return fmt.Sprintf("\033[38;5;%03dm%d\033[0m", col, temp) return fmt.Sprintf("\033[38;5;%03dm%d\033[0m", col, temp)
} }
t := c.TempC t := c.TempC
@ -160,135 +163,129 @@ func formatTemp(c cond) string {
if explicitPlus1 { if explicitPlus1 {
explicitPlus2 = false explicitPlus2 = false
} }
return pad(
return g.pad(
fmt.Sprintf("%s(%s) °%s", fmt.Sprintf("%s(%s) °%s",
color(t, explicitPlus1), color(t, explicitPlus1),
color(c.FeelsLikeC, explicitPlus2), color(c.FeelsLikeC, explicitPlus2),
unitTemp[config.Imperial]), unitTemp()[g.config.Imperial]),
15) 15)
} }
// if c.FeelsLikeC < t {
// if c.FeelsLikeC < 0 && t > 0 { return g.pad(fmt.Sprintf("%s °%s", color(c.FeelsLikeC, false), unitTemp()[g.config.Imperial]), 15)
// explicitPlus = true
// }
// return pad(fmt.Sprintf("%s%s%s °%s", color(c.FeelsLikeC, false), hyphen, color(t, explicitPlus), unitTemp[config.Imperial]), 15)
// } else if c.FeelsLikeC > t {
// if t < 0 && c.FeelsLikeC > 0 {
// explicitPlus = true
// }
// return pad(fmt.Sprintf("%s%s%s °%s", color(t, false), hyphen, color(c.FeelsLikeC, explicitPlus), unitTemp[config.Imperial]), 15)
// }
return pad(fmt.Sprintf("%s °%s", color(c.FeelsLikeC, false), unitTemp[config.Imperial]), 15)
} }
func formatWind(c cond) string { func (g *global) formatWind(c cond) string {
windInRightUnits := func(spd int) int { unitWindString := unitWind(0, g.config.Lang)
if config.WindMS { if g.config.WindMS {
spd = (spd * 1000) / 3600 unitWindString = unitWind(2, g.config.Lang)
} else { } else if g.config.Imperial {
if config.Imperial { unitWindString = unitWind(1, g.config.Lang)
spd = (spd * 1000) / 1609
}
}
return spd
}
color := func(spd int) string {
var col = 46
switch spd {
case 1, 2, 3:
col = 82
case 4, 5, 6:
col = 118
case 7, 8, 9:
col = 154
case 10, 11, 12:
col = 190
case 13, 14, 15:
col = 226
case 16, 17, 18, 19:
col = 220
case 20, 21, 22, 23:
col = 214
case 24, 25, 26, 27:
col = 208
case 28, 29, 30, 31:
col = 202
default:
if spd > 0 {
col = 196
}
}
spd = windInRightUnits(spd)
return fmt.Sprintf("\033[38;5;%03dm%d\033[0m", col, spd)
} }
unitWindString := unitWind(0, config.Lang) hyphen := "-"
if config.WindMS {
unitWindString = unitWind(2, config.Lang) cWindGustKmph := speedToColor(c.WindGustKmph, windInRightUnits(c.WindGustKmph, g.config.WindMS, g.config.Imperial))
} else { cWindspeedKmph := speedToColor(c.WindspeedKmph, windInRightUnits(c.WindspeedKmph, g.config.WindMS, g.config.Imperial))
if config.Imperial { if windInRightUnits(c.WindGustKmph, g.config.WindMS, g.config.Imperial) >
unitWindString = unitWind(1, config.Lang) windInRightUnits(c.WindspeedKmph, g.config.WindMS, g.config.Imperial) {
} return g.pad(
fmt.Sprintf("%s %s%s%s %s", windDir()[c.Winddir16Point], cWindspeedKmph, hyphen, cWindGustKmph, unitWindString),
15)
} }
hyphen := " - " return g.pad(fmt.Sprintf("%s %s %s", windDir()[c.Winddir16Point], cWindspeedKmph, unitWindString), 15)
// if (config.Lang == "sl") {
// hyphen = "-"
// }
hyphen = "-"
cWindGustKmph := color(c.WindGustKmph)
cWindspeedKmph := color(c.WindspeedKmph)
if windInRightUnits(c.WindGustKmph) > windInRightUnits(c.WindspeedKmph) {
return pad(fmt.Sprintf("%s %s%s%s %s", windDir[c.Winddir16Point], cWindspeedKmph, hyphen, cWindGustKmph, unitWindString), 15)
}
return pad(fmt.Sprintf("%s %s %s", windDir[c.Winddir16Point], cWindspeedKmph, unitWindString), 15)
} }
func formatVisibility(c cond) string { func windInRightUnits(spd int, windMS, imperial bool) int {
if config.Imperial { if windMS {
spd = (spd * 1000) / 3600
} else if imperial {
spd = (spd * 1000) / 1609
}
return spd
}
func speedToColor(spd, spdConverted int) string {
col := 46
switch spd {
case 1, 2, 3:
col = 82
case 4, 5, 6:
col = 118
case 7, 8, 9:
col = 154
case 10, 11, 12:
col = 190
case 13, 14, 15:
col = 226
case 16, 17, 18, 19:
col = 220
case 20, 21, 22, 23:
col = 214
case 24, 25, 26, 27:
col = 208
case 28, 29, 30, 31:
col = 202
default:
if spd > 0 {
col = 196
}
}
return fmt.Sprintf("\033[38;5;%03dm%d\033[0m", col, spdConverted)
}
func (g *global) formatVisibility(c cond) string {
if g.config.Imperial {
c.VisibleDistKM = (c.VisibleDistKM * 621) / 1000 c.VisibleDistKM = (c.VisibleDistKM * 621) / 1000
} }
return pad(fmt.Sprintf("%d %s", c.VisibleDistKM, unitVis(config.Imperial, config.Lang)), 15)
return g.pad(fmt.Sprintf("%d %s", c.VisibleDistKM, unitVis(g.config.Imperial, g.config.Lang)), 15)
} }
func formatRain(c cond) string { func (g *global) formatRain(c cond) string {
rainUnit := float32(c.PrecipMM) rainUnit := c.PrecipMM
if config.Imperial { if g.config.Imperial {
rainUnit = float32(c.PrecipMM) * 0.039 rainUnit = c.PrecipMM * 0.039
} }
if c.ChanceOfRain != "" { if c.ChanceOfRain != "" {
return pad(fmt.Sprintf( return g.pad(fmt.Sprintf(
"%.1f %s | %s%%", "%.1f %s | %s%%",
rainUnit, rainUnit,
unitRain(config.Imperial, config.Lang), unitRain(g.config.Imperial, g.config.Lang),
c.ChanceOfRain), 15) c.ChanceOfRain), 15)
} }
return pad(fmt.Sprintf("%.1f %s", rainUnit, unitRain(config.Imperial, config.Lang)), 15)
return g.pad(fmt.Sprintf("%.1f %s", rainUnit, unitRain(g.config.Imperial, g.config.Lang)), 15)
} }
func formatCond(cur []string, c cond, current bool) (ret []string) { func (g *global) formatCond(cur []string, c cond, current bool) []string {
var icon []string var (
if i, ok := codes[c.WeatherCode]; !ok { ret []string
icon = iconUnknown icon []string
)
if i, ok := codes()[c.WeatherCode]; !ok {
icon = getIcon("iconUnknown")
} else { } else {
icon = i icon = i
} }
if config.Inverse { if g.config.Inverse {
// inverting colors // inverting colors
for i := range icon { for i := range icon {
icon[i] = strings.Replace(icon[i], "38;5;226", "38;5;94", -1) icon[i] = strings.ReplaceAll(icon[i], "38;5;226", "38;5;94")
icon[i] = strings.Replace(icon[i], "38;5;250", "38;5;243", -1) icon[i] = strings.ReplaceAll(icon[i], "38;5;250", "38;5;243")
icon[i] = strings.Replace(icon[i], "38;5;21", "38;5;18", -1) icon[i] = strings.ReplaceAll(icon[i], "38;5;21", "38;5;18")
icon[i] = strings.Replace(icon[i], "38;5;255", "38;5;245", -1) icon[i] = strings.ReplaceAll(icon[i], "38;5;255", "38;5;245")
icon[i] = strings.Replace(icon[i], "38;5;111", "38;5;63", -1) icon[i] = strings.ReplaceAll(icon[i], "38;5;111", "38;5;63")
icon[i] = strings.Replace(icon[i], "38;5;251", "38;5;238", -1) icon[i] = strings.ReplaceAll(icon[i], "38;5;251", "38;5;238")
} }
} }
//desc := fmt.Sprintf("%-15.15v", c.WeatherDesc[0].Value) // desc := fmt.Sprintf("%-15.15v", c.WeatherDesc[0].Value)
desc := c.WeatherDesc[0].Value desc := c.WeatherDesc[0].Value
if config.RightToLeft { if g.config.RightToLeft {
for runewidth.StringWidth(desc) < 15 { for runewidth.StringWidth(desc) < 15 {
desc = " " + desc desc = " " + desc
} }
@ -306,7 +303,7 @@ func formatCond(cur []string, c cond, current bool) (ret []string) {
} }
} }
if current { if current {
if config.RightToLeft { if g.config.RightToLeft {
desc = c.WeatherDesc[0].Value desc = c.WeatherDesc[0].Value
if runewidth.StringWidth(desc) < 15 { if runewidth.StringWidth(desc) < 15 {
desc = strings.Repeat(" ", 15-runewidth.StringWidth(desc)) + desc desc = strings.Repeat(" ", 15-runewidth.StringWidth(desc)) + desc
@ -315,7 +312,7 @@ func formatCond(cur []string, c cond, current bool) (ret []string) {
desc = c.WeatherDesc[0].Value desc = c.WeatherDesc[0].Value
} }
} else { } else {
if config.RightToLeft { if g.config.RightToLeft {
if frstRune, size := utf8.DecodeRuneInString(desc); frstRune != ' ' { if frstRune, size := utf8.DecodeRuneInString(desc); frstRune != ' ' {
desc = "…" + desc[size:] desc = "…" + desc[size:]
for runewidth.StringWidth(desc) < 15 { for runewidth.StringWidth(desc) < 15 {
@ -325,32 +322,46 @@ func formatCond(cur []string, c cond, current bool) (ret []string) {
} else { } else {
if lastRune, size := utf8.DecodeLastRuneInString(desc); lastRune != ' ' { if lastRune, size := utf8.DecodeLastRuneInString(desc); lastRune != ' ' {
desc = desc[:len(desc)-size] + "…" desc = desc[:len(desc)-size] + "…"
//for numberOfSpaces < runewidth.StringWidth(fmt.Sprintf("%c", lastRune)) - 1 { // for numberOfSpaces < runewidth.StringWidth(fmt.Sprintf("%c", lastRune)) - 1 {
for runewidth.StringWidth(desc) < 15 { for runewidth.StringWidth(desc) < 15 {
desc = desc + " " desc += " "
} }
} }
} }
} }
if config.RightToLeft { if g.config.RightToLeft {
ret = append(ret, fmt.Sprintf("%v %v %v", cur[0], desc, icon[0]), fmt.Sprintf("%v %v %v", cur[1], formatTemp(c), icon[1]), fmt.Sprintf("%v %v %v", cur[2], formatWind(c), icon[2]), fmt.Sprintf("%v %v %v", cur[3], formatVisibility(c), icon[3]), fmt.Sprintf("%v %v %v", cur[4], formatRain(c), icon[4])) ret = append(
ret,
fmt.Sprintf("%v %v %v", cur[0], desc, icon[0]),
fmt.Sprintf("%v %v %v", cur[1], g.formatTemp(c), icon[1]),
fmt.Sprintf("%v %v %v", cur[2], g.formatWind(c), icon[2]),
fmt.Sprintf("%v %v %v", cur[3], g.formatVisibility(c), icon[3]),
fmt.Sprintf("%v %v %v", cur[4], g.formatRain(c), icon[4]))
} else { } else {
ret = append(ret, fmt.Sprintf("%v %v %v", cur[0], icon[0], desc), fmt.Sprintf("%v %v %v", cur[1], icon[1], formatTemp(c)), fmt.Sprintf("%v %v %v", cur[2], icon[2], formatWind(c)), fmt.Sprintf("%v %v %v", cur[3], icon[3], formatVisibility(c)), fmt.Sprintf("%v %v %v", cur[4], icon[4], formatRain(c))) ret = append(
ret,
fmt.Sprintf("%v %v %v", cur[0], icon[0], desc),
fmt.Sprintf("%v %v %v", cur[1], icon[1], g.formatTemp(c)),
fmt.Sprintf("%v %v %v", cur[2], icon[2], g.formatWind(c)),
fmt.Sprintf("%v %v %v", cur[3], icon[3], g.formatVisibility(c)),
fmt.Sprintf("%v %v %v", cur[4], icon[4], g.formatRain(c)))
} }
return
return ret
} }
func justifyCenter(s string, width int) string { func justifyCenter(s string, width int) string {
appendSide := 0 appendSide := 0
for runewidth.StringWidth(s) <= width { for runewidth.StringWidth(s) <= width {
if appendSide == 1 { if appendSide == 1 {
s = s + " " s += " "
appendSide = 0 appendSide = 0
} else { } else {
s = " " + s s = " " + s
appendSide = 1 appendSide = 1
} }
} }
return s return s
} }
@ -359,28 +370,31 @@ func reverse(s string) string {
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 { for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i] r[i], r[j] = r[j], r[i]
} }
return string(r) return string(r)
} }
func pad(s string, mustLen int) (ret string) { func (g *global) pad(s string, mustLen int) string {
var ret string
ret = s ret = s
realLen := utf8.RuneCountInString(ansiEsc.ReplaceAllLiteralString(s, "")) realLen := utf8.RuneCountInString(g.ansiEsc.ReplaceAllLiteralString(s, ""))
delta := mustLen - realLen delta := mustLen - realLen
if delta > 0 { if delta > 0 {
if config.RightToLeft { if g.config.RightToLeft {
ret = strings.Repeat(" ", delta) + ret + "\033[0m" ret = strings.Repeat(" ", delta) + ret + "\033[0m"
} else { } else {
ret += "\033[0m" + strings.Repeat(" ", delta) ret += "\033[0m" + strings.Repeat(" ", delta)
} }
} else if delta < 0 { } else if delta < 0 {
toks := ansiEsc.Split(s, 2) toks := g.ansiEsc.Split(s, 2)
tokLen := utf8.RuneCountInString(toks[0]) tokLen := utf8.RuneCountInString(toks[0])
esc := ansiEsc.FindString(s) esc := g.ansiEsc.FindString(s)
if tokLen > mustLen { if tokLen > mustLen {
ret = fmt.Sprintf("%.*s\033[0m", mustLen, toks[0]) ret = fmt.Sprintf("%.*s\033[0m", mustLen, toks[0])
} else { } else {
ret = fmt.Sprintf("%s%s%s", toks[0], esc, pad(toks[1], mustLen-tokLen)) ret = fmt.Sprintf("%s%s%s", toks[0], esc, g.pad(toks[1], mustLen-tokLen))
} }
} }
return
return ret
} }

213
internal/view/v1/icons.go Normal file
View file

@ -0,0 +1,213 @@
package v1
//nolint:funlen
func getIcon(name string) []string {
icon := map[string][]string{
"iconUnknown": {
" .-. ",
" __) ",
" ( ",
" `- ",
" • ",
},
"iconSunny": {
"\033[38;5;226m \\ / \033[0m",
"\033[38;5;226m .-. \033[0m",
"\033[38;5;226m ― ( ) ― \033[0m",
"\033[38;5;226m `- \033[0m",
"\033[38;5;226m / \\ \033[0m",
},
"iconPartlyCloudy": {
"\033[38;5;226m \\ /\033[0m ",
"\033[38;5;226m _ /\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m \\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
" ",
},
"iconCloudy": {
" ",
"\033[38;5;250m .--. \033[0m",
"\033[38;5;250m .-( ). \033[0m",
"\033[38;5;250m (___.__)__) \033[0m",
" ",
},
"iconVeryCloudy": {
" ",
"\033[38;5;240;1m .--. \033[0m",
"\033[38;5;240;1m .-( ). \033[0m",
"\033[38;5;240;1m (___.__)__) \033[0m",
" ",
},
"iconLightShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;111m \033[0m",
"\033[38;5;111m \033[0m",
},
"iconHeavyShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;240;1m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;240;1m( ). \033[0m",
"\033[38;5;226m /\033[38;5;240;1m(___(__) \033[0m",
"\033[38;5;21;1m \033[0m",
"\033[38;5;21;1m \033[0m",
},
"iconLightSnowShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;255m * * * \033[0m",
"\033[38;5;255m * * * \033[0m",
},
"iconHeavySnowShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;240;1m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;240;1m( ). \033[0m",
"\033[38;5;226m /\033[38;5;240;1m(___(__) \033[0m",
"\033[38;5;255;1m * * * * \033[0m",
"\033[38;5;255;1m * * * * \033[0m",
},
"iconLightSleetShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;111m \033[38;5;255m*\033[38;5;111m \033[38;5;255m* \033[0m",
"\033[38;5;255m *\033[38;5;111m \033[38;5;255m*\033[38;5;111m \033[0m",
},
"iconThunderyShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;228;5m ⚡\033[38;5;111;25m\033[38;5;228;5m⚡\033[38;5;111;25m \033[0m",
"\033[38;5;111m \033[0m",
},
"iconThunderyHeavyRain": {
"\033[38;5;240;1m .-. \033[0m",
"\033[38;5;240;1m ( ). \033[0m",
"\033[38;5;240;1m (___(__) \033[0m",
"\033[38;5;21;1m \033[38;5;228;5m⚡\033[38;5;21;25m\033[38;5;228;5m⚡\033[38;5;21;25m \033[0m",
"\033[38;5;21;1m \033[38;5;228;5m⚡\033[38;5;21;25m \033[0m",
},
"iconThunderySnowShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;255m *\033[38;5;228;5m⚡\033[38;5;255;25m*\033[38;5;228;5m⚡\033[38;5;255;25m* \033[0m",
"\033[38;5;255m * * * \033[0m",
},
"iconLightRain": {
"\033[38;5;250m .-. \033[0m",
"\033[38;5;250m ( ). \033[0m",
"\033[38;5;250m (___(__) \033[0m",
"\033[38;5;111m \033[0m",
"\033[38;5;111m \033[0m",
},
"iconHeavyRain": {
"\033[38;5;240;1m .-. \033[0m",
"\033[38;5;240;1m ( ). \033[0m",
"\033[38;5;240;1m (___(__) \033[0m",
"\033[38;5;21;1m \033[0m",
"\033[38;5;21;1m \033[0m",
},
"iconLightSnow": {
"\033[38;5;250m .-. \033[0m",
"\033[38;5;250m ( ). \033[0m",
"\033[38;5;250m (___(__) \033[0m",
"\033[38;5;255m * * * \033[0m",
"\033[38;5;255m * * * \033[0m",
},
"iconHeavySnow": {
"\033[38;5;240;1m .-. \033[0m",
"\033[38;5;240;1m ( ). \033[0m",
"\033[38;5;240;1m (___(__) \033[0m",
"\033[38;5;255;1m * * * * \033[0m",
"\033[38;5;255;1m * * * * \033[0m",
},
"iconLightSleet": {
"\033[38;5;250m .-. \033[0m",
"\033[38;5;250m ( ). \033[0m",
"\033[38;5;250m (___(__) \033[0m",
"\033[38;5;111m \033[38;5;255m*\033[38;5;111m \033[38;5;255m* \033[0m",
"\033[38;5;255m *\033[38;5;111m \033[38;5;255m*\033[38;5;111m \033[0m",
},
"iconFog": {
" ",
"\033[38;5;251m _ - _ - _ - \033[0m",
"\033[38;5;251m _ - _ - _ \033[0m",
"\033[38;5;251m _ - _ - _ - \033[0m",
" ",
},
}
return icon[name]
}
func codes() map[int][]string {
return map[int][]string{
113: getIcon("iconSunny"),
116: getIcon("iconPartlyCloudy"),
119: getIcon("iconCloudy"),
122: getIcon("iconVeryCloudy"),
143: getIcon("iconFog"),
176: getIcon("iconLightShowers"),
179: getIcon("iconLightSleetShowers"),
182: getIcon("iconLightSleet"),
185: getIcon("iconLightSleet"),
200: getIcon("iconThunderyShowers"),
227: getIcon("iconLightSnow"),
230: getIcon("iconHeavySnow"),
248: getIcon("iconFog"),
260: getIcon("iconFog"),
263: getIcon("iconLightShowers"),
266: getIcon("iconLightRain"),
281: getIcon("iconLightSleet"),
284: getIcon("iconLightSleet"),
293: getIcon("iconLightRain"),
296: getIcon("iconLightRain"),
299: getIcon("iconHeavyShowers"),
302: getIcon("iconHeavyRain"),
305: getIcon("iconHeavyShowers"),
308: getIcon("iconHeavyRain"),
311: getIcon("iconLightSleet"),
314: getIcon("iconLightSleet"),
317: getIcon("iconLightSleet"),
320: getIcon("iconLightSnow"),
323: getIcon("iconLightSnowShowers"),
326: getIcon("iconLightSnowShowers"),
329: getIcon("iconHeavySnow"),
332: getIcon("iconHeavySnow"),
335: getIcon("iconHeavySnowShowers"),
338: getIcon("iconHeavySnow"),
350: getIcon("iconLightSleet"),
353: getIcon("iconLightShowers"),
356: getIcon("iconHeavyShowers"),
359: getIcon("iconHeavyRain"),
362: getIcon("iconLightSleetShowers"),
365: getIcon("iconLightSleetShowers"),
368: getIcon("iconLightSnowShowers"),
371: getIcon("iconHeavySnowShowers"),
374: getIcon("iconLightSleetShowers"),
377: getIcon("iconLightSleet"),
386: getIcon("iconThunderyShowers"),
389: getIcon("iconThunderyHeavyRain"),
392: getIcon("iconThunderySnowShowers"),
395: getIcon("iconHeavySnowShowers"),
}
}

View file

@ -1,7 +1,8 @@
package main package v1
var ( //nolint:funlen
locale = map[string]string{ func locale() map[string]string {
return map[string]string{
"af": "af_ZA", "af": "af_ZA",
"am": "am_ET", "am": "am_ET",
"ar": "ar_TN", "ar": "ar_TN",
@ -73,8 +74,11 @@ var (
"zh": "zh_CN", "zh": "zh_CN",
"zu": "zu_ZA", "zu": "zu_ZA",
} }
}
localizedCaption = map[string]string{ //nolint:funlen
func localizedCaption() map[string]string {
return map[string]string{
"af": "Weer verslag vir:", "af": "Weer verslag vir:",
"am": "የአየር ሁኔታ ዘገባ ለ ፥", "am": "የአየር ሁኔታ ዘገባ ለ ፥",
"ar": "تقرير حالة ألطقس", "ar": "تقرير حالة ألطقس",
@ -147,8 +151,11 @@ var (
"zh-tw": "天氣預報:", "zh-tw": "天氣預報:",
"mg": "Vinavina toetr'andro hoan'ny:", "mg": "Vinavina toetr'andro hoan'ny:",
} }
}
daytimeTranslation = map[string][]string{ //nolint:misspell,funlen
func daytimeTranslation() map[string][]string {
return map[string][]string{
"af": {"Oggend", "Middag", "Vroegaand", "Laatnag"}, "af": {"Oggend", "Middag", "Vroegaand", "Laatnag"},
"am": {"ጠዋት", "ከሰዓት በኋላ", "ምሽት", "ሌሊት"}, "am": {"ጠዋት", "ከሰዓት በኋላ", "ምሽት", "ሌሊት"},
"ar": {"ﺎﻠﻠﻴﻟ", "ﺎﻠﻤﺳﺍﺀ", "ﺎﻠﻈﻫﺭ", "ﺎﻠﺼﺑﺎﺣ"}, "ar": {"ﺎﻠﻠﻴﻟ", "ﺎﻠﻤﺳﺍﺀ", "ﺎﻠﻈﻫﺭ", "ﺎﻠﺼﺑﺎﺣ"},
@ -161,7 +168,7 @@ var (
"ca": {"Matí", "Dia", "Tarda", "Nit"}, "ca": {"Matí", "Dia", "Tarda", "Nit"},
"cy": {"Bore", "Dydd", "Hwyr", "Nos"}, "cy": {"Bore", "Dydd", "Hwyr", "Nos"},
"da": {"Morgen", "Middag", "Aften", "Nat"}, "da": {"Morgen", "Middag", "Aften", "Nat"},
"de": {"Früh", "Mittag", "Abend", "Nacht"}, "de": {"Morgen", "Mittag", "Abend", "Nacht"},
"el": {"Πρωί", "Μεσημέρι", "Απόγευμα", "Βράδυ"}, "el": {"Πρωί", "Μεσημέρι", "Απόγευμα", "Βράδυ"},
"en": {"Morning", "Noon", "Evening", "Night"}, "en": {"Morning", "Noon", "Evening", "Night"},
"eo": {"Mateno", "Tago", "Vespero", "Nokto"}, "eo": {"Mateno", "Tago", "Vespero", "Nokto"},
@ -222,99 +229,110 @@ var (
"zu": {"Morning", "Noon", "Evening", "Night"}, "zu": {"Morning", "Noon", "Evening", "Night"},
"mg": {"Maraina", "Tolakandro", "Ariva", "Alina"}, "mg": {"Maraina", "Tolakandro", "Ariva", "Alina"},
} }
}
unitTemp = map[bool]string{ func unitTemp() map[bool]string {
return map[bool]string{
false: "C", false: "C",
true: "F", true: "F",
} }
}
localizedRain = map[string]map[bool]string{ func localizedRain() map[string]map[bool]string {
"en": map[bool]string{ return map[string]map[bool]string{
"en": {
false: "mm", false: "mm",
true: "in", true: "in",
}, },
"be": map[bool]string{ "be": {
false: "мм", false: "мм",
true: "in", true: "in",
}, },
"ru": map[bool]string{ "ru": {
false: "мм", false: "мм",
true: "in", true: "in",
}, },
"uk": map[bool]string{ "uk": {
false: "мм", false: "мм",
true: "in", true: "in",
}, },
} }
}
localizedVis = map[string]map[bool]string{ func localizedVis() map[string]map[bool]string {
"en": map[bool]string{ return map[string]map[bool]string{
"en": {
false: "km", false: "km",
true: "mi", true: "mi",
}, },
"be": map[bool]string{ "be": {
false: "км", false: "км",
true: "mi", true: "mi",
}, },
"ru": map[bool]string{ "ru": {
false: "км", false: "км",
true: "mi", true: "mi",
}, },
"uk": map[bool]string{ "uk": {
false: "км", false: "км",
true: "mi", true: "mi",
}, },
} }
}
localizedWind = map[string]map[int]string{ func localizedWind() map[string]map[int]string {
"en": map[int]string{ return map[string]map[int]string{
"en": {
0: "km/h", 0: "km/h",
1: "mph", 1: "mph",
2: "m/s", 2: "m/s",
}, },
"be": map[int]string{ "be": {
0: "км/г", 0: "км/г",
1: "mph", 1: "mph",
2: "м/c", 2: "м/c",
}, },
"ru": map[int]string{ "ru": {
0: "км/ч", 0: "км/ч",
1: "mph", 1: "mph",
2: "м/c", 2: "м/c",
}, },
"tr": map[int]string{ "tr": {
0: "km/sa", 0: "km/sa",
1: "mph", 1: "mph",
2: "m/s", 2: "m/s",
}, },
"uk": map[int]string{ "uk": {
0: "км/год", 0: "км/год",
1: "mph", 1: "mph",
2: "м/c", 2: "м/c",
}, },
} }
) }
func unitWind(unit int, lang string) string { func unitWind(unit int, lang string) string {
translation, ok := localizedWind[lang] translation, ok := localizedWind()[lang]
if !ok { if !ok {
translation = localizedWind["en"] translation = localizedWind()["en"]
} }
return translation[unit] return translation[unit]
} }
func unitVis(unit bool, lang string) string { func unitVis(unit bool, lang string) string {
translation, ok := localizedVis[lang] translation, ok := localizedVis()[lang]
if !ok { if !ok {
translation = localizedVis["en"] translation = localizedVis()["en"]
} }
return translation[unit] return translation[unit]
} }
func unitRain(unit bool, lang string) string { func unitRain(unit bool, lang string) string {
translation, ok := localizedRain[lang] translation, ok := localizedRain()[lang]
if !ok { if !ok {
translation = localizedRain["en"] translation = localizedRain()["en"]
} }
return translation[unit] return translation[unit]
} }

View file

@ -1,4 +1,4 @@
package main package v1
import ( import (
"math" "math"
@ -7,13 +7,19 @@ import (
"github.com/klauspost/lctime" "github.com/klauspost/lctime"
) )
var ( func slotTimes() []int {
slotTimes = [slotcount]int{9 * 60, 12 * 60, 18 * 60, 22 * 60} return []int{9 * 60, 12 * 60, 18 * 60, 22 * 60}
) }
//nolint:funlen,gocognit,cyclop
func (g *global) printDay(w weather) ([]string, error) {
var (
ret = []string{}
dateName string
names string
)
func printDay(w weather) (ret []string) {
hourly := w.Hourly hourly := w.Hourly
ret = make([]string, 5)
for i := range ret { for i := range ret {
ret[i] = "│" ret[i] = "│"
} }
@ -23,73 +29,67 @@ func printDay(w weather) (ret []string) {
for _, h := range hourly { for _, h := range hourly {
c := int(math.Mod(float64(h.Time), 100)) + 60*(h.Time/100) c := int(math.Mod(float64(h.Time), 100)) + 60*(h.Time/100)
for i, s := range slots { for i, s := range slots {
if math.Abs(float64(c-slotTimes[i])) < math.Abs(float64(s.Time-slotTimes[i])) { if math.Abs(float64(c-slotTimes()[i])) < math.Abs(float64(s.Time-slotTimes()[i])) {
h.Time = c h.Time = c
slots[i] = h slots[i] = h
} }
} }
} }
if config.RightToLeft { if g.config.RightToLeft {
slots[0], slots[3] = slots[3], slots[0] slots[0], slots[3] = slots[3], slots[0]
slots[1], slots[2] = slots[2], slots[1] slots[1], slots[2] = slots[2], slots[1]
} }
for i, s := range slots { for i, s := range slots {
if config.Narrow { if g.config.Narrow {
if i == 0 || i == 2 { if i == 0 || i == 2 {
continue continue
} }
} }
ret = formatCond(ret, s, false) ret = g.formatCond(ret, s, false)
for i := range ret { for i := range ret {
ret[i] = ret[i] + "│" ret[i] += "│"
} }
} }
d, _ := time.Parse("2006-01-02", w.Date) d, _ := time.Parse("2006-01-02", w.Date)
// dateFmt := "┤ " + d.Format("Mon 02. Jan") + " ├" // dateFmt := "┤ " + d.Format("Mon 02. Jan") + " ├"
if val, ok := locale[config.Lang]; ok { if val, ok := locale()[g.config.Lang]; ok {
lctime.SetLocale(val) err := lctime.SetLocale(val)
if err != nil {
return nil, err
}
} else { } else {
lctime.SetLocale("en_US") err := lctime.SetLocale("en_US")
if err != nil {
return nil, err
}
} }
dateName := ""
if config.RightToLeft { if g.config.RightToLeft {
dow := lctime.Strftime("%a", d) dow := lctime.Strftime("%a", d)
day := lctime.Strftime("%d", d) day := lctime.Strftime("%d", d)
month := lctime.Strftime("%b", d) month := lctime.Strftime("%b", d)
dateName = reverse(month) + " " + day + " " + reverse(dow) dateName = reverse(month) + " " + day + " " + reverse(dow)
} else { } else {
dateName = lctime.Strftime("%a %d %b", d) dateName = lctime.Strftime("%a %d %b", d)
if config.Lang == "ko" { if g.config.Lang == "ko" {
dateName = lctime.Strftime("%b %d일 %a", d) dateName = lctime.Strftime("%b %d일 %a", d)
} }
if config.Lang == "zh" || config.Lang == "zh-tw" || config.Lang == "zh-cn" { if g.config.Lang == "zh" || g.config.Lang == "zh-tw" || g.config.Lang == "zh-cn" {
dateName = lctime.Strftime("%b%d日%A", d) dateName = lctime.Strftime("%b%d日%A", d)
} }
} }
// appendSide := 0
// // for utf8.RuneCountInString(dateName) <= dateWidth {
// for runewidth.StringWidth(dateName) <= dateWidth {
// if appendSide == 1 {
// dateName = dateName + " "
// appendSide = 0
// } else {
// dateName = " " + dateName
// appendSide = 1
// }
// }
dateFmt := "┤" + justifyCenter(dateName, 12) + "├" dateFmt := "┤" + justifyCenter(dateName, 12) + "├"
trans := daytimeTranslation["en"] trans := daytimeTranslation()["en"]
if t, ok := daytimeTranslation[config.Lang]; ok { if t, ok := daytimeTranslation()[g.config.Lang]; ok {
trans = t trans = t
} }
if config.Narrow { if g.config.Narrow {
names := "│ " + justifyCenter(trans[1], 16) + names := "│ " + justifyCenter(trans[1], 16) +
"└──────┬──────┘" + justifyCenter(trans[3], 16) + " │" "└──────┬──────┘" + justifyCenter(trans[3], 16) + " │"
@ -97,16 +97,16 @@ func printDay(w weather) (ret []string) {
" ┌─────────────┐ ", " ┌─────────────┐ ",
"┌───────────────────────" + dateFmt + "───────────────────────┐", "┌───────────────────────" + dateFmt + "───────────────────────┐",
names, names,
"├──────────────────────────────┼──────────────────────────────┤"}, "├──────────────────────────────┼──────────────────────────────┤",
},
ret...) ret...)
return append(ret, return append(ret,
"└──────────────────────────────┴──────────────────────────────┘") "└──────────────────────────────┴──────────────────────────────┘"),
nil
} }
names := "" if g.config.RightToLeft {
if config.RightToLeft {
names = "│" + justifyCenter(trans[3], 29) + "│ " + justifyCenter(trans[2], 16) + names = "│" + justifyCenter(trans[3], 29) + "│ " + justifyCenter(trans[2], 16) +
"└──────┬──────┘" + justifyCenter(trans[1], 16) + " │" + justifyCenter(trans[0], 29) + "│" "└──────┬──────┘" + justifyCenter(trans[1], 16) + " │" + justifyCenter(trans[0], 29) + "│"
} else { } else {
@ -114,13 +114,17 @@ func printDay(w weather) (ret []string) {
"└──────┬──────┘" + justifyCenter(trans[2], 16) + " │" + justifyCenter(trans[3], 29) + "│" "└──────┬──────┘" + justifyCenter(trans[2], 16) + " │" + justifyCenter(trans[3], 29) + "│"
} }
//nolint:lll
ret = append([]string{ ret = append([]string{
" ┌─────────────┐ ", " ┌─────────────┐ ",
"┌──────────────────────────────┬───────────────────────" + dateFmt + "───────────────────────┬──────────────────────────────┐", "┌──────────────────────────────┬───────────────────────" + dateFmt + "───────────────────────┬──────────────────────────────┐",
names, names,
"├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤"}, "├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤",
},
ret...) ret...)
//nolint:lll
return append(ret, return append(ret,
"└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘") "└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘"),
nil
} }

View file

@ -30,7 +30,7 @@ DESCRIPTION = {
"Temperature in Fahrenheit", "Temperature in Fahrenheit",
"temperature_fahrenheit"), "temperature_fahrenheit"),
"uvIndex": ( "uvIndex": (
"Ultaviolet Radiation Index", "Ultraviolet Radiation Index",
"uv_index"), "uv_index"),
"visibility": ( "visibility": (
"Visible Distance in Kilometres", "Visible Distance in Kilometres",
@ -91,15 +91,15 @@ DESCRIPTION = {
# astronomy fields with time # astronomy fields with time
"moonrise": ( "moonrise": (
"Minutes since start of the day untill the moon appears above the horizon", "Minutes since start of the day until the moon appears above the horizon",
"astronomy_moonrise_min"), "astronomy_moonrise_min"),
"moonset": ( "moonset": (
"Minutes since start of the day untill the moon disappears below the horizon", "Minutes since start of the day until the moon disappears below the horizon",
"astronomy_moonset_min"), "astronomy_moonset_min"),
"sunrise": ( "sunrise": (
"Minutes since start of the day untill the sun appears above the horizon", "Minutes since start of the day until the sun appears above the horizon",
"astronomy_sunrise_min"), "astronomy_sunrise_min"),
"sunset": ( "sunset": (
"Minutes since start of the day untill the moon disappears below the horizon", "Minutes since start of the day until the moon disappears below the horizon",
"astronomy_sunset_min"), "astronomy_sunset_min"),
} }

View file

@ -90,10 +90,19 @@ PLAIN_TEXT_AGENTS = [
"aiohttp", "aiohttp",
"http_get", "http_get",
"xh", "xh",
"nushell",
] ]
PLAIN_TEXT_PAGES = [':help', ':bash.function', ':translation', ':iterm2'] PLAIN_TEXT_PAGES = [':help', ':bash.function', ':translation', ':iterm2']
TRANSLATION_TABLE = str.maketrans({
'\u2196': '\u256E', # '↖' -> '╮'
'\u2197': '\u256D', # '↗' -> '╭'
'\u2198': '\u2570', # '↘' -> '╰'
'\u2199': '\u256F', # '↙' -> '╯'
'\u26A1': '\u250C\u2518'
})
_IPLOCATION_ORDER = os.environ.get( _IPLOCATION_ORDER = os.environ.get(
"WTTR_IPLOCATION_ORDER", "WTTR_IPLOCATION_ORDER",
'geoip,ip2location,ipinfo') 'geoip,ip2location,ipinfo')

View file

@ -406,12 +406,17 @@ def location_processing(location, ip_addr):
if location and location.lstrip('~ ').startswith('@'): if location and location.lstrip('~ ').startswith('@'):
try: try:
location, region, country = _get_location( if (location.lstrip('~ ')[1:] == ""):
socket.gethostbyname( location, region, country = NOT_FOUND_LOCATION, None, None
location.lstrip('~ ')[1:]))
location = '~' + location else:
location = _fully_qualified_location(location, region, country) location, region, country = _get_location(
hide_full_address = not force_show_full_address socket.gethostbyname(
location.lstrip('~ ')[1:]))
location = '~' + location
location = _fully_qualified_location(location, region, country)
hide_full_address = not force_show_full_address
except: except:
location, region, country = NOT_FOUND_LOCATION, None, None location, region, country = NOT_FOUND_LOCATION, None, None

View file

@ -177,6 +177,8 @@ def hpa_to_mb(hpa):
def hpa_to_in(hpa): def hpa_to_in(hpa):
return round(hpa * 0.02953, 2) return round(hpa * 0.02953, 2)
def hpa_to_mmHg(hpa):
return round(hpa * 0.75006157584566 , 3)
def group_hours_to_days(lat, lng, hourlies, days_to_return): def group_hours_to_days(lat, lng, hourlies, days_to_return):
tf = timezonefinder.TimezoneFinder() tf = timezonefinder.TimezoneFinder()
@ -345,6 +347,7 @@ def _convert_hour(hour):
"visibility": 'not yet implemented', # str(details['vis_km']), "visibility": 'not yet implemented', # str(details['vis_km']),
"visibilityMiles": 'not yet implemented', # str(details['vis_miles']), "visibilityMiles": 'not yet implemented', # str(details['vis_miles']),
"pressure": str(hpa_to_mb(details['air_pressure_at_sea_level'])), "pressure": str(hpa_to_mb(details['air_pressure_at_sea_level'])),
"pressure_mmHg": str(hpa_to_mmHg(details['air_pressure_at_sea_level'])),
"pressureInches": str(hpa_to_in(details['air_pressure_at_sea_level'])), "pressureInches": str(hpa_to_in(details['air_pressure_at_sea_level'])),
"cloudcover": 'not yet implemented', # Convert from cloud_area_fraction?? str(details['cloud']), "cloudcover": 'not yet implemented', # Convert from cloud_area_fraction?? str(details['cloud']),
# metno doesn't have FeelsLikeC, but we-lang.go is using it in calcs, # metno doesn't have FeelsLikeC, but we-lang.go is using it in calcs,

View file

@ -79,6 +79,8 @@ def parse_query(args):
return result return result
if 'A' in q: if 'A' in q:
result['force-ansi'] = True result['force-ansi'] = True
if 'd' in q:
result['dumb'] = True
if 'n' in q: if 'n' in q:
result['narrow'] = True result['narrow'] = True
if 'm' in q: if 'm' in q:

View file

@ -44,6 +44,9 @@ def get_moon(parsed_query):
if parsed_query.get('no-terminal', False): if parsed_query.get('no-terminal', False):
stdout = globals.remove_ansi(stdout) stdout = globals.remove_ansi(stdout)
if parsed_query.get('dumb', False):
stdout = stdout.translate(globals.TRANSLATION_TABLE)
if html: if html:
p = Popen( p = Popen(
["bash", globals.ANSI2HTML, "--palette=solarized", "--bg=dark"], ["bash", globals.ANSI2HTML, "--palette=solarized", "--bg=dark"],

View file

@ -38,7 +38,7 @@ from astral import moon, sun
from scipy.interpolate import interp1d from scipy.interpolate import interp1d
from babel.dates import format_datetime from babel.dates import format_datetime
from globals import WWO_KEY, remove_ansi from globals import WWO_KEY, TRANSLATION_TABLE, remove_ansi
import constants import constants
import translations import translations
import parse_query import parse_query
@ -638,6 +638,8 @@ def main(query, parsed_query, data):
output += textual_information(data_parsed, geo_data, parsed_query) output += textual_information(data_parsed, geo_data, parsed_query)
if parsed_query.get('no-terminal', False): if parsed_query.get('no-terminal', False):
output = remove_ansi(output) output = remove_ansi(output)
if parsed_query.get('dumb', False):
output = output.translate(TRANSLATION_TABLE)
return output return output
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -13,8 +13,8 @@ from gevent.subprocess import Popen, PIPE
sys.path.insert(0, "..") sys.path.insert(0, "..")
from translations import get_message, SUPPORTED_LANGS from translations import get_message, SUPPORTED_LANGS
from globals import WEGO, NOT_FOUND_LOCATION, DEFAULT_LOCATION, ANSI2HTML, \ from globals import WEGO, TRANSLATION_TABLE, NOT_FOUND_LOCATION, \
error, remove_ansi DEFAULT_LOCATION, ANSI2HTML, error, remove_ansi
def get_wetter(parsed_query): def get_wetter(parsed_query):
@ -126,6 +126,9 @@ def _wego_postprocessing(location, parsed_query, stdout):
if parsed_query.get('no-city', False): if parsed_query.get('no-city', False):
stdout = "\n".join(stdout.splitlines()[2:]) + "\n" stdout = "\n".join(stdout.splitlines()[2:]) + "\n"
if parsed_query.get('dumb', False):
stdout = stdout.translate(TRANSLATION_TABLE)
if full_address \ if full_address \
and parsed_query.get('format', 'txt') != 'png' \ and parsed_query.get('format', 'txt') != 'png' \
and (not parsed_query.get('no-city') and (not parsed_query.get('no-city')

View file

@ -17,7 +17,7 @@ import fmt.png
import parse_query import parse_query
from translations import get_message, FULL_TRANSLATION, PARTIAL_TRANSLATION, SUPPORTED_LANGS from translations import get_message, FULL_TRANSLATION, PARTIAL_TRANSLATION, SUPPORTED_LANGS
from buttons import add_buttons from buttons import add_buttons
from globals import get_help_file, remove_ansi, \ from globals import get_help_file, remove_ansi, TRANSLATION_TABLE, \
BASH_FUNCTION_FILE, TRANSLATION_FILE, LOG_FILE, \ BASH_FUNCTION_FILE, TRANSLATION_FILE, LOG_FILE, \
NOT_FOUND_LOCATION, \ NOT_FOUND_LOCATION, \
MALFORMED_RESPONSE_HTML_PAGE, \ MALFORMED_RESPONSE_HTML_PAGE, \
@ -239,6 +239,8 @@ def _response(parsed_query, query, fast_mode=False):
message = get_message('FOLLOW_ME', parsed_query['lang']) message = get_message('FOLLOW_ME', parsed_query['lang'])
if parsed_query.get('no-terminal', False): if parsed_query.get('no-terminal', False):
message = remove_ansi(message) message = remove_ansi(message)
if parsed_query.get('dumb', False):
message = message.translate(TRANSLATION_TABLE)
output += '\n' + message + '\n' output += '\n' + message + '\n'
return cache.store(cache_signature, output) return cache.store(cache_signature, output)

View file

@ -6,21 +6,21 @@ gevent
dnspython dnspython
pylint pylint
cyrtranslit cyrtranslit
astral astral>=2.0,<=2.2
timezonefinder==2.1.2 timezonefinder==2.1.2
pytz pytz
pyte pyte
python-dateutil python-dateutil>=2.5.0,<=2.8.1
diagram diagram
pyjq pyjq
scipy scipy
numpy numpy
pillow pillow
babel babel
pylru pylru>=1.0.7,<=1.2.1
pysocks pysocks
supervisor supervisor
numba numba
emoji emoji>=1.6.0,<=1.7.0
grapheme grapheme
pycountry pycountry

View file

@ -30,6 +30,7 @@ View options:
1 # current weather + today's forecast 1 # current weather + today's forecast
2 # current weather + today's + tomorrow's forecast 2 # current weather + today's + tomorrow's forecast
A # ignore User-Agent and force ANSI output format (terminal) A # ignore User-Agent and force ANSI output format (terminal)
d # restrict output to standard console font glyphs
F # do not show the "Follow" line F # do not show the "Follow" line
n # narrow version (only day and night) n # narrow version (only day and night)
q # quiet version (no "Weather report" text) q # quiet version (no "Weather report" text)

View file

@ -3,24 +3,24 @@ Instruções:
$ curl wttr.in # o tempo na sua localização atual $ curl wttr.in # o tempo na sua localização atual
$ curl wttr.in/muc # o tempo no aeroporto de Munique $ curl wttr.in/muc # o tempo no aeroporto de Munique
Tipos de localização suportados: Tipos de locais suportados:
/paris # o nome de uma cidade /paris # o nome de uma cidade
/~Eiffel+tower # o nome de qualquer lugar famoso /~Eiffel+tower # o nome de um lugar famoso
/Москва # nome Unicode de qualquer lugar em qualquer idioma /Москва # o nome Unicode de qualquer lugar em qualquer idioma
/muc # o código de um aeroporto (3 letras) /muc # o código de um aeroporto (3 letras)
/@stackoverflow.com # o nome de um domínio web /@stackoverflow.com # o nome de um domínio web
/94107 # um código de área /94107 # um código de área
/-78.46,106.79 # coordenadas do GPS /-78.46,106.79 # as coordenadas do GPS de um lugar
Lugares especiais: Lugares especiais:
/moon # A fase da lua (crescente ,+US ou ,+France para estas cidades) /moon # a fase da ua (crescente ,+US ou ,+France para estas cidades)
/moon@2016-10-25 # A fase da lua em uma determinada data (@2016-10-25) /moon@2016-10-25 # a fase da Lua em uma determinada data (@2016-10-25)
Unidades: Unidades:
?m # métricas (SI) (o padrão em todos os lugares exceto nos EUA) ?m # Sistema Internacional de Unidades (SI) (o padrão em todos os lugares exceto nos EUA)
?u # Sistema Unificado de Clasificaçāo de Solo ou USCS (o padrão nos EUA) ?u # Sistema Unificado de Clasificaçāo de Solo ou USCS (o padrão nos EUA)
?M # mostrar a velocidade do vento em m/s ?M # mostrar a velocidade do vento em m/s
@ -48,13 +48,13 @@ As opções podem ser usadas em conjunto:
/Paris_0pq.png # em PNG as opções se especificam depois do caracter _ /Paris_0pq.png # em PNG as opções se especificam depois do caracter _
/Rome_0pq_lang=it.png # uma longa sequência de opções podem ser separadas pelo caracter _ /Rome_0pq_lang=it.png # uma longa sequência de opções podem ser separadas pelo caracter _
Localizaçāo: Tradução:
$ curl fr.wttr.in/Paris $ curl fr.wttr.in/Paris
$ curl wttr.in/paris?lang=fr $ curl wttr.in/paris?lang=fr
$ curl -H "Accept-Language: fr" wttr.in/paris $ curl -H "Accept-Language: fr" wttr.in/paris
Línguas suportadas: Idiomas suportadas:
FULL_TRANSLATION (suportadas) FULL_TRANSLATION (suportadas)
PARTIAL_TRANSLATION (em andamento) PARTIAL_TRANSLATION (em andamento)
@ -63,5 +63,4 @@ URLs especiais:
/:help # mostra esta página /:help # mostra esta página
/:bash.function # sugere uma função wttr() em bash /:bash.function # sugere uma função wttr() em bash
/:translation # mostra informação a respeito dos tradutores /:translation # mostra informações a respeito dos tradutores

View file

@ -17,8 +17,8 @@
266: Chuvisco : Light drizzle 266: Chuvisco : Light drizzle
281: Chuvisco gelado : Freezing drizzle 281: Chuvisco gelado : Freezing drizzle
284: Chuvisco muito gelado : Heavy freezing drizzle 284: Chuvisco muito gelado : Heavy freezing drizzle
293: Garoa irregular : Patchy light rain 293: Chuvisco irregular : Patchy light rain
296: Garoa : Light rain 296: Chuvisco : Light rain
299: Chuva moderada ocasional : Moderate rain at times 299: Chuva moderada ocasional : Moderate rain at times
302: Chuva moderada : Moderate rain 302: Chuva moderada : Moderate rain
305: Chuva forte ocasional : Heavy rain at times 305: Chuva forte ocasional : Heavy rain at times
@ -34,48 +34,14 @@
335: Neve forte irregular : Patchy heavy snow 335: Neve forte irregular : Patchy heavy snow
338: Neve forte : Heavy snow 338: Neve forte : Heavy snow
350: Pelotas de gelo : Ice pellets 350: Pelotas de gelo : Ice pellets
353: Chuveiro de garoa : Light rain shower 353: Chuva fraca : Light rain shower
356: Chuveiro de chuva moderada ou forte : Moderate or heavy rain shower 356: Chuva moderada ou forte : Moderate or heavy rain shower
359: Chuveiro de chuva torrencial : Torrential rain shower 359: Chuva torrencial : Torrential rain shower
362: Chuveiro de granizo fraco : Light sleet showers 362: Chuva de granizo fraco : Light sleet showers
365: Chuveiro de granizo moderada ou forte : Moderate or heavy sleet showers 365: Chuva de granizo moderada ou forte : Moderate or heavy sleet showers
368: Chuveiro de neve fraca : Light snow showers 368: Chuva com neve fraca : Light snow showers
371: Chuveiro de neve moderada ou forte : Moderate or heavy snow showers 371: Chuva com neve moderada ou forte : Moderate or heavy snow showers
386: Tempestate com garoa irregular : Patchy light rain with thunder 386: Tempestate com garoa irregular : Patchy light rain with thunder
389: Tempestade com chuva moderada ou forte : Moderate or heavy rain with thunder 389: Tempestade com chuva moderada ou forte : Moderate or heavy rain with thunder
392: Tempestade com neve fraca : Patchy light snow with thunder 392: Tempestade com neve fraca : Patchy light snow with thunder
395: Tempestade com neve moderada ou forte : Moderate or heavy snow with thunder 395: Tempestade com neve moderada ou forte : Moderate or heavy snow with thunder

View file

@ -0,0 +1,66 @@
Використання:
$ curl wttr.in # поточне місцеположення
$ curl wttr.in/kbp # погода в аеропорту Бориспіль (код ICAO: KBP)
Підтримуються наступні типи місцеположень:
/paris # місто
/~Eiffel+tower # будь-яке місцеположення
/Киів # юнікодне ім'я будь-якого місцеположення будь-якою мовою
/muc # код аеропорту ICAO (3 літери)
/@stackoverflow.com # доменне им'я
/94107 # поштовый індекс (тільки для США)
/-78.46,106.79 # GPS-координати
Спеціальні умовні місцеположення:
/moon # Фаза Місяця (додайте ,+US або ,+France для міста Moon у США або Франції)
/moon@2016-10-25 # Фаза Місяця для вказаної дати (@2016-10-25)
Одиниці вимірювань:
?m # метричні (СІ) (використовуються всюди крім США)
?u # USCS (використовуються у США)
?M # показувати швидкість вітру в м/с
Опції відображення:
?0 # тільки поточна погода
?1 # погода сьогодні + 1 день
?2 # погода сьогодні + 2 дня
?n # вузька версія (тільки день та ніч)
?q # тиха версія (без тексту "Прогноз погоди")
?Q # надтиха версія (без "Прогноз погоди", немає назви міста)
?T # відключити послідовності терміналу (без кольорів)
PNG-опції:
/paris.png # сгенерувати PNG-файл
?p # добавити рамку навколо
?t # transparency=150 (прозорість 150)
transparency=... # прозорість від 0 до 255 (255 = не прозорий)
Опції можна комбінувати:
/Paris?0pq
/Paris?0pq&lang=fr
/Paris_0pq.png # в PNG-запитах опції вказуються після знаку _
/Rome_0pq_lang=it.png # довгі опції розділяются знаком підкреслення _
Локалізація:
$ curl fr.wttr.in/Paris
$ curl wttr.in/paris?lang=fr
$ curl -H "Accept-Language: fr" wttr.in/paris
Мови що підтримуються:
FULL_TRANSLATION (підтримується)
PARTIAL_TRANSLATION (в процесі)
Спеціальні строрінки:
/:help # показати цю сторінку
/:bash.function # показати рекомендовану функцію wttr()
/:translation # показати список перекладачів wttr.in

View file

@ -1,9 +0,0 @@
module github.com/chubin/wttr.in/v2
go 1.15
require (
github.com/klauspost/lctime v0.1.0
github.com/mattn/go-colorable v0.1.11
github.com/mattn/go-runewidth v0.0.13
)

View file

@ -1,13 +0,0 @@
github.com/klauspost/lctime v0.1.0 h1:nINsuFc860M9cyYhT6vfg6U1USh7kiVBj/s/2b04U70=
github.com/klauspost/lctime v0.1.0/go.mod h1:OwdMhr8tbQvusAsnilqkkgDQqivWlqyg0w5cfXkLiDk=
github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View file

@ -1,187 +0,0 @@
package main
var (
iconUnknown = []string{
" .-. ",
" __) ",
" ( ",
" `- ",
" • "}
iconSunny = []string{
"\033[38;5;226m \\ / \033[0m",
"\033[38;5;226m .-. \033[0m",
"\033[38;5;226m ― ( ) ― \033[0m",
"\033[38;5;226m `- \033[0m",
"\033[38;5;226m / \\ \033[0m"}
iconPartlyCloudy = []string{
"\033[38;5;226m \\ /\033[0m ",
"\033[38;5;226m _ /\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m \\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
" "}
iconCloudy = []string{
" ",
"\033[38;5;250m .--. \033[0m",
"\033[38;5;250m .-( ). \033[0m",
"\033[38;5;250m (___.__)__) \033[0m",
" "}
iconVeryCloudy = []string{
" ",
"\033[38;5;240;1m .--. \033[0m",
"\033[38;5;240;1m .-( ). \033[0m",
"\033[38;5;240;1m (___.__)__) \033[0m",
" "}
iconLightShowers = []string{
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;111m \033[0m",
"\033[38;5;111m \033[0m"}
iconHeavyShowers = []string{
"\033[38;5;226m _`/\"\"\033[38;5;240;1m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;240;1m( ). \033[0m",
"\033[38;5;226m /\033[38;5;240;1m(___(__) \033[0m",
"\033[38;5;21;1m \033[0m",
"\033[38;5;21;1m \033[0m"}
iconLightSnowShowers = []string{
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;255m * * * \033[0m",
"\033[38;5;255m * * * \033[0m"}
iconHeavySnowShowers = []string{
"\033[38;5;226m _`/\"\"\033[38;5;240;1m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;240;1m( ). \033[0m",
"\033[38;5;226m /\033[38;5;240;1m(___(__) \033[0m",
"\033[38;5;255;1m * * * * \033[0m",
"\033[38;5;255;1m * * * * \033[0m"}
iconLightSleetShowers = []string{
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;111m \033[38;5;255m*\033[38;5;111m \033[38;5;255m* \033[0m",
"\033[38;5;255m *\033[38;5;111m \033[38;5;255m*\033[38;5;111m \033[0m"}
iconThunderyShowers = []string{
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;228;5m ⚡\033[38;5;111;25m\033[38;5;228;5m⚡\033[38;5;111;25m \033[0m",
"\033[38;5;111m \033[0m"}
iconThunderyHeavyRain = []string{
"\033[38;5;240;1m .-. \033[0m",
"\033[38;5;240;1m ( ). \033[0m",
"\033[38;5;240;1m (___(__) \033[0m",
"\033[38;5;21;1m \033[38;5;228;5m⚡\033[38;5;21;25m\033[38;5;228;5m⚡\033[38;5;21;25m \033[0m",
"\033[38;5;21;1m \033[38;5;228;5m⚡\033[38;5;21;25m \033[0m"}
iconThunderySnowShowers = []string{
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;255m *\033[38;5;228;5m⚡\033[38;5;255;25m*\033[38;5;228;5m⚡\033[38;5;255;25m* \033[0m",
"\033[38;5;255m * * * \033[0m"}
iconLightRain = []string{
"\033[38;5;250m .-. \033[0m",
"\033[38;5;250m ( ). \033[0m",
"\033[38;5;250m (___(__) \033[0m",
"\033[38;5;111m \033[0m",
"\033[38;5;111m \033[0m"}
iconHeavyRain = []string{
"\033[38;5;240;1m .-. \033[0m",
"\033[38;5;240;1m ( ). \033[0m",
"\033[38;5;240;1m (___(__) \033[0m",
"\033[38;5;21;1m \033[0m",
"\033[38;5;21;1m \033[0m"}
iconLightSnow = []string{
"\033[38;5;250m .-. \033[0m",
"\033[38;5;250m ( ). \033[0m",
"\033[38;5;250m (___(__) \033[0m",
"\033[38;5;255m * * * \033[0m",
"\033[38;5;255m * * * \033[0m"}
iconHeavySnow = []string{
"\033[38;5;240;1m .-. \033[0m",
"\033[38;5;240;1m ( ). \033[0m",
"\033[38;5;240;1m (___(__) \033[0m",
"\033[38;5;255;1m * * * * \033[0m",
"\033[38;5;255;1m * * * * \033[0m"}
iconLightSleet = []string{
"\033[38;5;250m .-. \033[0m",
"\033[38;5;250m ( ). \033[0m",
"\033[38;5;250m (___(__) \033[0m",
"\033[38;5;111m \033[38;5;255m*\033[38;5;111m \033[38;5;255m* \033[0m",
"\033[38;5;255m *\033[38;5;111m \033[38;5;255m*\033[38;5;111m \033[0m"}
iconFog = []string{
" ",
"\033[38;5;251m _ - _ - _ - \033[0m",
"\033[38;5;251m _ - _ - _ \033[0m",
"\033[38;5;251m _ - _ - _ - \033[0m",
" "}
codes = map[int][]string{
113: iconSunny,
116: iconPartlyCloudy,
119: iconCloudy,
122: iconVeryCloudy,
143: iconFog,
176: iconLightShowers,
179: iconLightSleetShowers,
182: iconLightSleet,
185: iconLightSleet,
200: iconThunderyShowers,
227: iconLightSnow,
230: iconHeavySnow,
248: iconFog,
260: iconFog,
263: iconLightShowers,
266: iconLightRain,
281: iconLightSleet,
284: iconLightSleet,
293: iconLightRain,
296: iconLightRain,
299: iconHeavyShowers,
302: iconHeavyRain,
305: iconHeavyShowers,
308: iconHeavyRain,
311: iconLightSleet,
314: iconLightSleet,
317: iconLightSleet,
320: iconLightSnow,
323: iconLightSnowShowers,
326: iconLightSnowShowers,
329: iconHeavySnow,
332: iconHeavySnow,
335: iconHeavySnowShowers,
338: iconHeavySnow,
350: iconLightSleet,
353: iconLightShowers,
356: iconHeavyShowers,
359: iconHeavyRain,
362: iconLightSleetShowers,
365: iconLightSleetShowers,
368: iconLightSnowShowers,
371: iconHeavySnowShowers,
374: iconLightSleetShowers,
377: iconLightSleet,
386: iconThunderyShowers,
389: iconThunderyHeavyRain,
392: iconThunderySnowShowers,
395: iconHeavySnowShowers,
}
)

View file

@ -1,156 +0,0 @@
// This code represents wttr.in view v1.
// It is based on wego (github.com/schachmat/wego) from which it diverged back in 2016.
package main
import (
_ "crypto/sha512"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
"os"
"os/user"
"path"
"regexp"
"strings"
"github.com/mattn/go-colorable"
"github.com/mattn/go-runewidth"
)
type configuration struct {
APIKey string
City string
Numdays int
Imperial bool
WindUnit bool
Inverse bool
Lang string
Narrow bool
LocationName string
WindMS bool
RightToLeft bool
}
var (
ansiEsc *regexp.Regexp
config configuration
configpath string
debug bool
)
const (
wuri = "http://127.0.0.1:5001/premium/v1/weather.ashx?"
suri = "http://127.0.0.1:5001/premium/v1/search.ashx?"
slotcount = 4
)
func configload() error {
b, err := ioutil.ReadFile(configpath)
if err == nil {
return json.Unmarshal(b, &config)
}
return err
}
func configsave() error {
j, err := json.MarshalIndent(config, "", "\t")
if err == nil {
return ioutil.WriteFile(configpath, j, 0600)
}
return err
}
func init() {
flag.IntVar(&config.Numdays, "days", 3, "Number of days of weather forecast to be displayed")
flag.StringVar(&config.Lang, "lang", "en", "Language of the report")
flag.StringVar(&config.City, "city", "New York", "City to be queried")
flag.BoolVar(&debug, "debug", false, "Print out raw json response for debugging purposes")
flag.BoolVar(&config.Imperial, "imperial", false, "Use imperial units")
flag.BoolVar(&config.Inverse, "inverse", false, "Use inverted colors")
flag.BoolVar(&config.Narrow, "narrow", false, "Narrow output (two columns)")
flag.StringVar(&config.LocationName, "location_name", "", "Location name (used in the caption)")
flag.BoolVar(&config.WindMS, "wind_in_ms", false, "Show wind speed in m/s")
flag.BoolVar(&config.RightToLeft, "right_to_left", false, "Right to left script")
configpath = os.Getenv("WEGORC")
if configpath == "" {
usr, err := user.Current()
if err != nil {
log.Fatalf("%v\nYou can set the environment variable WEGORC to point to your config file as a workaround.", err)
}
configpath = path.Join(usr.HomeDir, ".wegorc")
}
config.APIKey = ""
config.Imperial = false
config.Lang = "en"
err := configload()
if _, ok := err.(*os.PathError); ok {
log.Printf("No config file found. Creating %s ...", configpath)
if err2 := configsave(); err2 != nil {
log.Fatal(err2)
}
} else if err != nil {
log.Fatalf("could not parse %v: %v", configpath, err)
}
ansiEsc = regexp.MustCompile("\033.*?m")
}
func main() {
flag.Parse()
r := getDataFromAPI()
if r.Data.Req == nil || len(r.Data.Req) < 1 {
if r.Data.Err != nil && len(r.Data.Err) >= 1 {
log.Fatal(r.Data.Err[0].Msg)
}
log.Fatal("Malformed response.")
}
locationName := r.Data.Req[0].Query
if config.LocationName != "" {
locationName = config.LocationName
}
if config.Lang == "he" || config.Lang == "ar" || config.Lang == "fa" {
config.RightToLeft = true
}
if caption, ok := localizedCaption[config.Lang]; !ok {
fmt.Printf("Weather report: %s\n\n", locationName)
} else {
if config.RightToLeft {
caption = locationName + " " + caption
space := strings.Repeat(" ", 125-runewidth.StringWidth(caption))
fmt.Printf("%s%s\n\n", space, caption)
} else {
fmt.Printf("%s %s\n\n", caption, locationName)
}
}
stdout := colorable.NewColorableStdout()
if r.Data.Cur == nil || len(r.Data.Cur) < 1 {
log.Fatal("No weather data available.")
}
out := formatCond(make([]string, 5), r.Data.Cur[0], true)
for _, val := range out {
if config.RightToLeft {
fmt.Fprint(stdout, strings.Repeat(" ", 94))
} else {
fmt.Fprint(stdout, " ")
}
fmt.Fprintln(stdout, val)
}
if config.Numdays == 0 {
return
}
if r.Data.Weather == nil {
log.Fatal("No detailed weather forecast available.")
}
for _, d := range r.Data.Weather {
for _, val := range printDay(d) {
fmt.Fprintln(stdout, val)
}
}
}

266
srv.go Normal file
View file

@ -0,0 +1,266 @@
package main
import (
"crypto/tls"
"fmt"
"io"
stdlog "log"
"net/http"
"strings"
"time"
"github.com/alecthomas/kong"
log "github.com/sirupsen/logrus"
"github.com/chubin/wttr.in/internal/config"
geoip "github.com/chubin/wttr.in/internal/geo/ip"
geoloc "github.com/chubin/wttr.in/internal/geo/location"
"github.com/chubin/wttr.in/internal/logging"
"github.com/chubin/wttr.in/internal/processor"
"github.com/chubin/wttr.in/internal/types"
// v1 "github.com/chubin/wttr.in/internal/view/v1"
)
//nolint:gochecknoglobals
var cli struct {
ConfigFile string `name:"config-file" arg:"" optional:"" help:"Name of configuration file"`
ConfigCheck bool `name:"config-check" help:"Check configuration"`
ConfigDump bool `name:"config-dump" help:"Dump configuration"`
ConvertGeoIPCache bool `name:"convert-geo-ip-cache" help:"Convert Geo IP data cache to SQlite"`
ConvertGeoLocationCache bool `name:"convert-geo-location-cache" help:"Convert Geo Location data cache to SQlite"`
GeoResolve string `name:"geo-resolve" help:"Resolve location"`
LogLevel string `name:"log-level" short:"l" help:"Show log messages with level" default:"info"`
// V1 v1.Configuration
}
const logLineStart = "LOG_LINE_START "
func suppressMessages() []string {
return []string{
"error reading preface from client",
"TLS handshake error from",
"URL query contains semicolon, which is no longer a supported separator",
"connection error: PROTOCOL_ERROR",
}
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
func serveHTTP(mux *http.ServeMux, port int, logFile io.Writer, errs chan<- error) {
srv := &http.Server{
Addr: fmt.Sprintf(":%d", port),
ErrorLog: stdlog.New(logFile, logLineStart, stdlog.LstdFlags),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 1 * time.Second,
Handler: mux,
}
errs <- srv.ListenAndServe()
}
func serveHTTPS(mux *http.ServeMux, port int, certFile, keyFile string, logFile io.Writer, errs chan<- error) {
tlsConfig := &tls.Config{
// CipherSuites: []uint16{
// tls.TLS_CHACHA20_POLY1305_SHA256,
// tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
// tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
// },
// MinVersion: tls.VersionTLS13,
}
srv := &http.Server{
Addr: fmt.Sprintf(":%d", port),
ErrorLog: stdlog.New(logFile, logLineStart, stdlog.LstdFlags),
ReadTimeout: 5 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 1 * time.Second,
TLSConfig: tlsConfig,
Handler: mux,
}
errs <- srv.ListenAndServeTLS(certFile, keyFile)
}
func serve(conf *config.Config) error {
var (
// mux is main HTTP/HTTP requests multiplexer.
mux = http.NewServeMux()
// logger is optimized requests logger.
logger = logging.NewRequestLogger(
conf.Logging.AccessLog,
time.Duration(conf.Logging.Interval)*time.Second)
rp *processor.RequestProcessor
// errs is the servers errors channel.
errs = make(chan error, 1)
// numberOfServers started. If 0, exit.
numberOfServers int
errorsLog = logging.NewLogSuppressor(
conf.Logging.ErrorsLog,
suppressMessages(),
logLineStart,
)
err error
)
rp, err = processor.NewRequestProcessor(conf)
if err != nil {
return fmt.Errorf("log processor initialization: %w", err)
}
err = errorsLog.Open()
if err != nil {
return err
}
err = rp.Start()
if err != nil {
return err
}
mux.HandleFunc("/", mainHandler(rp, logger))
if conf.Server.PortHTTP != 0 {
go serveHTTP(mux, conf.Server.PortHTTP, errorsLog, errs)
numberOfServers++
}
if conf.Server.PortHTTPS != 0 {
go serveHTTPS(mux, conf.Server.PortHTTPS, conf.Server.TLSCertFile, conf.Server.TLSKeyFile, errorsLog, errs)
numberOfServers++
}
if numberOfServers == 0 {
return types.ErrNoServersConfigured
}
return <-errs // block until one of the servers writes an error
}
func mainHandler(
rp *processor.RequestProcessor,
logger *logging.RequestLogger,
) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if err := logger.Log(r); err != nil {
log.Println(err)
}
if checkURLForPNG(r) {
w.Write([]byte("PNG support temporary disabled"))
return
}
response, err := rp.ProcessRequest(r)
if err != nil {
log.Println(err)
return
}
if response.StatusCode == 0 {
log.Println("status code 0", response)
return
}
copyHeader(w.Header(), response.Header)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(response.StatusCode)
_, err = w.Write(response.Body)
if err != nil {
log.Println(err)
}
}
}
func main() {
var (
conf *config.Config
err error
)
ctx := kong.Parse(&cli)
ctx.FatalIfErrorf(setLogLevel(cli.LogLevel))
if cli.ConfigFile != "" {
conf, err = config.Load(cli.ConfigFile)
if err != nil {
log.Fatalf("reading config from %s: %s\n", cli.ConfigFile, err)
}
} else {
conf = config.Default()
}
if cli.ConfigDump {
//nolint:forbidigo
fmt.Print(string(conf.Dump()))
return
}
if cli.ConfigCheck {
return
}
switch {
case cli.ConvertGeoIPCache:
ctx.FatalIfErrorf(convertGeoIPCache(conf))
case cli.ConvertGeoLocationCache:
ctx.FatalIfErrorf(convertGeoLocationCache(conf))
case cli.GeoResolve != "":
sr := geoloc.NewSearcher(conf)
loc, err := sr.Search(cli.GeoResolve)
ctx.FatalIfErrorf(err)
if loc != nil {
//nolint:forbidigo
fmt.Println(*loc)
}
default:
err = serve(conf)
ctx.FatalIfErrorf(err)
}
}
func convertGeoIPCache(conf *config.Config) error {
geoIPCache, err := geoip.NewCache(conf)
if err != nil {
return err
}
return geoIPCache.ConvertCache()
}
func convertGeoLocationCache(conf *config.Config) error {
geoLocCache, err := geoloc.NewCache(conf)
if err != nil {
return err
}
return geoLocCache.ConvertCache(false)
}
func setLogLevel(logLevel string) error {
parsedLevel, err := log.ParseLevel(logLevel)
if err != nil {
return err
}
log.SetLevel(parsedLevel)
return nil
}
func checkURLForPNG(r *http.Request) bool {
url := r.URL.String()
return strings.Contains(url, ".png")
}