mirror of
https://github.com/chubin/wttr.in
synced 2024-11-14 16:17:19 +00:00
Merge branch 'master' of https://github.com/chubin/wttr.in
This commit is contained in:
commit
dd87ab5076
63 changed files with 4401 additions and 1104 deletions
30
.golangci.yaml
Normal file
30
.golangci.yaml
Normal 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
|
|
@ -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
9
Makefile
Normal 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 ./...
|
73
README.md
73
README.md
|
@ -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 Powershell’s `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
BIN
San_Francisco.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 63 KiB |
|
@ -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)
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
87
cmd/srv.go
87
cmd/srv.go
|
@ -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))
|
|
||||||
}
|
|
40
cmd/stat.go
40
cmd/stat.go
|
@ -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
26
go.mod
Normal 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
80
go.sum
Normal 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
179
internal/config/config.go
Normal 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
774
internal/fmt/png/colors.go
Normal 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
10
internal/fmt/png/go.mod
Normal 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
8
internal/fmt/png/go.sum
Normal 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
224
internal/fmt/png/png.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
88
internal/geo/ip/convert.go
Normal file
88
internal/geo/ip/convert.go
Normal 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
244
internal/geo/ip/ip.go
Normal 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, " "))
|
||||||
|
}
|
84
internal/geo/ip/ip_test.go
Normal file
84
internal/geo/ip/ip_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
218
internal/geo/location/cache.go
Normal file
218
internal/geo/location/cache.go
Normal 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)
|
||||||
|
}
|
145
internal/geo/location/convert.go
Normal file
145
internal/geo/location/convert.go
Normal 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)
|
||||||
|
}
|
25
internal/geo/location/location.go
Normal file
25
internal/geo/location/location.go
Normal 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)
|
||||||
|
}
|
77
internal/geo/location/nominatim.go
Normal file
77
internal/geo/location/nominatim.go
Normal 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
|
||||||
|
}
|
39
internal/geo/location/nominatim_locationiq.go
Normal file
39
internal/geo/location/nominatim_locationiq.go
Normal 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
|
||||||
|
}
|
42
internal/geo/location/nominatim_opencage.go
Normal file
42
internal/geo/location/nominatim_opencage.go
Normal 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
|
||||||
|
}
|
44
internal/geo/location/response.go
Normal file
44
internal/geo/location/response.go
Normal 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),
|
||||||
|
)}
|
||||||
|
}
|
42
internal/geo/location/search.go
Normal file
42
internal/geo/location/search.go
Normal 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
119
internal/logging/logging.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
84
internal/logging/suppress.go
Normal file
84
internal/logging/suppress.go
Normal 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))
|
||||||
|
}
|
98
internal/processor/peak.go
Normal file
98
internal/processor/peak.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
381
internal/processor/processor.go
Normal file
381
internal/processor/processor.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
72
internal/routing/routing.go
Normal file
72
internal/routing/routing.go
Normal 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
89
internal/stats/stats.go
Normal 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
14
internal/types/errors.go
Normal 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
8
internal/types/types.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package types
|
||||||
|
|
||||||
|
type CacheType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CacheTypeDB = "db"
|
||||||
|
CacheTypeFiles = "files"
|
||||||
|
)
|
18
internal/util/files.go
Normal file
18
internal/util/files.go
Normal 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
26
internal/util/http.go
Normal 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
15
internal/util/yaml.go
Normal 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)
|
||||||
|
}
|
|
@ -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
172
internal/view/v1/cmd.go
Normal 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
|
||||||
|
}
|
|
@ -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
213
internal/view/v1/icons.go
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
|
@ -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]
|
||||||
}
|
}
|
|
@ -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
|
||||||
}
|
}
|
|
@ -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"),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"],
|
||||||
|
|
|
@ -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__':
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
66
share/translations/ukr-help.txt
Normal file
66
share/translations/ukr-help.txt
Normal 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
|
|
@ -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
|
|
||||||
)
|
|
|
@ -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=
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
)
|
|
|
@ -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
266
srv.go
Normal 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")
|
||||||
|
}
|
Loading…
Reference in a new issue