Initial Commit

This commit is contained in:
2021-12-08 09:24:05 -08:00
commit c8bec472be
22 changed files with 3305 additions and 0 deletions

156
internal/cards/calc.go Normal file
View File

@@ -0,0 +1,156 @@
package cards
import (
"fmt"
"html/template"
"regexp"
"strings"
)
const calcExtraHead = `
<link rel="stylesheet" href="/static/katex.min.css">
<script defer src="/static/katex.min.js"></script>
<script src="/static/nerdamer.core.js"></script>
<script src="/static/Algebra.js"></script>
<script src="/static/Calculus.js"></script>
<script src="/static/Solve.js"></script>`
const solveRenderScript = `
<div id="calc-content" class="subtitle mx-2 my-0"></div>
<script>
window.onload = () => {
latex = nerdamer.convertToLaTeX(nerdamer('%s').toString())
katex.render(latex, document.getElementById('calc-content'))
}
</script>`
func init() {
// Register calc card
Register("calc", 0, NewCalcCard)
}
// CalcCard represents a calculation card
type CalcCard struct {
query string
useFunc bool
function string
args []string
expression string
}
// NewCalcCard is a NewCardFunc that creates a new CalcCard
func NewCalcCard(query, _ string) Card {
return &CalcCard{query: query, useFunc: true}
}
func (cc *CalcCard) Matches() bool {
// If query has solve prefix
if strings.HasPrefix(cc.query, "solve") {
// Set function to solve
cc.function = "solve"
// Compile regex for specifying veriable to solve for
forRgx := regexp.MustCompile(`solve (.+) for (.+)`)
// If query matches regex
if forRgx.MatchString(cc.query) {
// Find furst regex match
matches := forRgx.FindStringSubmatch(cc.query)
// Append function aeguments. First is equation, second variable
cc.args = append(cc.args, matches[1], matches[2])
} else {
// Append function arguments assuming variable is x
cc.args = append(cc.args, cc.StripKey(), "x")
}
return true
}
// If query has integrate prefix
if strings.HasPrefix(cc.query, "integrate") ||
strings.HasPrefix(cc.query, "integral of") ||
strings.HasPrefix(cc.query, "integral") {
// Set function to integrate
cc.function = "integrate"
// Append function arguments
cc.args = append(cc.args, cc.StripKey())
return true
}
// If query has derivative prefix
if strings.HasPrefix(cc.query, "diff") ||
strings.HasPrefix(cc.query, "derive") ||
strings.HasPrefix(cc.query, "differentiate") ||
strings.HasPrefix(cc.query, "derivative of") ||
strings.HasPrefix(cc.query, "derivative") {
// Set function to diff
cc.function = "diff"
// Append function arguments
cc.args = append(cc.args, cc.StripKey())
return true
}
// If query has calculate prefix
if strings.HasPrefix(cc.query, "calculate") {
// This is an expression, so no function needed
cc.useFunc = false
// Set expression to query stripped of keys
cc.expression = cc.StripKey()
return true
}
return false
}
func (cc *CalcCard) StripKey() string {
// Compile regex for words to be removed
trimRgx := regexp.MustCompile(`^(.*?)solve|integrate|integral(?: of)?|diff|differentiate|derivative(?: of)?|derive|calculate(.*)$`)
// Return string with words removed
return trimRgx.ReplaceAllString(cc.query, "${1}${2}")
}
func (cc *CalcCard) Content() template.HTML {
var input string
// If function is being used
if cc.useFunc {
// Set input to formatted function
input = formatFunc(cc.function, cc.args)
} else {
// Set input to expression
input = cc.expression
}
// Return script with given input
return template.HTML(fmt.Sprintf(
solveRenderScript,
input,
))
}
func (cc *CalcCard) Footer() template.HTML {
return ""
}
func (cc *CalcCard) Returned() bool {
return true
}
func (cc *CalcCard) RunQuery() error {
return nil
}
func (cc *CalcCard) Title() string {
return "Calculation"
}
func (cc *CalcCard) Head() template.HTML {
return calcExtraHead
}
func formatFunc(function string, args []string) string {
// Format as function(arg1,arg2...)
return function + "(" + strings.Join(args, ",") + ")"
}

88
internal/cards/cards.go Normal file
View File

@@ -0,0 +1,88 @@
package cards
import (
"errors"
"html/template"
"sort"
)
var ErrCardRegistered = errors.New("card with that name has already been registered")
// cardRegistration represents a card that has been registered
type cardRegistration struct {
name string
priority int
newFn NewCardFunc
}
// cards stores all registered cards
var cards = []cardRegistration{}
// NewCardFunc creates and returns a new card.
// This should not be an expensive operation
type NewCardFunc func(query, userAgent string) Card
// Card represents a search result card
type Card interface {
// RunQuery runs any HTTP or other requests
RunQuery() error
// Returned returns whether the query
// returned any information
Returned() bool
// Matches returns whether the query matches
// for the card
Matches() bool
// StripKey removes the key words and returns
// the updated query
StripKey() string
// Title returns the card title
Title() string
// Content returns the contents of the card
Content() template.HTML
// Footer returns the contents of the card footer
Footer() template.HTML
// Head returns any extras to include in <head>
Head() template.HTML
}
// Register adds a new card to the library
func Register(name string, priority int, fn NewCardFunc) {
// For every registered card
for _, cardReg := range cards {
// If priority already exists, increase
if cardReg.priority == priority {
priority++
}
// If card already registered, return
if cardReg.name == name {
return
}
}
// Add card to slice
cards = append(cards, cardRegistration{name, priority, fn})
}
// GetCard returns a matching registered card
func GetCard(query, userAgent string) Card {
// Sort slice by priority
sort.Slice(cards, func(i, j int) bool {
return cards[i].priority < cards[j].priority
})
// For every registered card
for _, cardReg := range cards {
// Create new card
card := cardReg.newFn(query, userAgent)
// If card matches, return it
if card.Matches() {
return card
}
}
return nil
}

115
internal/cards/ddg.go Normal file
View File

@@ -0,0 +1,115 @@
package cards
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"time"
)
func init() {
Register("ddg", 3, NewDDGCard)
}
// ddgAttribution is HTML for attribution of DuckDuckGo at their request
const ddgAttribution = `<p class="is-size-7 pt-2">
<span class="iconify" data-icon="simple-icons:duckduckgo" data-inline="true"></span>&nbsp;
Results from
<a href="https://duckduckgo.com/%s">DuckDuckGo &raquo;</a>
</p>`
// DDGCard represents a DuckDuckGo Instant Answer API card
type DDGCard struct {
query string
userAgent string
resp DDGInstAns
}
// DDGInstAns represents a DuckDuckGo Instant Answer API response
type DDGInstAns struct {
Abstract string
AbstractText string
AbstractSource string
AbstractURL string
Image string
Heading string
Answer string
AnswerType string
}
// NewDDGCard isa NewCardFunc that creates a new DDGCard
func NewDDGCard(query, userAgent string) Card {
return &DDGCard{
query: url.QueryEscape(query),
userAgent: userAgent,
}
}
// RunQuery requests the query from the instant answer API
func (ddg *DDGCard) RunQuery() error {
http.DefaultClient.Timeout = 5 * time.Second
// Create new API request
req, err := http.NewRequest(
http.MethodGet,
"https://api.duckduckgo.com/?q="+ddg.query+"&format=json",
nil,
)
if err != nil {
return err
}
req.Header.Set("User-Agent", ddg.userAgent)
// Perform request
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
// Decode response into repsonse struct
err = json.NewDecoder(res.Body).Decode(&ddg.resp)
if err != nil {
return err
}
return nil
}
func (ddg *DDGCard) Returned() bool {
// Value was returned if abstract is not empty
return ddg.resp.Abstract != ""
}
func (ddg *DDGCard) Matches() bool {
// Everything matches since there are no keys
return true
}
func (ddg *DDGCard) StripKey() string {
// No key to strip, so return query
return ddg.query
}
func (ddg *DDGCard) Title() string {
return ddg.resp.Heading
}
func (ddg *DDGCard) Content() template.HTML {
// Return abstract with attribution
return template.HTML(ddg.resp.Abstract + fmt.Sprintf(ddgAttribution, ddg.query))
}
func (ddg *DDGCard) Footer() template.HTML {
// Return footer with abstract url and source
return template.HTML(fmt.Sprintf(
`<div class="card-footer"><a class="card-footer-item" href="%s">%s</a></div>`,
ddg.resp.AbstractURL,
ddg.resp.AbstractSource,
))
}
func (ddg *DDGCard) Head() template.HTML {
return ""
}

View File

@@ -0,0 +1,247 @@
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>Wind Direction:</b> %s</p>
<p><b>Location:</b> %s</p>
<p><b>Timezone:</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
}
func (mc *MetaweatherCard) Returned() bool {
return len(mc.location) > 0 && mc.resp.Detail != "Not found."
}
func (mc *MetaweatherCard) Matches() bool {
return strings.HasPrefix(mc.query, "weather in") ||
strings.HasPrefix(mc.query, "weather") ||
strings.HasSuffix(mc.query, "weather")
}
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)
}
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 compass wind direction
mc.resp.Consolidated[0].WindCompass,
// Write title
mc.resp.Title,
// Write timezone
mc.resp.Timezone,
))
}
func (mc *MetaweatherCard) Title() string {
return "Weather"
}
func (mc *MetaweatherCard) Footer() template.HTML {
// Return footer with link to metaweather
return `<div class="card-footer"><a class="card-footer-item" href="https://www.metaweather.com/">Metaweather</a></div>`
}
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)
}

89
internal/cards/plot.go Normal file
View File

@@ -0,0 +1,89 @@
package cards
import (
"fmt"
"html/template"
"strings"
)
const plotExtraHead = `
<script src="/static/function-plot.js"></script>
<style>
.top-right-legend {
display: none;
}
</style>`
const plotScript = `
<div id="plot-content" class="container"></div>
<script>
plotFn = () => functionPlot({
target: '#plot-content',
grid: true,
width: document.getElementById('plot-content').offsetWidth,
data: [{
fn: '%s'
}]
})
new ResizeObserver(plotFn).observe(document.getElementById('plot-content'))
</script>`
func init() {
// Register plot card
Register("plot", 2, NewPlotCard)
}
// PlotCard represents a card with an equation plot
type PlotCard struct {
query string
}
// NewPlotCard is a NewCardFunc that creates a new PlotCard
func NewPlotCard(query, _ string) Card {
return &PlotCard{query: query}
}
func (pc *PlotCard) Matches() bool {
return strings.HasPrefix(pc.query, "plot") ||
strings.HasPrefix(pc.query, "graph") ||
strings.HasPrefix(pc.query, "draw")
}
func (pc *PlotCard) StripKey() string {
query := strings.TrimPrefix(pc.query, "plot")
query = strings.TrimPrefix(query, "graph")
query = strings.TrimPrefix(query, "draw")
return strings.TrimSpace(query)
}
func (pc *PlotCard) Content() template.HTML {
return template.HTML(fmt.Sprintf(
plotScript,
pc.StripKey(),
))
}
// Since this card is frontend, this cannot be checked.
// Therefore, it will always return true.
func (pc *PlotCard) Returned() bool {
return true
}
func (pc *PlotCard) Title() string {
return "Plot (" + pc.StripKey() + ")"
}
func (pc *PlotCard) Head() template.HTML {
return plotExtraHead
}
func (pc *PlotCard) Footer() template.HTML {
return ""
}
func (pc *PlotCard) RunQuery() error {
return nil
}