scope/internal/cards/metaweather.go

275 lines
7.7 KiB
Go

/*
* Scope - A simple and minimal metasearch engine
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cards
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/bcicen/go-units"
"github.com/spf13/viper"
)
func init() {
Register("metaweather", 1, NewMetaweatherCard)
}
const metaweatherContent = `
<div class="columns">
<div class="column">
<span class="iconify" data-width="75" data-height="75" data-icon="%s"></span><p class="subtitle">%s</p>
</div>
<div class="column has-text-right">
<p class="title has-text-weight-normal">%s</p>
</div>
</div>
<div class="columns">
<div class="column has-text-centered">
<p><b>Min Temp:</b> %s</p>
<p><b>Max Temp:</b> %s</p>
<p><b>Wind Speed:</b> %s</p>
</div>
<div class="column has-text-centered">
<p><b>Humidity:</b> %d%%</p>
<p><b>Visibility:</b> %s</p>
<p><b>Predictability:</b> %d%%</p>
</div>
<div class="column has-text-centered">
<p><b>Air Pressure:</b> %s</p>
<p><b>Wind Direction:</b> %s</p>
<p><b>Location:</b> %s</p>
</div>
</div>`
// weatherIconMap maps metaweather abbreviations
// to iconify icons
var weatherIconMap = map[string]string{
"sn": "bi:cloud-snow",
"sl": "bi:cloud-sleet",
"h": "bi:cloud-hail",
"t": "bi:cloud-lightning-rain",
"hr": "bi:cloud-rain-heavy",
"lr": "bi:cloud-drizzle",
"s": "wi:day-rain",
"hc": "bi:cloud-fill",
"lc": "bi:cloud",
"c": "akar-icons:sun",
}
// MetaweatherCard represents a metaweather card
type MetaweatherCard struct {
query string
userAgent string
location []MetaweatherLocation
resp MetaweatherResponse
}
// NewMetaweatherCard is a NewCardFunc that creates a new MetaweatherCard
func NewMetaweatherCard(query, userAgent string) Card {
return &MetaweatherCard{
query: query,
userAgent: userAgent,
}
}
// MetaweatherLocation represents a location
type MetaweatherLocation struct {
Title string `json:"title"`
WOEID int `json:"woeid"`
}
// MetaweatherResponse represents a response from
// the metaweather API
type MetaweatherResponse struct {
Detail string `json:"detail"`
Title string `json:"title"`
Timezone string `json:"timezone"`
Consolidated []struct {
ID int `json:"id"`
State string `json:"weather_state_name"`
StateAbbr string `json:"weather_state_abbr"`
WindCompass string `json:"wind_direction_compass"`
MinTemp float64 `json:"min_temp"`
MaxTemp float64 `json:"max_temp"`
CurrentTemp float64 `json:"the_temp"`
WindSpeed float64 `json:"wind_speed"`
WindDirection float64 `json:"wind_direction"`
AirPressure float64 `json:"air_pressure"`
Visibility float64 `json:"visibility"`
Humidity int `json:"humidity"`
Predictability int `json:"predictability"`
} `json:"consolidated_weather"`
}
// RunQuery searches for the location and then runs an API query
// using the returned WOEID
func (mc *MetaweatherCard) RunQuery() error {
http.DefaultClient.Timeout = 5 * time.Second
// Create location search request
req, err := http.NewRequest(
http.MethodGet,
"https://www.metaweather.com/api/location/search/?query="+url.QueryEscape(mc.StripKey()),
nil,
)
if err != nil {
return err
}
req.Header.Set("User-Agent", mc.userAgent)
// Perform request
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
// Decode response into location
err = json.NewDecoder(res.Body).Decode(&mc.location)
if err != nil {
return err
}
res.Body.Close()
// If no locations, search returned no results
if len(mc.location) == 0 {
return nil
}
// Create request for weather data
req, err = http.NewRequest(
http.MethodGet,
"https://www.metaweather.com/api/location/"+strconv.Itoa(mc.location[0].WOEID),
nil,
)
if err != nil {
return err
}
req.Header.Set("User-Agent", mc.userAgent)
// Perform request
res, err = http.DefaultClient.Do(req)
if err != nil {
return err
}
// Decode response into response struct
err = json.NewDecoder(res.Body).Decode(&mc.resp)
if err != nil {
return err
}
return nil
}
// Returned checks whether no location was found or response
// said not found.
func (mc *MetaweatherCard) Returned() bool {
return len(mc.location) > 0 && mc.resp.Detail != "Not found."
}
// Matches determines whether the query matches the keys for
// MetaweatherCard
func (mc *MetaweatherCard) Matches() bool {
return strings.HasPrefix(mc.query, "weather in") ||
strings.HasPrefix(mc.query, "weather") ||
strings.HasSuffix(mc.query, "weather")
}
// StripKey removes keys related to MetaweatherCard
func (mc *MetaweatherCard) StripKey() string {
query := strings.TrimPrefix(mc.query, "weather in")
query = strings.TrimPrefix(query, "weather")
query = strings.TrimSuffix(query, "weather")
return strings.TrimSpace(query)
}
// Content returns metaweatherContent with all information added
func (mc *MetaweatherCard) Content() template.HTML {
return template.HTML(fmt.Sprintf(
metaweatherContent,
// Write iconift icon for weather state
weatherIconMap[mc.resp.Consolidated[0].StateAbbr],
// Write weather state
mc.resp.Consolidated[0].State,
// Convert and write current temperature
convert(mc.resp.Consolidated[0].CurrentTemp, "temperature", units.Celsius),
// Convert and write minimum temperature
convert(mc.resp.Consolidated[0].MinTemp, "temperature", units.Celsius),
// Convert and write maximum temperature
convert(mc.resp.Consolidated[0].MaxTemp, "temperature", units.Celsius),
// Write wind speed
fmt.Sprintf("%.2f mph", mc.resp.Consolidated[0].WindSpeed),
// Write humidity percentafe
mc.resp.Consolidated[0].Humidity,
// Convert and write visibility
convert(mc.resp.Consolidated[0].Visibility, "visibility", units.Mile),
// Write predictability percentage
mc.resp.Consolidated[0].Predictability,
// Write air pressure
convert(mc.resp.Consolidated[0].AirPressure, "pressure", units.HectoPascal),
// Write compass wind direction
mc.resp.Consolidated[0].WindCompass,
// Write title
mc.resp.Title,
))
}
// Title of MetaweatherCard is "Weather"
func (mc *MetaweatherCard) Title() string {
return "Weather"
}
// Footer returns a footer with a link to metaweather
func (mc *MetaweatherCard) Footer() template.HTML {
return `<div class="card-footer"><a class="card-footer-item" href="https://www.metaweather.com/">Metaweather</a></div>`
}
// Head returns an empty string as no extra head tags
// are required for MetaweatherCard
func (mc *MetaweatherCard) Head() template.HTML {
return ""
}
// convert converts a value to a unit specified in the config, and then formats it
func convert(val float64, suffix string, defaultUnit units.Unit) string {
// Find unit in config
unit, err := units.Find(viper.GetString("search.cards.weather.units." + suffix))
if err != nil {
unit = defaultUnit
}
// Convert value to specified unit
newVal, err := units.ConvertFloat(val, defaultUnit, unit)
opts := units.FmtOptions{
Label: true,
Short: true,
Precision: 2,
}
if err != nil {
return units.NewValue(val, defaultUnit).Fmt(opts)
}
return newVal.Fmt(opts)
}