Initial commit
This commit is contained in:
31
internal/widget/bookmarks.go
Normal file
31
internal/widget/bookmarks.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type Bookmarks struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
Groups []struct {
|
||||
Title string `yaml:"title"`
|
||||
Color *HSLColorField `yaml:"color"`
|
||||
Links []struct {
|
||||
Title string `yaml:"title"`
|
||||
URL string `yaml:"url"`
|
||||
} `yaml:"links"`
|
||||
} `yaml:"groups"`
|
||||
}
|
||||
|
||||
func (widget *Bookmarks) Initialize() error {
|
||||
widget.withTitle("Bookmarks").withError(nil)
|
||||
widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Bookmarks) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
||||
30
internal/widget/calendar.go
Normal file
30
internal/widget/calendar.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Calendar struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Calendar *feed.Calendar
|
||||
}
|
||||
|
||||
func (widget *Calendar) Initialize() error {
|
||||
widget.withTitle("Calendar").withCacheOnTheHour()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Calendar) Update(ctx context.Context) {
|
||||
widget.Calendar = feed.NewCalendar(time.Now())
|
||||
widget.withError(nil).scheduleNextUpdate()
|
||||
}
|
||||
|
||||
func (widget *Calendar) Render() template.HTML {
|
||||
return widget.render(widget, assets.CalendarTemplate)
|
||||
}
|
||||
152
internal/widget/fields.go
Normal file
152
internal/widget/fields.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
||||
var EnvFieldPattern = regexp.MustCompile(`^\${([A-Z_]+)}$`)
|
||||
|
||||
const (
|
||||
HSLHueMax = 360
|
||||
HSLSaturationMax = 100
|
||||
HSLLightnessMax = 100
|
||||
)
|
||||
|
||||
type HSLColorField struct {
|
||||
Hue uint16
|
||||
Saturation uint8
|
||||
Lightness uint8
|
||||
}
|
||||
|
||||
func (c *HSLColorField) String() string {
|
||||
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
|
||||
}
|
||||
|
||||
func (c *HSLColorField) AsCSSValue() template.CSS {
|
||||
return template.CSS(c.String())
|
||||
}
|
||||
|
||||
func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := HSLColorPattern.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 4 {
|
||||
return fmt.Errorf("invalid HSL color format: %s", value)
|
||||
}
|
||||
|
||||
hue, err := strconv.ParseUint(matches[1], 10, 16)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hue > HSLHueMax {
|
||||
return fmt.Errorf("HSL hue must be between 0 and %d", HSLHueMax)
|
||||
}
|
||||
|
||||
saturation, err := strconv.ParseUint(matches[2], 10, 8)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if saturation > HSLSaturationMax {
|
||||
return fmt.Errorf("HSL saturation must be between 0 and %d", HSLSaturationMax)
|
||||
}
|
||||
|
||||
lightness, err := strconv.ParseUint(matches[3], 10, 8)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if lightness > HSLLightnessMax {
|
||||
return fmt.Errorf("HSL lightness must be between 0 and %d", HSLLightnessMax)
|
||||
}
|
||||
|
||||
c.Hue = uint16(hue)
|
||||
c.Saturation = uint8(saturation)
|
||||
c.Lightness = uint8(lightness)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var DurationPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
|
||||
|
||||
type DurationField time.Duration
|
||||
|
||||
func (d *DurationField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := DurationPattern.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 3 {
|
||||
return fmt.Errorf("invalid duration format: %s", value)
|
||||
}
|
||||
|
||||
duration, err := strconv.Atoi(matches[1])
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch matches[2] {
|
||||
case "s":
|
||||
*d = DurationField(time.Duration(duration) * time.Second)
|
||||
case "m":
|
||||
*d = DurationField(time.Duration(duration) * time.Minute)
|
||||
case "h":
|
||||
*d = DurationField(time.Duration(duration) * time.Hour)
|
||||
case "d":
|
||||
*d = DurationField(time.Duration(duration) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type OptionalEnvString string
|
||||
|
||||
func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
|
||||
err := node.Decode(&value)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := EnvFieldPattern.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 2 {
|
||||
*f = OptionalEnvString(value)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
value, found := os.LookupEnv(matches[1])
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("environment variable %s not found", matches[1])
|
||||
}
|
||||
|
||||
*f = OptionalEnvString(value)
|
||||
|
||||
return nil
|
||||
}
|
||||
52
internal/widget/hacker-news.go
Normal file
52
internal/widget/hacker-news.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type HackerNews struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Posts feed.ForumPosts `yaml:"-"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *HackerNews) Initialize() error {
|
||||
widget.withTitle("Hacker News").withCacheDuration(30 * time.Minute)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 15
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *HackerNews) Update(ctx context.Context) {
|
||||
posts, err := feed.FetchHackerNewsTopPosts(40)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
posts.CalculateEngagement()
|
||||
posts.SortByEngagement()
|
||||
|
||||
if widget.Limit < len(posts) {
|
||||
posts = posts[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Posts = posts
|
||||
}
|
||||
|
||||
func (widget *HackerNews) Render() template.HTML {
|
||||
return widget.render(widget, assets.ForumPostsTemplate)
|
||||
}
|
||||
45
internal/widget/iframe.go
Normal file
45
internal/widget/iframe.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type IFrame struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
Source string `yaml:"source"`
|
||||
Height int `yaml:"height"`
|
||||
}
|
||||
|
||||
func (widget *IFrame) Initialize() error {
|
||||
widget.withTitle("IFrame").withError(nil)
|
||||
|
||||
if widget.Source == "" {
|
||||
return errors.New("missing source for iframe")
|
||||
}
|
||||
|
||||
_, err := url.Parse(widget.Source)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid source for iframe: %v", err)
|
||||
}
|
||||
|
||||
if widget.Height == 50 {
|
||||
widget.Height = 300
|
||||
} else if widget.Height < 50 {
|
||||
widget.Height = 50
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.render(widget, assets.IFrameTemplate)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *IFrame) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
||||
100
internal/widget/monitor.go
Normal file
100
internal/widget/monitor.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
func statusCodeToText(status int) string {
|
||||
if status == 200 {
|
||||
return "OK"
|
||||
}
|
||||
if status == 404 {
|
||||
return "Not Found"
|
||||
}
|
||||
if status == 403 {
|
||||
return "Forbidden"
|
||||
}
|
||||
if status == 401 {
|
||||
return "Unauthorized"
|
||||
}
|
||||
if status >= 400 {
|
||||
return "Client Error"
|
||||
}
|
||||
if status >= 500 {
|
||||
return "Server Error"
|
||||
}
|
||||
|
||||
return strconv.Itoa(status)
|
||||
}
|
||||
|
||||
func statusCodeToStyle(status int) string {
|
||||
if status == 200 {
|
||||
return "good"
|
||||
}
|
||||
|
||||
return "bad"
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Sites []struct {
|
||||
Title string `yaml:"title"`
|
||||
Url string `yaml:"url"`
|
||||
IconUrl string `yaml:"icon"`
|
||||
Status *feed.SiteStatus `yaml:"-"`
|
||||
StatusText string `yaml:"-"`
|
||||
StatusStyle string `yaml:"-"`
|
||||
} `yaml:"sites"`
|
||||
}
|
||||
|
||||
func (widget *Monitor) Initialize() error {
|
||||
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Monitor) Update(ctx context.Context) {
|
||||
requests := make([]*http.Request, len(widget.Sites))
|
||||
|
||||
for i := range widget.Sites {
|
||||
request, err := http.NewRequest("HEAD", widget.Sites[i].Url, nil)
|
||||
|
||||
if err != nil {
|
||||
message := fmt.Errorf("failed to create http request for %s: %s", widget.Sites[i].Url, err)
|
||||
widget.withNotice(message)
|
||||
continue
|
||||
}
|
||||
|
||||
requests[i] = request
|
||||
}
|
||||
|
||||
statuses, err := feed.FetchStatusesForRequests(requests)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range widget.Sites {
|
||||
site := &widget.Sites[i]
|
||||
status := &statuses[i]
|
||||
|
||||
site.Status = status
|
||||
|
||||
if !status.TimedOut {
|
||||
site.StatusText = statusCodeToText(status.Code)
|
||||
site.StatusStyle = statusCodeToStyle(status.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (widget *Monitor) Render() template.HTML {
|
||||
return widget.render(widget, assets.MonitorTemplate)
|
||||
}
|
||||
66
internal/widget/reddit.go
Normal file
66
internal/widget/reddit.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Reddit struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Posts feed.ForumPosts `yaml:"-"`
|
||||
Subreddit string `yaml:"subreddit"`
|
||||
Style string `yaml:"style"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *Reddit) Initialize() error {
|
||||
if widget.Subreddit == "" {
|
||||
return errors.New("no subreddit specified")
|
||||
}
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 15
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Reddit) Update(ctx context.Context) {
|
||||
posts, err := feed.FetchSubredditPosts(widget.Subreddit)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(posts) > widget.Limit {
|
||||
posts = posts[:widget.Limit]
|
||||
}
|
||||
|
||||
posts.SortByEngagement()
|
||||
widget.Posts = posts
|
||||
}
|
||||
|
||||
func (widget *Reddit) Render() template.HTML {
|
||||
if widget.Style == "horizontal-cards" {
|
||||
return widget.render(widget, assets.RedditCardsHorizontalTemplate)
|
||||
}
|
||||
|
||||
if widget.Style == "vertical-cards" {
|
||||
return widget.render(widget, assets.RedditCardsVerticalTemplate)
|
||||
}
|
||||
|
||||
return widget.render(widget, assets.ForumPostsTemplate)
|
||||
|
||||
}
|
||||
51
internal/widget/releases.go
Normal file
51
internal/widget/releases.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Releases struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Releases feed.AppReleases `yaml:"-"`
|
||||
Repositories []string `yaml:"repositories"`
|
||||
Token OptionalEnvString `yaml:"token"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *Releases) Initialize() error {
|
||||
widget.withTitle("Releases").withCacheDuration(2 * time.Hour)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 10
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Releases) Update(ctx context.Context) {
|
||||
releases, err := feed.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token))
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(releases) > widget.Limit {
|
||||
releases = releases[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Releases = releases
|
||||
}
|
||||
|
||||
func (widget *Releases) Render() template.HTML {
|
||||
return widget.render(widget, assets.ReleasesTemplate)
|
||||
}
|
||||
55
internal/widget/rss.go
Normal file
55
internal/widget/rss.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type RSS struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
FeedRequests []feed.RSSFeedRequest `yaml:"feeds"`
|
||||
Style string `yaml:"style"`
|
||||
Items feed.RSSFeedItems `yaml:"-"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *RSS) Initialize() error {
|
||||
widget.withTitle("RSS Feed").withCacheDuration(1 * time.Hour)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 25
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *RSS) Update(ctx context.Context) {
|
||||
items, err := feed.GetItemsFromRSSFeeds(widget.FeedRequests)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) > widget.Limit {
|
||||
items = items[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Items = items
|
||||
}
|
||||
|
||||
func (widget *RSS) Render() template.HTML {
|
||||
if widget.Style == "horizontal-cards" {
|
||||
return widget.render(widget, assets.RSSCardsTemplate)
|
||||
}
|
||||
|
||||
return widget.render(widget, assets.RSSListTemplate)
|
||||
}
|
||||
37
internal/widget/stocks.go
Normal file
37
internal/widget/stocks.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Stocks struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Stocks feed.Stocks `yaml:"-"`
|
||||
Tickers []feed.StockRequest `yaml:"stocks"`
|
||||
}
|
||||
|
||||
func (widget *Stocks) Initialize() error {
|
||||
widget.withTitle("Stocks").withCacheDuration(time.Hour)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Stocks) Update(ctx context.Context) {
|
||||
stocks, err := feed.FetchStocksDataFromYahoo(widget.Tickers)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
stocks.SortByAbsChange()
|
||||
widget.Stocks = stocks
|
||||
}
|
||||
|
||||
func (widget *Stocks) Render() template.HTML {
|
||||
return widget.render(widget, assets.StocksTemplate)
|
||||
}
|
||||
42
internal/widget/twitch-channels.go
Normal file
42
internal/widget/twitch-channels.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type TwitchChannels struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
ChannelsRequest []string `yaml:"channels"`
|
||||
Channels []feed.TwitchChannel `yaml:"-"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Initialize() error {
|
||||
widget.withTitle("Twitch Channels").withCacheDuration(time.Minute * 10)
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Update(ctx context.Context) {
|
||||
channels, err := feed.FetchChannelsFromTwitch(widget.ChannelsRequest)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
channels.SortByViewers()
|
||||
widget.Channels = channels
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Render() template.HTML {
|
||||
return widget.render(widget, assets.TwitchChannelsTemplate)
|
||||
}
|
||||
46
internal/widget/twitch-top-games.go
Normal file
46
internal/widget/twitch-top-games.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type TwitchGames struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Categories []feed.TwitchCategory `yaml:"-"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *TwitchGames) Initialize() error {
|
||||
widget.withTitle("Top games on Twitch").withCacheDuration(time.Minute * 10)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 10
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *TwitchGames) Update(ctx context.Context) {
|
||||
categories, err := feed.FetchTopGamesFromTwitch(widget.Exclude, widget.Limit)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.Categories = categories
|
||||
}
|
||||
|
||||
func (widget *TwitchGames) Render() template.HTML {
|
||||
return widget.render(widget, assets.TwitchGamesListTemplate)
|
||||
}
|
||||
45
internal/widget/videos.go
Normal file
45
internal/widget/videos.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Videos struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Videos feed.Videos `yaml:"-"`
|
||||
Channels []string `yaml:"channels"`
|
||||
Limit int `yaml:"limit"`
|
||||
}
|
||||
|
||||
func (widget *Videos) Initialize() error {
|
||||
widget.withTitle("Videos").withCacheDuration(time.Hour)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 25
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Videos) Update(ctx context.Context) {
|
||||
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(videos) > widget.Limit {
|
||||
videos = videos[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Videos = videos
|
||||
}
|
||||
|
||||
func (widget *Videos) Render() template.HTML {
|
||||
return widget.render(widget, assets.VideosTemplate)
|
||||
}
|
||||
50
internal/widget/weather.go
Normal file
50
internal/widget/weather.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Weather struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Location string `yaml:"location"`
|
||||
HideLocation bool `yaml:"hide-location"`
|
||||
Place *feed.PlaceJson `yaml:"-"`
|
||||
Weather *feed.Weather `yaml:"-"`
|
||||
TimeLabels [12]string `yaml:"-"`
|
||||
}
|
||||
|
||||
var timeLabels = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
|
||||
|
||||
func (widget *Weather) Initialize() error {
|
||||
widget.withTitle("Weather").withCacheOnTheHour()
|
||||
widget.TimeLabels = timeLabels
|
||||
|
||||
place, err := feed.FetchPlaceFromName(widget.Location)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching data for %s: %v", widget.Location, err)
|
||||
}
|
||||
|
||||
widget.Place = place
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Weather) Update(ctx context.Context) {
|
||||
weather, err := feed.FetchWeatherForPlace(widget.Place)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.Weather = weather
|
||||
}
|
||||
|
||||
func (widget *Weather) Render() template.HTML {
|
||||
return widget.render(widget, assets.WeatherTemplate)
|
||||
}
|
||||
282
internal/widget/widget.go
Normal file
282
internal/widget/widget.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func New(widgetType string) (Widget, error) {
|
||||
switch widgetType {
|
||||
case "calendar":
|
||||
return &Calendar{}, nil
|
||||
case "weather":
|
||||
return &Weather{}, nil
|
||||
case "bookmarks":
|
||||
return &Bookmarks{}, nil
|
||||
case "iframe":
|
||||
return &IFrame{}, nil
|
||||
case "hacker-news":
|
||||
return &HackerNews{}, nil
|
||||
case "releases":
|
||||
return &Releases{}, nil
|
||||
case "videos":
|
||||
return &Videos{}, nil
|
||||
case "stocks":
|
||||
return &Stocks{}, nil
|
||||
case "reddit":
|
||||
return &Reddit{}, nil
|
||||
case "rss":
|
||||
return &RSS{}, nil
|
||||
case "monitor":
|
||||
return &Monitor{}, nil
|
||||
case "twitch-top-games":
|
||||
return &TwitchGames{}, nil
|
||||
case "twitch-channels":
|
||||
return &TwitchChannels{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||
}
|
||||
}
|
||||
|
||||
type Widgets []Widget
|
||||
|
||||
func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
|
||||
var nodes []yaml.Node
|
||||
|
||||
if err := node.Decode(&nodes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, node := range nodes {
|
||||
meta := struct {
|
||||
Type string `yaml:"type"`
|
||||
}{}
|
||||
|
||||
if err := node.Decode(&meta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
widget, err := New(meta.Type)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = node.Decode(widget); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = widget.Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*w = append(*w, widget)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Widget interface {
|
||||
Initialize() error
|
||||
RequiresUpdate(*time.Time) bool
|
||||
Update(context.Context)
|
||||
Render() template.HTML
|
||||
GetType() string
|
||||
}
|
||||
|
||||
type cacheType int
|
||||
|
||||
const (
|
||||
cacheTypeInfinite cacheType = iota
|
||||
cacheTypeDuration
|
||||
cacheTypeOnTheHour
|
||||
)
|
||||
|
||||
type widgetBase struct {
|
||||
Type string `yaml:"type"`
|
||||
Title string `yaml:"title"`
|
||||
CustomCacheDuration DurationField `yaml:"cache"`
|
||||
ContentAvailable bool `yaml:"-"`
|
||||
Error error `yaml:"-"`
|
||||
Notice error `yaml:"-"`
|
||||
templateBuffer bytes.Buffer `yaml:"-"`
|
||||
cacheDuration time.Duration `yaml:"-"`
|
||||
cacheType cacheType `yaml:"-"`
|
||||
nextUpdate time.Time `yaml:"-"`
|
||||
updateRetriedTimes int `yaml:"-"`
|
||||
}
|
||||
|
||||
func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
|
||||
if w.cacheType == cacheTypeInfinite {
|
||||
return false
|
||||
}
|
||||
|
||||
if w.nextUpdate.IsZero() {
|
||||
return true
|
||||
}
|
||||
|
||||
return now.After(w.nextUpdate)
|
||||
}
|
||||
|
||||
func (w *widgetBase) Update(ctx context.Context) {
|
||||
|
||||
}
|
||||
|
||||
func (w *widgetBase) GetType() string {
|
||||
return w.Type
|
||||
}
|
||||
|
||||
func (w *widgetBase) render(data any, t *template.Template) template.HTML {
|
||||
w.templateBuffer.Reset()
|
||||
err := t.Execute(&w.templateBuffer, data)
|
||||
|
||||
if err != nil {
|
||||
w.ContentAvailable = false
|
||||
w.Error = err
|
||||
|
||||
slog.Error("failed to render template", "error", err)
|
||||
|
||||
// need to immediately re-render with the error,
|
||||
// otherwise risk breaking the page since the widget
|
||||
// will likely be partially rendered with tags not closed.
|
||||
w.templateBuffer.Reset()
|
||||
err2 := t.Execute(&w.templateBuffer, data)
|
||||
|
||||
if err2 != nil {
|
||||
slog.Error("failed to render error within widget", "error", err2, "initial_error", err)
|
||||
w.templateBuffer.Reset()
|
||||
// TODO: add some kind of a generic widget error template when the widget
|
||||
// failed to render, and we also failed to re-render the widget with the error
|
||||
}
|
||||
}
|
||||
|
||||
return template.HTML(w.templateBuffer.String())
|
||||
}
|
||||
|
||||
func (w *widgetBase) withTitle(title string) *widgetBase {
|
||||
if w.Title == "" {
|
||||
w.Title = title
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
|
||||
w.cacheType = cacheTypeDuration
|
||||
|
||||
if duration == -1 || w.CustomCacheDuration == 0 {
|
||||
w.cacheDuration = duration
|
||||
} else {
|
||||
w.cacheDuration = time.Duration(w.CustomCacheDuration)
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *widgetBase) withCacheOnTheHour() *widgetBase {
|
||||
w.cacheType = cacheTypeOnTheHour
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *widgetBase) withNotice(err error) *widgetBase {
|
||||
w.Notice = err
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *widgetBase) withError(err error) *widgetBase {
|
||||
if err == nil && !w.ContentAvailable {
|
||||
w.ContentAvailable = true
|
||||
}
|
||||
|
||||
w.Error = err
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *widgetBase) canContinueUpdateAfterHandlingErr(err error) bool {
|
||||
// TODO: needs covering more edge cases.
|
||||
// if there's partial content and we update early there's a chance
|
||||
// the early update returns even less content than the initial update.
|
||||
// need some kind of mechanism that tells us whether we should update early
|
||||
// or not depending on the number of things that failed during the initial
|
||||
// and subsequent update and how they failed - ie whether it was server
|
||||
// error (like gateway timeout, do retry early) or client error (like
|
||||
// hitting a rate limit, don't retry early). will require reworking a
|
||||
// good amount of code in the feed package and probably having a custom
|
||||
// error type that holds more information because screw wrapping errors.
|
||||
// alternatively have a resource cache and only refetch the failed resources,
|
||||
// then rebuild the widget.
|
||||
|
||||
if err != nil {
|
||||
w.scheduleEarlyUpdate()
|
||||
|
||||
if !errors.Is(err, feed.ErrPartialContent) {
|
||||
w.withError(err)
|
||||
w.withNotice(nil)
|
||||
return false
|
||||
}
|
||||
|
||||
w.withError(nil)
|
||||
w.withNotice(err)
|
||||
return true
|
||||
}
|
||||
|
||||
w.withNotice(nil)
|
||||
w.withError(nil)
|
||||
w.scheduleNextUpdate()
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *widgetBase) getNextUpdateTime() time.Time {
|
||||
now := time.Now()
|
||||
|
||||
if w.cacheType == cacheTypeDuration {
|
||||
return now.Add(w.cacheDuration)
|
||||
}
|
||||
|
||||
if w.cacheType == cacheTypeOnTheHour {
|
||||
return now.Add(time.Duration(
|
||||
((60-now.Minute())*60)-now.Second(),
|
||||
) * time.Second)
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (w *widgetBase) scheduleNextUpdate() *widgetBase {
|
||||
w.nextUpdate = w.getNextUpdateTime()
|
||||
w.updateRetriedTimes = 0
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *widgetBase) scheduleEarlyUpdate() *widgetBase {
|
||||
w.updateRetriedTimes++
|
||||
|
||||
if w.updateRetriedTimes > 5 {
|
||||
w.updateRetriedTimes = 5
|
||||
}
|
||||
|
||||
nextEarlyUpdate := time.Now().Add(time.Duration(math.Pow(float64(w.updateRetriedTimes), 2)) * time.Minute)
|
||||
nextUsualUpdate := w.getNextUpdateTime()
|
||||
|
||||
if nextEarlyUpdate.After(nextUsualUpdate) {
|
||||
w.nextUpdate = nextUsualUpdate
|
||||
} else {
|
||||
w.nextUpdate = nextEarlyUpdate
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
Reference in New Issue
Block a user