2024-04-27 21:10:24 +02:00
|
|
|
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
|
2024-05-18 21:40:33 +02:00
|
|
|
case "clock":
|
|
|
|
return &Clock{}, nil
|
2024-04-27 21:10:24 +02:00
|
|
|
case "weather":
|
|
|
|
return &Weather{}, nil
|
|
|
|
case "bookmarks":
|
|
|
|
return &Bookmarks{}, nil
|
|
|
|
case "iframe":
|
|
|
|
return &IFrame{}, nil
|
2024-06-02 20:01:20 +02:00
|
|
|
case "html":
|
|
|
|
return &HTML{}, nil
|
2024-04-27 21:10:24 +02:00
|
|
|
case "hacker-news":
|
|
|
|
return &HackerNews{}, nil
|
|
|
|
case "releases":
|
|
|
|
return &Releases{}, nil
|
|
|
|
case "videos":
|
|
|
|
return &Videos{}, nil
|
2024-05-28 19:01:26 +02:00
|
|
|
case "markets", "stocks":
|
|
|
|
return &Markets{}, nil
|
2024-04-27 21:10:24 +02:00
|
|
|
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
|
2024-05-12 13:20:34 +02:00
|
|
|
case "lobsters":
|
|
|
|
return &Lobsters{}, nil
|
2024-05-30 23:53:59 +02:00
|
|
|
case "change-detection":
|
|
|
|
return &ChangeDetection{}, nil
|
2024-05-12 04:35:25 +02:00
|
|
|
case "repository":
|
|
|
|
return &Repository{}, nil
|
2024-05-16 02:04:08 +02:00
|
|
|
case "search":
|
|
|
|
return &Search{}, nil
|
2024-05-31 03:03:21 +02:00
|
|
|
case "extension":
|
|
|
|
return &Extension{}, nil
|
2024-04-27 21:10:24 +02:00
|
|
|
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
|
|
|
|
}
|