Repository: chubin/wttr.in
Branch: master
Commit: 771d003db43b
Files: 222
Total size: 1.0 MB
Directory structure:
gitextract_3puwzenn/
├── .flake8
├── .github/
│ └── workflows/
│ └── makefile.yml
├── .gitignore
├── .golangci.yaml
├── .travis.yml
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── bin/
│ ├── geo-proxy.py
│ ├── proxy.py
│ └── srv.py
├── config/
│ └── services/
│ └── services.yaml
├── doc/
│ ├── integrations.md
│ └── terminal-images.md
├── go.mod
├── go.sum
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── fmt/
│ │ └── png/
│ │ ├── colors.go
│ │ ├── go.mod
│ │ ├── go.sum
│ │ └── png.go
│ ├── geo/
│ │ ├── ip/
│ │ │ ├── convert.go
│ │ │ ├── ip.go
│ │ │ └── ip_test.go
│ │ └── location/
│ │ ├── cache.go
│ │ ├── convert.go
│ │ ├── location.go
│ │ ├── nominatim.go
│ │ ├── nominatim_locationiq.go
│ │ ├── nominatim_opencage.go
│ │ ├── response.go
│ │ └── search.go
│ ├── logging/
│ │ ├── logging.go
│ │ └── suppress.go
│ ├── options/
│ │ ├── options.go
│ │ ├── parse.go
│ │ └── processlog.go
│ ├── processor/
│ │ ├── j1.go
│ │ ├── peak.go
│ │ └── processor.go
│ ├── routing/
│ │ └── routing.go
│ ├── stats/
│ │ └── stats.go
│ ├── types/
│ │ ├── errors.go
│ │ └── types.go
│ ├── util/
│ │ ├── files.go
│ │ ├── http.go
│ │ └── yaml.go
│ └── view/
│ └── v1/
│ ├── api.go
│ ├── cmd.go
│ ├── format.go
│ ├── icons.go
│ ├── locale.go
│ └── view1.go
├── lib/
│ ├── airports.py
│ ├── buttons.py
│ ├── cache.py
│ ├── constants.py
│ ├── datasource/
│ │ └── README.md
│ ├── duplicate_translations.py
│ ├── extract_emoji.py
│ ├── fields.py
│ ├── fmt/
│ │ ├── __init__.py
│ │ ├── png.py
│ │ └── unicodedata2.py
│ ├── globals.py
│ ├── limits.py
│ ├── location.py
│ ├── metno.py
│ ├── parse_query.py
│ ├── proxy_log.py
│ ├── translations.py
│ ├── translations_v2.py
│ ├── view/
│ │ ├── __init__.py
│ │ ├── line.py
│ │ ├── moon.py
│ │ ├── prometheus.py
│ │ ├── v2.py
│ │ └── wttr.py
│ ├── weather_data.py
│ └── wttr_srv.py
├── requirements.txt
├── share/
│ ├── aliases
│ ├── ansi2html.sh
│ ├── bash-function.txt
│ ├── blacklist
│ ├── docker/
│ │ └── supervisord.conf
│ ├── help.txt
│ ├── iterm2.txt
│ ├── list-of-iata-codes.txt
│ ├── salt/
│ │ ├── README.md
│ │ ├── init.sls
│ │ ├── pillar.sls
│ │ ├── start.sh
│ │ ├── wegorc
│ │ └── wttr.service
│ ├── screenrc
│ ├── scripts/
│ │ ├── build-welang.sh
│ │ ├── clean-cache.sh
│ │ ├── log-space.sh
│ │ └── start-screen.sh
│ ├── static/
│ │ ├── malformed-response.html
│ │ └── style.css
│ ├── systemd/
│ │ ├── README.md
│ │ ├── wttrin.service
│ │ └── wttrin.sh
│ ├── templates/
│ │ └── index.html
│ ├── test-thunder.txt
│ ├── translation.txt
│ └── translations/
│ ├── af-help.txt
│ ├── af.txt
│ ├── am-help.txt
│ ├── am.txt
│ ├── ar-help.txt
│ ├── ar.txt
│ ├── az.txt
│ ├── be-help.txt
│ ├── be.txt
│ ├── bg-help.txt
│ ├── bn-help.txt
│ ├── bn.txt
│ ├── bs.txt
│ ├── ca-help.txt
│ ├── ca.txt
│ ├── cs-help.txt
│ ├── cs.txt
│ ├── cy.txt
│ ├── da-help.txt
│ ├── da.txt
│ ├── de-help.txt
│ ├── de.txt
│ ├── dk-help.txt
│ ├── el-help.txt
│ ├── el.txt
│ ├── en.txt
│ ├── eo.txt
│ ├── es-help.txt
│ ├── es.txt
│ ├── et-help.txt
│ ├── et.txt
│ ├── eu-help.txt
│ ├── eu.txt
│ ├── fa-help.txt
│ ├── fa.txt
│ ├── fr-help.txt
│ ├── fr.txt
│ ├── fy.txt
│ ├── ga.txt
│ ├── gl-help.txt
│ ├── gl.txt
│ ├── gu-help.txt
│ ├── gu.txt
│ ├── he.txt
│ ├── hi-help.txt
│ ├── hi.txt
│ ├── hr.txt
│ ├── hu-help.txt
│ ├── hu.txt
│ ├── hy.txt
│ ├── ia-help.txt
│ ├── ia.txt
│ ├── id-help.txt
│ ├── id.txt
│ ├── is.txt
│ ├── it-help.txt
│ ├── it.txt
│ ├── ja.txt
│ ├── kk-help.txt
│ ├── kk.txt
│ ├── lt-help.txt
│ ├── lt.txt
│ ├── lv-help.txt
│ ├── lv.txt
│ ├── messages/
│ │ ├── en.yaml
│ │ └── gu.yaml
│ ├── mg-help.txt
│ ├── mg.txt
│ ├── mk.txt
│ ├── mr-help.txt
│ ├── mr.txt
│ ├── nb-help.txt
│ ├── nb.txt
│ ├── nl-help.txt
│ ├── nl.txt
│ ├── nn.txt
│ ├── oc-help.txt
│ ├── oc.txt
│ ├── pl-help.txt
│ ├── pl.txt
│ ├── pt-br-help.txt
│ ├── pt-br.txt
│ ├── pt-help.txt
│ ├── pt.txt
│ ├── ro-help.txt
│ ├── ro.txt
│ ├── ru-help.txt
│ ├── ru.txt
│ ├── sl.txt
│ ├── ta-help.txt
│ ├── ta.txt
│ ├── te-help.txt
│ ├── te.txt
│ ├── th-help.txt
│ ├── th.txt
│ ├── tr-help.txt
│ ├── tr.txt
│ ├── uk-help.txt
│ ├── uk.txt
│ ├── ukr-help.txt
│ ├── uz.txt
│ ├── vi-help.txt
│ ├── vi.txt
│ ├── zh-cn-help.txt
│ ├── zh-cn.txt
│ ├── zh-tw-help.txt
│ └── zh-tw.txt
├── spec/
│ └── options/
│ └── options.yaml
├── srv.go
└── test/
├── proxy-data/
│ ├── data1
│ └── data1.headers
├── query.sh
└── test-data/
└── signatures
================================================
FILE CONTENTS
================================================
================================================
FILE: .flake8
================================================
[flake8]
ignore = E402,E501
================================================
FILE: .github/workflows/makefile.yml
================================================
name: Makefile CI
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Set up Python environment
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11' # Specify the Python version you need (3.11 required for pyjq)
- name: Create and activate virtual environment
run: |
python -m venv venv
source venv/bin/activate
echo "Virtual environment created and activated."
- name: Install Python dependencies
run: |
sudo apt-get install libjq-dev
source venv/bin/activate
pip install -r requirements.txt
- name: Add Python virtual environment to PATH
run: |
echo "${{ github.workspace }}/venv/bin" >> $GITHUB_PATH
# Set up Go environment
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24' # Replace with the needed Go version
- name: Install gofumpt
run: |
go install mvdan.cc/gofumpt@latest
- name: Install goimports
run: |
go install golang.org/x/tools/cmd/goimports@latest
- name: Install swagger
run: |
cd /tmp \
&& go install github.com/go-swagger/go-swagger/cmd/swagger@latest
- name: Add Go bin to PATH
run: |
echo "${{ runner.temp }}/go/bin" >> $GITHUB_PATH
- name: Install dependencies
run: make
- name: Run check
run: make check
================================================
FILE: .gitignore
================================================
ve/
share/static/fonts/
*.pyc
data/
log/
.idea/
*.swp
*.mmdb
*.dat
================================================
FILE: .golangci.yaml
================================================
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
================================================
FILE: .travis.yml
================================================
group: travis_latest
language: python
cache: pip
python:
- 3.7
install:
- pip install flake8 -r requirements.txt
before_script:
# stop the build if there are Python syntax errors or undefined names
- flake8 bin lib --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
- flake8 bin lib --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
script:
- true # pytest --capture=sys # add other tests here
notifications:
on_success: change
on_failure: change # `always` will be the setting once code changes slow down.
================================================
FILE: Dockerfile
================================================
# Build stage
FROM golang:1-alpine as builder
WORKDIR /app
COPY ./share/we-lang/ /app
RUN apk add --no-cache git
RUN go get -u github.com/mattn/go-colorable && \
go get -u github.com/klauspost/lctime && \
go get -u github.com/mattn/go-runewidth && \
cd /app && CGO_ENABLED=0 go build .
# Application stage
FROM alpine:3.21.1
WORKDIR /app
COPY ./requirements.txt /app
ENV LLVM_CONFIG=/usr/bin/llvm11-config
RUN apk add --no-cache --virtual .build \
autoconf \
automake \
g++ \
gcc \
jpeg-dev \
llvm11-dev\
make \
zlib-dev \
&& apk add --no-cache \
python3 \
py3-pip \
py3-scipy \
py3-wheel \
py3-gevent \
zlib \
jpeg \
llvm11 \
libtool \
supervisor \
py3-numpy-dev \
python3-dev && \
mkdir -p /app/cache && \
mkdir -p /var/log/supervisor && \
mkdir -p /etc/supervisor/conf.d && \
chmod -R o+rw /var/log/supervisor && \
chmod -R o+rw /var/run && \
pip install -r requirements.txt --no-cache-dir && \
apk del --no-cache -r .build
COPY --from=builder /app/wttr.in /app/bin/wttr.in
COPY ./bin /app/bin
COPY ./lib /app/lib
COPY ./share /app/share
COPY share/docker/supervisord.conf /etc/supervisor/supervisord.conf
ENV WTTR_MYDIR="/app"
ENV WTTR_GEOLITE="/app/GeoLite2-City.mmdb"
ENV WTTR_WEGO="/app/bin/wttr.in"
ENV WTTR_LISTEN_HOST="0.0.0.0"
ENV WTTR_LISTEN_PORT="8002"
EXPOSE 8002
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: Makefile
================================================
srv: srv.go internal/*/*.go internal/*/*/*.go
#go build -o srv -ldflags '-w -linkmode external -extldflags "-static"' ./
go build -o srv ./
check:
true
go-test:
go test ./...
lint:
golangci-lint run ./...
================================================
FILE: README.md
================================================
*wttr.in — the right way to ~check~ `curl` the weather!*
wttr.in is a console-oriented weather forecast service that supports various information
representation methods like terminal-oriented ANSI-sequences for console HTTP clients
(curl, httpie, or wget), HTML for web browsers, or PNG for graphical viewers.
Originally started as a small project, a wrapper for [wego](https://github.com/schachmat/wego),
intended to demonstrate the power of the console-oriented services,
*wttr.in* became a popular weather reporting service, handling tens of millions[¹](#wttrin-usage-stats) of queries daily.
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) | [Installation](https://github.com/chubin/wttr.in#installation)
## Usage
You can access the service from a shell or from a Web browser like this:
$ curl wttr.in
Weather for City: Paris, France
\ / Clear
.-. 10 – 11 °C
― ( ) ― ↑ 11 km/h
`-’ 10 km
/ \ 0.0 mm
Here is an example weather report:

Or in PowerShell:
```PowerShell
Invoke-RestMethod https://wttr.in
```
Want to get the weather information for a specific location? You can add the desired location to the URL in your
request like this:
$ curl wttr.in/London
$ curl wttr.in/Moscow
$ curl wttr.in/Salt+Lake+City
If you omit the location name, you will get the report for your current location based on your IP address.
Use 3-letter airport codes in order to get the weather information at a certain airport:
$ curl wttr.in/muc # Weather for IATA: muc, Munich International Airport, Germany
$ curl wttr.in/ham # Weather for IATA: ham, Hamburg Airport, Germany
Let's say you'd like to get the weather for a geographical location other than a town or city - maybe an attraction
in a city, a mountain name, or some special location. Add the character `~` before the name to look up that special
location name before the weather is then retrieved:
$ curl wttr.in/~Vostok+Station
$ curl wttr.in/~Eiffel+Tower
$ curl wttr.in/~Kilimanjaro
For these examples, you'll see a line below the weather forecast output that shows the geolocation
results of looking up the location:
Location: Vostok Station, станция Восток, AAT, Antarctica [-78.4642714,106.8364678]
Location: Tour Eiffel, 5, Avenue Anatole France, Gros-Caillou, 7e, Paris, Île-de-France, 75007, France [48.8582602,2.29449905432]
Location: Kilimanjaro, Northern, Tanzania [-3.4762789,37.3872648]
You can also use IP-addresses (direct) or domain names (prefixed with `@`) to specify a location:
$ curl wttr.in/@github.com
$ curl wttr.in/@msu.ru
To get detailed information online, you can access the [/:help](https://wttr.in/:help) page:
$ curl wttr.in/:help
### 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.
You can override this behavior by adding `?u`, `?m` or `?M` to a URL like this:
$ curl wttr.in/Amsterdam?u # USCS (used by default in US)
$ curl wttr.in/Amsterdam?m # metric (SI) (used by default everywhere except US)
$ curl wttr.in/Amsterdam?M # metric (SI), but show wind speed in m/s
If you have several options to pass, write them without delimiters in between for the one-letter options,
and use `&` as a delimiter for the long options with values:
$ curl 'wttr.in/Amsterdam?m2&lang=nl'
It would be a rough equivalent of `-m2 --lang nl` for the GNU CLI syntax.
## Supported output formats and views
wttr.in currently supports five output formats:
* ANSI for the terminal;
* Plain-text for the terminal and scripts;
* HTML for the browser;
* PNG for the graphical viewers;
* JSON for scripts and APIs;
* Prometheus metrics for scripts and APIs.
The ANSI and HTML formats are selected based on the User-Agent string.
To force plain text, which disables colors:
$ 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:
$ wget wttr.in/Paris.png
You can use all of the options with the PNG-format like in an URL, but you have
to separate them with `_` instead of `?` and `&`:
$ wget wttr.in/Paris_0tqp_lang=fr.png
Useful options for the PNG format:
* `t` for transparency (`transparency=150`);
* transparency=0..255 for a custom transparency level.
Transparency is a useful feature when weather PNGs are used to add weather data to pictures:
$ convert source.jpg <( curl wttr.in/Oymyakon_tqp0.png ) -geometry +50+50 -composite target.jpg
In this example:
* `source.jpg` - source file;
* `target.jpg` - target file;
* `Oymyakon` - name of the location;
* `tqp0` - options (recommended).

You can embed a special wttr.in widget, that displays the weather condition for the current or a selected location, into a HTML page using the [wttr-switcher](https://github.com/midzer/wttr-switcher). That is how it looks like: [wttr-switcher-example](https://midzer.github.io/wttr-switcher/) or on a real world web site: https://feuerwehr-eisolzried.de/.

## One-line output
One-line output format is convenient to be used to show weather info
in status bar of different programs, such as *tmux*, *weechat*, etc.
For one-line output format, specify additional URL parameter `format`:
```
$ curl wttr.in/Nuremberg?format=3
Nuremberg: 🌦 +11⁰C
```
Available preconfigured formats: 1, 2, 3, 4 and the custom format using the percent notation (see below).
* 1: Current weather at location: `🌦 +11⁰C`
* 2: Current weather at location with more details: `🌦 🌡️+11°C 🌬️↓4km/h`
* 3: Name of location and current weather at location: `Nuremberg: 🌦 +11⁰C`
* 4: Name of location and current weather at location with more details: `Nuremberg: 🌦 🌡️+11°C 🌬️↓4km/h`
You can specify multiple locations separated with `:` (for repeating queries):
```
$ curl wttr.in/Nuremberg:Hamburg:Berlin?format=3
Nuremberg: 🌦 +11⁰C
```
Or to process all this queries at once:
```
$ curl -s 'wttr.in/{Nuremberg,Hamburg,Berlin}?format=3'
Nuremberg: 🌦 +11⁰C
Hamburg: 🌦 +8⁰C
Berlin: 🌦 +8⁰C
```
To specify your own custom output format, use the special `%`-notation:
```
c Weather condition,
C Weather condition textual name,
x Weather condition, plain-text symbol,
h Humidity,
t Temperature (Actual),
f Temperature (Feels Like),
w Wind,
l Location,
m Moon phase 🌑🌒🌓🌔🌕🌖🌗🌘,
M Moon day,
p Precipitation (mm/3 hours),
P Pressure (hPa),
e Dew point,
u UV index (1-12),
D Dawn*,
S Sunrise*,
z Zenith*,
s Sunset*,
d Dusk*,
T Current time*,
Z Local timezone.
(*times are shown in the local timezone)
```
So, these two calls are the same:
```
$ curl wttr.in/London?format=3
London: ⛅️ +7⁰C
$ curl wttr.in/London?format="%l:+%c+%t\n"
London: ⛅️ +7⁰C
```
## Integrations
Thanks to the ease of integrating *wttr.in* into any program, there are a
plethora of popular integrations across various libraries, programming
languages, and systems.
*wttr.in* is compatible with:
* terminal managers,
* window managers,
* editors,
* chat clients,
and more, these integrations enhance workflow efficiency by embedding weather information directly into user interfaces.
See the full list of integrations here: [wttr.in integrations](doc/integrations.md)
and some of them below.
### tmux
When using in `tmux.conf`, you have to escape `%` with `%`, i.e. write there `%%` instead of `%`.
The output does not contain new line by default, when the %-notation is used, but it does contain it when preconfigured format (`1`,`2`,`3` etc.)
are used. To have the new line in the output when the %-notation is used, use '\n' and single quotes when doing a query from the shell.
In programs, that are querying the service automatically (such as tmux), it is better to use some reasonable update interval. In tmux, you can configure it with `status-interval`.
If several, `:` separated locations, are specified in the query, specify update period
as an additional query parameter `period=`:
```
set -g status-interval 60
WEATHER='#(curl -s wttr.in/London:Stockholm:Moscow\?format\="%%l:+%%c%%20%%t%%60%%w&period=60")'
set -g status-right "$WEATHER ..."
```

### WeeChat
To embed in to an IRC ([WeeChat](https://github.com/weechat/weechat)) client's existing status bar:
```
/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"
/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
/wttr
```

### conky
Conky usage example:
```
${texeci 1800 curl wttr.in/kyiv_0pq_lang=uk.png
| convert - -transparent black $HOME/.config/conky/out.png}
${image $HOME/.config/conky/out.png -p 0,0}
```

### IRC
IRC integration example:
* https://github.com/OpenSourceTreasure/Mirc-ASCII-weather-translate-pixel-editor
### Emojis support
To see emojis in terminal, you need:
1. Terminal support for emojis (was added to Cairo 1.15.8);
2. Font with emojis support.
For the emoji font, we recommend *Noto Color Emoji*, and a good alternative option would be the *Emoji One* font;
both of them support all necessary emoji glyphs.
Font configuration:
```xml
$ cat ~/.config/fontconfig/fonts.conf
serif
Noto Color Emoji
sans-serif
Noto Color Emoji
monospace
Noto Color Emoji
```
(to apply the configuration, run `fc-cache -f -v`).
In some cases, `tmux` and the terminal understanding of some emoji characters may differ, which may
cause strange effects similar to that described in #579.
### Squeak
To embed into the world main docking bar:
```smalltalk
wttr := (UpdatingStringMorph on: [(WebClient httpGet: 'https://wttr.in/?format=%20%20%l:%20%C+%t') content] selector: #value)
stepTime: 60000;
useStringFormat;
yourself.
dockingBar := World mainDockingBars first.
dockingBar addMorph: wttr after: (dockingBar findA: ClockMorph).
```

## Data-rich output format (v2)
In the experimental data-rich output format, that is available under the view code `v2`,
a lot of additional weather and astronomical information is available:
* Temperature, and precipitation changes forecast throughout the days;
* Moonphase for today and the next three days;
* The current weather condition, temperature, humidity, wind speed and direction, pressure;
* Timezone;
* Dawn, sunrise, noon, sunset, dusk time for he selected location;
* Precise geographical coordinates for the selected location.
```
$ curl v2.wttr.in/München
```
or
```
$ curl wttr.in/München?format=v2
```
or, if you prefer Nerd Fonts instead of Emoji, `v2d` (day) or `v2n` (night):
```
$ curl v2d.wttr.in/München
```

(The mode is experimental, and it has several limitations currently:
* It works only in terminal;
* Only English is supported).
Currently, you need some tweaks for some terminals, to get the best possible visualization.
### URXVT
Depending on your configuration you might be taking all steps, or only a few. URXVT currently doesn't support emoji related fonts, but we can get almost the same effect using *Font-Symbola*. So add to your `.Xresources` file the following line:
```
xft:symbola:size=10:minspace=False
```
You can add it _after_ your preferred font and it will only show up when required.
Then, if you see or feel like you're having spacing issues, add this: `URxvt.letterSpace: 0`
For some reason URXVT sometimes stops deciding right the word spacing and we need to force it this way.
The result, should look like:

## Map view (v3)
In the experimental map view, that is available under the view code `v3`,
weather information about a geographical region is available:
```
$ curl v3.wttr.in/Bayern.sxl
```

or directly in browser:
* https://v3.wttr.in/Bayern
The map view currently supports three formats:
* PNG (for browser and messengers);
* Sixel (terminal inline images support);
* IIP (terminal with iterm2 inline images protocol support).
Terminal with inline images protocols support:
⟶ *Detailed article: [Images in terminal](doc/terminal-images.md)*
| Terminal | Environment | Images support | Protocol |
| --------------------- | --------- | ------------- | --------- |
| uxterm | X11 | yes | Sixel |
| mlterm | X11 | yes | Sixel |
| kitty | X11 | yes | Kitty |
| wezterm | X11 | yes | IIP |
| Darktile | X11 | yes | Sixel |
| Jexer | X11 | yes | Sixel |
| GNOME Terminal | X11 | [in-progress](https://gitlab.gnome.org/GNOME/vte/-/issues/253) | Sixel |
| alacritty | X11 | [in-progress](https://github.com/alacritty/alacritty/issues/910) | Sixel |
| foot | Wayland | yes | Sixel |
| DomTerm | Web | yes | Sixel |
| Yaft | FB | yes | Sixel |
| iTerm2 | Mac OS X| yes | IIP |
| mintty | Windows | yes | Sixel |
| Windows Terminal | Windows | [in-progress](https://github.com/microsoft/terminal/issues/448) | Sixel |
| [RLogin](http://nanno.dip.jp/softlib/man/rlogin/) | Windows | yes | Sixel | |
## Different output formats
### JSON output
The JSON format is a feature providing access to *wttr.in* data through an easy-to-parse format, without requiring the user to create a complex script to reinterpret wttr.in's graphical output.
To fetch information in JSON format, use the following syntax:
$ curl wttr.in/Detroit?format=j1
This will fetch information on the Detroit region in JSON format. The j1 format code is used to allow for the use of other layouts for the JSON output.
The result will look something like the following:
```json
{
"current_condition": [
{
"FeelsLikeC": "25",
"FeelsLikeF": "76",
"cloudcover": "100",
"humidity": "76",
"observation_time": "04:08 PM",
"precipMM": "0.2",
"pressure": "1019",
"temp_C": "22",
"temp_F": "72",
"uvIndex": 5,
"visibility": "16",
"weatherCode": "122",
"weatherDesc": [
{
"value": "Overcast"
}
],
"weatherIconUrl": [
{
"value": ""
}
],
"winddir16Point": "NNE",
"winddirDegree": "20",
"windspeedKmph": "7",
"windspeedMiles": "4"
}
],
...
```
Most of these values are self-explanatory, aside from `weatherCode`. The `weatherCode` is an enumeration which you can find at either [the WorldWeatherOnline website](https://www.worldweatheronline.com/developer/api/docs/weather-icons.aspx) or [in the wttr.in source code](https://github.com/chubin/wttr.in/blob/master/lib/constants.py).
A smaller version `format=j2` without hourly data is also availble. Can work well for microcontrollers with limited memory.
### 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.
To fetch information in Prometheus format, use the following syntax:
$ curl wttr.in/Detroit?format=p1
This will fetch information on the Detroit region in Prometheus Metrics format. The `p1` format code is used to allow for the use of other layouts for the Prometheus Metrics output.
A possible configuration for Prometheus could look like this:
```yaml
- job_name: 'wttr_in_detroit'
static_configs:
- targets: ['wttr.in']
metrics_path: '/Detroit'
params:
format: ['p1']
```
The result will look something like the following:
# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius
temperature_feels_like_celsius{forecast="current"} 7
# HELP temperature_feels_like_fahrenheit Feels Like Temperature in Fahrenheit
temperature_feels_like_fahrenheit{forecast="current"} 45
[truncated]
...
## Moon phases
wttr.in can also be used to check the phase of the Moon. This example shows how to see the current Moon phase
in the full-output mode:
$ curl wttr.in/Moon
Get the moon phase for a particular date by adding `@YYYY-MM-DD`:
$ curl wttr.in/Moon@2016-12-25
The moon phase information uses [pyphoon](https://github.com/chubin/pyphoon) as its backend.
To get the moon phase information in the online mode, use `%m`:
$ curl wttr.in/London?format=%m
🌖
Keep in mind that the Unicode representation of moon phases suffers 2 caveats:
- With some fonts, the representation `🌘` is ambiguous, for it either seem
almost-shadowed or almost-lit, depending on whether your terminal is in
light mode or dark mode. Relying on colored fonts like `noto-fonts` works
around this problem.
- The representation `🌘` is also ambiguous, for it means "last quarter" in
northern hemisphere, but "first quarter" in souther hemisphere. It also means
nothing in tropical zones. This is a limitation that
[Unicode](https://www.unicode.org/L2/L2017/17304-moon-var.pdf) is aware about.
But it has not been worked around at `wttr.in` yet.
See #247, #364 for the corresponding tracking issues,
and [pyphoon#1](https://github.com/chubin/pyphoon/issues/1) for pyphoon. Any help is welcome.
## Internationalization and localization
wttr.in supports multilingual locations names that can be specified in any language in the world
(it may be surprising, but many locations in the world don't have an English name).
The query string should be specified in Unicode (hex-encoded or not). Spaces in the query string
must be replaced with `+`:
$ curl wttr.in/станция+Восток
Weather report: станция Восток
Overcast
.--. -65 – -47 °C
.-( ). ↑ 23 km/h
(___.__)__) 15 km
0.0 mm
The language used for the output (except the location name) does not depend on the input language
and it is either English (by default) or the preferred language of the browser (if the query
was issued from a browser) that is specified in the query headers (`Accept-Language`).
The language can be set explicitly when using console clients by using command-line options like this:
curl -H "Accept-Language: fr" wttr.in
http GET wttr.in Accept-Language:ru
The preferred language can be forced using the `lang` option:
$ curl wttr.in/Berlin?lang=de
The third option is to choose the language using the DNS name used in the query:
$ curl de.wttr.in/Berlin
wttr.in is currently translated into 54 languages, and the number of supported languages is constantly growing.
See [/:translation](https://wttr.in/:translation) to learn more about the translation process,
to see the list of supported languages and contributors, or to know how you can help to translate wttr.in
in your language.

## Installation
To install the application:
1. Install external dependencies
2. Install Python dependencies used by the service
3. Configure IP2Location (optional)
4. Get a WorldWeatherOnline API and configure wego
5. Configure wttr.in
6. Configure the HTTP-frontend service
### Install external dependencies
wttr.in has the following external dependencies:
* [golang](https://golang.org/doc/install), wego dependency
* [wego](https://github.com/schachmat/wego), weather client for terminal
After you install [golang](https://golang.org/doc/install), install `wego`:
```bash
go install github.com/schachmat/wego@latest
```
### Install Python dependencies
Python requirements:
* Flask
* geoip2
* geopy
* requests
* gevent
If you want to get weather reports as PNG files, you'll also need to install:
* PIL
* pyte (>=0.6)
* necessary fonts
You can install most of them using `pip`.
Some python package use LLVM, so install it first:
```bash
apt-get install llvm-7 llvm-7-dev
```
If `virtualenv` is used:
```bash
virtualenv -p python3 ve
ve/bin/pip3 install -r requirements.txt
ve/bin/python3 bin/srv.py
```
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/).
### Configure IP2Location (optional)
If you want to use the IP2location service for IP-addresses that are not covered by GeoLite2,
you have to obtain a API key of that service, and after that save into the `~/.ip2location.key` file:
```
$ echo 'YOUR_IP2LOCATION_KEY' > ~/.ip2location.key
```
If you don't have this file, the service will be silently skipped (it is not a big problem,
because the MaxMind database is pretty good).
### Installation with Docker
* Install Docker
* Build Docker Image
* These files should be mounted by the user at runtime:
```
/root/.wegorc
/root/.ip2location.key (optional)
/app/airports.dat
/app/GeoLite2-City.mmdb
```
### Get a WorldWeatherOnline key and configure wego
To get a WorldWeatherOnline API key, you must register here:
https://developer.worldweatheronline.com/auth/register
After you have a WorldWeatherOnline key, you can save it into the
WWO key file: `~/.wwo.key`
Also, you have to specify the key in the `wego` configuration:
```json
$ cat ~/.wegorc
{
"APIKey": "00XXXXXXXXXXXXXXXXXXXXXXXXXXX",
"City": "London",
"Numdays": 3,
"Imperial": false,
"Lang": "en"
}
```
The `City` parameter in `~/.wegorc` is ignored.
### Configure wttr.in
Configure the following environment variables that define the path to the local `wttr.in`
installation, to the GeoLite database, and to the `wego` installation. For example:
```bash
export WTTR_MYDIR="/home/igor/wttr.in"
export WTTR_GEOLITE="/home/igor/wttr.in/GeoLite2-City.mmdb"
export WTTR_WEGO="/home/igor/go/bin/wego"
export WTTR_LISTEN_HOST="0.0.0.0"
export WTTR_LISTEN_PORT="8002"
```
### Configure the HTTP-frontend service
It's recommended that you also configure the web server that will be used to access the service:
```nginx
server {
listen [::]:80;
server_name wttr.in *.wttr.in;
access_log /var/log/nginx/wttr.in-access.log main;
error_log /var/log/nginx/wttr.in-error.log;
location / {
proxy_pass http://127.0.0.1:8002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 10m;
client_body_buffer_size 128k;
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_temp_file_write_size 64k;
expires off;
}
}
```
## wttr.in usage stats
As of March 2026, *wttr.in* handles 22-27 million queries per day from 300,000 to 320,000 users, according to the access logs.

================================================
FILE: bin/geo-proxy.py
================================================
import gevent
from gevent.pywsgi import WSGIServer
from gevent.queue import Queue
from gevent.monkey import patch_all
from gevent.subprocess import Popen, PIPE, STDOUT
patch_all()
import sys
import os
import json
from flask import (
Flask,
request,
render_template,
send_from_directory,
send_file,
make_response,
jsonify,
Response,
)
app = Flask(__name__)
MYDIR = os.path.abspath(os.path.dirname("__file__"))
sys.path.append(os.path.join(MYDIR, "lib"))
CACHEDIR = os.path.join(MYDIR, "cache")
from geopy.geocoders import Nominatim # , Mapzen
# geomapzen = Mapzen("mapzen-RBNbmcZ") # Nominatim()
geoosm = Nominatim(timeout=7, user_agent="wttrin-geo/0.0.2")
import airports
# from tzwhere import tzwhere
import timezonefinder
tf = timezonefinder.TimezoneFinder()
def load_cache(location_string):
try:
location_string = location_string.replace("/", "_")
cachefile = os.path.join(CACHEDIR, location_string)
return json.loads(open(cachefile, "r").read())
except Exception:
return None
def shorten_full_address(address):
parts = address.split(",")
if len(parts) > 6:
parts = parts[:2] + [x for x in parts[-4:] if len(x) < 20]
return ",".join(parts)
return address
def save_cache(location_string, answer):
location_string = location_string.replace("/", "_")
cachefile = os.path.join(CACHEDIR, location_string)
open(cachefile, "w").write(json.dumps(answer))
def query_osm(location_string):
try:
location = geoosm.geocode(location_string)
return {
"address": location.address,
"latitude": location.latitude,
"longitude": location.longitude,
}
except Exception as e:
print(e)
return None
def add_timezone_information(geo_data):
# tzwhere_ = tzwhere.tzwhere()
# timezone_str = tzwhere_.tzNameAt(geo_data["latitude"], geo_data["longitude"])
timezone_str = tf.certain_timezone_at(
lat=geo_data["latitude"], lng=geo_data["longitude"]
)
answer = geo_data.copy()
answer["timezone"] = timezone_str
return answer
@app.route("/")
def find_location(location):
airport_gps_location = airports.get_airport_gps_location(
location.upper().lstrip("~")
)
is_airport = False
if airport_gps_location is not None:
location = airport_gps_location
is_airport = True
location = location.replace("+", " ")
answer = load_cache(location)
loaded_answer = None
if answer is not None:
loaded_answer = answer.copy()
print("cache found: %s" % location)
if answer is None:
answer = query_osm(location)
if is_airport:
answer["address"] = shorten_full_address(answer["address"])
if "timezone" not in answer:
answer = add_timezone_information(answer)
if answer is not None and loaded_answer != answer:
save_cache(location, answer)
if answer is None:
return ""
else:
r = Response(json.dumps(answer))
r.headers["Content-Type"] = "text/json; charset=utf-8"
return r
app.config["JSON_AS_ASCII"] = False
server = WSGIServer(("127.0.0.1", 8004), app)
server.serve_forever()
================================================
FILE: bin/proxy.py
================================================
# vim: fileencoding=utf-8
"""
The proxy server acts as a backend for the wttr.in service.
It caches the answers and handles various data sources transforming their
answers into format supported by the wttr.in service.
If WTTRIN_TEST is specified, it works in a special test mode:
it does not fetch and does not store the data in the cache,
but is using the fake data from "test/proxy-data".
"""
from __future__ import print_function
from gevent.pywsgi import WSGIServer
from gevent.monkey import patch_all
patch_all()
# pylint: disable=wrong-import-position,wrong-import-order
import sys
import os
import time
import json
import hashlib
import re
import logging
import requests
import cyrtranslit
from flask import Flask, request
APP = Flask(__name__)
MYDIR = os.path.abspath(os.path.dirname(os.path.dirname("__file__")))
sys.path.append("%s/lib/" % MYDIR)
import proxy_log
import globals
from globals import (
PROXY_CACHEDIR,
PROXY_PORT,
USE_METNO,
USER_AGENT,
MISSING_TRANSLATION_LOG,
)
from metno import create_standard_json_from_metno, metno_request
from translations import PROXY_LANGS
# pylint: enable=wrong-import-position
proxy_logger = proxy_log.LoggerWWO(globals.PROXY_LOG_ACCESS, globals.PROXY_LOG_ERRORS)
def is_testmode():
"""Server is running in the wttr.in test mode"""
return "WTTRIN_TEST" in os.environ
def load_translations():
"""
load all translations
"""
translations = {}
for f_name in PROXY_LANGS:
f_name = "share/translations/%s.txt" % f_name
translation = {}
lang = f_name.split("/")[-1].split(".", 1)[0]
with open(f_name, "r") as f_file:
for line in f_file:
if ":" not in line:
continue
if line.count(":") == 3:
_, trans, orig, _ = line.strip().split(":", 4)
else:
_, trans, orig = line.strip().split(":", 3)
trans = trans.strip()
orig = orig.strip()
translation[orig.lower()] = trans
translations[lang] = translation
return translations
TRANSLATIONS = load_translations()
def _is_metno():
return USE_METNO
def _find_srv_for_query(path, query): # pylint: disable=unused-argument
if _is_metno():
return "https://api.met.no"
return "http://api.worldweatheronline.com"
def _cache_file(path, query):
"""Return cache file name for specified `path` and `query`
and for the current time.
Do smooth load on the server, expiration time
is slightly varied basing on the path+query sha1 hash digest.
"""
digest = hashlib.sha1(("%s %s" % (path, query)).encode("utf-8")).hexdigest()
digest_number = ord(digest[0].upper())
penalty = 0
expiry_interval = 60 * (digest_number + penalty)
timestamp = "%010d" % (int(time.time()) // expiry_interval * expiry_interval)
filename = os.path.join(PROXY_CACHEDIR, timestamp, path, query)
return filename
def _load_content_and_headers(path, query):
if is_testmode():
cache_file = "test/proxy-data/data1"
else:
cache_file = _cache_file(path, query)
try:
return (
open(cache_file, "r").read(),
json.loads(open(cache_file + ".headers", "r").read()),
)
except IOError:
return None, None
def _touch_empty_file(path, query):
cache_file = _cache_file(path, query)
cache_dir = os.path.dirname(cache_file)
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
open(cache_file, "w").write("")
def _save_content_and_headers(path, query, content, headers):
cache_file = _cache_file(path, query)
cache_dir = os.path.dirname(cache_file)
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
open(cache_file + ".headers", "w").write(json.dumps(headers))
open(cache_file, "wb").write(content)
def translate(text, lang):
"""
Translate `text` into `lang`.
If `text` is comma-separated, translate each term independently.
If no translation found, leave it untouched.
"""
def _log_unknown_translation(lang, text):
with open(MISSING_TRANSLATION_LOG % lang, "a") as f_missing_translation:
f_missing_translation.write(text + "\n")
if "," in text:
terms = text.split(",")
translated_terms = [translate(term.strip(), lang) for term in terms]
return ", ".join(translated_terms)
if lang not in TRANSLATIONS:
_log_unknown_translation(lang, "UNKNOWN_LANGUAGE")
return text
if text.lower() not in TRANSLATIONS.get(lang, {}):
_log_unknown_translation(lang, text)
return text
translated = TRANSLATIONS.get(lang, {}).get(text.lower(), text)
return translated
def cyr(to_translate):
"""
Transliterate `to_translate` from latin into cyrillic
"""
return cyrtranslit.to_cyrillic(to_translate)
def _patch_greek(original):
return original.replace("Ηλιόλουστη/ο", "Ηλιόλουστη")
def add_translations(content, lang):
"""
Add `lang` translation to `content` (JSON)
returned by the data source
"""
if content == "{}":
return {}
languages_to_translate = TRANSLATIONS.keys()
try:
d = json.loads(content) # pylint: disable=invalid-name
except (ValueError, TypeError) as exception:
print("---")
print(exception)
print("---")
return {}
if "current_condition" not in d["data"]:
return content
try:
weather_condition = (
d["data"]["current_condition"][0]["weatherDesc"][0]["value"]
.capitalize()
.strip()
)
d["data"]["current_condition"][0]["weatherDesc"][0]["value"] = weather_condition
if lang in languages_to_translate:
d["data"]["current_condition"][0]["lang_%s" % lang] = [
{"value": translate(weather_condition, lang)}
]
elif lang == "sr":
d["data"]["current_condition"][0]["lang_%s" % lang] = [
{
"value": cyr(
d["data"]["current_condition"][0]["lang_%s" % lang][0]["value"]
)
}
]
elif lang == "el":
d["data"]["current_condition"][0]["lang_%s" % lang] = [
{
"value": _patch_greek(
d["data"]["current_condition"][0]["lang_%s" % lang][0]["value"]
)
}
]
elif lang == "sr-lat":
d["data"]["current_condition"][0]["lang_%s" % lang] = [
{"value": d["data"]["current_condition"][0]["lang_sr"][0]["value"]}
]
fixed_weather = []
for w in d["data"]["weather"]: # pylint: disable=invalid-name
fixed_hourly = []
for h in w["hourly"]: # pylint: disable=invalid-name
weather_condition = h["weatherDesc"][0]["value"].strip()
if lang in languages_to_translate:
h["lang_%s" % lang] = [
{"value": translate(weather_condition, lang)}
]
elif lang == "sr":
h["lang_%s" % lang] = [
{"value": cyr(h["lang_%s" % lang][0]["value"])}
]
elif lang == "el":
h["lang_%s" % lang] = [
{"value": _patch_greek(h["lang_%s" % lang][0]["value"])}
]
elif lang == "sr-lat":
h["lang_%s" % lang] = [{"value": h["lang_sr"][0]["value"]}]
fixed_hourly.append(h)
w["hourly"] = fixed_hourly
fixed_weather.append(w)
d["data"]["weather"] = fixed_weather
content = json.dumps(d)
except (IndexError, ValueError) as exception:
print(exception)
return content
def _fetch_content_and_headers(path, query_string, **kwargs):
content, headers = _load_content_and_headers(path, query_string)
if content is None:
srv = _find_srv_for_query(path, query_string)
url = "%s/%s?%s" % (srv, path, query_string)
attempts = 10
response = None
error = ""
while attempts:
try:
response = requests.get(url, timeout=2, **kwargs)
except requests.ReadTimeout:
attempts -= 1
continue
try:
data = json.loads(response.content)
error = data.get("data", {}).get("error", "")
if error:
try:
error = error[0]["msg"]
except (ValueError, IndexError):
error = "invalid error format: %s" % error
break
except ValueError:
attempts -= 1
error = "invalid response"
proxy_logger.log(query_string, error)
_touch_empty_file(path, query_string)
if response:
headers = {}
headers["Content-Type"] = response.headers["content-type"]
_save_content_and_headers(path, query_string, response.content, headers)
content = response.content
else:
content = "{}"
return content, headers
def _make_query(path, query_string):
if _is_metno():
path, query, days = metno_request(path, query_string)
if USER_AGENT == "":
raise ValueError(
"User agent must be set to adhere to metno ToS: https://api.met.no/doc/TermsOfService"
)
content, headers = _fetch_content_and_headers(
path, query, headers={"User-Agent": USER_AGENT}
)
content = create_standard_json_from_metno(content, days)
else:
# WWO tweaks
query_string += "&extra=localObsTime"
query_string += "&includelocation=yes"
content, headers = _fetch_content_and_headers(path, query_string)
return content, headers
def _normalize_query_string(query_string):
# Normalized query string has the following fixes:
# 1. Uses , for the coordinates separation
# 2. Limits number of digits after .
coord_match = re.search(r"q=[^&]*", query_string)
coords_str = coord_match.group(0)
coords = re.findall(r"[-0-9.]+", coords_str)
if len(coords) != 2:
return query_string
lat = str(round(float(coords[0]), 2))
lng = str(round(float(coords[1]), 2))
query_string = re.sub(r"q=[^&]*", "q=" + lat + "," + lng, query_string)
# print(query_string)
# nqs = query_string.replace("%2C", ",")
# lat, lng = nqs.split(",", 1)
# nqs = f"{lat:.2f},{lng:.2f}"
return query_string
@APP.route("/")
def proxy(path):
"""
Main proxy function. Handles incoming HTTP queries.
"""
lang = request.args.get("lang", "en")
query_string = request.query_string.decode("utf-8")
query_string = _normalize_query_string(query_string)
query_string = query_string.replace("sr-lat", "sr")
query_string = query_string.replace("lang=None", "lang=en")
content = ""
headers = ""
content, headers = _make_query(path, query_string)
# _log_query(path, query_string, error)
content = add_translations(content, lang)
if "Unable to find any" in content:
print(query_string)
return content, 200, headers
if __name__ == "__main__":
# app.run(host='0.0.0.0', port=5001, debug=False)
# app.debug = True
if len(sys.argv) == 1:
bind_addr = "0.0.0.0"
logging.getLogger('werkzeug').setLevel(logging.ERROR) # Suppress Werkzeug logs
SERVER = WSGIServer((bind_addr, PROXY_PORT), APP, log=None)
SERVER.serve_forever()
else:
print("running single request from command line arg")
APP.testing = True
with APP.test_client() as c:
resp = c.get(sys.argv[1])
print("Status: " + resp.status)
# print('Headers: ' + dumps(resp.headers))
print(resp.data.decode("utf-8"))
================================================
FILE: bin/srv.py
================================================
#!/usr/bin/env python
# vim: set encoding=utf-8
from gevent.pywsgi import WSGIServer
from gevent.monkey import patch_all
patch_all()
# pylint: disable=wrong-import-position,wrong-import-order
import sys
import os
import jinja2
from flask import Flask, request, send_from_directory, send_file
APP = Flask(__name__)
MYDIR = os.path.abspath(os.path.dirname(os.path.dirname("__file__")))
sys.path.append("%s/lib/" % MYDIR)
import wttr_srv
from globals import TEMPLATES, STATIC, LISTEN_HOST, LISTEN_PORT
# pylint: enable=wrong-import-position,wrong-import-order
# from view.v3 import v3_file
MY_LOADER = jinja2.ChoiceLoader(
[
APP.jinja_loader,
jinja2.FileSystemLoader(TEMPLATES),
]
)
APP.jinja_loader = MY_LOADER
# @APP.route("/v3/")
# def send_v3(location):
# filepath = v3_file(location)
# if filepath.startswith("ERROR"):
# return filepath.rstrip("\n") + "\n"
# return send_file(filepath)
@APP.route("/files/")
def send_static(path):
"Send any static file located in /files/"
return send_from_directory(STATIC, path)
@APP.route("/favicon.ico")
def send_favicon():
"Send static file favicon.ico"
return send_from_directory(STATIC, "favicon.ico")
@APP.route("/malformed-response.html")
def send_malformed():
"Send static file malformed-response.html"
return send_from_directory(STATIC, "malformed-response.html")
@APP.route("/")
@APP.route("/")
def wttr(location=None):
"Main function wrapper"
return wttr_srv.wttr(location, request)
SERVER = WSGIServer(
(LISTEN_HOST, int(os.environ.get("WTTRIN_SRV_PORT", LISTEN_PORT))), APP
)
SERVER.serve_forever()
================================================
FILE: config/services/services.yaml
================================================
services:
- name: "main server"
command: "while true; do sudo /wttr.in/bin/srv big-cache.yaml ; sleep 5; done"
workdir: "$HOME"
port: ...
- name: "geo server"
command: "while true; do /wttr.in/bin/srv geo.yaml; sleep 5; done"
workdir: "$HOME"
env:
- NOMINATIM_OPENCAGE
port: 8085
- name: "proxy"
command: ve/bin/python3 bin/proxy.py
workdir: "/wttr.in/wttr.in-v2-v2"
port: 5001
- name: "format=j1"
command: "WTTRIN_SRV_PORT=9003 ve/bin/python3 bin/srv.py"
workdir: "/wttr.in/wttr.in-v2-v2"
port: 9003
- name: "format=line"
command: "WTTRIN_SRV_PORT=9004 ve/bin/python3 bin/srv.py"
workdir: "/wttr.in/wttr.in-v2-v2"
port: 9004
- name: "format=v1"
command: "WTTRIN_SRV_PORT=9005 ve/bin/python3 bin/srv.py"
workdir: "/wttr.in/wttr.in-v2-v2"
port: 9005
- name: "filetype=png"
command: "WTTRIN_SRV_PORT=9006 ve/bin/python3 bin/srv.py"
workdir: "/wttr.in/wttr.in-v2-v2"
port: 9006
================================================
FILE: doc/integrations.md
================================================
## Integrations
Thanks to the ease of integrating *wttr.in* into any program, there are a
plethora of popular integrations across various libraries, programming
languages, and systems.
*wttr.in* is compatible with:
* terminal managers,
* window managers,
* editors,
* chat clients,
and more, these integrations enhance workflow efficiency by embedding weather information directly into user interfaces.
Below, we've compiled a list of some of these integrations. While not
exhaustive, it serves as a guide to help you use *wttr.in* within your favorite
application or add integration to a new one.
| Integration For | Short Description | Repository |
|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
| **Terminal Managers** | | |
| [tmux](#tmux) | Allows embedding weather data in the tmux status bar with customizable update intervals. | - |
| **Window Managers Statusbars** | | |
| [Waybar](#Waybar) | A custom module in Rust for displaying weather in Waybar, with support for custom indicators and error handling. | [wttrbar](https://github.com/bjesus/wttrbar) |
| [Xmobar](#Xmobar) | A Python script for integrating weather data into Xmobar. | [weather-xmobar-wttr.in](https://github.com/alexeygumirov/weather-xmobar-wttr.in) |
| [AwesomeWM](#AwesomeWM) | Displays weather in AwesomeWM with a focus on clean UI and tooltips. | [wttr-widget](https://github.com/pivaldi/wttr-widget) |
| **Editors** | | |
| [Emacs](#Emacs) | An Emacs frontend for showing weather forecasts directly within Emacs. | [emacs-wttrin](https://github.com/cjennings/emacs-wttrin) |
| [Emacs](#Emacs) | Displays weather in the Emacs mode line with emoji support, configurable for updates. | [display-wttr](https://git.sr.ht/~josegpt/display-wttr) |
| **Chats** | | |
| [Conky](#Conky) | Example script for displaying weather in Conky using transparent images. | - |
| [WeeChat](#WeeChat) | Script for embedding weather in the WeeChat IRC client's status bar. | - |
| [IRC](#IRC) | Uses Qt-based mirc bot to show weather updates. | [IRC integration](https://github.com/OpenSourceTreasure/Mirc-ASCII-weather-translate-pixel-editor) |
| **Other** | | |
| [ObsidianMD](#ObsidianMD) | Script for embedding weather in Squeak's world main docking bar. | - |
| [RainMeter](#Rainmeter) | | - |
| [Squeak](#Squeak) | | - |
| [Twitch](#Twitch) | | - |
| [GoogleSheets/Excel365](#google-sheets--excel-365) | Direct wttr.in access from Google Sheets / Excel 365 | - |
| [Raycast](#Raycast) | Weather extension for Raycast | - |
## Terminal Managers
### tmux
When using in `tmux.conf`, you have to escape `%` with `%`, i.e. write there `%%` instead of `%`.
The output does not contain new line by default, when the %-notation is used, but it does contain it when preconfigured format (`1`,`2`,`3` etc.)
are used. To have the new line in the output when the %-notation is used, use '\n' and single quotes when doing a query from the shell.
In programs, that are querying the service automatically (such as tmux), it is better to use some reasonable update interval. In tmux, you can configure it with `status-interval`.
If several, `:` separated locations, are specified in the query, specify update period
as an additional query parameter `period=`:
```
set -g status-interval 60
WEATHER='#(curl -s wttr.in/London:Stockholm:Moscow\?format\="%%l:+%%c%%20%%t%%60%%w&period=60")'
set -g status-right "$WEATHER ..."
```

## Window Managers Statusbars
### Waybar
[wttrbar](https://github.com/bjesus/wttrbar) by *bjesus*
A custom module for displaying weather in Waybar using wttr.in. It’s written in
Rust for reliability and supports features like custom indicators (e.g.,
temperature in Celsius or Fahrenheit), location specification, and date
formatting.
Example configuration:
```json
"custom/weather": {
"format": "{}°",
"tooltip": true,
"interval": 3600,
"exec": "wttrbar --location=Paris --main-indicator=FeelsLikeC --date-format=%d.%m.%Y",
"return-type": "json"
}
```
Installation:
Compile using cargo build --release or download pre-built binaries. Requires a font supporting emojis (e.g., Noto Emoji) for weather icons.
Features: Supports custom styling based on weather conditions (e.g., sunny), handles wttr.in errors gracefully, and allows wind direction display with Unicode arrows.
### Xmobar
[weather-xmobar-wttr.in](https://github.com/alexeygumirov/weather-xmobar-wttr.in) by *alexeygumirov*
A Python-based script for displaying weather status in Xmobar, leveraging
wttr.in. It provides a lightweight solution for integrating weather data into
the Xmobar status bar.

(displays weather conditions in a compact format suitable for Xmobar).
### AwesomeWM
[wttr-widget](https://github.com/pivaldi/wttr-widget) by *pivaldi*
A weather widget for AwesomeWM that uses wttr.in to display weather information. It’s designed to integrate seamlessly with the Awesome window manager, providing a tooltip with detailed weather data.
Displays weather conditions with a focus on a clean, customizable UI. The screenshot shows a tooltip with weather details like temperature and conditions.

(shows a tooltip with weather data integrated into AwesomeWM)
## Editors
### Emacs
#### emacs-wttrin by bcbcarl
[emacs-wttrin](https://github.com/bcbcarl/emacs-wttrin) by *bcbcarl*
An Emacs frontend for wttr.in, allowing users to view weather forecasts directly within Emacs. It’s designed for simplicity and integration into the Emacs workflow.
Users can configure it to fetch weather for specific locations and display it in a buffer.
#### display-wttr by josegpt
[display-wttr](https://git.sr.ht/~josegpt/display-wttr) by *josegpt*
Displays wttr.in weather data in the Emacs mode line with emoji support (requires Emacs 28 or later). It’s lightweight and configurable for periodic updates.
Configuration Example:
```elisp
(use-package display-wttr
:config
(display-wttr-mode))
```
Supports custom locations, update intervals, and emoji-based weather display. The repository has moved to https://git.sr.ht/~josegpt/display-wttr.
Screenshot:

(shows weather with emojis in the Emacs mode line).
## Chats
### conky
Conky usage example:
```
${texeci 1800 curl wttr.in/kyiv_0pq_lang=uk.png
| convert - -transparent black $HOME/.config/conky/out.png}
${image $HOME/.config/conky/out.png -p 0,0}
```

### WeeChat
To embed in to an IRC ([WeeChat](https://github.com/weechat/weechat)) client's existing status bar:
```
/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"
/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
/wttr
```

### IRC
[IRC integration](https://github.com/OpenSourceTreasure/Mirc-ASCII-weather-translate-pixel-editor)

## Other
### ObsidianMD Integration
A script for ObsidianMD (a note-taking app) integrates wttr.in to embed weather data in daily notes using the Templater plugin. It fetches and displays weather details for a specified location only for the current day’s note.
*Usage:* Add a script to the Templater plugin to check if the note’s date matches today and fetch weather data. Example script:
```javascript
<%* if (tp.file.title === tp.date.now()) { %>
<%* const weather = await requestUrl('https://wttr.in/Berlin?format=%l:+%c+%C+%t+feels+like+%f\nTime:+++++%T\nSunrise:++%S\nSunset:+++%s\nMoon:+++++%m\nWind:+++++%w\nRainfall:+%p\nHumidity:+%h') %>
<%* tR += weather.text %>
<%* } else { %>
Update the weather report!
<%* } %>
```
This displays weather details like temperature, sunrise/sunset, moon phase, wind, rainfall, and humidity in the note body.
*Features:* Updates only for the current day’s note, supports custom formats, and can be triggered manually via a hotkey.
### Rainmeter
Rainmeter, a desktop customization tool for Windows, can use wttr.in to display
weather data by parsing its JSON output. This is particularly useful for
creating custom desktop widgets.
Use a URL like `https://wttr.in/Alexandria,Virginia?format=j1` to fetch
JSON data, which includes detailed weather metrics (e.g., temperature,
humidity, wind speed, weather code). A Lua script can map weather codes to
local icons for visualization.
Parses JSON for detailed weather data, supports integration with
custom icons (referencing WorldWeatherOnline’s weather codes), and allows for
flexible widget design.
### Squeak
To embed into the world main docking bar:
```smalltalk
wttr := (UpdatingStringMorph on: [(WebClient httpGet: 'https://wttr.in/?format=%20%20%l:%20%C+%t') content] selector: #value)
stepTime: 60000;
useStringFormat;
yourself.
dockingBar := World mainDockingBars first.
dockingBar addMorph: wttr after: (dockingBar findA: ClockMorph).
```

### Twitch
wttr.in is used to create a custom `!weather` command for Twitch streams, allowing viewers to query weather for a specific location (e.g., `!weather Toronto`).
The command formats wttr.in output for display in chat.
```bash
curl "https://wttr.in/Toronto?format=:+%c+%l+is+currently+%C+with+a+temperature+of+%t+(+%f).+The+wind+is+blowing+from+%w.+The+Humidity+is+currently+%h&u"
```
This outputs, e.g., : ⛅️ Toronto is currently Partly Cloudy with a temperature
of +7°C (+45°F). The wind is blowing from NE. The humidity is currently 65%. To
display Fahrenheit in parentheses, add `?u` to the URL for USCS units.
Features: Customizable output for Twitch chat, supports both Celsius and
Fahrenheit, and handles location-based queries dynamically.
Details: [wttr.in-on-twitch](https://www.reddit.com/r/commandline/comments/1eqoa0w/creating_a_weather_command_using_wttrin_service/)
### Google Sheets / Excel 365
It is possible to show the live weather in Google Sheets.
Assume you want the weather image for a specific location, and the location is either hardcoded or stored in a cell.
Example 1: Hardcoded Location (London):
In a cell (e.g., A1), enter:
excel
```
=IMAGE("https://wttr.in/London_0pq.png", 1)
```
This inserts a weather image for London, resized to fit the cell while maintaining the aspect ratio.
Example 2: Dynamic Location (from a cell):
If cell B1 contains the location (e.g., London), use:
```excel
=IMAGE("https://wttr.in/"&B1&"_0pq.png", 4, 100, 200)
```
This inserts the image with a custom size (100px height, 200px width). Adjust height and width as needed.
Customize the wttr.in URL (Optional):
* `_0pq`: Simple weather image (current conditions, no background).
* `_m`: Metric units (e.g., Celsius).
* `_u`: USCS units (e.g., Fahrenheit).
* `_t`: Transparent background.
Example with metric units and transparency:
```
=IMAGE("https://wttr.in/"&B1&"_0pqt_m.png", 1)
```

### Raycast
* Raycast Store: https://www.raycast.com/tonka3000/weather
* Source code: [Github](https://github.com/raycast/extensions/tree/542ed079c2eb5a95df0835d83ab1f1c2b1970e44/extensions/weather/)
* Author: [tonka3000](https://github.com/tonka3000)
Raycast is a handy tool for Mac users that helps them work faster and more
efficiently. It's popular among developers, designers, and tech enthusiasts who
want to streamline their workflows.
With Raycast, you can quickly launch apps,
search for files, and perform everyday tasks all from one central place,
boosting productivity and saving time.
With the "Weather" extension, you can quickly check information about the current weather.

================================================
FILE: doc/terminal-images.md
================================================
## Map view (v3)
In the experimental map view, that is available under the view code `v3`,
weather information about a geographical region is available:
```
$ curl v3.wttr.in/Bayern.sxl
```

or directly in browser:
* https://v3.wttr.in/Bayern
The map view currently supports three formats:
* PNG (for browser and messangers);
* Sixel (terminal inline images support);
* IIP (terminal with iterm2 inline images protocol support).
## Terminal with images support
| Terminal | Environment | Images support | Protocol |
| --------------------- | --------- | ------------- | --------- |
| uxterm | X11 | yes | Sixel |
| mlterm | X11 | yes | Sixel |
| kitty | X11 | yes | Kitty |
| wezterm | X11 | yes | IIP |
| Darktile | X11 | yes | Sixel |
| Jexer | X11 | yes | Sixel |
| GNOME Terminal | X11 | [in-progress](https://gitlab.gnome.org/GNOME/vte/-/issues/253) | Sixel |
| alacritty | X11 | [in-progress](https://github.com/alacritty/alacritty/issues/910) | Sixel |
| st | X11 | [stixel](https://github.com/vizs/stixel) or [st-sixel](https://github.com/galatolofederico/st-sixel) | Sixel |
| Konsole | X11 | yes | Sixel |
| DomTerm | Web | yes | Sixel |
| Yaft | FB | yes | Sixel |
| iTerm2 | Mac OS X| yes | IIP |
| mintty | Windows | yes | Sixel |
| Windows Terminal | Windows | [in-progress](https://github.com/microsoft/terminal/issues/448) | Sixel |
| [RLogin](http://nanno.dip.jp/softlib/man/rlogin/) | Windows | yes | Sixel | |
Support in all VTE-based terminals: termite, terminator, etc is more or less the same as in the GNOME Terminal
## Notes
### xterm/uxterm
To start xterm/uxterm with Sixel support:
```
uxterm -ti vt340
```
### Kitty
To view images in kitty:
```
curl -s v3.wttr.in/Tabasco.png | kitty icat --align=left
```
or even without `curl` at all, because `icat` knows how to handle URLs:
```
kitty icat --align=left https://v3.wttr.in/Tabasco.png
```
================================================
FILE: go.mod
================================================
module github.com/chubin/wttr.in
go 1.16
require (
github.com/alecthomas/kong v1.7.0
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 v1.0.2
github.com/itchyny/gojq v0.12.11 // indirect
github.com/klauspost/lctime v0.1.0
github.com/lib/pq v1.8.0 // indirect
github.com/mattn/go-colorable v0.1.14
github.com/mattn/go-runewidth v0.0.16
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/robfig/cron v1.2.0
github.com/samonzeweb/godb v1.0.13
github.com/sirupsen/logrus v1.9.3
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
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sys v0.30.0 // indirect
google.golang.org/appengine v1.6.3 // indirect
gopkg.in/yaml.v3 v3.0.1
)
================================================
FILE: go.sum
================================================
github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v0.7.1 h1:azoTh0IOfwlAX3qN9sHWTxACE2oV8Bg2gAwBsMwDQY4=
github.com/alecthomas/kong v0.7.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U=
github.com/alecthomas/kong v1.7.0 h1:MnT8+5JxFDCvISeI6vgd/mFbAJwueJ/pqQNzZMsiqZE=
github.com/alecthomas/kong v1.7.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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-20190920000552-128d9f4ae1cd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/denisenkom/go-mssqldb v0.0.0-20200910202707-1e08a3fab204/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
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/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
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/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/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.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
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 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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.13 h1:dEWijZGizhSN7oOLFYq0+NquG54DCJ9WG55bEcH7GOA=
github.com/samonzeweb/godb v1.0.13/go.mod h1:Dcm9f9+aO6bQin4Ce4X3oOM2gvhGMt2naLIDLPQSaWQ=
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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/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 v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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-20220722155257-8c9f86f7a55f/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/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.3/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
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=
================================================
FILE: internal/config/config.go
================================================
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 {
// Address1 contains address of the uplink server in form IP:PORT
// for format=j1 queries.
Address1 string `yaml:"address1,omitempty"`
// Address2 contains address of the uplink server in form IP:PORT
// for format=* queries.
Address2 string `yaml:"address2,omitempty"`
// Address3 contains address of the uplink server in form IP:PORT
// for all other queries.
Address3 string `yaml:"address3,omitempty"`
// Address4 contains address of the uplink server in form IP:PORT
// for PNG queries.
Address4 string `yaml:"address4,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 `yaml:"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{
Address1: "127.0.0.1:9002",
Address2: "127.0.0.1:9002",
Address3: "127.0.0.1:9002",
Address4: "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
}
================================================
FILE: internal/fmt/png/colors.go
================================================
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,
},
}
================================================
FILE: internal/fmt/png/go.mod
================================================
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
)
================================================
FILE: internal/fmt/png/go.sum
================================================
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=
================================================
FILE: internal/fmt/png/png.go
================================================
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)
}
}
================================================
FILE: internal/geo/ip/convert.go
================================================
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
}
================================================
FILE: internal/geo/ip/ip.go
================================================
package ip
import (
"fmt"
"log"
"net/http"
"os"
"path"
"regexp"
"strconv"
"strings"
"github.com/samonzeweb/godb"
"github.com/samonzeweb/godb/adapters/sqlite"
"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"
)
// 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, " "))
}
================================================
FILE: internal/geo/ip/ip_test.go
================================================
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)
}
}
}
================================================
FILE: internal/geo/location/cache.go
================================================
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)
}
================================================
FILE: internal/geo/location/convert.go
================================================
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)
}
================================================
FILE: internal/geo/location/location.go
================================================
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)
}
================================================
FILE: internal/geo/location/nominatim.go
================================================
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
}
================================================
FILE: internal/geo/location/nominatim_locationiq.go
================================================
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
}
================================================
FILE: internal/geo/location/nominatim_opencage.go
================================================
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
}
================================================
FILE: internal/geo/location/response.go
================================================
package location
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"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")
}
if strings.Contains(locationName, ".html") || strings.Contains(locationName, ".txt") {
return errorResponse("invalid location")
}
loc, err = c.Resolve(locationName)
if err != nil {
log.Println("geo/location error:", locationName, r.RemoteAddr)
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),
)}
}
================================================
FILE: internal/geo/location/search.go
================================================
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
}
================================================
FILE: internal/logging/logging.go
================================================
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"
}
// Do not log 127.0.0.1 connections
if le.IP == "127.0.0.1" {
return nil
}
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,
)
}
================================================
FILE: internal/logging/suppress.go
================================================
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))
}
================================================
FILE: internal/options/options.go
================================================
package options
import (
"fmt"
"io/ioutil"
"gopkg.in/yaml.v3"
)
// WttrInOptions represents the configuration for wttr.in query options and format specifiers.
type WttrInOptions struct {
QueryOptions []QueryOption `yaml:"query_options"`
FormatSpecifiers []FormatSpecifier `yaml:"format_specifiers"`
}
// QueryOption defines a single query option for wttr.in, such as `lang` or `format`.
type QueryOption struct {
// Name is the long name of the option (e.g., "lang", "current_only").
Name string `yaml:"name"`
// Short is the short name of the option (e.g., "m"), omitted if not present.
Short string `yaml:"short,omitempty"`
// Description of the option's purpose (e.g., "Specify output language").
Description string `yaml:"description"`
// Type is the data type of the option (e.g., "boolean", "string", "integer").
Type string `yaml:"type"`
// ValuesMap is the map of possible values to their descriptions (e.g., for `lang`, `format`).
ValuesMap map[string]string `yaml:"values_map,omitempty"`
// Values is the list of supported values (e.g., ["true", "false"] for booleans).
Values []string `yaml:"values,omitempty"`
// Range is the numeric range for integer options (e.g., min and max for `transparency`).
Range *Range `yaml:"range,omitempty"`
// Default is the default value of the option (e.g., "en" for `lang`, null for `background`).
Default interface{} `yaml:"default"`
// Validate contains validation conditions in function call style (e.g., "length 6" for `background`).
Validate []string `yaml:"validate,omitempty"`
// Active indicates if the option is implemented (true) or proposed (false).
Active bool `yaml:"active"`
// Note includes additional notes or caveats (e.g., "Proposed but not officially supported").
Note string `yaml:"note,omitempty"`
}
// Range defines a numeric range for integer-type query options.
type Range struct {
// Min is the minimum value of the range (e.g., 0 for `transparency`).
Min int `yaml:"min"`
// Max is the maximum value of the range, nullable for unbounded (e.g., null for `date`).
Max *int `yaml:"max"`
}
// FormatSpecifier defines a format specifier for the `format` option (e.g., %c, %t).
type FormatSpecifier struct {
// Specifier is the format specifier (e.g., "%c").
Specifier string `yaml:"specifier"`
// Description provides the output description of the specifier (e.g., "Weather condition").
Description string `yaml:"description"`
// Active indicates if the specifier is implemented (true) or proposed (false).
Active bool `yaml:"active"`
// Note contains additional notes, if any (e.g., "Proposed in issue #585").
Note string `yaml:"note,omitempty"`
}
// NewFromFile reads a QueryOption from a YAML file and returns a pointer to it.
func NewFromFile(filename string) (*WttrInOptions, error) {
// Read the YAML file
data, err := ioutil.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
// Unmarshal the YAML content into a QueryOption struct
var option WttrInOptions
if err := yaml.Unmarshal(data, &option); err != nil {
return nil, fmt.Errorf("failed to unmarshal YAML: %w", err)
}
return &option, nil
}
================================================
FILE: internal/options/parse.go
================================================
package options
import (
"errors"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
)
// Errors for query parsing and validation
var (
ErrInvalidQueryString = errors.New("ERR001: failed to parse query string")
ErrMultipleValues = errors.New("ERR002: option has multiple values")
ErrUnknownOption = errors.New("ERR003: unknown option")
ErrOptionNotImplemented = errors.New("ERR004: option is not implemented")
ErrBooleanValueRequired = errors.New("ERR005: option requires boolean value (true/false)")
ErrInvalidBooleanValue = errors.New("ERR006: invalid boolean value")
ErrValueRequired = errors.New("ERR007: option requires a value")
ErrInvalidStringValue = errors.New("ERR008: invalid string value")
ErrInvalidValidationRule = errors.New("ERR009: invalid validation rule")
ErrInvalidLengthRule = errors.New("ERR010: invalid length rule")
ErrInvalidLength = errors.New("ERR011: invalid length")
ErrInvalidRegexpRule = errors.New("ERR012: invalid regexp rule")
ErrInvalidRegexp = errors.New("ERR013: invalid regexp match")
ErrUnsupportedRule = errors.New("ERR014: unsupported validation rule")
ErrInvalidInteger = errors.New("ERR015: option requires an integer")
ErrBelowMinimum = errors.New("ERR016: value below minimum")
ErrAboveMaximum = errors.New("ERR017: value exceeds maximum")
ErrUnsupportedType = errors.New("ERR018: unsupported option type")
ErrInactiveFormatSpecifier = errors.New("ERR019: format specifier is not implemented")
)
// ParseQueryString parses a wttr.in query string into a map of option names to values.
// It validates option names, types, content, and values based on the provided WttrInOptions.
// Returns the parsed map or an error if parsing or validation fails.
func ParseQueryString(query string, config *WttrInOptions) (map[string]string, error) {
// Initialize result map and lookup tables
result := make(map[string]string)
shortToName, nameToOption := buildOptionLookups(config.QueryOptions)
// Parse query string
query = strings.ReplaceAll(
strings.ReplaceAll(query, "%25", "%"),
"%", "%25")
parsed, err := url.ParseQuery(query)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidQueryString, err)
}
// Process query parameters
for key, values := range parsed {
if len(values) == 0 || (len(values) == 1 && values[0] == "") {
if err := handleFlagOption(key, shortToName, nameToOption, result); err != nil {
return nil, err
}
continue
}
if len(values) > 1 {
return nil, fmt.Errorf("%w: %s: %v", ErrMultipleValues, key, values)
}
if err := handleValueOption(key, values[0], shortToName, nameToOption, result); err != nil {
return nil, err
}
}
// Validate format specifiers if format=custom
if formatValue, exists := result["format"]; exists && formatValue == "custom" {
for _, spec := range config.FormatSpecifiers {
if !spec.Active {
return nil, fmt.Errorf("%w: %s", ErrInactiveFormatSpecifier, spec.Specifier)
}
}
}
return result, nil
}
// buildOptionLookups creates lookup maps for short-to-long names and option details.
func buildOptionLookups(options []QueryOption) (map[string]string, map[string]QueryOption) {
shortToName := make(map[string]string)
nameToOption := make(map[string]QueryOption)
for _, opt := range options {
nameToOption[opt.Name] = opt
if opt.Short != "" {
shortToName[opt.Short] = opt.Name
}
}
return shortToName, nameToOption
}
// handleFlagOption processes flag options (e.g., "0pq" or "m") without values.
func handleFlagOption(key string, shortToName map[string]string, nameToOption map[string]QueryOption, result map[string]string) error {
// Handle bundled short options (e.g., "0pq")
if len(key) > 1 && !strings.Contains(key, "=") {
for _, short := range strings.Split(key, "") {
if err := validateAndSetFlag(short, shortToName, nameToOption, result); err != nil {
return err
}
}
return nil
}
// Handle single flag
return validateAndSetFlag(key, shortToName, nameToOption, result)
}
// validateAndSetFlag validates and sets a single flag option.
func validateAndSetFlag(key string, shortToName map[string]string, nameToOption map[string]QueryOption, result map[string]string) error {
name, exists := shortToName[key]
if !exists {
name = key // Try long name
}
opt, exists := nameToOption[name]
if !exists {
return fmt.Errorf("%w: %s", ErrUnknownOption, key)
}
if !opt.Active {
return fmt.Errorf("%w: %s", ErrOptionNotImplemented, name)
}
if opt.Type != "boolean" {
return fmt.Errorf("%w: %s", ErrValueRequired, key)
}
result[name] = "true"
return nil
}
// handleValueOption processes options with values (e.g., "lang=fr").
func handleValueOption(key, value string, shortToName map[string]string, nameToOption map[string]QueryOption, result map[string]string) error {
// Resolve option name
name, exists := shortToName[key]
if !exists {
name = key // Assume long name
}
opt, exists := nameToOption[name]
if !exists {
return fmt.Errorf("%w: %s", ErrUnknownOption, key)
}
if !opt.Active {
return fmt.Errorf("%w: %s", ErrOptionNotImplemented, name)
}
// Validate based on type
switch opt.Type {
case "boolean":
return validateBooleanOption(name, value, opt, result)
case "string":
return validateStringOption(name, value, opt, result)
case "integer":
return validateIntegerOption(name, value, opt, result)
default:
return fmt.Errorf("%w: %s: %s", ErrUnsupportedType, name, opt.Type)
}
}
// validateBooleanOption validates and sets boolean option values.
func validateBooleanOption(name, value string, opt QueryOption, result map[string]string) error {
if value != "true" && value != "false" {
return fmt.Errorf("%w: %s: %s", ErrBooleanValueRequired, name, value)
}
if len(opt.Values) > 0 {
for _, v := range opt.Values {
if v == value {
result[name] = value
return nil
}
}
return fmt.Errorf("%w: %s: %s, expected one of %v", ErrInvalidBooleanValue, name, value, opt.Values)
}
result[name] = value
return nil
}
// validateStringOption validates and sets string option values, including background-specific rules.
func validateStringOption(name, value string, opt QueryOption, result map[string]string) error {
if len(opt.ValuesMap) > 0 {
if _, valid := opt.ValuesMap[value]; !valid {
return fmt.Errorf("%w: %s: %s, expected one of %v", ErrInvalidStringValue, name, value, getMapKeys(opt.ValuesMap))
}
}
if len(opt.Values) > 0 {
for _, v := range opt.Values {
if v == value {
result[name] = value
return nil
}
}
return fmt.Errorf("%w: %s: %s, expected one of %v", ErrInvalidStringValue, name, value, opt.Values)
}
if name == "background" && len(opt.Validate) > 0 {
for _, rule := range opt.Validate {
if err := applyValidationRule(name, value, rule); err != nil {
return err
}
}
}
result[name] = value
return nil
}
// applyValidationRule applies a single validation rule (e.g., "length 6", "regexp [0-9a-fA-F]{6}").
func applyValidationRule(name, value, rule string) error {
parts := strings.SplitN(rule, " ", 2)
if len(parts) < 2 {
return fmt.Errorf("%w: %s: %s", ErrInvalidValidationRule, name, rule)
}
switch parts[0] {
case "length":
length, err := strconv.Atoi(parts[1])
if err != nil {
return fmt.Errorf("%w: %s: %s", ErrInvalidLengthRule, name, rule)
}
if len(value) != length {
return fmt.Errorf("%w: %s: %s, expected length %d", ErrInvalidLength, name, value, length)
}
case "regexp":
re, err := regexp.Compile(parts[1])
if err != nil {
return fmt.Errorf("%w: %s: %s", ErrInvalidRegexpRule, name, rule)
}
if !re.MatchString(value) {
return fmt.Errorf("%w: %s: %s, expected match for %s", ErrInvalidRegexp, name, value, parts[1])
}
default:
return fmt.Errorf("%w: %s: %s", ErrUnsupportedRule, name, rule)
}
return nil
}
// validateIntegerOption validates and sets integer option values.
func validateIntegerOption(name, value string, opt QueryOption, result map[string]string) error {
num, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("%w: %s: %s", ErrInvalidInteger, name, value)
}
if opt.Range != nil {
if num < opt.Range.Min {
return fmt.Errorf("%w: %s: %d, minimum is %d", ErrBelowMinimum, name, num, opt.Range.Min)
}
if opt.Range.Max != nil && num > *opt.Range.Max {
return fmt.Errorf("%w: %s: %d, maximum is %d", ErrAboveMaximum, name, num, *opt.Range.Max)
}
}
result[name] = value
return nil
}
// getMapKeys returns a sorted slice of keys from a map for error messages.
func getMapKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// Sort for consistent error messages
for i := 0; i < len(keys)-1; i++ {
for j := i + 1; j < len(keys); j++ {
if keys[i] > keys[j] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
return keys
}
================================================
FILE: internal/options/processlog.go
================================================
package options
import (
"bufio"
"fmt"
"os"
"strings"
)
// ProcessLogFile reads a wttr.in log file, parses queries, and writes invalid entries to an error file.
// Each log line is expected in the format: "timestamp request_id protocol ip query user_agent".
// Invalid queries and their error messages are written to errorFilePath.
func ProcessLogFile(logFilePath, errorFilePath string, config *WttrInOptions) error {
logFile, err := openLogFile(logFilePath)
if err != nil {
return err
}
defer logFile.Close()
errorFile, writer, err := openErrorFile(errorFilePath)
if err != nil {
return err
}
defer errorFile.Close()
defer writer.Flush()
return processLogLines(logFile, writer, config)
}
// openLogFile opens the log file for reading.
func openLogFile(logFilePath string) (*os.File, error) {
logFile, err := os.Open(logFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err)
}
return logFile, nil
}
// openErrorFile creates and opens the error file for writing.
func openErrorFile(errorFilePath string) (*os.File, *bufio.Writer, error) {
errorFile, err := os.Create(errorFilePath)
if err != nil {
return nil, nil, fmt.Errorf("failed to create error file: %w", err)
}
writer := bufio.NewWriter(errorFile)
return errorFile, writer, nil
}
// processLogLines reads and processes each line of the log file.
func processLogLines(logFile *os.File, writer *bufio.Writer, config *WttrInOptions) error {
scanner := bufio.NewScanner(logFile)
lineNumber := 0
for scanner.Scan() {
lineNumber++
if err := processLogLine(scanner.Text(), lineNumber, writer, config); err != nil {
return err
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read log file: %w", err)
}
return nil
}
// processLogLine processes a single log line and writes errors to the error file.
func processLogLine(line string, lineNumber int, writer *bufio.Writer, config *WttrInOptions) error {
query, err := extractQueryFromLine(line, lineNumber, writer)
if err != nil {
return err
}
_, err = ParseQueryString(query, config)
if err != nil {
_, writeErr := fmt.Fprintf(writer, "Line %d: Query: %s, Error: %v\n", lineNumber, query, err)
if writeErr != nil {
return fmt.Errorf("failed to write to error file at line %d: %w", lineNumber, writeErr)
}
}
return nil
}
// extractQueryFromLine extracts the query from a log line and validates its format.
func extractQueryFromLine(line string, lineNumber int, writer *bufio.Writer) (string, error) {
fields := strings.Fields(line)
if len(fields) < 5 {
_, err := fmt.Fprintf(writer, "Line %d: Invalid log format: %s\n", lineNumber, line)
if err != nil {
return "", fmt.Errorf("failed to write to error file at line %d: %w", lineNumber, err)
}
return "", nil
}
query := fields[4]
if strings.Contains(query, "?") {
parts := strings.SplitN(query, "?", 2)
if len(parts) == 2 {
return parts[1], nil
}
return "", nil
}
return "", nil
}
================================================
FILE: internal/processor/j1.go
================================================
package processor
import (
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
)
func getAny(req *http.Request, tr1, tr2, tr3, tr4 *http.Transport) (*ResponseWithHeader, error) {
uri := strings.ReplaceAll(req.URL.RequestURI(), "%", "%25")
u, err := url.Parse(uri)
if err != nil {
return nil, err
}
format := u.Query().Get("format")
if format == "j1" {
return getJ1(req, tr1)
} else if format != "" {
return getFormat(req, tr2)
}
// log.Println(req.URL.Query())
// log.Println()
if checkURLForPNG(req) {
return getDefault(req, tr4)
}
return getDefault(req, tr3)
}
func getJ1(req *http.Request, transport *http.Transport) (*ResponseWithHeader, error) {
return getUpstream(req, transport)
}
func getFormat(req *http.Request, transport *http.Transport) (*ResponseWithHeader, error) {
return getUpstream(req, transport)
}
func getDefault(req *http.Request, transport *http.Transport) (*ResponseWithHeader, error) {
return getUpstream(req, transport)
}
func getUpstream(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
}
func checkURLForPNG(r *http.Request) bool {
url := r.URL.String()
return strings.Contains(url, ".png") && !strings.Contains(url, "/files/")
}
================================================
FILE: internal/processor/peak.go
================================================
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
})
}
================================================
FILE: internal/processor/processor.go
================================================
package processor
import (
"context"
"fmt"
"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",
"zig",
"node",
}
}
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
upstreamTransport1 *http.Transport
upstreamTransport2 *http.Transport
upstreamTransport3 *http.Transport
upstreamTransport4 *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,
}
transport1 := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, config.Uplink.Address1)
},
}
transport2 := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, config.Uplink.Address2)
},
}
transport3 := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, config.Uplink.Address3)
},
}
transport4 := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, config.Uplink.Address4)
},
}
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(),
upstreamTransport1: transport1,
upstreamTransport2: transport2,
upstreamTransport3: transport3,
upstreamTransport4: transport4,
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 getAny(r, rp.upstreamTransport1, rp.upstreamTransport2, rp.upstreamTransport3, rp.upstreamTransport4)
}
// 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,
// respond with an error message.
// (WAS: 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)
return &ResponseWithHeader{
InProgress: false,
Expires: time.Now().Add(time.Duration(randInt(1000, 1500)) * time.Second),
Body: []byte("This query is already being processed"),
StatusCode: 200,
}
}
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
)
// Indicate, that the request is being handled.
rp.lruCache.Add(cacheDigest, ResponseWithHeader{InProgress: true})
// 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")
}
response, err = getAny(r, rp.upstreamTransport1, rp.upstreamTransport2, rp.upstreamTransport3, rp.upstreamTransport4)
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
}
// 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(`
301 Moved
301 Moved
The document has moved
here.
`, 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,
}
}
================================================
FILE: internal/routing/routing.go
================================================
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
})
}
================================================
FILE: internal/stats/stats.go
================================================
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(),
}
}
================================================
FILE: internal/types/errors.go
================================================
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")
)
================================================
FILE: internal/types/types.go
================================================
package types
type CacheType string
const (
CacheTypeDB = "db"
CacheTypeFiles = "files"
)
================================================
FILE: internal/util/files.go
================================================
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)
}
================================================
FILE: internal/util/http.go
================================================
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
}
================================================
FILE: internal/util/yaml.go
================================================
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)
}
================================================
FILE: internal/view/v1/api.go
================================================
//nolint:forbidigo,funlen,nestif,goerr113,gocognit,cyclop
package v1
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"strconv"
"strings"
)
//nolint:tagliatelle
type cond struct {
ChanceOfRain string `json:"chanceofrain"`
FeelsLikeC int `json:",string"`
PrecipMM float32 `json:"precipMM,string"`
TempC int `json:"tempC,string"`
TempC2 int `json:"temp_C,string"`
Time int `json:"time,string"`
VisibleDistKM int `json:"visibility,string"`
WeatherCode int `json:"weatherCode,string"`
WeatherDesc []struct{ Value string }
WindGustKmph int `json:",string"`
Winddir16Point string
WindspeedKmph int `json:"windspeedKmph,string"`
}
type astro struct {
Moonrise string
Moonset string
Sunrise string
Sunset string
}
type weather struct {
Astronomy []astro
Date string
Hourly []cond
MaxtempC int `json:"maxtempC,string"`
MintempC int `json:"mintempC,string"`
}
type loc struct {
Query string `json:"query"`
Type string `json:"type"`
}
//nolint:tagliatelle
type resp struct {
Data struct {
Cur []cond `json:"current_condition"`
Err []struct{ Msg string } `json:"error"`
Req []loc `json:"request"`
Weather []weather `json:"weather"`
} `json:"data"`
}
func (g *global) getDataFromAPI() (*resp, error) {
var (
ret resp
params []string
)
if len(g.config.APIKey) == 0 {
return nil, fmt.Errorf("no API key specified. Setup instructions are in the README")
}
params = append(params, "key="+g.config.APIKey)
// non-flag shortcut arguments will overwrite possible flag arguments
for _, arg := range flag.Args() {
if v, err := strconv.Atoi(arg); err == nil && len(arg) == 1 {
g.config.Numdays = v
} else {
g.config.City = arg
}
}
if len(g.config.City) > 0 {
params = append(params, "q="+url.QueryEscape(g.config.City))
}
params = append(params, "format=json", "num_of_days="+strconv.Itoa(g.config.Numdays), "tp=3")
if g.config.Lang != "" {
params = append(params, "lang="+g.config.Lang)
}
if g.debug {
fmt.Fprintln(os.Stderr, params)
}
res, err := http.Get(wuri + strings.Join(params, "&"))
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
if g.debug {
var out bytes.Buffer
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")
}
if g.config.Lang == "" {
if err = json.Unmarshal(body, &ret); err != nil {
return nil, err
}
} else {
if err = g.unmarshalLang(body, &ret); err != nil {
return nil, err
}
}
return &ret, nil
}
func (g *global) unmarshalLang(body []byte, r *resp) error {
var rv map[string]interface{}
if err := json.Unmarshal(body, &rv); err != nil {
return err
}
if data, ok := rv["data"].(map[string]interface{}); ok {
if ccs, ok := data["current_condition"].([]interface{}); ok {
for _, cci := range ccs {
cc, ok := cci.(map[string]interface{})
if !ok {
continue
}
langs, ok := cc["lang_"+g.config.Lang].([]interface{})
if !ok || len(langs) == 0 {
continue
}
weatherDesc, ok := cc["weatherDesc"].([]interface{})
if !ok || len(weatherDesc) == 0 {
continue
}
weatherDesc[0] = langs[0]
}
}
if ws, ok := data["weather"].([]interface{}); ok {
for _, wi := range ws {
w, ok := wi.(map[string]interface{})
if !ok {
continue
}
if hs, ok := w["hourly"].([]interface{}); ok {
for _, hi := range hs {
h, ok := hi.(map[string]interface{})
if !ok {
continue
}
langs, ok := h["lang_"+g.config.Lang].([]interface{})
if !ok || len(langs) == 0 {
continue
}
weatherDesc, ok := h["weatherDesc"].([]interface{})
if !ok || len(weatherDesc) == 0 {
continue
}
weatherDesc[0] = langs[0]
}
}
}
}
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(rv); err != nil {
return err
}
if err := json.NewDecoder(&buf).Decode(r); err != nil {
return err
}
return nil
}
================================================
FILE: internal/view/v1/cmd.go
================================================
// 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
}
================================================
FILE: internal/view/v1/format.go
================================================
//nolint:funlen,nestif,cyclop,gocognit,gocyclo
package v1
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/mattn/go-runewidth"
)
func windDir() map[string]string {
return map[string]string{
"N": "\033[1m↓\033[0m",
"NNE": "\033[1m↓\033[0m",
"NE": "\033[1m↙\033[0m",
"ENE": "\033[1m↙\033[0m",
"E": "\033[1m←\033[0m",
"ESE": "\033[1m←\033[0m",
"SE": "\033[1m↖\033[0m",
"SSE": "\033[1m↖\033[0m",
"S": "\033[1m↑\033[0m",
"SSW": "\033[1m↑\033[0m",
"SW": "\033[1m↗\033[0m",
"WSW": "\033[1m↗\033[0m",
"W": "\033[1m→\033[0m",
"WNW": "\033[1m→\033[0m",
"NW": "\033[1m↘\033[0m",
"NNW": "\033[1m↘\033[0m",
}
}
func (g *global) formatTemp(c cond) string {
color := func(temp int, explicitPlus bool) string {
var col int
//nolint:dupl
if !g.config.Inverse {
// Extremely cold temperature must be shown with violet
// because dark blue is too dark
col = 165
switch temp {
case -15, -14, -13:
col = 171
case -12, -11, -10:
col = 33
case -9, -8, -7:
col = 39
case -6, -5, -4:
col = 45
case -3, -2, -1:
col = 51
case 0, 1:
col = 50
case 2, 3:
col = 49
case 4, 5:
col = 48
case 6, 7:
col = 47
case 8, 9:
col = 46
case 10, 11, 12:
col = 82
case 13, 14, 15:
col = 118
case 16, 17, 18:
col = 154
case 19, 20, 21:
col = 190
case 22, 23, 24:
col = 226
case 25, 26, 27:
col = 220
case 28, 29, 30:
col = 214
case 31, 32, 33:
col = 208
case 34, 35, 36:
col = 202
default:
if temp > 0 {
col = 196
}
}
} else {
col = 16
switch temp {
case -15, -14, -13:
col = 17
case -12, -11, -10:
col = 18
case -9, -8, -7:
col = 19
case -6, -5, -4:
col = 20
case -3, -2, -1:
col = 21
case 0, 1:
col = 30
case 2, 3:
col = 28
case 4, 5:
col = 29
case 6, 7:
col = 30
case 8, 9:
col = 34
case 10, 11, 12:
col = 35
case 13, 14, 15:
col = 36
case 16, 17, 18:
col = 40
case 19, 20, 21:
col = 59
case 22, 23, 24:
col = 100
case 25, 26, 27:
col = 101
case 28, 29, 30:
col = 94
case 31, 32, 33:
col = 166
case 34, 35, 36:
col = 52
default:
if temp > 0 {
col = 196
}
}
}
if g.config.Imperial {
temp = (temp*18 + 320) / 10
}
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)
}
t := c.TempC
if t == 0 {
t = c.TempC2
}
// hyphen := " - "
// if (config.Lang == "sl") {
// hyphen = "-"
// }
// hyphen = ".."
explicitPlus1 := false
explicitPlus2 := false
if c.FeelsLikeC != t {
if t > 0 {
explicitPlus1 = true
}
if c.FeelsLikeC > 0 {
explicitPlus2 = true
}
if explicitPlus1 {
explicitPlus2 = false
}
return g.pad(
fmt.Sprintf("%s(%s) °%s",
color(t, explicitPlus1),
color(c.FeelsLikeC, explicitPlus2),
unitTemp()[g.config.Imperial]),
15)
}
return g.pad(fmt.Sprintf("%s °%s", color(c.FeelsLikeC, false), unitTemp()[g.config.Imperial]), 15)
}
func (g *global) formatWind(c cond) string {
unitWindString := unitWind(0, g.config.Lang)
if g.config.WindMS {
unitWindString = unitWind(2, g.config.Lang)
} else if g.config.Imperial {
unitWindString = unitWind(1, g.config.Lang)
}
hyphen := "-"
cWindGustKmph := speedToColor(c.WindGustKmph, windInRightUnits(c.WindGustKmph, g.config.WindMS, g.config.Imperial))
cWindspeedKmph := speedToColor(c.WindspeedKmph, windInRightUnits(c.WindspeedKmph, g.config.WindMS, g.config.Imperial))
if windInRightUnits(c.WindGustKmph, g.config.WindMS, g.config.Imperial) >
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)
}
return g.pad(fmt.Sprintf("%s %s %s", windDir()[c.Winddir16Point], cWindspeedKmph, unitWindString), 15)
}
func windInRightUnits(spd int, windMS, imperial bool) int {
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
}
return g.pad(fmt.Sprintf("%d %s", c.VisibleDistKM, unitVis(g.config.Imperial, g.config.Lang)), 15)
}
func (g *global) formatRain(c cond) string {
rainUnit := c.PrecipMM
if g.config.Imperial {
rainUnit = c.PrecipMM * 0.039
}
if c.ChanceOfRain != "" {
return g.pad(fmt.Sprintf(
"%.1f %s | %s%%",
rainUnit,
unitRain(g.config.Imperial, g.config.Lang),
c.ChanceOfRain), 15)
}
return g.pad(fmt.Sprintf("%.1f %s", rainUnit, unitRain(g.config.Imperial, g.config.Lang)), 15)
}
func (g *global) formatCond(cur []string, c cond, current bool) []string {
var (
ret []string
icon []string
)
if i, ok := codes()[c.WeatherCode]; !ok {
icon = getIcon("iconUnknown")
} else {
icon = i
}
if g.config.Inverse {
// inverting colors
for i := range icon {
icon[i] = strings.ReplaceAll(icon[i], "38;5;226", "38;5;94")
icon[i] = strings.ReplaceAll(icon[i], "38;5;250", "38;5;243")
icon[i] = strings.ReplaceAll(icon[i], "38;5;21", "38;5;18")
icon[i] = strings.ReplaceAll(icon[i], "38;5;255", "38;5;245")
icon[i] = strings.ReplaceAll(icon[i], "38;5;111", "38;5;63")
icon[i] = strings.ReplaceAll(icon[i], "38;5;251", "38;5;238")
}
}
// desc := fmt.Sprintf("%-15.15v", c.WeatherDesc[0].Value)
desc := c.WeatherDesc[0].Value
if g.config.RightToLeft {
for runewidth.StringWidth(desc) < 15 {
desc = " " + desc
}
for runewidth.StringWidth(desc) > 15 {
_, size := utf8.DecodeLastRuneInString(desc)
desc = desc[size:]
}
} else {
for runewidth.StringWidth(desc) < 15 {
desc += " "
}
for runewidth.StringWidth(desc) > 15 {
_, size := utf8.DecodeLastRuneInString(desc)
desc = desc[:len(desc)-size]
}
}
if current {
if g.config.RightToLeft {
desc = c.WeatherDesc[0].Value
if runewidth.StringWidth(desc) < 15 {
desc = strings.Repeat(" ", 15-runewidth.StringWidth(desc)) + desc
}
} else {
desc = c.WeatherDesc[0].Value
}
} else {
if g.config.RightToLeft {
if frstRune, size := utf8.DecodeRuneInString(desc); frstRune != ' ' {
desc = "…" + desc[size:]
for runewidth.StringWidth(desc) < 15 {
desc = " " + desc
}
}
} else {
if lastRune, size := utf8.DecodeLastRuneInString(desc); lastRune != ' ' {
desc = desc[:len(desc)-size] + "…"
// for numberOfSpaces < runewidth.StringWidth(fmt.Sprintf("%c", lastRune)) - 1 {
for runewidth.StringWidth(desc) < 15 {
desc += " "
}
}
}
}
if g.config.RightToLeft {
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 {
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 ret
}
func justifyCenter(s string, width int) string {
appendSide := 0
for runewidth.StringWidth(s) <= width {
if appendSide == 1 {
s += " "
appendSide = 0
} else {
s = " " + s
appendSide = 1
}
}
return s
}
func reverse(s string) string {
r := []rune(s)
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]
}
return string(r)
}
func (g *global) pad(s string, mustLen int) string {
var ret string
ret = s
realLen := utf8.RuneCountInString(g.ansiEsc.ReplaceAllLiteralString(s, ""))
delta := mustLen - realLen
if delta > 0 {
if g.config.RightToLeft {
ret = strings.Repeat(" ", delta) + ret + "\033[0m"
} else {
ret += "\033[0m" + strings.Repeat(" ", delta)
}
} else if delta < 0 {
toks := g.ansiEsc.Split(s, 2)
tokLen := utf8.RuneCountInString(toks[0])
esc := g.ansiEsc.FindString(s)
if tokLen > mustLen {
ret = fmt.Sprintf("%.*s\033[0m", mustLen, toks[0])
} else {
ret = fmt.Sprintf("%s%s%s", toks[0], esc, g.pad(toks[1], mustLen-tokLen))
}
}
return ret
}
================================================
FILE: internal/view/v1/icons.go
================================================
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"),
}
}
================================================
FILE: internal/view/v1/locale.go
================================================
package v1
//nolint:funlen
func locale() map[string]string {
return map[string]string{
"af": "af_ZA",
"am": "am_ET",
"ar": "ar_TN",
"az": "az_AZ",
"be": "be_BY",
"bg": "bg_BG",
"bn": "bn_IN",
"bs": "bs_BA",
"ca": "ca_ES",
"cs": "cs_CZ",
"cy": "cy_GB",
"da": "da_DK",
"de": "de_DE",
"el": "el_GR",
"eo": "eo",
"es": "es_ES",
"et": "et_EE",
"eu": "eu_ES",
"fa": "fa_IR",
"fi": "fi_FI",
"fr": "fr_FR",
"fy": "fy_NL",
"ga": "ga_IE",
"gl": "gl_ES",
"he": "he_IL",
"hi": "hi_IN",
"hr": "hr_HR",
"hu": "hu_HU",
"hy": "hy_AM",
"ia": "ia",
"id": "id_ID",
"is": "is_IS",
"it": "it_IT",
"ja": "ja_JP",
"jv": "en_US",
"ka": "ka_GE",
"kk": "kk_KZ",
"ko": "ko_KR",
"ky": "ky_KG",
"lt": "lt_LT",
"lv": "lv_LV",
"mg": "mg_MG",
"mk": "mk_MK",
"ml": "ml_IN",
"mr": "mr_IN",
"nb": "nb_NO",
"nl": "nl_NL",
"nn": "nn_NO",
"oc": "oc_FR",
"pl": "pl_PL",
"pt-br": "pt_BR",
"pt": "pt_PT",
"ro": "ro_RO",
"ru": "ru_RU",
"sk": "sk_SK",
"sl": "sl_SI",
"sr-lat": "sr_RS@latin",
"sr": "sr_RS",
"sv": "sv_SE",
"sw": "sw_KE",
"ta": "ta_IN",
"th": "th_TH",
"tr": "tr_TR",
"uk": "uk_UA",
"uz": "uz_UZ",
"vi": "vi_VN",
"zh-cn": "zh_CN",
"zh-tw": "zh_TW",
"zh": "zh_CN",
"zu": "zu_ZA",
}
}
//nolint:funlen
func localizedCaption() map[string]string {
return map[string]string{
"af": "Weer verslag vir:",
"am": "የአየር ሁኔታ ዘገባ ለ ፥",
"ar": "تقرير حالة ألطقس",
"az": "Hava proqnozu:",
"be": "Прагноз надвор'я для:",
"bg": "Прогноза за времето в:",
"bn": "আবহাওয়া সঙ্ক্রান্ত তথ্য",
"bs": "Vremenske prognoze za:",
"ca": "Informe del temps per a:",
"cs": "Předpověď počasí pro:",
"cy": "Adroddiad tywydd ar gyfer:",
"da": "Vejret i:",
"de": "Wetterbericht für:",
"el": "Πρόγνωση καιρού για:",
"eo": "Veterprognozo por:",
"es": "El tiempo en:",
"et": "Ilmaprognoos:",
"eu": "Eguraldia:",
"fa": "اوه و بآ تیعضو شرازگ",
"fi": "Säätiedotus:",
"fr": "Prévisions météo pour:",
"fy": "Waarberjocht foar:",
"ga": "Réamhaisnéis na haimsire do:",
"gl": "Previsión do tempo en:",
"he": ":ריוואה גזמ תיזחת",
"hi": "मौसम की जानकारी",
"hr": "Vremenska prognoza za:",
"hu": "Időjárás előrejelzés:",
"hy": "Եղանակի տեսություն:",
"ia": "Le tempore a:",
"id": "Prakiraan cuaca:",
"it": "Previsioni meteo:",
"is": "Veðurskýrsla fyrir:",
"ja": "天気予報:",
"jv": "Weather forecast for:",
"ka": "ამინდის პროგნოზი:",
"kk": "Ауа райы:",
"ko": "일기 예보:",
"ky": "Аба ырайы:",
"lt": "Orų prognozė:",
"lv": "Laika ziņas:",
"mk": "Прогноза за времето во:",
"ml": "കാലാവസ്ഥ റിപ്പോർട്ട്:",
"mr": "हवामानाचा अंदाज:",
"nb": "Værmelding for:",
"nl": "Weerbericht voor:",
"nn": "Vêrmelding for:",
"oc": "Previsions metèo per:",
"pl": "Pogoda w:",
"pt": "Previsão do tempo para:",
"pt-br": "Previsão do tempo para:",
"ro": "Prognoza meteo pentru:",
"ru": "Прогноз погоды:",
"sk": "Predpoveď počasia pre:",
"sl": "Vremenska napoved za",
"sr": "Временска прогноза за:",
"sr-lat": "Vremenska prognoza za:",
"sv": "Väderleksprognos för:",
"sw": "Ripoti ya hali ya hewa, jiji la:",
"ta": "வானிலை அறிக்கை",
"te": "వాతావరణ సమాచారము:",
"th": "รายงานสภาพอากาศ:",
"tr": "Hava beklentisi:",
"uk": "Прогноз погоди для:",
"uz": "Ob-havo bashorati:",
"vi": "Báo cáo thời tiết:",
"zu": "Isimo sezulu:",
"zh": "天气预报:",
"zh-cn": "天气预报:",
"zh-tw": "天氣預報:",
"mg": "Vinavina toetr'andro hoan'ny:",
}
}
//nolint:misspell,funlen
func daytimeTranslation() map[string][]string {
return map[string][]string{
"af": {"Oggend", "Middag", "Vroegaand", "Laatnag"},
"am": {"ጠዋት", "ከሰዓት በኋላ", "ምሽት", "ሌሊት"},
"ar": {"ﺎﻠﻠﻴﻟ", "ﺎﻠﻤﺳﺍﺀ", "ﺎﻠﻈﻫﺭ", "ﺎﻠﺼﺑﺎﺣ"},
"az": {"Səhər", "Gün", "Axşam", "Gecə"},
"be": {"Раніца", "Дзень", "Вечар", "Ноч"},
"bg": {"Сутрин", "Обяд", "Вечер", "Нощ"},
"bn": {"সকাল", "দুপুর", "সন্ধ্যা", "রাত্রি"},
"bs": {"Ujutro", "Dan", "Večer", "Noć"},
"cs": {"Ráno", "Ve dne", "Večer", "V noci"},
"ca": {"Matí", "Dia", "Tarda", "Nit"},
"cy": {"Bore", "Dydd", "Hwyr", "Nos"},
"da": {"Morgen", "Middag", "Aften", "Nat"},
"de": {"Morgen", "Mittag", "Abend", "Nacht"},
"el": {"Πρωί", "Μεσημέρι", "Απόγευμα", "Βράδυ"},
"en": {"Morning", "Noon", "Evening", "Night"},
"eo": {"Mateno", "Tago", "Vespero", "Nokto"},
"es": {"Mañana", "Mediodía", "Tarde", "Noche"},
"et": {"Hommik", "Päev", "Õhtu", "Öösel"},
"eu": {"Goiza", "Eguerdia", "Arratsaldea", "Gaua"},
"fa": {"حبص", "رهظ", "رصع", "بش"},
"fi": {"Aamu", "Keskipäivä", "Ilta", "Yö"},
"fr": {"Matin", "Après-midi", "Soir", "Nuit"},
"fy": {"Moarns", "Middeis", "Jûns", "Nachts"},
"ga": {"Maidin", "Nóin", "Tráthnóna", "Oíche"},
"gl": {"Mañá", "Mediodía", "Tarde", "Noite"},
"he": {"רקוב", "םוֹיְ", "ברֶעֶ", "הלָיְלַ"},
"hi": {"प्रातःकाल", "दोपहर", "सायंकाल", "रात"},
"hr": {"Jutro", "Dan", "Večer", "Noć"},
"hu": {"Reggel", "Dél", "Este", "Éjszaka"},
"hy": {"Առավոտ", "Կեսօր", "Երեկո", "Գիշեր"},
"ia": {"Matino", "Mediedie", "Vespere", "Nocte"},
"id": {"Pagi", "Hari", "Petang", "Malam"},
"it": {"Mattina", "Pomeriggio", "Sera", "Notte"},
"is": {"Morgunn", "Dagur", "Kvöld", "Nótt"},
"ja": {"朝", "昼", "夕", "夜"},
"jv": {"Morning", "Noon", "Evening", "Night"},
"ka": {"დილა", "დღე", "საღამო", "ღამე"},
"kk": {"Таң", "Күндіз", "Кеш", "Түн"},
"ko": {"아침", "낮", "저녁", "밤"},
"ky": {"Эртең", "Күн", "Кеч", "Түн"},
"lt": {"Rytas", "Diena", "Vakaras", "Naktis"},
"lv": {"Rīts", "Diena", "Vakars", "Nakts"},
"mk": {"Утро", "Пладне", "Вечер", "Ноќ"},
"ml": {"രാവിലെ", "മധ്യാഹ്നം", "വൈകുന്നേരം", "രാത്രി"},
"mr": {"सकाळ", "दुपार", "संध्याकाळ", "रात्र"},
"nl": {"'s Ochtends", "'s Middags", "'s Avonds", "'s Nachts"},
"nb": {"Morgen", "Middag", "Kveld", "Natt"},
"nn": {"Morgon", "Middag", "Kveld", "Natt"},
"oc": {"Matin", "Jorn", "Vèspre", "Nuèch"},
"pl": {"Ranek", "Dzień", "Wieczór", "Noc"},
"pt": {"Manhã", "Meio-dia", "Tarde", "Noite"},
"pt-br": {"Manhã", "Meio-dia", "Tarde", "Noite"},
"ro": {"Dimineaţă", "Amiază", "Seară", "Noapte"},
"ru": {"Утро", "День", "Вечер", "Ночь"},
"sk": {"Ráno", "Cez deň", "Večer", "V noci"},
"sl": {"Jutro", "Dan", "Večer", "Noč"},
"sr": {"Јутро", "Подне", "Вече", "Ноћ"},
"sr-lat": {"Jutro", "Podne", "Veče", "Noć"},
"sv": {"Morgon", "Eftermiddag", "Kväll", "Natt"},
"sw": {"Asubuhi", "Adhuhuri", "Jioni", "Usiku"},
"ta": {"காலை", "நண்பகல்", "சாயங்காலம்", "இரவு"},
"te": {"ఉదయం", "రోజు", "సాయంత్రం", "రాత్రి"},
"th": {"เช้า", "วัน", "เย็น", "คืน"},
"tr": {"Sabah", "Öğle", "Akşam", "Gece"},
"uk": {"Ранок", "День", "Вечір", "Ніч"},
"uz": {"Ertalab", "Kunduzi", "Kechqurun", "Kecha"},
"vi": {"Sáng", "Trưa", "Chiều", "Tối"},
"zh": {"早上", "中午", "傍晚", "夜间"},
"zh-cn": {"早上", "中午", "傍晚", "夜间"},
"zh-tw": {"早上", "中午", "傍晚", "夜間"},
"zu": {"Morning", "Noon", "Evening", "Night"},
"mg": {"Maraina", "Tolakandro", "Ariva", "Alina"},
}
}
func unitTemp() map[bool]string {
return map[bool]string{
false: "C",
true: "F",
}
}
func localizedRain() map[string]map[bool]string {
return map[string]map[bool]string{
"en": {
false: "mm",
true: "in",
},
"be": {
false: "мм",
true: "in",
},
"ru": {
false: "мм",
true: "in",
},
"uk": {
false: "мм",
true: "in",
},
}
}
func localizedVis() map[string]map[bool]string {
return map[string]map[bool]string{
"en": {
false: "km",
true: "mi",
},
"be": {
false: "км",
true: "mi",
},
"ru": {
false: "км",
true: "mi",
},
"uk": {
false: "км",
true: "mi",
},
}
}
func localizedWind() map[string]map[int]string {
return map[string]map[int]string{
"en": {
0: "km/h",
1: "mph",
2: "m/s",
},
"be": {
0: "км/г",
1: "mph",
2: "м/c",
},
"ru": {
0: "км/ч",
1: "mph",
2: "м/c",
},
"tr": {
0: "km/sa",
1: "mph",
2: "m/s",
},
"uk": {
0: "км/год",
1: "mph",
2: "м/c",
},
}
}
func unitWind(unit int, lang string) string {
translation, ok := localizedWind()[lang]
if !ok {
translation = localizedWind()["en"]
}
return translation[unit]
}
func unitVis(unit bool, lang string) string {
translation, ok := localizedVis()[lang]
if !ok {
translation = localizedVis()["en"]
}
return translation[unit]
}
func unitRain(unit bool, lang string) string {
translation, ok := localizedRain()[lang]
if !ok {
translation = localizedRain()["en"]
}
return translation[unit]
}
================================================
FILE: internal/view/v1/view1.go
================================================
package v1
import (
"math"
"time"
"github.com/klauspost/lctime"
)
func slotTimes() []int {
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
)
hourly := w.Hourly
for i := range ret {
ret[i] = "│"
}
// find hourly data which fits the desired times of day best
var slots [slotcount]cond
for _, h := range hourly {
c := int(math.Mod(float64(h.Time), 100)) + 60*(h.Time/100)
for i, s := range slots {
if math.Abs(float64(c-slotTimes()[i])) < math.Abs(float64(s.Time-slotTimes()[i])) {
h.Time = c
slots[i] = h
}
}
}
if g.config.RightToLeft {
slots[0], slots[3] = slots[3], slots[0]
slots[1], slots[2] = slots[2], slots[1]
}
for i, s := range slots {
if g.config.Narrow {
if i == 0 || i == 2 {
continue
}
}
ret = g.formatCond(ret, s, false)
for i := range ret {
ret[i] += "│"
}
}
d, _ := time.Parse("2006-01-02", w.Date)
// dateFmt := "┤ " + d.Format("Mon 02. Jan") + " ├"
if val, ok := locale()[g.config.Lang]; ok {
err := lctime.SetLocale(val)
if err != nil {
return nil, err
}
} else {
err := lctime.SetLocale("en_US")
if err != nil {
return nil, err
}
}
if g.config.RightToLeft {
dow := lctime.Strftime("%a", d)
day := lctime.Strftime("%d", d)
month := lctime.Strftime("%b", d)
dateName = reverse(month) + " " + day + " " + reverse(dow)
} else {
switch g.config.Lang {
case "ko":
date_format = "%b %d일 %a"
case "lv":
date_format = "%a., %d. %b."
case "zh", "zh-cn", "zh-tw":
date_format = "%b%d日%A"
default:
date_format = "%a %d %b"
}
dateName = lctime.Strftime(date_format, d)
}
dateFmt := "┤" + justifyCenter(dateName, 12) + "├"
trans := daytimeTranslation()["en"]
if t, ok := daytimeTranslation()[g.config.Lang]; ok {
trans = t
}
if g.config.Narrow {
names := "│ " + justifyCenter(trans[1], 16) +
"└──────┬──────┘" + justifyCenter(trans[3], 16) + " │"
ret = append([]string{
" ┌─────────────┐ ",
"┌───────────────────────" + dateFmt + "───────────────────────┐",
names,
"├──────────────────────────────┼──────────────────────────────┤",
},
ret...)
return append(ret,
"└──────────────────────────────┴──────────────────────────────┘"),
nil
}
if g.config.RightToLeft {
names = "│" + justifyCenter(trans[3], 29) + "│ " + justifyCenter(trans[2], 16) +
"└──────┬──────┘" + justifyCenter(trans[1], 16) + " │" + justifyCenter(trans[0], 29) + "│"
} else {
names = "│" + justifyCenter(trans[0], 29) + "│ " + justifyCenter(trans[1], 16) +
"└──────┬──────┘" + justifyCenter(trans[2], 16) + " │" + justifyCenter(trans[3], 29) + "│"
}
//nolint:lll
ret = append([]string{
" ┌─────────────┐ ",
"┌──────────────────────────────┬───────────────────────" + dateFmt + "───────────────────────┬──────────────────────────────┐",
names,
"├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤",
},
ret...)
//nolint:lll
return append(ret,
"└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘"),
nil
}
================================================
FILE: lib/airports.py
================================================
import csv
AIRPORTS_DAT_FILE = "/home/igor/wttrin-geo/share/airports.dat"
def load_aiports_index():
file_ = open(AIRPORTS_DAT_FILE, "r")
reader = csv.reader(file_)
airport_index = {}
for line in reader:
airport_index[line[4]] = line
return airport_index
AIRPORTS_INDEX = load_aiports_index()
def get_airport_gps_location(iata_code):
if iata_code in AIRPORTS_INDEX:
airport = AIRPORTS_INDEX[iata_code]
return "%s,%s airport" % (airport[6], airport[7]) # , airport[1])
return None
================================================
FILE: lib/buttons.py
================================================
TWITTER_BUTTON = """
"""
GITHUB_BUTTON = """
wttr.in
"""
GITHUB_BUTTON_2 = """
wego
"""
GITHUB_BUTTON_3 = """
pyphoon
"""
GITHUB_BUTTON_FOOTER = """
"""
def add_buttons(output):
"""
Add buttons to html output
"""
return output.replace(
"