mirror of
https://github.com/Xevion/glance.git
synced 2025-12-15 08:11:59 -06:00
Restructure & refactor codebase
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
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"`
|
||||
Icon CustomIcon `yaml:"icon"`
|
||||
SameTab bool `yaml:"same-tab"`
|
||||
HideArrow bool `yaml:"hide-arrow"`
|
||||
} `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
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
StartSunday bool `yaml:"start-sunday"`
|
||||
}
|
||||
|
||||
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.StartSunday)
|
||||
widget.withError(nil).scheduleNextUpdate()
|
||||
}
|
||||
|
||||
func (widget *Calendar) Render() template.HTML {
|
||||
return widget.render(widget, assets.CalendarTemplate)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type ChangeDetection struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
ChangeDetections feed.ChangeDetectionWatches `yaml:"-"`
|
||||
WatchUUIDs []string `yaml:"watches"`
|
||||
InstanceURL string `yaml:"instance-url"`
|
||||
Token OptionalEnvString `yaml:"token"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *ChangeDetection) Initialize() error {
|
||||
widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 10
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
if widget.InstanceURL == "" {
|
||||
widget.InstanceURL = "https://www.changedetection.io"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *ChangeDetection) Update(ctx context.Context) {
|
||||
if len(widget.WatchUUIDs) == 0 {
|
||||
uuids, err := feed.FetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.WatchUUIDs = uuids
|
||||
}
|
||||
|
||||
watches, err := feed.FetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token))
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(watches) > widget.Limit {
|
||||
watches = watches[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.ChangeDetections = watches
|
||||
}
|
||||
|
||||
func (widget *ChangeDetection) Render() template.HTML {
|
||||
return widget.render(widget, assets.ChangeDetectionTemplate)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type Clock struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
HourFormat string `yaml:"hour-format"`
|
||||
Timezones []struct {
|
||||
Timezone string `yaml:"timezone"`
|
||||
Label string `yaml:"label"`
|
||||
} `yaml:"timezones"`
|
||||
}
|
||||
|
||||
func (widget *Clock) Initialize() error {
|
||||
widget.withTitle("Clock").withError(nil)
|
||||
|
||||
if widget.HourFormat == "" {
|
||||
widget.HourFormat = "24h"
|
||||
} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
|
||||
return errors.New("invalid hour format for clock widget, must be either 12h or 24h")
|
||||
}
|
||||
|
||||
for t := range widget.Timezones {
|
||||
if widget.Timezones[t].Timezone == "" {
|
||||
return errors.New("missing timezone value for clock widget")
|
||||
}
|
||||
|
||||
_, err := time.LoadLocation(widget.Timezones[t].Timezone)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
|
||||
}
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Clock) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type containerWidgetBase struct {
|
||||
Widgets Widgets `yaml:"widgets"`
|
||||
}
|
||||
|
||||
func (widget *containerWidgetBase) Update(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
now := time.Now()
|
||||
|
||||
for w := range widget.Widgets {
|
||||
widget := widget.Widgets[w]
|
||||
|
||||
if !widget.RequiresUpdate(&now) {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
widget.Update(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (widget *containerWidgetBase) SetProviders(providers *Providers) {
|
||||
for i := range widget.Widgets {
|
||||
widget.Widgets[i].SetProviders(providers)
|
||||
}
|
||||
}
|
||||
|
||||
func (widget *containerWidgetBase) RequiresUpdate(now *time.Time) bool {
|
||||
for i := range widget.Widgets {
|
||||
if widget.Widgets[i].RequiresUpdate(now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type CustomApi struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
URL OptionalEnvString `yaml:"url"`
|
||||
Template string `yaml:"template"`
|
||||
Frameless bool `yaml:"frameless"`
|
||||
Headers map[string]OptionalEnvString `yaml:"headers"`
|
||||
APIRequest *http.Request `yaml:"-"`
|
||||
compiledTemplate *template.Template `yaml:"-"`
|
||||
CompiledHTML template.HTML `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *CustomApi) Initialize() error {
|
||||
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
|
||||
|
||||
if widget.URL == "" {
|
||||
return errors.New("URL is required for the custom API widget")
|
||||
}
|
||||
|
||||
if widget.Template == "" {
|
||||
return errors.New("template is required for the custom API widget")
|
||||
}
|
||||
|
||||
compiledTemplate, err := template.New("").Funcs(feed.CustomAPITemplateFuncs).Parse(widget.Template)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed parsing custom API widget template: %w", err)
|
||||
}
|
||||
|
||||
widget.compiledTemplate = compiledTemplate
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, widget.URL.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for key, value := range widget.Headers {
|
||||
req.Header.Add(key, value.String())
|
||||
}
|
||||
|
||||
widget.APIRequest = req
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *CustomApi) Update(ctx context.Context) {
|
||||
compiledHTML, err := feed.FetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.CompiledHTML = compiledHTML
|
||||
}
|
||||
|
||||
func (widget *CustomApi) Render() template.HTML {
|
||||
return widget.render(widget, assets.CustomAPITemplate)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type DNSStats struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
|
||||
TimeLabels [8]string `yaml:"-"`
|
||||
Stats *feed.DNSStats `yaml:"-"`
|
||||
|
||||
HourFormat string `yaml:"hour-format"`
|
||||
Service string `yaml:"service"`
|
||||
URL OptionalEnvString `yaml:"url"`
|
||||
Token OptionalEnvString `yaml:"token"`
|
||||
Username OptionalEnvString `yaml:"username"`
|
||||
Password OptionalEnvString `yaml:"password"`
|
||||
}
|
||||
|
||||
func makeDNSTimeLabels(format string) [8]string {
|
||||
now := time.Now()
|
||||
var labels [8]string
|
||||
|
||||
for i := 24; i > 0; i -= 3 {
|
||||
labels[7-(i/3-1)] = strings.ToLower(now.Add(-time.Duration(i) * time.Hour).Format(format))
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
func (widget *DNSStats) Initialize() error {
|
||||
widget.
|
||||
withTitle("DNS Stats").
|
||||
withTitleURL(string(widget.URL)).
|
||||
withCacheDuration(10 * time.Minute)
|
||||
|
||||
if widget.Service != "adguard" && widget.Service != "pihole" {
|
||||
return errors.New("DNS stats service must be either 'adguard' or 'pihole'")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *DNSStats) Update(ctx context.Context) {
|
||||
var stats *feed.DNSStats
|
||||
var err error
|
||||
|
||||
if widget.Service == "adguard" {
|
||||
stats, err = feed.FetchAdguardStats(string(widget.URL), string(widget.Username), string(widget.Password))
|
||||
} else {
|
||||
stats, err = feed.FetchPiholeStats(string(widget.URL), string(widget.Token))
|
||||
}
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if widget.HourFormat == "24h" {
|
||||
widget.TimeLabels = makeDNSTimeLabels("15:00")
|
||||
} else {
|
||||
widget.TimeLabels = makeDNSTimeLabels("3PM")
|
||||
}
|
||||
|
||||
widget.Stats = stats
|
||||
}
|
||||
|
||||
func (widget *DNSStats) Render() template.HTML {
|
||||
return widget.render(widget, assets.DNSStatsTemplate)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Extension struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
URL string `yaml:"url"`
|
||||
FallbackContentType string `yaml:"fallback-content-type"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
Extension feed.Extension `yaml:"-"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *Extension) Initialize() error {
|
||||
widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
|
||||
|
||||
if widget.URL == "" {
|
||||
return errors.New("no extension URL specified")
|
||||
}
|
||||
|
||||
_, err := url.Parse(widget.URL)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Extension) Update(ctx context.Context) {
|
||||
extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
|
||||
URL: widget.URL,
|
||||
FallbackContentType: widget.FallbackContentType,
|
||||
Parameters: widget.Parameters,
|
||||
AllowHtml: widget.AllowHtml,
|
||||
})
|
||||
|
||||
widget.canContinueUpdateAfterHandlingErr(err)
|
||||
|
||||
widget.Extension = extension
|
||||
|
||||
if extension.Title != "" {
|
||||
widget.Title = extension.Title
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.render(widget, assets.ExtensionTemplate)
|
||||
}
|
||||
|
||||
func (widget *Extension) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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
|
||||
}
|
||||
|
||||
replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
groups := EnvFieldPattern.FindStringSubmatch(whole)
|
||||
|
||||
if len(groups) != 3 {
|
||||
return whole
|
||||
}
|
||||
|
||||
prefix, key := groups[1], groups[2]
|
||||
|
||||
if prefix == `\` {
|
||||
if len(whole) >= 2 {
|
||||
return whole[1:]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
value, found := os.LookupEnv(key)
|
||||
|
||||
if !found {
|
||||
err = fmt.Errorf("environment variable %s not found", key)
|
||||
return ""
|
||||
}
|
||||
|
||||
return prefix + value
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*f = OptionalEnvString(replaced)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *OptionalEnvString) String() string {
|
||||
return string(*f)
|
||||
}
|
||||
|
||||
type CustomIcon struct {
|
||||
URL string
|
||||
IsFlatIcon bool
|
||||
// TODO: along with whether the icon is flat, we also need to know
|
||||
// whether the icon is black or white by default in order to properly
|
||||
// invert the color based on the theme being light or dark
|
||||
}
|
||||
|
||||
func (i *CustomIcon) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prefix, icon, found := strings.Cut(value, ":")
|
||||
if !found {
|
||||
i.URL = value
|
||||
return nil
|
||||
}
|
||||
|
||||
switch prefix {
|
||||
case "si":
|
||||
i.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
|
||||
i.IsFlatIcon = true
|
||||
case "di":
|
||||
// syntax: di:<icon_name>[.svg|.png]
|
||||
// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
|
||||
// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
|
||||
// any other extension will be interpreted as .svg
|
||||
basename, ext, found := strings.Cut(icon, ".")
|
||||
if !found {
|
||||
ext = "svg"
|
||||
basename = icon
|
||||
}
|
||||
|
||||
if ext != "svg" && ext != "png" {
|
||||
ext = "svg"
|
||||
}
|
||||
|
||||
i.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext
|
||||
default:
|
||||
i.URL = value
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
containerWidgetBase `yaml:",inline"`
|
||||
}
|
||||
|
||||
func (widget *Group) Initialize() error {
|
||||
widget.withError(nil)
|
||||
widget.HideHeader = true
|
||||
|
||||
for i := range widget.Widgets {
|
||||
widget.Widgets[i].SetHideHeader(true)
|
||||
|
||||
if widget.Widgets[i].GetType() == "group" {
|
||||
return errors.New("nested groups are not supported")
|
||||
} else if widget.Widgets[i].GetType() == "split-column" {
|
||||
return errors.New("split columns inside of groups are not supported")
|
||||
}
|
||||
|
||||
if err := widget.Widgets[i].Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Group) Update(ctx context.Context) {
|
||||
widget.containerWidgetBase.Update(ctx)
|
||||
}
|
||||
|
||||
func (widget *Group) SetProviders(providers *Providers) {
|
||||
widget.containerWidgetBase.SetProviders(providers)
|
||||
}
|
||||
|
||||
func (widget *Group) RequiresUpdate(now *time.Time) bool {
|
||||
return widget.containerWidgetBase.RequiresUpdate(now)
|
||||
}
|
||||
|
||||
func (widget *Group) Render() template.HTML {
|
||||
return widget.render(widget, assets.GroupTemplate)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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"`
|
||||
SortBy string `yaml:"sort-by"`
|
||||
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
||||
ShowThumbnails bool `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *HackerNews) Initialize() error {
|
||||
widget.
|
||||
withTitle("Hacker News").
|
||||
withTitleURL("https://news.ycombinator.com/").
|
||||
withCacheDuration(30 * time.Minute)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 15
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" {
|
||||
widget.SortBy = "top"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *HackerNews) Update(ctx context.Context) {
|
||||
posts, err := feed.FetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if widget.ExtraSortBy == "engagement" {
|
||||
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)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
)
|
||||
|
||||
type HTML struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Source template.HTML `yaml:"source"`
|
||||
}
|
||||
|
||||
func (widget *HTML) Initialize() error {
|
||||
widget.withTitle("").withError(nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *HTML) Render() template.HTML {
|
||||
return widget.Source
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Lobsters struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Posts feed.ForumPosts `yaml:"-"`
|
||||
InstanceURL string `yaml:"instance-url"`
|
||||
CustomURL string `yaml:"custom-url"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
SortBy string `yaml:"sort-by"`
|
||||
Tags []string `yaml:"tags"`
|
||||
ShowThumbnails bool `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *Lobsters) Initialize() error {
|
||||
widget.withTitle("Lobsters").withCacheDuration(time.Hour)
|
||||
|
||||
if widget.InstanceURL == "" {
|
||||
widget.withTitleURL("https://lobste.rs")
|
||||
} else {
|
||||
widget.withTitleURL(widget.InstanceURL)
|
||||
}
|
||||
|
||||
if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
|
||||
widget.SortBy = "hot"
|
||||
}
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 15
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Lobsters) Update(ctx context.Context) {
|
||||
posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if widget.Limit < len(posts) {
|
||||
posts = posts[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Posts = posts
|
||||
}
|
||||
|
||||
func (widget *Lobsters) Render() template.HTML {
|
||||
return widget.render(widget, assets.ForumPostsTemplate)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Markets struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
StocksRequests []feed.MarketRequest `yaml:"stocks"`
|
||||
MarketRequests []feed.MarketRequest `yaml:"markets"`
|
||||
Sort string `yaml:"sort-by"`
|
||||
Markets feed.Markets `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *Markets) Initialize() error {
|
||||
widget.withTitle("Markets").withCacheDuration(time.Hour)
|
||||
|
||||
if len(widget.MarketRequests) == 0 {
|
||||
widget.MarketRequests = widget.StocksRequests
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Markets) Update(ctx context.Context) {
|
||||
markets, err := feed.FetchMarketsDataFromYahoo(widget.MarketRequests)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if widget.Sort == "absolute-change" {
|
||||
markets.SortByAbsChange()
|
||||
}
|
||||
|
||||
if widget.Sort == "change" {
|
||||
markets.SortByChange()
|
||||
}
|
||||
|
||||
widget.Markets = markets
|
||||
}
|
||||
|
||||
func (widget *Markets) Render() template.HTML {
|
||||
return widget.render(widget, assets.MarketsTemplate)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Monitor struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Sites []struct {
|
||||
*feed.SiteStatusRequest `yaml:",inline"`
|
||||
Status *feed.SiteStatus `yaml:"-"`
|
||||
Title string `yaml:"title"`
|
||||
Icon CustomIcon `yaml:"icon"`
|
||||
SameTab bool `yaml:"same-tab"`
|
||||
StatusText string `yaml:"-"`
|
||||
StatusStyle string `yaml:"-"`
|
||||
AltStatusCodes []int `yaml:"alt-status-codes"`
|
||||
} `yaml:"sites"`
|
||||
Style string `yaml:"style"`
|
||||
ShowFailingOnly bool `yaml:"show-failing-only"`
|
||||
HasFailing bool `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *Monitor) Initialize() error {
|
||||
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Monitor) Update(ctx context.Context) {
|
||||
requests := make([]*feed.SiteStatusRequest, len(widget.Sites))
|
||||
|
||||
for i := range widget.Sites {
|
||||
requests[i] = widget.Sites[i].SiteStatusRequest
|
||||
}
|
||||
|
||||
statuses, err := feed.FetchStatusForSites(requests)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.HasFailing = false
|
||||
|
||||
for i := range widget.Sites {
|
||||
site := &widget.Sites[i]
|
||||
status := &statuses[i]
|
||||
site.Status = status
|
||||
|
||||
if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.TimedOut || status.Error != nil) {
|
||||
widget.HasFailing = true
|
||||
}
|
||||
|
||||
if !status.TimedOut {
|
||||
site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
|
||||
site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (widget *Monitor) Render() template.HTML {
|
||||
if widget.Style == "compact" {
|
||||
return widget.render(widget, assets.MonitorCompactTemplate)
|
||||
}
|
||||
|
||||
return widget.render(widget, assets.MonitorTemplate)
|
||||
}
|
||||
|
||||
func statusCodeToText(status int, altStatusCodes []int) string {
|
||||
if status == 200 || slices.Contains(altStatusCodes, status) {
|
||||
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, altStatusCodes []int) string {
|
||||
if status == 200 || slices.Contains(altStatusCodes, status) {
|
||||
return "ok"
|
||||
}
|
||||
|
||||
return "error"
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"strings"
|
||||
"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"`
|
||||
ShowThumbnails bool `yaml:"show-thumbnails"`
|
||||
ShowFlairs bool `yaml:"show-flairs"`
|
||||
SortBy string `yaml:"sort-by"`
|
||||
TopPeriod string `yaml:"top-period"`
|
||||
Search string `yaml:"search"`
|
||||
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
RequestUrlTemplate string `yaml:"request-url-template"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if !isValidRedditSortType(widget.SortBy) {
|
||||
widget.SortBy = "hot"
|
||||
}
|
||||
|
||||
if !isValidRedditTopPeriod(widget.TopPeriod) {
|
||||
widget.TopPeriod = "day"
|
||||
}
|
||||
|
||||
if widget.RequestUrlTemplate != "" {
|
||||
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
|
||||
return errors.New("no `{REQUEST-URL}` placeholder specified")
|
||||
}
|
||||
}
|
||||
|
||||
widget.
|
||||
withTitle("/r/" + widget.Subreddit).
|
||||
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
|
||||
withCacheDuration(30 * time.Minute)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidRedditSortType(sortBy string) bool {
|
||||
return sortBy == "hot" ||
|
||||
sortBy == "new" ||
|
||||
sortBy == "top" ||
|
||||
sortBy == "rising"
|
||||
}
|
||||
|
||||
func isValidRedditTopPeriod(period string) bool {
|
||||
return period == "hour" ||
|
||||
period == "day" ||
|
||||
period == "week" ||
|
||||
period == "month" ||
|
||||
period == "year" ||
|
||||
period == "all"
|
||||
}
|
||||
|
||||
func (widget *Reddit) Update(ctx context.Context) {
|
||||
// TODO: refactor, use a struct to pass all of these
|
||||
posts, err := feed.FetchSubredditPosts(
|
||||
widget.Subreddit,
|
||||
widget.SortBy,
|
||||
widget.TopPeriod,
|
||||
widget.Search,
|
||||
widget.CommentsUrlTemplate,
|
||||
widget.RequestUrlTemplate,
|
||||
widget.ShowFlairs,
|
||||
)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(posts) > widget.Limit {
|
||||
posts = posts[:widget.Limit]
|
||||
}
|
||||
|
||||
if widget.ExtraSortBy == "engagement" {
|
||||
posts.CalculateEngagement()
|
||||
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)
|
||||
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Releases struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Releases feed.AppReleases `yaml:"-"`
|
||||
releaseRequests []*feed.ReleaseRequest `yaml:"-"`
|
||||
Repositories []string `yaml:"repositories"`
|
||||
Token OptionalEnvString `yaml:"token"`
|
||||
GitLabToken OptionalEnvString `yaml:"gitlab-token"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
ShowSourceIcon bool `yaml:"show-source-icon"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var tokenAsString = widget.Token.String()
|
||||
var gitLabTokenAsString = widget.GitLabToken.String()
|
||||
|
||||
for _, repository := range widget.Repositories {
|
||||
parts := strings.SplitN(repository, ":", 2)
|
||||
var request *feed.ReleaseRequest
|
||||
if len(parts) == 1 {
|
||||
request = &feed.ReleaseRequest{
|
||||
Source: feed.ReleaseSourceGithub,
|
||||
Repository: repository,
|
||||
}
|
||||
|
||||
if widget.Token != "" {
|
||||
request.Token = &tokenAsString
|
||||
}
|
||||
} else if len(parts) == 2 {
|
||||
if parts[0] == string(feed.ReleaseSourceGitlab) {
|
||||
request = &feed.ReleaseRequest{
|
||||
Source: feed.ReleaseSourceGitlab,
|
||||
Repository: parts[1],
|
||||
}
|
||||
|
||||
if widget.GitLabToken != "" {
|
||||
request.Token = &gitLabTokenAsString
|
||||
}
|
||||
} else if parts[0] == string(feed.ReleaseSourceDockerHub) {
|
||||
request = &feed.ReleaseRequest{
|
||||
Source: feed.ReleaseSourceDockerHub,
|
||||
Repository: parts[1],
|
||||
}
|
||||
} else if parts[0] == string(feed.ReleaseSourceCodeberg) {
|
||||
request = &feed.ReleaseRequest{
|
||||
Source: feed.ReleaseSourceCodeberg,
|
||||
Repository: parts[1],
|
||||
}
|
||||
} else {
|
||||
return errors.New("invalid repository source " + parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
widget.releaseRequests = append(widget.releaseRequests, request)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Releases) Update(ctx context.Context) {
|
||||
releases, err := feed.FetchLatestReleases(widget.releaseRequests)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(releases) > widget.Limit {
|
||||
releases = releases[:widget.Limit]
|
||||
}
|
||||
|
||||
for i := range releases {
|
||||
releases[i].SourceIconURL = widget.Providers.AssetResolver("icons/" + string(releases[i].Source) + ".svg")
|
||||
}
|
||||
|
||||
widget.Releases = releases
|
||||
}
|
||||
|
||||
func (widget *Releases) Render() template.HTML {
|
||||
return widget.render(widget, assets.ReleasesTemplate)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
RequestedRepository string `yaml:"repository"`
|
||||
Token OptionalEnvString `yaml:"token"`
|
||||
PullRequestsLimit int `yaml:"pull-requests-limit"`
|
||||
IssuesLimit int `yaml:"issues-limit"`
|
||||
CommitsLimit int `yaml:"commits-limit"`
|
||||
RepositoryDetails feed.RepositoryDetails
|
||||
}
|
||||
|
||||
func (widget *Repository) Initialize() error {
|
||||
widget.withTitle("Repository").withCacheDuration(1 * time.Hour)
|
||||
|
||||
if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 {
|
||||
widget.PullRequestsLimit = 3
|
||||
}
|
||||
|
||||
if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 {
|
||||
widget.IssuesLimit = 3
|
||||
}
|
||||
|
||||
if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 {
|
||||
widget.CommitsLimit = -1
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Repository) Update(ctx context.Context) {
|
||||
details, err := feed.FetchRepositoryDetailsFromGithub(
|
||||
widget.RequestedRepository,
|
||||
string(widget.Token),
|
||||
widget.PullRequestsLimit,
|
||||
widget.IssuesLimit,
|
||||
widget.CommitsLimit,
|
||||
)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.RepositoryDetails = details
|
||||
}
|
||||
|
||||
func (widget *Repository) Render() template.HTML {
|
||||
return widget.render(widget, assets.RepositoryTemplate)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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"`
|
||||
ThumbnailHeight float64 `yaml:"thumbnail-height"`
|
||||
CardHeight float64 `yaml:"card-height"`
|
||||
Items feed.RSSFeedItems `yaml:"-"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
SingleLineTitles bool `yaml:"single-line-titles"`
|
||||
NoItemsMessage string `yaml:"-"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if widget.ThumbnailHeight < 0 {
|
||||
widget.ThumbnailHeight = 0
|
||||
}
|
||||
|
||||
if widget.CardHeight < 0 {
|
||||
widget.CardHeight = 0
|
||||
}
|
||||
|
||||
if widget.Style == "detailed-list" {
|
||||
for i := range widget.FeedRequests {
|
||||
widget.FeedRequests[i].IsDetailed = true
|
||||
}
|
||||
}
|
||||
|
||||
widget.NoItemsMessage = "No items were returned from the feeds."
|
||||
|
||||
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.RSSHorizontalCardsTemplate)
|
||||
}
|
||||
|
||||
if widget.Style == "horizontal-cards-2" {
|
||||
return widget.render(widget, assets.RSSHorizontalCards2Template)
|
||||
}
|
||||
|
||||
if widget.Style == "detailed-list" {
|
||||
return widget.render(widget, assets.RSSDetailedListTemplate)
|
||||
}
|
||||
|
||||
return widget.render(widget, assets.RSSListTemplate)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type SearchBang struct {
|
||||
Title string
|
||||
Shortcut string
|
||||
URL string
|
||||
}
|
||||
|
||||
type Search struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
SearchEngine string `yaml:"search-engine"`
|
||||
Bangs []SearchBang `yaml:"bangs"`
|
||||
NewTab bool `yaml:"new-tab"`
|
||||
Autofocus bool `yaml:"autofocus"`
|
||||
}
|
||||
|
||||
func convertSearchUrl(url string) string {
|
||||
// Go's template is being stubborn and continues to escape the curlies in the
|
||||
// URL regardless of what the type of the variable is so this is my way around it
|
||||
return strings.ReplaceAll(url, "{QUERY}", "!QUERY!")
|
||||
}
|
||||
|
||||
var searchEngines = map[string]string{
|
||||
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
|
||||
"google": "https://www.google.com/search?q={QUERY}",
|
||||
}
|
||||
|
||||
func (widget *Search) Initialize() error {
|
||||
widget.withTitle("Search").withError(nil)
|
||||
|
||||
if widget.SearchEngine == "" {
|
||||
widget.SearchEngine = "duckduckgo"
|
||||
}
|
||||
|
||||
if url, ok := searchEngines[widget.SearchEngine]; ok {
|
||||
widget.SearchEngine = url
|
||||
}
|
||||
|
||||
widget.SearchEngine = convertSearchUrl(widget.SearchEngine)
|
||||
|
||||
for i := range widget.Bangs {
|
||||
if widget.Bangs[i].Shortcut == "" {
|
||||
return fmt.Errorf("Search bang %d has no shortcut", i+1)
|
||||
}
|
||||
|
||||
if widget.Bangs[i].URL == "" {
|
||||
return fmt.Errorf("Search bang %d has no URL", i+1)
|
||||
}
|
||||
|
||||
widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL)
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.render(widget, assets.SearchTemplate)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Search) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type SplitColumn struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
containerWidgetBase `yaml:",inline"`
|
||||
MaxColumns int `yaml:"max-columns"`
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) Initialize() error {
|
||||
widget.withError(nil).withTitle("Split Column").SetHideHeader(true)
|
||||
|
||||
for i := range widget.Widgets {
|
||||
if err := widget.Widgets[i].Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if widget.MaxColumns < 2 {
|
||||
widget.MaxColumns = 2
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) Update(ctx context.Context) {
|
||||
widget.containerWidgetBase.Update(ctx)
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) SetProviders(providers *Providers) {
|
||||
widget.containerWidgetBase.SetProviders(providers)
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) RequiresUpdate(now *time.Time) bool {
|
||||
return widget.containerWidgetBase.RequiresUpdate(now)
|
||||
}
|
||||
|
||||
func (widget *SplitColumn) Render() template.HTML {
|
||||
return widget.render(widget, assets.SplitColumnTemplate)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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"`
|
||||
SortBy string `yaml:"sort-by"`
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Initialize() error {
|
||||
widget.
|
||||
withTitle("Twitch Channels").
|
||||
withTitleURL("https://www.twitch.tv/directory/following").
|
||||
withCacheDuration(time.Minute * 10)
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
if widget.SortBy != "viewers" && widget.SortBy != "live" {
|
||||
widget.SortBy = "viewers"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Update(ctx context.Context) {
|
||||
channels, err := feed.FetchChannelsFromTwitch(widget.ChannelsRequest)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if widget.SortBy == "viewers" {
|
||||
channels.SortByViewers()
|
||||
} else if widget.SortBy == "live" {
|
||||
channels.SortByLive()
|
||||
}
|
||||
|
||||
widget.Channels = channels
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Render() template.HTML {
|
||||
return widget.render(widget, assets.TwitchChannelsTemplate)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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").
|
||||
withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT").
|
||||
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)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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:"-"`
|
||||
VideoUrlTemplate string `yaml:"video-url-template"`
|
||||
Style string `yaml:"style"`
|
||||
CollapseAfterRows int `yaml:"collapse-after-rows"`
|
||||
Channels []string `yaml:"channels"`
|
||||
Limit int `yaml:"limit"`
|
||||
IncludeShorts bool `yaml:"include-shorts"`
|
||||
}
|
||||
|
||||
func (widget *Videos) Initialize() error {
|
||||
widget.withTitle("Videos").withCacheDuration(time.Hour)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 25
|
||||
}
|
||||
|
||||
if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 {
|
||||
widget.CollapseAfterRows = 4
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Videos) Update(ctx context.Context) {
|
||||
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(videos) > widget.Limit {
|
||||
videos = videos[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Videos = videos
|
||||
}
|
||||
|
||||
func (widget *Videos) Render() template.HTML {
|
||||
if widget.Style == "grid-cards" {
|
||||
return widget.render(widget, assets.VideosGridTemplate)
|
||||
}
|
||||
|
||||
return widget.render(widget, assets.VideosTemplate)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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"`
|
||||
ShowAreaName bool `yaml:"show-area-name"`
|
||||
HideLocation bool `yaml:"hide-location"`
|
||||
HourFormat string `yaml:"hour-format"`
|
||||
Units string `yaml:"units"`
|
||||
Place *feed.PlaceJson `yaml:"-"`
|
||||
Weather *feed.Weather `yaml:"-"`
|
||||
TimeLabels [12]string `yaml:"-"`
|
||||
}
|
||||
|
||||
var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
|
||||
var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"}
|
||||
|
||||
func (widget *Weather) Initialize() error {
|
||||
widget.withTitle("Weather").withCacheOnTheHour()
|
||||
|
||||
if widget.Location == "" {
|
||||
return fmt.Errorf("location must be specified for weather widget")
|
||||
}
|
||||
|
||||
if widget.HourFormat == "" || widget.HourFormat == "12h" {
|
||||
widget.TimeLabels = timeLabels12h
|
||||
} else if widget.HourFormat == "24h" {
|
||||
widget.TimeLabels = timeLabels24h
|
||||
} else {
|
||||
return fmt.Errorf("invalid hour format '%s' for weather widget, must be either 12h or 24h", widget.HourFormat)
|
||||
}
|
||||
|
||||
if widget.Units == "" {
|
||||
widget.Units = "metric"
|
||||
} else if widget.Units != "metric" && widget.Units != "imperial" {
|
||||
return fmt.Errorf("invalid units '%s' for weather, must be either metric or imperial", widget.Units)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Weather) Update(ctx context.Context) {
|
||||
if widget.Place == nil {
|
||||
place, err := feed.FetchPlaceFromName(widget.Location)
|
||||
|
||||
if err != nil {
|
||||
widget.withError(err).scheduleEarlyUpdate()
|
||||
return
|
||||
}
|
||||
|
||||
widget.Place = place
|
||||
}
|
||||
|
||||
weather, err := feed.FetchWeatherForPlace(widget.Place, widget.Units)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.Weather = weather
|
||||
}
|
||||
|
||||
func (widget *Weather) Render() template.HTML {
|
||||
return widget.render(widget, assets.WeatherTemplate)
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var uniqueID atomic.Uint64
|
||||
|
||||
func New(widgetType string) (Widget, error) {
|
||||
var widget Widget
|
||||
|
||||
switch widgetType {
|
||||
case "calendar":
|
||||
widget = &Calendar{}
|
||||
case "clock":
|
||||
widget = &Clock{}
|
||||
case "weather":
|
||||
widget = &Weather{}
|
||||
case "bookmarks":
|
||||
widget = &Bookmarks{}
|
||||
case "iframe":
|
||||
widget = &IFrame{}
|
||||
case "html":
|
||||
widget = &HTML{}
|
||||
case "hacker-news":
|
||||
widget = &HackerNews{}
|
||||
case "releases":
|
||||
widget = &Releases{}
|
||||
case "videos":
|
||||
widget = &Videos{}
|
||||
case "markets", "stocks":
|
||||
widget = &Markets{}
|
||||
case "reddit":
|
||||
widget = &Reddit{}
|
||||
case "rss":
|
||||
widget = &RSS{}
|
||||
case "monitor":
|
||||
widget = &Monitor{}
|
||||
case "twitch-top-games":
|
||||
widget = &TwitchGames{}
|
||||
case "twitch-channels":
|
||||
widget = &TwitchChannels{}
|
||||
case "lobsters":
|
||||
widget = &Lobsters{}
|
||||
case "change-detection":
|
||||
widget = &ChangeDetection{}
|
||||
case "repository":
|
||||
widget = &Repository{}
|
||||
case "search":
|
||||
widget = &Search{}
|
||||
case "extension":
|
||||
widget = &Extension{}
|
||||
case "group":
|
||||
widget = &Group{}
|
||||
case "dns-stats":
|
||||
widget = &DNSStats{}
|
||||
case "split-column":
|
||||
widget = &SplitColumn{}
|
||||
case "custom-api":
|
||||
widget = &CustomApi{}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||
}
|
||||
|
||||
widget.SetID(uniqueID.Add(1))
|
||||
|
||||
return widget, nil
|
||||
}
|
||||
|
||||
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
|
||||
SetProviders(*Providers)
|
||||
Update(context.Context)
|
||||
Render() template.HTML
|
||||
GetType() string
|
||||
GetID() uint64
|
||||
SetID(uint64)
|
||||
HandleRequest(w http.ResponseWriter, r *http.Request)
|
||||
SetHideHeader(bool)
|
||||
}
|
||||
|
||||
type cacheType int
|
||||
|
||||
const (
|
||||
cacheTypeInfinite cacheType = iota
|
||||
cacheTypeDuration
|
||||
cacheTypeOnTheHour
|
||||
)
|
||||
|
||||
type widgetBase struct {
|
||||
ID uint64 `yaml:"-"`
|
||||
Providers *Providers `yaml:"-"`
|
||||
Type string `yaml:"type"`
|
||||
Title string `yaml:"title"`
|
||||
TitleURL string `yaml:"title-url"`
|
||||
CSSClass string `yaml:"css-class"`
|
||||
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:"-"`
|
||||
HideHeader bool `yaml:"-"`
|
||||
}
|
||||
|
||||
type Providers struct {
|
||||
AssetResolver func(string) string
|
||||
}
|
||||
|
||||
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) GetID() uint64 {
|
||||
return w.ID
|
||||
}
|
||||
|
||||
func (w *widgetBase) SetID(id uint64) {
|
||||
w.ID = id
|
||||
}
|
||||
|
||||
func (w *widgetBase) SetHideHeader(value bool) {
|
||||
w.HideHeader = value
|
||||
}
|
||||
|
||||
func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (w *widgetBase) GetType() string {
|
||||
return w.Type
|
||||
}
|
||||
|
||||
func (w *widgetBase) SetProviders(providers *Providers) {
|
||||
w.Providers = providers
|
||||
}
|
||||
|
||||
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) withTitleURL(titleURL string) *widgetBase {
|
||||
if w.TitleURL == "" {
|
||||
w.TitleURL = titleURL
|
||||
}
|
||||
|
||||
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