widget.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. package widget
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "html/template"
  8. "log/slog"
  9. "math"
  10. "net/http"
  11. "sync/atomic"
  12. "time"
  13. "github.com/glanceapp/glance/internal/feed"
  14. "gopkg.in/yaml.v3"
  15. )
  16. var uniqueID atomic.Uint64
  17. func New(widgetType string) (Widget, error) {
  18. var widget Widget
  19. switch widgetType {
  20. case "calendar":
  21. widget = &Calendar{}
  22. case "clock":
  23. widget = &Clock{}
  24. case "weather":
  25. widget = &Weather{}
  26. case "bookmarks":
  27. widget = &Bookmarks{}
  28. case "iframe":
  29. widget = &IFrame{}
  30. case "html":
  31. widget = &HTML{}
  32. case "hacker-news":
  33. widget = &HackerNews{}
  34. case "releases":
  35. widget = &Releases{}
  36. case "videos":
  37. widget = &Videos{}
  38. case "markets", "stocks":
  39. widget = &Markets{}
  40. case "reddit":
  41. widget = &Reddit{}
  42. case "rss":
  43. widget = &RSS{}
  44. case "monitor":
  45. widget = &Monitor{}
  46. case "twitch-top-games":
  47. widget = &TwitchGames{}
  48. case "twitch-channels":
  49. widget = &TwitchChannels{}
  50. case "lobsters":
  51. widget = &Lobsters{}
  52. case "change-detection":
  53. widget = &ChangeDetection{}
  54. case "repository":
  55. widget = &Repository{}
  56. case "search":
  57. widget = &Search{}
  58. case "extension":
  59. widget = &Extension{}
  60. case "group":
  61. widget = &Group{}
  62. default:
  63. return nil, fmt.Errorf("unknown widget type: %s", widgetType)
  64. }
  65. widget.SetID(uniqueID.Add(1))
  66. return widget, nil
  67. }
  68. type Widgets []Widget
  69. func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
  70. var nodes []yaml.Node
  71. if err := node.Decode(&nodes); err != nil {
  72. return err
  73. }
  74. for _, node := range nodes {
  75. meta := struct {
  76. Type string `yaml:"type"`
  77. }{}
  78. if err := node.Decode(&meta); err != nil {
  79. return err
  80. }
  81. widget, err := New(meta.Type)
  82. if err != nil {
  83. return err
  84. }
  85. if err = node.Decode(widget); err != nil {
  86. return err
  87. }
  88. *w = append(*w, widget)
  89. }
  90. return nil
  91. }
  92. type Widget interface {
  93. Initialize() error
  94. RequiresUpdate(*time.Time) bool
  95. SetProviders(*Providers)
  96. Update(context.Context)
  97. Render() template.HTML
  98. GetType() string
  99. GetID() uint64
  100. SetID(uint64)
  101. HandleRequest(w http.ResponseWriter, r *http.Request)
  102. SetHideHeader(bool)
  103. }
  104. type cacheType int
  105. const (
  106. cacheTypeInfinite cacheType = iota
  107. cacheTypeDuration
  108. cacheTypeOnTheHour
  109. )
  110. type widgetBase struct {
  111. ID uint64 `yaml:"-"`
  112. Providers *Providers `yaml:"-"`
  113. Type string `yaml:"type"`
  114. Title string `yaml:"title"`
  115. TitleURL string `yaml:"title-url"`
  116. CSSClass string `yaml:"css-class"`
  117. CustomCacheDuration DurationField `yaml:"cache"`
  118. ContentAvailable bool `yaml:"-"`
  119. Error error `yaml:"-"`
  120. Notice error `yaml:"-"`
  121. templateBuffer bytes.Buffer `yaml:"-"`
  122. cacheDuration time.Duration `yaml:"-"`
  123. cacheType cacheType `yaml:"-"`
  124. nextUpdate time.Time `yaml:"-"`
  125. updateRetriedTimes int `yaml:"-"`
  126. HideHeader bool `yaml:"-"`
  127. }
  128. type Providers struct {
  129. AssetResolver func(string) string
  130. }
  131. func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
  132. if w.cacheType == cacheTypeInfinite {
  133. return false
  134. }
  135. if w.nextUpdate.IsZero() {
  136. return true
  137. }
  138. return now.After(w.nextUpdate)
  139. }
  140. func (w *widgetBase) Update(ctx context.Context) {
  141. }
  142. func (w *widgetBase) GetID() uint64 {
  143. return w.ID
  144. }
  145. func (w *widgetBase) SetID(id uint64) {
  146. w.ID = id
  147. }
  148. func (w *widgetBase) SetHideHeader(value bool) {
  149. w.HideHeader = value
  150. }
  151. func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
  152. http.Error(w, "not implemented", http.StatusNotImplemented)
  153. }
  154. func (w *widgetBase) GetType() string {
  155. return w.Type
  156. }
  157. func (w *widgetBase) SetProviders(providers *Providers) {
  158. w.Providers = providers
  159. }
  160. func (w *widgetBase) render(data any, t *template.Template) template.HTML {
  161. w.templateBuffer.Reset()
  162. err := t.Execute(&w.templateBuffer, data)
  163. if err != nil {
  164. w.ContentAvailable = false
  165. w.Error = err
  166. slog.Error("failed to render template", "error", err)
  167. // need to immediately re-render with the error,
  168. // otherwise risk breaking the page since the widget
  169. // will likely be partially rendered with tags not closed.
  170. w.templateBuffer.Reset()
  171. err2 := t.Execute(&w.templateBuffer, data)
  172. if err2 != nil {
  173. slog.Error("failed to render error within widget", "error", err2, "initial_error", err)
  174. w.templateBuffer.Reset()
  175. // TODO: add some kind of a generic widget error template when the widget
  176. // failed to render, and we also failed to re-render the widget with the error
  177. }
  178. }
  179. return template.HTML(w.templateBuffer.String())
  180. }
  181. func (w *widgetBase) withTitle(title string) *widgetBase {
  182. if w.Title == "" {
  183. w.Title = title
  184. }
  185. return w
  186. }
  187. func (w *widgetBase) withTitleURL(titleURL string) *widgetBase {
  188. if w.TitleURL == "" {
  189. w.TitleURL = titleURL
  190. }
  191. return w
  192. }
  193. func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
  194. w.cacheType = cacheTypeDuration
  195. if duration == -1 || w.CustomCacheDuration == 0 {
  196. w.cacheDuration = duration
  197. } else {
  198. w.cacheDuration = time.Duration(w.CustomCacheDuration)
  199. }
  200. return w
  201. }
  202. func (w *widgetBase) withCacheOnTheHour() *widgetBase {
  203. w.cacheType = cacheTypeOnTheHour
  204. return w
  205. }
  206. func (w *widgetBase) withNotice(err error) *widgetBase {
  207. w.Notice = err
  208. return w
  209. }
  210. func (w *widgetBase) withError(err error) *widgetBase {
  211. if err == nil && !w.ContentAvailable {
  212. w.ContentAvailable = true
  213. }
  214. w.Error = err
  215. return w
  216. }
  217. func (w *widgetBase) canContinueUpdateAfterHandlingErr(err error) bool {
  218. // TODO: needs covering more edge cases.
  219. // if there's partial content and we update early there's a chance
  220. // the early update returns even less content than the initial update.
  221. // need some kind of mechanism that tells us whether we should update early
  222. // or not depending on the number of things that failed during the initial
  223. // and subsequent update and how they failed - ie whether it was server
  224. // error (like gateway timeout, do retry early) or client error (like
  225. // hitting a rate limit, don't retry early). will require reworking a
  226. // good amount of code in the feed package and probably having a custom
  227. // error type that holds more information because screw wrapping errors.
  228. // alternatively have a resource cache and only refetch the failed resources,
  229. // then rebuild the widget.
  230. if err != nil {
  231. w.scheduleEarlyUpdate()
  232. if !errors.Is(err, feed.ErrPartialContent) {
  233. w.withError(err)
  234. w.withNotice(nil)
  235. return false
  236. }
  237. w.withError(nil)
  238. w.withNotice(err)
  239. return true
  240. }
  241. w.withNotice(nil)
  242. w.withError(nil)
  243. w.scheduleNextUpdate()
  244. return true
  245. }
  246. func (w *widgetBase) getNextUpdateTime() time.Time {
  247. now := time.Now()
  248. if w.cacheType == cacheTypeDuration {
  249. return now.Add(w.cacheDuration)
  250. }
  251. if w.cacheType == cacheTypeOnTheHour {
  252. return now.Add(time.Duration(
  253. ((60-now.Minute())*60)-now.Second(),
  254. ) * time.Second)
  255. }
  256. return time.Time{}
  257. }
  258. func (w *widgetBase) scheduleNextUpdate() *widgetBase {
  259. w.nextUpdate = w.getNextUpdateTime()
  260. w.updateRetriedTimes = 0
  261. return w
  262. }
  263. func (w *widgetBase) scheduleEarlyUpdate() *widgetBase {
  264. w.updateRetriedTimes++
  265. if w.updateRetriedTimes > 5 {
  266. w.updateRetriedTimes = 5
  267. }
  268. nextEarlyUpdate := time.Now().Add(time.Duration(math.Pow(float64(w.updateRetriedTimes), 2)) * time.Minute)
  269. nextUsualUpdate := w.getNextUpdateTime()
  270. if nextEarlyUpdate.After(nextUsualUpdate) {
  271. w.nextUpdate = nextUsualUpdate
  272. } else {
  273. w.nextUpdate = nextEarlyUpdate
  274. }
  275. return w
  276. }