glance/internal/widget/widget.go

349 lines
7.4 KiB
Go
Raw Normal View History

2024-04-27 21:10:24 +02:00
package widget
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"math"
2024-08-01 19:26:38 +02:00
"net/http"
"sync/atomic"
2024-04-27 21:10:24 +02:00
"time"
"github.com/glanceapp/glance/internal/feed"
"gopkg.in/yaml.v3"
)
2024-08-01 19:26:38 +02:00
var uniqueID atomic.Uint64
2024-04-27 21:10:24 +02:00
func New(widgetType string) (Widget, error) {
2024-08-01 19:26:38 +02:00
var widget Widget
2024-04-27 21:10:24 +02:00
switch widgetType {
case "calendar":
2024-08-01 19:26:38 +02:00
widget = &Calendar{}
case "clock":
2024-08-01 19:26:38 +02:00
widget = &Clock{}
2024-04-27 21:10:24 +02:00
case "weather":
2024-08-01 19:26:38 +02:00
widget = &Weather{}
2024-04-27 21:10:24 +02:00
case "bookmarks":
2024-08-01 19:26:38 +02:00
widget = &Bookmarks{}
2024-04-27 21:10:24 +02:00
case "iframe":
2024-08-01 19:26:38 +02:00
widget = &IFrame{}
2024-06-02 20:01:20 +02:00
case "html":
2024-08-01 19:26:38 +02:00
widget = &HTML{}
2024-04-27 21:10:24 +02:00
case "hacker-news":
2024-08-01 19:26:38 +02:00
widget = &HackerNews{}
2024-04-27 21:10:24 +02:00
case "releases":
2024-08-01 19:26:38 +02:00
widget = &Releases{}
2024-04-27 21:10:24 +02:00
case "videos":
2024-08-01 19:26:38 +02:00
widget = &Videos{}
case "markets", "stocks":
2024-08-01 19:26:38 +02:00
widget = &Markets{}
2024-04-27 21:10:24 +02:00
case "reddit":
2024-08-01 19:26:38 +02:00
widget = &Reddit{}
2024-04-27 21:10:24 +02:00
case "rss":
2024-08-01 19:26:38 +02:00
widget = &RSS{}
2024-04-27 21:10:24 +02:00
case "monitor":
2024-08-01 19:26:38 +02:00
widget = &Monitor{}
2024-04-27 21:10:24 +02:00
case "twitch-top-games":
2024-08-01 19:26:38 +02:00
widget = &TwitchGames{}
2024-04-27 21:10:24 +02:00
case "twitch-channels":
2024-08-01 19:26:38 +02:00
widget = &TwitchChannels{}
2024-05-12 13:20:34 +02:00
case "lobsters":
2024-08-01 19:26:38 +02:00
widget = &Lobsters{}
2024-05-30 23:53:59 +02:00
case "change-detection":
2024-08-01 19:26:38 +02:00
widget = &ChangeDetection{}
2024-05-12 04:35:25 +02:00
case "repository":
2024-08-01 19:26:38 +02:00
widget = &Repository{}
2024-05-16 02:04:08 +02:00
case "search":
2024-08-01 19:26:38 +02:00
widget = &Search{}
2024-05-31 03:03:21 +02:00
case "extension":
2024-08-01 19:26:38 +02:00
widget = &Extension{}
2024-08-01 22:34:07 +02:00
case "group":
widget = &Group{}
2024-08-23 00:11:45 +02:00
case "dns-stats":
widget = &DNSStats{}
2024-04-27 21:10:24 +02:00
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}
2024-08-01 19:26:38 +02:00
widget.SetID(uniqueID.Add(1))
return widget, nil
2024-04-27 21:10:24 +02:00
}
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
}
*w = append(*w, widget)
}
return nil
}
type Widget interface {
Initialize() error
RequiresUpdate(*time.Time) bool
2024-08-27 18:17:41 +02:00
SetProviders(*Providers)
2024-04-27 21:10:24 +02:00
Update(context.Context)
Render() template.HTML
GetType() string
2024-08-01 19:26:38 +02:00
GetID() uint64
SetID(uint64)
HandleRequest(w http.ResponseWriter, r *http.Request)
2024-08-01 22:34:07 +02:00
SetHideHeader(bool)
2024-04-27 21:10:24 +02:00
}
type cacheType int
const (
cacheTypeInfinite cacheType = iota
cacheTypeDuration
cacheTypeOnTheHour
)
type widgetBase struct {
2024-08-01 19:26:38 +02:00
ID uint64 `yaml:"-"`
2024-08-27 18:17:41 +02:00
Providers *Providers `yaml:"-"`
2024-04-27 21:10:24 +02:00
Type string `yaml:"type"`
Title string `yaml:"title"`
2024-06-29 17:10:43 +02:00
TitleURL string `yaml:"title-url"`
CSSClass string `yaml:"css-class"`
2024-04-27 21:10:24 +02:00
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:"-"`
2024-08-01 22:34:07 +02:00
HideHeader bool `yaml:"-"`
2024-04-27 21:10:24 +02:00
}
2024-08-27 18:17:41 +02:00
type Providers struct {
AssetResolver func(string) string
}
2024-04-27 21:10:24 +02:00
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) {
}
2024-08-01 19:26:38 +02:00
func (w *widgetBase) GetID() uint64 {
return w.ID
}
func (w *widgetBase) SetID(id uint64) {
w.ID = id
}
2024-08-01 22:34:07 +02:00
func (w *widgetBase) SetHideHeader(value bool) {
w.HideHeader = value
}
2024-08-01 19:26:38 +02:00
func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
}
2024-04-27 21:10:24 +02:00
func (w *widgetBase) GetType() string {
return w.Type
}
2024-08-27 18:17:41 +02:00
func (w *widgetBase) SetProviders(providers *Providers) {
w.Providers = providers
}
2024-04-27 21:10:24 +02:00
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
}
2024-06-29 17:10:43 +02:00
func (w *widgetBase) withTitleURL(titleURL string) *widgetBase {
if w.TitleURL == "" {
w.TitleURL = titleURL
}
return w
}
2024-04-27 21:10:24 +02:00
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
}