From e6272e35ee50ea5561bc1b0789349ef8d2313612 Mon Sep 17 00:00:00 2001 From: chand1012 Date: Wed, 15 May 2024 21:02:44 -0400 Subject: [PATCH 1/2] Add initial crypto widget --- internal/assets/templates.go | 1 + internal/assets/templates/crypto.html | 39 +++++++++++ internal/feed/coingecko.go | 99 +++++++++++++++++++++++++++ internal/feed/primitives.go | 20 ++++++ internal/feed/utils.go | 73 ++++++++++++++++++++ internal/widget/crypto.go | 46 +++++++++++++ internal/widget/widget.go | 2 + 7 files changed, 280 insertions(+) create mode 100644 internal/assets/templates/crypto.html create mode 100644 internal/feed/coingecko.go create mode 100644 internal/widget/crypto.go diff --git a/internal/assets/templates.go b/internal/assets/templates.go index b8aa6aed..c329d6c1 100644 --- a/internal/assets/templates.go +++ b/internal/assets/templates.go @@ -25,6 +25,7 @@ var ( VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") StocksTemplate = compileTemplate("stocks.html", "widget-base.html") + CryptoTemplate = compileTemplate("crypto.html", "widget-base.html") RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html") RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html") RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html") diff --git a/internal/assets/templates/crypto.html b/internal/assets/templates/crypto.html new file mode 100644 index 00000000..9a1b66aa --- /dev/null +++ b/internal/assets/templates/crypto.html @@ -0,0 +1,39 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +{{ if ne .Style "dynamic-columns-experimental" }} + +{{ else }} +
+ {{ range .Cryptos }} +
+ {{ template "crypto" . }} +
+ {{ end }} +
+{{ end }} +{{ end }} + +{{ define "crypto" }} +
+ {{ .Symbol }} +
{{ .Name }}
+
+ + + + + + + +
+
{{ printf "%+.2f" .PercentChange }}%
+
{{ .Currency }}{{ .Price | formatPrice }}
+
+{{ end }} diff --git a/internal/feed/coingecko.go b/internal/feed/coingecko.go new file mode 100644 index 00000000..162c3973 --- /dev/null +++ b/internal/feed/coingecko.go @@ -0,0 +1,99 @@ +package feed + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" +) + +type coingeckoMarketChartResponse struct { + Prices [][]float64 `json:"prices"` + MarketCaps [][]float64 `json:"market_caps"` + TotalVolumes [][]float64 `json:"total_volumes"` +} + +func fetchSingle(crypto *Cryptocurrency, days int) error { + if crypto.Days == 0 { + crypto.Days = days + } + + if crypto.Currency == "" { + crypto.Currency = "usd" + } + + if crypto.SymbolLink == "" { + crypto.SymbolLink = fmt.Sprintf("https://www.coingecko.com/en/coins/%s", crypto.ID) + } + + reqURL := fmt.Sprintf("https://api.coingecko.com/api/v3/coins/%s/market_chart?vs_currency=%s&days=%d", crypto.ID, crypto.Currency, crypto.Days) + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return err + } + // perform request + resp, err := defaultClient.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, reqURL) + } + + var response coingeckoMarketChartResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return err + } + + if len(response.Prices) == 0 { + return fmt.Errorf("no data in response for %s", reqURL) + } + + crypto.Price = response.Prices[len(response.Prices)-1][1] + + // calculate the percent change + crypto.PercentChange = percentChange(crypto.Price, response.Prices[0][1]) + + var prices []float64 + for _, price := range response.Prices { + prices = append(prices, price[1]) + } + + crypto.SvgChartPoints = SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) + + // finally convert the currency into the proper symbol + crypto.Currency = currencyCodeToSymbol(crypto.Currency) + return nil +} + +func FetchCryptoDataFromCoinGecko(cryptos Cryptocurrencies, days int) (Cryptocurrencies, error) { + // truncate down to 30 cryptos + // this is to prevent the rate limit from being hit + // as the free tier of the CoinGecko API only allows 30 requests per minute + + if len(cryptos) > 30 { + cryptos = cryptos[:30] + } + + var wg sync.WaitGroup + wg.Add(len(cryptos)) + + for i := range cryptos { + go func(crypto *Cryptocurrency) { + defer wg.Done() + err := fetchSingle(crypto, days) + if err != nil { + slog.Error("Failed to fetch crypto data", "error", err) + } + }(&cryptos[i]) + } + + wg.Wait() + + return cryptos, nil +} diff --git a/internal/feed/primitives.go b/internal/feed/primitives.go index 7982360e..e133628b 100644 --- a/internal/feed/primitives.go +++ b/internal/feed/primitives.go @@ -209,3 +209,23 @@ func (v Videos) SortByNewest() Videos { return v } + +type Cryptocurrency struct { + Name string `yaml:"name"` + ID string `yaml:"id"` // see https://api.coingecko.com/api/v3/coins/list for a list of ids + Currency string `yaml:"currency"` // Currency is the currency to convert the price to + Days int `yaml:"days"` // Days is the number of days to fetch the price for. If 0, use the stock's default of 21 days + SymbolLink string `yaml:"symbol-link"` // SymbolLink is the link to the symbol. Defaults to CoinGecko + Symbol string `yaml:"symbol"` // Symbol is for display + Price float64 `yaml:"-"` + PercentChange float64 `yaml:"-"` + SvgChartPoints string `yaml:"-"` +} + +type Cryptocurrencies []Cryptocurrency + +func (t Cryptocurrencies) SortByPercentChange() { + sort.Slice(t, func(i, j int) bool { + return t[i].PercentChange > t[j].PercentChange + }) +} diff --git a/internal/feed/utils.go b/internal/feed/utils.go index dcf044a7..4b9a0a3f 100644 --- a/internal/feed/utils.go +++ b/internal/feed/utils.go @@ -77,3 +77,76 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T { return values } + +func currencyCodeToSymbol(code string) string { + currencySymbols := map[string]string{ + "btc": "₿", + "eth": "Ξ", + "ltc": "Ł", + "bch": "₡", + "bnb": "Ƀ", + "eos": "", // no symbol + "xrp": "", // no symbol + "xlm": "", // no symbol + "link": "", // no symbol + "dot": "", // no symbol + "yfi": "", // no symbol + "usd": "$", + "aed": "د.إ", + "ars": "$", + "aud": "AU$", + "bdt": "৳", + "bhd": ".د.ب", + "bmd": "$", + "brl": "R$", + "cad": "CA$", + "chf": "CHF", + "clp": "$", + "cny": "¥", + "czk": "Kč", + "dkk": "kr", + "eur": "€", + "gbp": "£", + "gel": "ლ", + "hkd": "HK$", + "huf": "Ft", + "idr": "Rp", + "ils": "₪", + "inr": "₹", + "jpy": "¥", + "krw": "₩", + "kwd": "د.ك", + "lkr": "Rs", + "mmk": "K", + "mxn": "$", + "myr": "RM", + "ngn": "₦", + "nok": "kr", + "nzd": "NZ$", + "php": "₱", + "pkr": "Rs", + "pln": "zł", + "rub": "₽", + "sar": "ر.س", + "sek": "kr", + "sgd": "SGD", + "thb": "฿", + "try": "₺", + "twd": "NT$", + "uah": "₴", + "vef": "Bs", + "vnd": "₫", + "zar": "R", + "xdr": "", // no symbol + "xag": "", // no symbol + "xau": "", // no symbol + "bits": "", // no symbol + "sats": "", // no symbol + } + + symbol, ok := currencySymbols[code] + if !ok { + symbol = strings.ToUpper(code) + " " + } + return symbol +} diff --git a/internal/widget/crypto.go b/internal/widget/crypto.go new file mode 100644 index 00000000..1066cf64 --- /dev/null +++ b/internal/widget/crypto.go @@ -0,0 +1,46 @@ +package widget + +import ( + "context" + "html/template" + "time" + + "github.com/glanceapp/glance/internal/assets" + "github.com/glanceapp/glance/internal/feed" +) + +type Crypto struct { + widgetBase `yaml:",inline"` + Cryptos feed.Cryptocurrencies `yaml:"cryptos"` + Sort string `yaml:"sort-by"` + Style string `yaml:"style"` + Days int `yaml:"days"` +} + +func (widget *Crypto) Initialize() error { + widget.withTitle("Crypto").withCacheDuration(time.Hour) + + if widget.Days == 0 { + widget.Days = 1 + } + + return nil +} + +func (widget *Crypto) Update(ctx context.Context) { + cryptos, err := feed.FetchCryptoDataFromCoinGecko(widget.Cryptos, widget.Days) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.Sort == "percent-change" { + cryptos.SortByPercentChange() + } + + widget.Cryptos = cryptos +} + +func (widget *Crypto) Render() template.HTML { + return widget.render(widget, assets.CryptoTemplate) +} diff --git a/internal/widget/widget.go b/internal/widget/widget.go index 3707b7ea..e73529f8 100644 --- a/internal/widget/widget.go +++ b/internal/widget/widget.go @@ -33,6 +33,8 @@ func New(widgetType string) (Widget, error) { return &Videos{}, nil case "stocks": return &Stocks{}, nil + case "crypto": + return &Crypto{}, nil case "reddit": return &Reddit{}, nil case "rss": From 93af94ff59721f5591b238cccf78427979351aea Mon Sep 17 00:00:00 2001 From: chand1012 Date: Wed, 15 May 2024 21:17:03 -0400 Subject: [PATCH 2/2] Add documentation for crypto widget --- README.md | 1 + docs/configuration.md | 66 ++++++++++++++++++++++++++ docs/images/crypto-widget-preview.png | Bin 0 -> 76262 bytes 3 files changed, 67 insertions(+) create mode 100644 docs/images/crypto-widget-preview.png diff --git a/README.md b/README.md index 715c8e57..d13a4ae3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ * Latest YouTube videos from specific channels * Calendar * Stocks +* Cryptocurrencies * iframe * Twitch channels & top games * GitHub releases diff --git a/docs/configuration.md b/docs/configuration.md index e698fdd7..6ff261ad 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1038,6 +1038,72 @@ The link to go to when clicking on the symbol. `chart-link` The link to go to when clicking on the chart. +### Crypto +Display a list of cryptocurrencies, their current value, change for the day and a small single day chart. Data is taken from CoinGecko. + +Example: +```yaml +- type: crypto + cryptos: + - name: Bitcoin + symbol: BTC + id: bitcoin + - name: Ethereum + symbol: ETH + id: ethereum + - name: Avalanche + symbol: AVAX + id: avalanche-2 + - name: Solana + symbol: SOL + id: solana + - name: Dogecoin + symbol: DOGE + id: dogecoin +``` + +Preview: +![](images/crypto-widget-preview.png) + +#### Properties +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| cryptos | array | yes | | +| style | string | no | | +| sort-by | string | no | | +| days | string | no | 1 | + +#### Properties for each cryptocurrency +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| symbol | string | yes | | +| name | string | yes | | +| id | string | yes | | +| currency | string | no | usd | +| symbol-link | string | no | | +| days | string | no | | + +##### `cryptos` +An array of cryptocurrencies for which to display information about. + +##### `currency` +The currency in which to display the value of the cryptocurrency. Possible values are `usd`, `eur`, `gbp`, `jpy`, `cny`, `krw`, `inr`, `rub`, `brl`, `cad`, `aud`, `chf`, `hkd`, `twd`, `sgd`, `thb`, `idr`, `myr`, `php`, `zar`, `sek`, `nok`, `dkk`, `pln`, `huf`, `czk`, `ils`, `try`, `clp`, `mxn`, `php`, `cop`, `ars`, `pen`, `vef`, `ngn`, `kes`, `egp`, `aed`, `sar`, `qar`, `omr`, `kwd`, `bhd`, `jod`, `ils`, `lbp`, `jmd`, `tt`, `ttd`, `bsd`, `gyd`, `htg`, `npr`, `lkr`, `mvr`, `mur`, `scr`, `xaf`, `xof`, `xpf`, `xdr`, `xag`, `xau`, `bits`, `sats`. + +##### `symbol-link` +The link to go to when clicking on the symbol. Defaults to the CoinGecko page for the cryptocurrency. + +##### `days` +Number of days to show in the chart. + +##### `symbol` +The symbol of the cryptocurrency. + +##### `name` +The name of the cryptocurrency. + +##### `id` +The ID of the cryptocurrency as seen on CoinGecko. + ### Twitch Channels Display a list of channels from Twitch. diff --git a/docs/images/crypto-widget-preview.png b/docs/images/crypto-widget-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..4128f0cf2474ae9917bcde2fba1925a53e7c1bc8 GIT binary patch literal 76262 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