+ {{ if .IsLive }}
+
+
+
{{ .StreamTitle }}
+
+ {{ end }}
{{ if .Exists }}
-
+
{{ else }}
diff --git a/internal/assets/templates/twitch-games-list.html b/internal/glance/templates/twitch-games-list.html
similarity index 100%
rename from internal/assets/templates/twitch-games-list.html
rename to internal/glance/templates/twitch-games-list.html
diff --git a/internal/glance/templates/v0.7-update-notice-page.html b/internal/glance/templates/v0.7-update-notice-page.html
new file mode 100644
index 0000000..1f3f524
--- /dev/null
+++ b/internal/glance/templates/v0.7-update-notice-page.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
Update notice
+
+
+
+
+
+
+
+
UPDATE NOTICE
+
+
+ The default location of glance.yml in the Docker image has
+ changed since v0.7.0, please see the migration guide
+ for instructions or visit the release notes
+ to find out more about why this change was necessary. Sorry for the inconvenience.
+
+
+
Migration should take around 5 minutes.
+
+
+
+
+
diff --git a/internal/assets/templates/video-card-contents.html b/internal/glance/templates/video-card-contents.html
similarity index 100%
rename from internal/assets/templates/video-card-contents.html
rename to internal/glance/templates/video-card-contents.html
diff --git a/internal/assets/templates/videos-grid.html b/internal/glance/templates/videos-grid.html
similarity index 100%
rename from internal/assets/templates/videos-grid.html
rename to internal/glance/templates/videos-grid.html
diff --git a/internal/assets/templates/videos.html b/internal/glance/templates/videos.html
similarity index 100%
rename from internal/assets/templates/videos.html
rename to internal/glance/templates/videos.html
diff --git a/internal/assets/templates/weather.html b/internal/glance/templates/weather.html
similarity index 100%
rename from internal/assets/templates/weather.html
rename to internal/glance/templates/weather.html
diff --git a/internal/assets/templates/widget-base.html b/internal/glance/templates/widget-base.html
similarity index 72%
rename from internal/assets/templates/widget-base.html
rename to internal/glance/templates/widget-base.html
index bdd30b9..5861b52 100644
--- a/internal/assets/templates/widget-base.html
+++ b/internal/glance/templates/widget-base.html
@@ -15,7 +15,9 @@
{{ else }}
{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}
{{ end}}
diff --git a/internal/feed/utils.go b/internal/glance/utils.go
similarity index 53%
rename from internal/feed/utils.go
rename to internal/glance/utils.go
index a6e3f8d..105cd0d 100644
--- a/internal/feed/utils.go
+++ b/internal/glance/utils.go
@@ -1,19 +1,19 @@
-package feed
+package glance
import (
- "errors"
+ "bytes"
"fmt"
+ "html/template"
+ "net/http"
"net/url"
+ "os"
"regexp"
"slices"
"strings"
"time"
)
-var (
- ErrNoContent = errors.New("failed to retrieve any content")
- ErrPartialContent = errors.New("failed to retrieve some of the content")
-)
+var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
func percentChange(current, previous float64) float64 {
return (current/previous - 1) * 100
@@ -25,7 +25,6 @@ func extractDomainFromUrl(u string) string {
}
parsed, err := url.Parse(u)
-
if err != nil {
return ""
}
@@ -33,7 +32,7 @@ func extractDomainFromUrl(u string) string {
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
}
-func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
+func svgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
if len(values) < 2 {
return ""
}
@@ -86,6 +85,21 @@ func stripURLScheme(url string) string {
return urlSchemePattern.ReplaceAllString(url, "")
}
+func isRunningInsideDockerContainer() bool {
+ _, err := os.Stat("/.dockerenv")
+ return err == nil
+}
+
+func prefixStringLines(prefix string, s string) string {
+ lines := strings.Split(s, "\n")
+
+ for i, line := range lines {
+ lines[i] = prefix + line
+ }
+
+ return strings.Join(lines, "\n")
+}
+
func limitStringLength(s string, max int) (string, bool) {
asRunes := []rune(s)
@@ -98,7 +112,6 @@ func limitStringLength(s string, max int) (string, bool) {
func parseRFC3339Time(t string) time.Time {
parsed, err := time.Parse(time.RFC3339, t)
-
if err != nil {
return time.Now()
}
@@ -106,6 +119,14 @@ func parseRFC3339Time(t string) time.Time {
return parsed
}
+func boolToString(b bool, trueValue, falseValue string) string {
+ if b {
+ return trueValue
+ }
+
+ return falseValue
+}
+
func normalizeVersionFormat(version string) string {
version = strings.ToLower(strings.TrimSpace(version))
@@ -115,3 +136,45 @@ func normalizeVersionFormat(version string) string {
return version
}
+
+func titleToSlug(s string) string {
+ s = strings.ToLower(s)
+ s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
+ s = strings.Trim(s, "-")
+
+ return s
+}
+
+func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
+ server := http.FileServer(fs)
+ cacheControlValue := fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // TODO: fix always setting cache control even if the file doesn't exist
+ w.Header().Set("Cache-Control", cacheControlValue)
+ server.ServeHTTP(w, r)
+ })
+}
+
+func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTML, error) {
+ var b bytes.Buffer
+
+ err := t.Execute(&b, data)
+ if err != nil {
+ return "", fmt.Errorf("executing template: %w", err)
+ }
+
+ return template.HTML(b.String()), nil
+}
+
+func stringToBool(s string) bool {
+ return s == "true" || s == "yes"
+}
+
+func itemAtIndexOrDefault[T any](items []T, index int, def T) T {
+ if index >= len(items) {
+ return def
+ }
+
+ return items[index]
+}
diff --git a/internal/glance/widget-bookmarks.go b/internal/glance/widget-bookmarks.go
new file mode 100644
index 0000000..3c7a69c
--- /dev/null
+++ b/internal/glance/widget-bookmarks.go
@@ -0,0 +1,34 @@
+package glance
+
+import (
+ "html/template"
+)
+
+var bookmarksWidgetTemplate = mustParseTemplate("bookmarks.html", "widget-base.html")
+
+type bookmarksWidget 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 customIconField `yaml:"icon"`
+ SameTab bool `yaml:"same-tab"`
+ HideArrow bool `yaml:"hide-arrow"`
+ } `yaml:"links"`
+ } `yaml:"groups"`
+}
+
+func (widget *bookmarksWidget) initialize() error {
+ widget.withTitle("Bookmarks").withError(nil)
+ widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate)
+
+ return nil
+}
+
+func (widget *bookmarksWidget) Render() template.HTML {
+ return widget.cachedHTML
+}
diff --git a/internal/glance/widget-calendar.go b/internal/glance/widget-calendar.go
new file mode 100644
index 0000000..518bc22
--- /dev/null
+++ b/internal/glance/widget-calendar.go
@@ -0,0 +1,86 @@
+package glance
+
+import (
+ "context"
+ "html/template"
+ "time"
+)
+
+var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html")
+
+type calendarWidget struct {
+ widgetBase `yaml:",inline"`
+ Calendar *calendar
+ StartSunday bool `yaml:"start-sunday"`
+}
+
+func (widget *calendarWidget) initialize() error {
+ widget.withTitle("Calendar").withCacheOnTheHour()
+
+ return nil
+}
+
+func (widget *calendarWidget) update(ctx context.Context) {
+ widget.Calendar = newCalendar(time.Now(), widget.StartSunday)
+ widget.withError(nil).scheduleNextUpdate()
+}
+
+func (widget *calendarWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, calendarWidgetTemplate)
+}
+
+type calendar struct {
+ CurrentDay int
+ CurrentWeekNumber int
+ CurrentMonthName string
+ CurrentYear int
+ Days []int
+}
+
+// TODO: very inflexible, refactor to allow more customizability
+// TODO: allow changing between showing the previous and next week and the entire month
+func newCalendar(now time.Time, startSunday bool) *calendar {
+ year, week := now.ISOWeek()
+ weekday := now.Weekday()
+ if !startSunday {
+ weekday = (weekday + 6) % 7 // Shift Monday to 0
+ }
+
+ currentMonthDays := daysInMonth(now.Month(), year)
+
+ var previousMonthDays int
+
+ if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
+ previousMonthDays = daysInMonth(12, year-1)
+ } else {
+ previousMonthDays = daysInMonth(previousMonthNumber, year)
+ }
+
+ startDaysFrom := now.Day() - int(weekday) - 7
+
+ days := make([]int, 21)
+
+ for i := 0; i < 21; i++ {
+ day := startDaysFrom + i
+
+ if day < 1 {
+ day = previousMonthDays + day
+ } else if day > currentMonthDays {
+ day = day - currentMonthDays
+ }
+
+ days[i] = day
+ }
+
+ return &calendar{
+ CurrentDay: now.Day(),
+ CurrentWeekNumber: week,
+ CurrentMonthName: now.Month().String(),
+ CurrentYear: year,
+ Days: days,
+ }
+}
+
+func daysInMonth(m time.Month, year int) int {
+ return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
+}
diff --git a/internal/feed/changedetection.go b/internal/glance/widget-changedetection.go
similarity index 52%
rename from internal/feed/changedetection.go
rename to internal/glance/widget-changedetection.go
index 793416d..ed2fc86 100644
--- a/internal/feed/changedetection.go
+++ b/internal/glance/widget-changedetection.go
@@ -1,7 +1,9 @@
-package feed
+package glance
import (
+ "context"
"fmt"
+ "html/template"
"log/slog"
"net/http"
"sort"
@@ -9,7 +11,65 @@ import (
"time"
)
-type ChangeDetectionWatch struct {
+var changeDetectionWidgetTemplate = mustParseTemplate("change-detection.html", "widget-base.html")
+
+type changeDetectionWidget struct {
+ widgetBase `yaml:",inline"`
+ ChangeDetections changeDetectionWatchList `yaml:"-"`
+ WatchUUIDs []string `yaml:"watches"`
+ InstanceURL string `yaml:"instance-url"`
+ Token optionalEnvField `yaml:"token"`
+ Limit int `yaml:"limit"`
+ CollapseAfter int `yaml:"collapse-after"`
+}
+
+func (widget *changeDetectionWidget) 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 *changeDetectionWidget) update(ctx context.Context) {
+ if len(widget.WatchUUIDs) == 0 {
+ uuids, err := fetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
+
+ if !widget.canContinueUpdateAfterHandlingErr(err) {
+ return
+ }
+
+ widget.WatchUUIDs = uuids
+ }
+
+ watches, err := 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 *changeDetectionWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, changeDetectionWidgetTemplate)
+}
+
+type changeDetectionWatch struct {
Title string
URL string
LastChanged time.Time
@@ -17,9 +77,9 @@ type ChangeDetectionWatch struct {
PreviousHash string
}
-type ChangeDetectionWatches []ChangeDetectionWatch
+type changeDetectionWatchList []changeDetectionWatch
-func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
+func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList {
sort.Slice(r, func(i, j int) bool {
return r[i].LastChanged.After(r[j].LastChanged)
})
@@ -35,15 +95,14 @@ type changeDetectionResponseJson struct {
PreviousHash string `json:"previous_md5"`
}
-func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
+func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
if token != "" {
request.Header.Add("x-api-key", token)
}
- uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request)
-
+ uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultHTTPClient, request)
if err != nil {
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
}
@@ -57,8 +116,8 @@ func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]str
return uuids, nil
}
-func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
- watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
+func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (changeDetectionWatchList, error) {
+ watches := make(changeDetectionWatchList, 0, len(requestedWatchIDs))
if len(requestedWatchIDs) == 0 {
return watches, nil
@@ -76,10 +135,9 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
requests[i] = request
}
- task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
+ task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultHTTPClient)
job := newJob(task, requests).withWorkers(15)
responses, errs, err := workerPoolDo(job)
-
if err != nil {
return nil, err
}
@@ -89,13 +147,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
for i := range responses {
if errs[i] != nil {
failed++
- slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL)
+ slog.Error("Failed to fetch or parse change detection watch", "url", requests[i].URL, "error", errs[i])
continue
}
watchJson := responses[i]
- watch := ChangeDetectionWatch{
+ watch := changeDetectionWatch{
URL: watchJson.URL,
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
}
@@ -126,13 +184,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
}
if len(watches) == 0 {
- return nil, ErrNoContent
+ return nil, errNoContent
}
- watches.SortByNewest()
+ watches.sortByNewest()
if failed > 0 {
- return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
+ return watches, fmt.Errorf("%w: could not get %d watches", errPartialContent, failed)
}
return watches, nil
diff --git a/internal/widget/clock.go b/internal/glance/widget-clock.go
similarity index 51%
rename from internal/widget/clock.go
rename to internal/glance/widget-clock.go
index efe8ccd..c69fc95 100644
--- a/internal/widget/clock.go
+++ b/internal/glance/widget-clock.go
@@ -1,15 +1,15 @@
-package widget
+package glance
import (
"errors"
"fmt"
"html/template"
"time"
-
- "github.com/glanceapp/glance/internal/assets"
)
-type Clock struct {
+var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html")
+
+type clockWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
HourFormat string `yaml:"hour-format"`
@@ -19,32 +19,30 @@ type Clock struct {
} `yaml:"timezones"`
}
-func (widget *Clock) Initialize() error {
+func (widget *clockWidget) 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")
+ return errors.New("hour-format 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")
+ return errors.New("missing timezone value")
}
- _, 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)
+ if _, err := time.LoadLocation(widget.Timezones[t].Timezone); err != nil {
+ return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err)
}
}
- widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
+ widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate)
return nil
}
-func (widget *Clock) Render() template.HTML {
+func (widget *clockWidget) Render() template.HTML {
return widget.cachedHTML
}
diff --git a/internal/glance/widget-container.go b/internal/glance/widget-container.go
new file mode 100644
index 0000000..4c9f33a
--- /dev/null
+++ b/internal/glance/widget-container.go
@@ -0,0 +1,58 @@
+package glance
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+type containerWidgetBase struct {
+ Widgets widgets `yaml:"widgets"`
+}
+
+func (widget *containerWidgetBase) _initializeWidgets() error {
+ for i := range widget.Widgets {
+ if err := widget.Widgets[i].initialize(); err != nil {
+ return formatWidgetInitError(err, widget.Widgets[i])
+ }
+ }
+
+ return nil
+}
+
+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 *widgetProviders) {
+ 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
+}
diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go
new file mode 100644
index 0000000..17f3ee8
--- /dev/null
+++ b/internal/glance/widget-custom-api.go
@@ -0,0 +1,208 @@
+package glance
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/tidwall/gjson"
+)
+
+var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
+
+type customAPIWidget struct {
+ widgetBase `yaml:",inline"`
+ URL optionalEnvField `yaml:"url"`
+ Template string `yaml:"template"`
+ Frameless bool `yaml:"frameless"`
+ Headers map[string]optionalEnvField `yaml:"headers"`
+ APIRequest *http.Request `yaml:"-"`
+ compiledTemplate *template.Template `yaml:"-"`
+ CompiledHTML template.HTML `yaml:"-"`
+}
+
+func (widget *customAPIWidget) initialize() error {
+ widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
+
+ if widget.URL == "" {
+ return errors.New("URL is required")
+ }
+
+ if widget.Template == "" {
+ return errors.New("template is required")
+ }
+
+ compiledTemplate, err := template.New("").Funcs(customAPITemplateFuncs).Parse(widget.Template)
+ if err != nil {
+ return fmt.Errorf("parsing 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 *customAPIWidget) update(ctx context.Context) {
+ compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
+ if !widget.canContinueUpdateAfterHandlingErr(err) {
+ return
+ }
+
+ widget.CompiledHTML = compiledHTML
+}
+
+func (widget *customAPIWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, customAPIWidgetTemplate)
+}
+
+func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
+ emptyBody := template.HTML("")
+
+ resp, err := defaultHTTPClient.Do(req)
+ if err != nil {
+ return emptyBody, err
+ }
+ defer resp.Body.Close()
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return emptyBody, err
+ }
+
+ body := string(bodyBytes)
+
+ if !gjson.Valid(body) {
+ truncatedBody, isTruncated := limitStringLength(body, 100)
+ if isTruncated {
+ truncatedBody += "...
"
+ }
+
+ slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), "body", truncatedBody)
+ return emptyBody, errors.New("invalid response JSON")
+ }
+
+ var templateBuffer bytes.Buffer
+
+ data := CustomAPITemplateData{
+ JSON: decoratedGJSONResult{gjson.Parse(body)},
+ Response: resp,
+ }
+
+ err = tmpl.Execute(&templateBuffer, &data)
+ if err != nil {
+ return emptyBody, err
+ }
+
+ return template.HTML(templateBuffer.String()), nil
+}
+
+type decoratedGJSONResult struct {
+ gjson.Result
+}
+
+type CustomAPITemplateData struct {
+ JSON decoratedGJSONResult
+ Response *http.Response
+}
+
+func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
+ decoratedResults := make([]decoratedGJSONResult, len(results))
+
+ for i, result := range results {
+ decoratedResults[i] = decoratedGJSONResult{result}
+ }
+
+ return decoratedResults
+}
+
+func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
+ if key == "" {
+ return gJsonResultArrayToDecoratedResultArray(r.Result.Array())
+ }
+
+ return gJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
+}
+
+func (r *decoratedGJSONResult) String(key string) string {
+ if key == "" {
+ return r.Result.String()
+ }
+
+ return r.Get(key).String()
+}
+
+func (r *decoratedGJSONResult) Int(key string) int64 {
+ if key == "" {
+ return r.Result.Int()
+ }
+
+ return r.Get(key).Int()
+}
+
+func (r *decoratedGJSONResult) Float(key string) float64 {
+ if key == "" {
+ return r.Result.Float()
+ }
+
+ return r.Get(key).Float()
+}
+
+func (r *decoratedGJSONResult) Bool(key string) bool {
+ if key == "" {
+ return r.Result.Bool()
+ }
+
+ return r.Get(key).Bool()
+}
+
+var customAPITemplateFuncs = func() template.FuncMap {
+ funcs := template.FuncMap{
+ "toFloat": func(a int64) float64 {
+ return float64(a)
+ },
+ "toInt": func(a float64) int64 {
+ return int64(a)
+ },
+ "mathexpr": func(left float64, op string, right float64) float64 {
+ if right == 0 {
+ return 0
+ }
+
+ switch op {
+ case "+":
+ return left + right
+ case "-":
+ return left - right
+ case "*":
+ return left * right
+ case "/":
+ return left / right
+ default:
+ return 0
+ }
+ },
+ }
+
+ for key, value := range globalTemplateFunctions {
+ funcs[key] = value
+ }
+
+ return funcs
+}()
diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go
new file mode 100644
index 0000000..8b004b6
--- /dev/null
+++ b/internal/glance/widget-dns-stats.go
@@ -0,0 +1,352 @@
+package glance
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+)
+
+var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html")
+
+type dnsStatsWidget struct {
+ widgetBase `yaml:",inline"`
+
+ TimeLabels [8]string `yaml:"-"`
+ Stats *dnsStats `yaml:"-"`
+
+ HourFormat string `yaml:"hour-format"`
+ Service string `yaml:"service"`
+ AllowInsecure bool `yaml:"allow-insecure"`
+ URL optionalEnvField `yaml:"url"`
+ Token optionalEnvField `yaml:"token"`
+ Username optionalEnvField `yaml:"username"`
+ Password optionalEnvField `yaml:"password"`
+}
+
+func makeDNSWidgetTimeLabels(format string) [8]string {
+ now := time.Now()
+ var labels [8]string
+
+ for h := 24; h > 0; h -= 3 {
+ labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format))
+ }
+
+ return labels
+}
+
+func (widget *dnsStatsWidget) initialize() error {
+ widget.
+ withTitle("DNS Stats").
+ withTitleURL(string(widget.URL)).
+ withCacheDuration(10 * time.Minute)
+
+ if widget.Service != "adguard" && widget.Service != "pihole" {
+ return errors.New("service must be either 'adguard' or 'pihole'")
+ }
+
+ return nil
+}
+
+func (widget *dnsStatsWidget) update(ctx context.Context) {
+ var stats *dnsStats
+ var err error
+
+ if widget.Service == "adguard" {
+ stats, err = fetchAdguardStats(string(widget.URL), widget.AllowInsecure, string(widget.Username), string(widget.Password))
+ } else {
+ stats, err = fetchPiholeStats(string(widget.URL), widget.AllowInsecure, string(widget.Token))
+ }
+
+ if !widget.canContinueUpdateAfterHandlingErr(err) {
+ return
+ }
+
+ if widget.HourFormat == "24h" {
+ widget.TimeLabels = makeDNSWidgetTimeLabels("15:00")
+ } else {
+ widget.TimeLabels = makeDNSWidgetTimeLabels("3PM")
+ }
+
+ widget.Stats = stats
+}
+
+func (widget *dnsStatsWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, dnsStatsWidgetTemplate)
+}
+
+type dnsStats struct {
+ TotalQueries int
+ BlockedQueries int
+ BlockedPercent int
+ ResponseTime int
+ DomainsBlocked int
+ Series [8]dnsStatsSeries
+ TopBlockedDomains []dnsStatsBlockedDomain
+}
+
+type dnsStatsSeries struct {
+ Queries int
+ Blocked int
+ PercentTotal int
+ PercentBlocked int
+}
+
+type dnsStatsBlockedDomain struct {
+ Domain string
+ PercentBlocked int
+}
+
+type adguardStatsResponse struct {
+ TotalQueries int `json:"num_dns_queries"`
+ QueriesSeries []int `json:"dns_queries"`
+ BlockedQueries int `json:"num_blocked_filtering"`
+ BlockedSeries []int `json:"blocked_filtering"`
+ ResponseTime float64 `json:"avg_processing_time"`
+ TopBlockedDomains []map[string]int `json:"top_blocked_domains"`
+}
+
+func fetchAdguardStats(instanceURL string, allowInsecure bool, username, password string) (*dnsStats, error) {
+ requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats"
+
+ request, err := http.NewRequest("GET", requestURL, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ request.SetBasicAuth(username, password)
+
+ var client requestDoer
+ if !allowInsecure {
+ client = defaultHTTPClient
+ } else {
+ client = defaultInsecureHTTPClient
+ }
+
+ responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request)
+ if err != nil {
+ return nil, err
+ }
+
+ var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
+
+ stats := &dnsStats{
+ TotalQueries: responseJson.TotalQueries,
+ BlockedQueries: responseJson.BlockedQueries,
+ ResponseTime: int(responseJson.ResponseTime * 1000),
+ TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount),
+ }
+
+ if stats.TotalQueries <= 0 {
+ return stats, nil
+ }
+
+ stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
+
+ for i := 0; i < topBlockedDomainsCount; i++ {
+ domain := responseJson.TopBlockedDomains[i]
+ var firstDomain string
+
+ for k := range domain {
+ firstDomain = k
+ break
+ }
+
+ if firstDomain == "" {
+ continue
+ }
+
+ stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{
+ Domain: firstDomain,
+ })
+
+ if stats.BlockedQueries > 0 {
+ stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
+ }
+ }
+
+ queriesSeries := responseJson.QueriesSeries
+ blockedSeries := responseJson.BlockedSeries
+
+ const bars = 8
+ const hoursSpan = 24
+ const hoursPerBar int = hoursSpan / bars
+
+ if len(queriesSeries) > hoursSpan {
+ queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
+ } else if len(queriesSeries) < hoursSpan {
+ queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
+ }
+
+ if len(blockedSeries) > hoursSpan {
+ blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
+ } else if len(blockedSeries) < hoursSpan {
+ blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
+ }
+
+ maxQueriesInSeries := 0
+
+ for i := 0; i < bars; i++ {
+ queries := 0
+ blocked := 0
+
+ for j := 0; j < hoursPerBar; j++ {
+ queries += queriesSeries[i*hoursPerBar+j]
+ blocked += blockedSeries[i*hoursPerBar+j]
+ }
+
+ stats.Series[i] = dnsStatsSeries{
+ Queries: queries,
+ Blocked: blocked,
+ }
+
+ if queries > 0 {
+ stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
+ }
+
+ if queries > maxQueriesInSeries {
+ maxQueriesInSeries = queries
+ }
+ }
+
+ for i := 0; i < bars; i++ {
+ stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
+ }
+
+ return stats, nil
+}
+
+type piholeStatsResponse struct {
+ TotalQueries int `json:"dns_queries_today"`
+ QueriesSeries map[int64]int `json:"domains_over_time"`
+ BlockedQueries int `json:"ads_blocked_today"`
+ BlockedSeries map[int64]int `json:"ads_over_time"`
+ BlockedPercentage float64 `json:"ads_percentage_today"`
+ TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
+ DomainsBlocked int `json:"domains_being_blocked"`
+}
+
+// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
+// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
+type piholeTopBlockedDomains map[string]int
+
+func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
+ // NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
+ // because of the UnmarshalJSON method getting called recursively
+ temp := make(map[string]int)
+
+ err := json.Unmarshal(data, &temp)
+ if err != nil {
+ *p = make(piholeTopBlockedDomains)
+ } else {
+ *p = temp
+ }
+
+ return nil
+}
+
+func fetchPiholeStats(instanceURL string, allowInsecure bool, token string) (*dnsStats, error) {
+ if token == "" {
+ return nil, errors.New("missing API token")
+ }
+
+ requestURL := strings.TrimRight(instanceURL, "/") +
+ "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
+
+ request, err := http.NewRequest("GET", requestURL, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var client requestDoer
+ if !allowInsecure {
+ client = defaultHTTPClient
+ } else {
+ client = defaultInsecureHTTPClient
+ }
+
+ responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request)
+ if err != nil {
+ return nil, err
+ }
+
+ stats := &dnsStats{
+ TotalQueries: responseJson.TotalQueries,
+ BlockedQueries: responseJson.BlockedQueries,
+ BlockedPercent: int(responseJson.BlockedPercentage),
+ DomainsBlocked: responseJson.DomainsBlocked,
+ }
+
+ if len(responseJson.TopBlockedDomains) > 0 {
+ domains := make([]dnsStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains))
+
+ for domain, count := range responseJson.TopBlockedDomains {
+ domains = append(domains, dnsStatsBlockedDomain{
+ Domain: domain,
+ PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100),
+ })
+ }
+
+ sort.Slice(domains, func(a, b int) bool {
+ return domains[a].PercentBlocked > domains[b].PercentBlocked
+ })
+
+ stats.TopBlockedDomains = domains[:min(len(domains), 5)]
+ }
+
+ // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
+ if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
+ slog.Warn(
+ "DNS stats for pihole: did not get expected 144 data points",
+ "len(queries)", len(responseJson.QueriesSeries),
+ "len(blocked)", len(responseJson.BlockedSeries),
+ )
+ return stats, nil
+ }
+
+ var lowestTimestamp int64 = 0
+
+ for timestamp := range responseJson.QueriesSeries {
+ if lowestTimestamp == 0 || timestamp < lowestTimestamp {
+ lowestTimestamp = timestamp
+ }
+ }
+
+ maxQueriesInSeries := 0
+
+ for i := 0; i < 8; i++ {
+ queries := 0
+ blocked := 0
+
+ for j := 0; j < 18; j++ {
+ index := lowestTimestamp + int64(i*10800+j*600)
+
+ queries += responseJson.QueriesSeries[index]
+ blocked += responseJson.BlockedSeries[index]
+ }
+
+ if queries > maxQueriesInSeries {
+ maxQueriesInSeries = queries
+ }
+
+ stats.Series[i] = dnsStatsSeries{
+ Queries: queries,
+ Blocked: blocked,
+ }
+
+ if queries > 0 {
+ stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
+ }
+ }
+
+ for i := 0; i < 8; i++ {
+ stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
+ }
+
+ return stats, nil
+}
diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go
new file mode 100644
index 0000000..fb1bef3
--- /dev/null
+++ b/internal/glance/widget-docker-containers.go
@@ -0,0 +1,272 @@
+package glance
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+)
+
+var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html", "widget-base.html")
+
+type dockerContainersWidget struct {
+ widgetBase `yaml:",inline"`
+ HideByDefault bool `yaml:"hide-by-default"`
+ SockPath string `yaml:"sock-path"`
+ Containers dockerContainerList `yaml:"-"`
+}
+
+func (widget *dockerContainersWidget) initialize() error {
+ widget.withTitle("Docker Containers").withCacheDuration(1 * time.Minute)
+
+ if widget.SockPath == "" {
+ widget.SockPath = "/var/run/docker.sock"
+ }
+
+ return nil
+}
+
+func (widget *dockerContainersWidget) update(ctx context.Context) {
+ containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault)
+ if !widget.canContinueUpdateAfterHandlingErr(err) {
+ return
+ }
+
+ containers.sortByStateIconThenTitle()
+ widget.Containers = containers
+}
+
+func (widget *dockerContainersWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, dockerContainersWidgetTemplate)
+}
+
+const (
+ dockerContainerLabelHide = "glance.hide"
+ dockerContainerLabelTitle = "glance.title"
+ dockerContainerLabelURL = "glance.url"
+ dockerContainerLabelDescription = "glance.description"
+ dockerContainerLabelSameTab = "glance.same-tab"
+ dockerContainerLabelIcon = "glance.icon"
+ dockerContainerLabelID = "glance.id"
+ dockerContainerLabelParent = "glance.parent"
+)
+
+const (
+ dockerContainerStateIconOK = "ok"
+ dockerContainerStateIconPaused = "paused"
+ dockerContainerStateIconWarn = "warn"
+ dockerContainerStateIconOther = "other"
+)
+
+var dockerContainerStateIconPriorities = map[string]int{
+ dockerContainerStateIconWarn: 0,
+ dockerContainerStateIconOther: 1,
+ dockerContainerStateIconPaused: 2,
+ dockerContainerStateIconOK: 3,
+}
+
+type dockerContainerJsonResponse struct {
+ Names []string `json:"Names"`
+ Image string `json:"Image"`
+ State string `json:"State"`
+ Status string `json:"Status"`
+ Labels dockerContainerLabels `json:"Labels"`
+}
+
+type dockerContainerLabels map[string]string
+
+func (l *dockerContainerLabels) getOrDefault(label, def string) string {
+ if l == nil {
+ return def
+ }
+
+ v, ok := (*l)[label]
+ if !ok {
+ return def
+ }
+
+ if v == "" {
+ return def
+ }
+
+ return v
+}
+
+type dockerContainer struct {
+ Title string
+ URL string
+ SameTab bool
+ Image string
+ State string
+ StateText string
+ StateIcon string
+ Description string
+ Icon customIconField
+ Children dockerContainerList
+}
+
+type dockerContainerList []dockerContainer
+
+func (containers dockerContainerList) sortByStateIconThenTitle() {
+ sort.SliceStable(containers, func(a, b int) bool {
+ p := &dockerContainerStateIconPriorities
+ if containers[a].StateIcon != containers[b].StateIcon {
+ return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon]
+ }
+
+ return strings.ToLower(containers[a].Title) < strings.ToLower(containers[b].Title)
+ })
+}
+
+func dockerContainerStateToStateIcon(state string) string {
+ switch state {
+ case "running":
+ return dockerContainerStateIconOK
+ case "paused":
+ return dockerContainerStateIconPaused
+ case "exited", "unhealthy", "dead":
+ return dockerContainerStateIconWarn
+ default:
+ return dockerContainerStateIconOther
+ }
+}
+
+func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContainerList, error) {
+ containers, err := fetchAllDockerContainersFromSock(socketPath)
+ if err != nil {
+ return nil, fmt.Errorf("fetching containers: %w", err)
+ }
+
+ containers, children := groupDockerContainerChildren(containers, hideByDefault)
+ dockerContainers := make(dockerContainerList, 0, len(containers))
+
+ for i := range containers {
+ container := &containers[i]
+
+ dc := dockerContainer{
+ Title: deriveDockerContainerTitle(container),
+ URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""),
+ Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""),
+ SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")),
+ Image: container.Image,
+ State: strings.ToLower(container.State),
+ StateText: strings.ToLower(container.Status),
+ Icon: newCustomIconField(container.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")),
+ }
+
+ if idValue := container.Labels.getOrDefault(dockerContainerLabelID, ""); idValue != "" {
+ if children, ok := children[idValue]; ok {
+ for i := range children {
+ child := &children[i]
+ dc.Children = append(dc.Children, dockerContainer{
+ Title: deriveDockerContainerTitle(child),
+ StateText: child.Status,
+ StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)),
+ })
+ }
+ }
+ }
+
+ dc.Children.sortByStateIconThenTitle()
+
+ stateIconSupersededByChild := false
+ for i := range dc.Children {
+ if dc.Children[i].StateIcon == dockerContainerStateIconWarn {
+ dc.StateIcon = dockerContainerStateIconWarn
+ stateIconSupersededByChild = true
+ break
+ }
+ }
+ if !stateIconSupersededByChild {
+ dc.StateIcon = dockerContainerStateToStateIcon(dc.State)
+ }
+
+ dockerContainers = append(dockerContainers, dc)
+ }
+
+ return dockerContainers, nil
+}
+
+func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string {
+ if v := container.Labels.getOrDefault(dockerContainerLabelTitle, ""); v != "" {
+ return v
+ }
+
+ return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/")
+}
+
+func groupDockerContainerChildren(
+ containers []dockerContainerJsonResponse,
+ hideByDefault bool,
+) (
+ []dockerContainerJsonResponse,
+ map[string][]dockerContainerJsonResponse,
+) {
+ parents := make([]dockerContainerJsonResponse, 0, len(containers))
+ children := make(map[string][]dockerContainerJsonResponse)
+
+ for i := range containers {
+ container := &containers[i]
+
+ if isDockerContainerHidden(container, hideByDefault) {
+ continue
+ }
+
+ isParent := container.Labels.getOrDefault(dockerContainerLabelID, "") != ""
+ parent := container.Labels.getOrDefault(dockerContainerLabelParent, "")
+
+ if !isParent && parent != "" {
+ children[parent] = append(children[parent], *container)
+ } else {
+ parents = append(parents, *container)
+ }
+ }
+
+ return parents, children
+}
+
+func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefault bool) bool {
+ if v := container.Labels.getOrDefault(dockerContainerLabelHide, ""); v != "" {
+ return stringToBool(v)
+ }
+
+ return hideByDefault
+}
+
+func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) {
+ client := &http.Client{
+ Timeout: 3 * time.Second,
+ Transport: &http.Transport{
+ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+ return net.Dial("unix", socketPath)
+ },
+ },
+ }
+
+ request, err := http.NewRequest("GET", "http://docker/containers/json?all=true", nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("sending request to socket: %w", err)
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("non-200 response status: %s", response.Status)
+ }
+
+ var containers []dockerContainerJsonResponse
+ if err := json.NewDecoder(response.Body).Decode(&containers); err != nil {
+ return nil, fmt.Errorf("decoding response: %w", err)
+ }
+
+ return containers, nil
+}
diff --git a/internal/glance/widget-extension.go b/internal/glance/widget-extension.go
new file mode 100644
index 0000000..d90c48f
--- /dev/null
+++ b/internal/glance/widget-extension.go
@@ -0,0 +1,152 @@
+package glance
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html"
+ "html/template"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+var extensionWidgetTemplate = mustParseTemplate("extension.html", "widget-base.html")
+
+type extensionWidget 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 extension `yaml:"-"`
+ cachedHTML template.HTML `yaml:"-"`
+}
+
+func (widget *extensionWidget) initialize() error {
+ widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
+
+ if widget.URL == "" {
+ return errors.New("URL is required")
+ }
+
+ if _, err := url.Parse(widget.URL); err != nil {
+ return fmt.Errorf("parsing URL: %v", err)
+ }
+
+ return nil
+}
+
+func (widget *extensionWidget) update(ctx context.Context) {
+ extension, err := fetchExtension(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.renderTemplate(widget, extensionWidgetTemplate)
+}
+
+func (widget *extensionWidget) Render() template.HTML {
+ return widget.cachedHTML
+}
+
+type extensionType int
+
+const (
+ extensionContentHTML extensionType = iota
+ extensionContentUnknown = iota
+)
+
+var extensionStringToType = map[string]extensionType{
+ "html": extensionContentHTML,
+}
+
+const (
+ extensionHeaderTitle = "Widget-Title"
+ extensionHeaderContentType = "Widget-Content-Type"
+)
+
+type extensionRequestOptions struct {
+ URL string `yaml:"url"`
+ FallbackContentType string `yaml:"fallback-content-type"`
+ Parameters map[string]string `yaml:"parameters"`
+ AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
+}
+
+type extension struct {
+ Title string
+ Content template.HTML
+}
+
+func convertExtensionContent(options extensionRequestOptions, content []byte, contentType extensionType) template.HTML {
+ switch contentType {
+ case extensionContentHTML:
+ if options.AllowHtml {
+ return template.HTML(content)
+ }
+
+ fallthrough
+ default:
+ return template.HTML(html.EscapeString(string(content)))
+ }
+}
+
+func fetchExtension(options extensionRequestOptions) (extension, error) {
+ request, _ := http.NewRequest("GET", options.URL, nil)
+
+ query := url.Values{}
+
+ for key, value := range options.Parameters {
+ query.Set(key, value)
+ }
+
+ request.URL.RawQuery = query.Encode()
+
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ slog.Error("Failed fetching extension", "url", options.URL, "error", err)
+ return extension{}, fmt.Errorf("%w: request failed: %w", errNoContent, err)
+ }
+
+ defer response.Body.Close()
+
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ slog.Error("Failed reading response body of extension", "url", options.URL, "error", err)
+ return extension{}, fmt.Errorf("%w: could not read body: %w", errNoContent, err)
+ }
+
+ extension := extension{}
+
+ if response.Header.Get(extensionHeaderTitle) == "" {
+ extension.Title = "Extension"
+ } else {
+ extension.Title = response.Header.Get(extensionHeaderTitle)
+ }
+
+ contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)]
+
+ if !ok {
+ contentType, ok = extensionStringToType[options.FallbackContentType]
+
+ if !ok {
+ contentType = extensionContentUnknown
+ }
+ }
+
+ extension.Content = convertExtensionContent(options, body, contentType)
+
+ return extension, nil
+}
diff --git a/internal/glance/widget-group.go b/internal/glance/widget-group.go
new file mode 100644
index 0000000..2ea3813
--- /dev/null
+++ b/internal/glance/widget-group.go
@@ -0,0 +1,52 @@
+package glance
+
+import (
+ "context"
+ "errors"
+ "html/template"
+ "time"
+)
+
+var groupWidgetTemplate = mustParseTemplate("group.html", "widget-base.html")
+
+type groupWidget struct {
+ widgetBase `yaml:",inline"`
+ containerWidgetBase `yaml:",inline"`
+}
+
+func (widget *groupWidget) 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.containerWidgetBase._initializeWidgets(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (widget *groupWidget) update(ctx context.Context) {
+ widget.containerWidgetBase._update(ctx)
+}
+
+func (widget *groupWidget) setProviders(providers *widgetProviders) {
+ widget.containerWidgetBase._setProviders(providers)
+}
+
+func (widget *groupWidget) requiresUpdate(now *time.Time) bool {
+ return widget.containerWidgetBase._requiresUpdate(now)
+}
+
+func (widget *groupWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, groupWidgetTemplate)
+}
diff --git a/internal/glance/widget-hacker-news.go b/internal/glance/widget-hacker-news.go
new file mode 100644
index 0000000..ad00df0
--- /dev/null
+++ b/internal/glance/widget-hacker-news.go
@@ -0,0 +1,152 @@
+package glance
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type hackerNewsWidget struct {
+ widgetBase `yaml:",inline"`
+ Posts forumPostList `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 *hackerNewsWidget) 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 *hackerNewsWidget) update(ctx context.Context) {
+ posts, err := 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 *hackerNewsWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, forumPostsTemplate)
+}
+
+type hackerNewsPostResponseJson struct {
+ Id int `json:"id"`
+ Score int `json:"score"`
+ Title string `json:"title"`
+ TargetUrl string `json:"url,omitempty"`
+ CommentCount int `json:"descendants"`
+ TimePosted int64 `json:"time"`
+}
+
+func fetchHackerNewsPostIds(sort string) ([]int, error) {
+ request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
+ response, err := decodeJsonFromRequest[[]int](defaultHTTPClient, request)
+ if err != nil {
+ return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent)
+ }
+
+ return response, nil
+}
+
+func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) {
+ requests := make([]*http.Request, len(postIds))
+
+ for i, id := range postIds {
+ request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
+ requests[i] = request
+ }
+
+ task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultHTTPClient)
+ job := newJob(task, requests).withWorkers(30)
+ results, errs, err := workerPoolDo(job)
+ if err != nil {
+ return nil, err
+ }
+
+ posts := make(forumPostList, 0, len(postIds))
+
+ for i := range results {
+ if errs[i] != nil {
+ slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
+ continue
+ }
+
+ var commentsUrl string
+
+ if commentsUrlTemplate == "" {
+ commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
+ } else {
+ commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
+ }
+
+ posts = append(posts, forumPost{
+ Title: results[i].Title,
+ DiscussionUrl: commentsUrl,
+ TargetUrl: results[i].TargetUrl,
+ TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
+ CommentCount: results[i].CommentCount,
+ Score: results[i].Score,
+ TimePosted: time.Unix(results[i].TimePosted, 0),
+ })
+ }
+
+ if len(posts) == 0 {
+ return nil, errNoContent
+ }
+
+ if len(posts) != len(postIds) {
+ return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent)
+ }
+
+ return posts, nil
+}
+
+func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) {
+ postIds, err := fetchHackerNewsPostIds(sort)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(postIds) > limit {
+ postIds = postIds[:limit]
+ }
+
+ return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
+}
diff --git a/internal/widget/html.go b/internal/glance/widget-html.go
similarity index 56%
rename from internal/widget/html.go
rename to internal/glance/widget-html.go
index 6c66488..0e32a46 100644
--- a/internal/widget/html.go
+++ b/internal/glance/widget-html.go
@@ -1,20 +1,20 @@
-package widget
+package glance
import (
"html/template"
)
-type HTML struct {
+type htmlWidget struct {
widgetBase `yaml:",inline"`
Source template.HTML `yaml:"source"`
}
-func (widget *HTML) Initialize() error {
+func (widget *htmlWidget) initialize() error {
widget.withTitle("").withError(nil)
return nil
}
-func (widget *HTML) Render() template.HTML {
+func (widget *htmlWidget) Render() template.HTML {
return widget.Source
}
diff --git a/internal/widget/iframe.go b/internal/glance/widget-iframe.go
similarity index 50%
rename from internal/widget/iframe.go
rename to internal/glance/widget-iframe.go
index 44d0822..830b383 100644
--- a/internal/widget/iframe.go
+++ b/internal/glance/widget-iframe.go
@@ -1,32 +1,30 @@
-package widget
+package glance
import (
"errors"
"fmt"
"html/template"
"net/url"
-
- "github.com/glanceapp/glance/internal/assets"
)
-type IFrame struct {
+var iframeWidgetTemplate = mustParseTemplate("iframe.html", "widget-base.html")
+
+type iframeWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
Source string `yaml:"source"`
Height int `yaml:"height"`
}
-func (widget *IFrame) Initialize() error {
+func (widget *iframeWidget) initialize() error {
widget.withTitle("IFrame").withError(nil)
if widget.Source == "" {
- return errors.New("missing source for iframe")
+ return errors.New("source is required")
}
- _, err := url.Parse(widget.Source)
-
- if err != nil {
- return fmt.Errorf("invalid source for iframe: %v", err)
+ if _, err := url.Parse(widget.Source); err != nil {
+ return fmt.Errorf("parsing URL: %v", err)
}
if widget.Height == 50 {
@@ -35,11 +33,11 @@ func (widget *IFrame) Initialize() error {
widget.Height = 50
}
- widget.cachedHTML = widget.render(widget, assets.IFrameTemplate)
+ widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate)
return nil
}
-func (widget *IFrame) Render() template.HTML {
+func (widget *iframeWidget) Render() template.HTML {
return widget.cachedHTML
}
diff --git a/internal/glance/widget-lobsters.go b/internal/glance/widget-lobsters.go
new file mode 100644
index 0000000..786d1df
--- /dev/null
+++ b/internal/glance/widget-lobsters.go
@@ -0,0 +1,144 @@
+package glance
+
+import (
+ "context"
+ "html/template"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type lobstersWidget struct {
+ widgetBase `yaml:",inline"`
+ Posts forumPostList `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 *lobstersWidget) 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 *lobstersWidget) update(ctx context.Context) {
+ posts, err := 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 *lobstersWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, forumPostsTemplate)
+}
+
+type lobstersPostResponseJson struct {
+ CreatedAt string `json:"created_at"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Score int `json:"score"`
+ CommentCount int `json:"comment_count"`
+ CommentsURL string `json:"comments_url"`
+ Tags []string `json:"tags"`
+}
+
+type lobstersFeedResponseJson []lobstersPostResponseJson
+
+func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) {
+ request, err := http.NewRequest("GET", feedUrl, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultHTTPClient, request)
+ if err != nil {
+ return nil, err
+ }
+
+ posts := make(forumPostList, 0, len(feed))
+
+ for i := range feed {
+ createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
+
+ posts = append(posts, forumPost{
+ Title: feed[i].Title,
+ DiscussionUrl: feed[i].CommentsURL,
+ TargetUrl: feed[i].URL,
+ TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
+ CommentCount: feed[i].CommentCount,
+ Score: feed[i].Score,
+ TimePosted: createdAt,
+ Tags: feed[i].Tags,
+ })
+ }
+
+ if len(posts) == 0 {
+ return nil, errNoContent
+ }
+
+ return posts, nil
+}
+
+func fetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (forumPostList, error) {
+ var feedUrl string
+
+ if customURL != "" {
+ feedUrl = customURL
+ } else {
+ if instanceURL != "" {
+ instanceURL = strings.TrimRight(instanceURL, "/") + "/"
+ } else {
+ instanceURL = "https://lobste.rs/"
+ }
+
+ if sortBy == "hot" {
+ sortBy = "hottest"
+ } else if sortBy == "new" {
+ sortBy = "newest"
+ }
+
+ if len(tags) == 0 {
+ feedUrl = instanceURL + sortBy + ".json"
+ } else {
+ tags := strings.Join(tags, ",")
+ feedUrl = instanceURL + "t/" + tags + ".json"
+ }
+ }
+
+ posts, err := fetchLobstersPostsFromFeed(feedUrl)
+ if err != nil {
+ return nil, err
+ }
+
+ return posts, nil
+}
diff --git a/internal/glance/widget-markets.go b/internal/glance/widget-markets.go
new file mode 100644
index 0000000..48df6fc
--- /dev/null
+++ b/internal/glance/widget-markets.go
@@ -0,0 +1,205 @@
+package glance
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "math"
+ "net/http"
+ "sort"
+ "time"
+)
+
+var marketsWidgetTemplate = mustParseTemplate("markets.html", "widget-base.html")
+
+type marketsWidget struct {
+ widgetBase `yaml:",inline"`
+ StocksRequests []marketRequest `yaml:"stocks"`
+ MarketRequests []marketRequest `yaml:"markets"`
+ Sort string `yaml:"sort-by"`
+ Markets marketList `yaml:"-"`
+}
+
+func (widget *marketsWidget) initialize() error {
+ widget.withTitle("Markets").withCacheDuration(time.Hour)
+
+ // legacy support, remove in v0.10.0
+ if len(widget.MarketRequests) == 0 {
+ widget.MarketRequests = widget.StocksRequests
+ }
+
+ return nil
+}
+
+func (widget *marketsWidget) update(ctx context.Context) {
+ markets, err := 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 *marketsWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, marketsWidgetTemplate)
+}
+
+type marketRequest struct {
+ Name string `yaml:"name"`
+ Symbol string `yaml:"symbol"`
+ ChartLink string `yaml:"chart-link"`
+ SymbolLink string `yaml:"symbol-link"`
+}
+
+type market struct {
+ marketRequest
+ Currency string
+ Price float64
+ PercentChange float64
+ SvgChartPoints string
+}
+
+type marketList []market
+
+func (t marketList) sortByAbsChange() {
+ sort.Slice(t, func(i, j int) bool {
+ return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
+ })
+}
+
+func (t marketList) sortByChange() {
+ sort.Slice(t, func(i, j int) bool {
+ return t[i].PercentChange > t[j].PercentChange
+ })
+}
+
+type marketResponseJson struct {
+ Chart struct {
+ Result []struct {
+ Meta struct {
+ Currency string `json:"currency"`
+ Symbol string `json:"symbol"`
+ RegularMarketPrice float64 `json:"regularMarketPrice"`
+ ChartPreviousClose float64 `json:"chartPreviousClose"`
+ } `json:"meta"`
+ Indicators struct {
+ Quote []struct {
+ Close []float64 `json:"close,omitempty"`
+ } `json:"quote"`
+ } `json:"indicators"`
+ } `json:"result"`
+ } `json:"chart"`
+}
+
+// TODO: allow changing chart time frame
+const marketChartDays = 21
+
+func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, error) {
+ requests := make([]*http.Request, 0, len(marketRequests))
+
+ for i := range marketRequests {
+ request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
+ requests = append(requests, request)
+ }
+
+ job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultHTTPClient), requests)
+ responses, errs, err := workerPoolDo(job)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", errNoContent, err)
+ }
+
+ markets := make(marketList, 0, len(responses))
+ var failed int
+
+ for i := range responses {
+ if errs[i] != nil {
+ failed++
+ slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
+ continue
+ }
+
+ response := responses[i]
+
+ if len(response.Chart.Result) == 0 {
+ failed++
+ slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
+ continue
+ }
+
+ prices := response.Chart.Result[0].Indicators.Quote[0].Close
+
+ if len(prices) > marketChartDays {
+ prices = prices[len(prices)-marketChartDays:]
+ }
+
+ previous := response.Chart.Result[0].Meta.RegularMarketPrice
+
+ if len(prices) >= 2 && prices[len(prices)-2] != 0 {
+ previous = prices[len(prices)-2]
+ }
+
+ points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
+
+ currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
+
+ if !exists {
+ currency = response.Chart.Result[0].Meta.Currency
+ }
+
+ markets = append(markets, market{
+ marketRequest: marketRequests[i],
+ Price: response.Chart.Result[0].Meta.RegularMarketPrice,
+ Currency: currency,
+ PercentChange: percentChange(
+ response.Chart.Result[0].Meta.RegularMarketPrice,
+ previous,
+ ),
+ SvgChartPoints: points,
+ })
+ }
+
+ if len(markets) == 0 {
+ return nil, errNoContent
+ }
+
+ if failed > 0 {
+ return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", errPartialContent, failed)
+ }
+
+ return markets, nil
+}
+
+var currencyToSymbol = map[string]string{
+ "USD": "$",
+ "EUR": "€",
+ "JPY": "¥",
+ "CAD": "C$",
+ "AUD": "A$",
+ "GBP": "£",
+ "CHF": "Fr",
+ "NZD": "N$",
+ "INR": "₹",
+ "BRL": "R$",
+ "RUB": "₽",
+ "TRY": "₺",
+ "ZAR": "R",
+ "CNY": "¥",
+ "KRW": "₩",
+ "HKD": "HK$",
+ "SGD": "S$",
+ "SEK": "kr",
+ "NOK": "kr",
+ "DKK": "kr",
+ "PLN": "zł",
+ "PHP": "₱",
+}
diff --git a/internal/glance/widget-monitor.go b/internal/glance/widget-monitor.go
new file mode 100644
index 0000000..09d92ab
--- /dev/null
+++ b/internal/glance/widget-monitor.go
@@ -0,0 +1,176 @@
+package glance
+
+import (
+ "context"
+ "errors"
+ "html/template"
+ "net/http"
+ "slices"
+ "strconv"
+ "time"
+)
+
+var (
+ monitorWidgetTemplate = mustParseTemplate("monitor.html", "widget-base.html")
+ monitorWidgetCompactTemplate = mustParseTemplate("monitor-compact.html", "widget-base.html")
+)
+
+type monitorWidget struct {
+ widgetBase `yaml:",inline"`
+ Sites []struct {
+ *SiteStatusRequest `yaml:",inline"`
+ Status *SiteStatus `yaml:"-"`
+ Title string `yaml:"title"`
+ Icon customIconField `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 *monitorWidget) initialize() error {
+ widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
+
+ return nil
+}
+
+func (widget *monitorWidget) update(ctx context.Context) {
+ requests := make([]*SiteStatusRequest, len(widget.Sites))
+
+ for i := range widget.Sites {
+ requests[i] = widget.Sites[i].SiteStatusRequest
+ }
+
+ statuses, err := 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 *monitorWidget) Render() template.HTML {
+ if widget.Style == "compact" {
+ return widget.renderTemplate(widget, monitorWidgetCompactTemplate)
+ }
+
+ return widget.renderTemplate(widget, monitorWidgetTemplate)
+}
+
+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"
+}
+
+type SiteStatusRequest struct {
+ URL string `yaml:"url"`
+ CheckURL string `yaml:"check-url"`
+ AllowInsecure bool `yaml:"allow-insecure"`
+}
+
+type SiteStatus struct {
+ Code int
+ TimedOut bool
+ ResponseTime time.Duration
+ Error error
+}
+
+func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
+ var url string
+ if statusRequest.CheckURL != "" {
+ url = statusRequest.CheckURL
+ } else {
+ url = statusRequest.URL
+ }
+ request, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return SiteStatus{
+ Error: err,
+ }, nil
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
+ defer cancel()
+ request = request.WithContext(ctx)
+ requestSentAt := time.Now()
+ var response *http.Response
+
+ if !statusRequest.AllowInsecure {
+ response, err = defaultHTTPClient.Do(request)
+ } else {
+ response, err = defaultInsecureHTTPClient.Do(request)
+ }
+
+ status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
+
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) {
+ status.TimedOut = true
+ }
+
+ status.Error = err
+ return status, nil
+ }
+
+ defer response.Body.Close()
+
+ status.Code = response.StatusCode
+
+ return status, nil
+}
+
+func fetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
+ job := newJob(fetchSiteStatusTask, requests).withWorkers(20)
+ results, _, err := workerPoolDo(job)
+ if err != nil {
+ return nil, err
+ }
+
+ return results, nil
+}
diff --git a/internal/feed/reddit.go b/internal/glance/widget-reddit.go
similarity index 51%
rename from internal/feed/reddit.go
rename to internal/glance/widget-reddit.go
index 297020c..2046bd6 100644
--- a/internal/feed/reddit.go
+++ b/internal/glance/widget-reddit.go
@@ -1,14 +1,131 @@
-package feed
+package glance
import (
+ "context"
+ "errors"
"fmt"
"html"
+ "html/template"
"net/http"
"net/url"
"strings"
"time"
)
+var (
+ redditWidgetHorizontalCardsTemplate = mustParseTemplate("reddit-horizontal-cards.html", "widget-base.html")
+ redditWidgetVerticalCardsTemplate = mustParseTemplate("reddit-vertical-cards.html", "widget-base.html")
+)
+
+type redditWidget struct {
+ widgetBase `yaml:",inline"`
+ Posts forumPostList `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 *redditWidget) initialize() error {
+ if widget.Subreddit == "" {
+ return errors.New("subreddit is required")
+ }
+
+ 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 *redditWidget) update(ctx context.Context) {
+ // TODO: refactor, use a struct to pass all of these
+ posts, err := 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 *redditWidget) Render() template.HTML {
+ if widget.Style == "horizontal-cards" {
+ return widget.renderTemplate(widget, redditWidgetHorizontalCardsTemplate)
+ }
+
+ if widget.Style == "vertical-cards" {
+ return widget.renderTemplate(widget, redditWidgetVerticalCardsTemplate)
+ }
+
+ return widget.renderTemplate(widget, forumPostsTemplate)
+
+}
+
type subredditResponseJson struct {
Data struct {
Children []struct {
@@ -44,7 +161,7 @@ func templateRedditCommentsURL(template, subreddit, postId, postPath string) str
return template
}
-func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) {
+func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (forumPostList, error) {
query := url.Values{}
var requestUrl string
@@ -68,15 +185,13 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
}
request, err := http.NewRequest("GET", requestUrl, nil)
-
if err != nil {
return nil, err
}
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
- addBrowserUserAgentHeader(request)
- responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request)
-
+ setBrowserUserAgentHeader(request)
+ responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultHTTPClient, request)
if err != nil {
return nil, err
}
@@ -85,7 +200,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
return nil, fmt.Errorf("no posts found")
}
- posts := make(ForumPosts, 0, len(responseJson.Data.Children))
+ posts := make(forumPostList, 0, len(responseJson.Data.Children))
for i := range responseJson.Data.Children {
post := &responseJson.Data.Children[i].Data
@@ -102,7 +217,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
}
- forumPost := ForumPost{
+ forumPost := forumPost{
Title: html.UnescapeString(post.Title),
DiscussionUrl: commentsUrl,
TargetUrlDomain: post.Domain,
diff --git a/internal/glance/widget-releases.go b/internal/glance/widget-releases.go
new file mode 100644
index 0000000..0ac6caa
--- /dev/null
+++ b/internal/glance/widget-releases.go
@@ -0,0 +1,394 @@
+package glance
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+)
+
+var releasesWidgetTemplate = mustParseTemplate("releases.html", "widget-base.html")
+
+type releasesWidget struct {
+ widgetBase `yaml:",inline"`
+ Releases appReleaseList `yaml:"-"`
+ releaseRequests []*releaseRequest `yaml:"-"`
+ Repositories []string `yaml:"repositories"`
+ Token optionalEnvField `yaml:"token"`
+ GitLabToken optionalEnvField `yaml:"gitlab-token"`
+ Limit int `yaml:"limit"`
+ CollapseAfter int `yaml:"collapse-after"`
+ ShowSourceIcon bool `yaml:"show-source-icon"`
+}
+
+func (widget *releasesWidget) 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 *releaseRequest
+ if len(parts) == 1 {
+ request = &releaseRequest{
+ source: releaseSourceGithub,
+ repository: repository,
+ }
+
+ if widget.Token != "" {
+ request.token = &tokenAsString
+ }
+ } else if len(parts) == 2 {
+ if parts[0] == string(releaseSourceGitlab) {
+ request = &releaseRequest{
+ source: releaseSourceGitlab,
+ repository: parts[1],
+ }
+
+ if widget.GitLabToken != "" {
+ request.token = &gitLabTokenAsString
+ }
+ } else if parts[0] == string(releaseSourceDockerHub) {
+ request = &releaseRequest{
+ source: releaseSourceDockerHub,
+ repository: parts[1],
+ }
+ } else if parts[0] == string(releaseSourceCodeberg) {
+ request = &releaseRequest{
+ source: releaseSourceCodeberg,
+ repository: parts[1],
+ }
+ } else {
+ return errors.New("invalid repository source " + parts[0])
+ }
+ }
+
+ widget.releaseRequests = append(widget.releaseRequests, request)
+ }
+
+ return nil
+}
+
+func (widget *releasesWidget) update(ctx context.Context) {
+ releases, err := 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 *releasesWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, releasesWidgetTemplate)
+}
+
+type releaseSource string
+
+const (
+ releaseSourceCodeberg releaseSource = "codeberg"
+ releaseSourceGithub releaseSource = "github"
+ releaseSourceGitlab releaseSource = "gitlab"
+ releaseSourceDockerHub releaseSource = "dockerhub"
+)
+
+type appRelease struct {
+ Source releaseSource
+ SourceIconURL string
+ Name string
+ Version string
+ NotesUrl string
+ TimeReleased time.Time
+ Downvotes int
+}
+
+type appReleaseList []appRelease
+
+func (r appReleaseList) sortByNewest() appReleaseList {
+ sort.Slice(r, func(i, j int) bool {
+ return r[i].TimeReleased.After(r[j].TimeReleased)
+ })
+
+ return r
+}
+
+type releaseRequest struct {
+ source releaseSource
+ repository string
+ token *string
+}
+
+func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) {
+ job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
+ results, errs, err := workerPoolDo(job)
+ if err != nil {
+ return nil, err
+ }
+
+ var failed int
+
+ releases := make(appReleaseList, 0, len(requests))
+
+ for i := range results {
+ if errs[i] != nil {
+ failed++
+ slog.Error("Failed to fetch release", "source", requests[i].source, "repository", requests[i].repository, "error", errs[i])
+ continue
+ }
+
+ releases = append(releases, *results[i])
+ }
+
+ if failed == len(requests) {
+ return nil, errNoContent
+ }
+
+ releases.sortByNewest()
+
+ if failed > 0 {
+ return releases, fmt.Errorf("%w: could not get %d releases", errPartialContent, failed)
+ }
+
+ return releases, nil
+}
+
+func fetchLatestReleaseTask(request *releaseRequest) (*appRelease, error) {
+ switch request.source {
+ case releaseSourceCodeberg:
+ return fetchLatestCodebergRelease(request)
+ case releaseSourceGithub:
+ return fetchLatestGithubRelease(request)
+ case releaseSourceGitlab:
+ return fetchLatestGitLabRelease(request)
+ case releaseSourceDockerHub:
+ return fetchLatestDockerHubRelease(request)
+ }
+
+ return nil, errors.New("unsupported source")
+}
+
+type githubReleaseLatestResponseJson struct {
+ TagName string `json:"tag_name"`
+ PublishedAt string `json:"published_at"`
+ HtmlUrl string `json:"html_url"`
+ Reactions struct {
+ Downvotes int `json:"-1"`
+ } `json:"reactions"`
+}
+
+func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) {
+ httpRequest, err := http.NewRequest(
+ "GET",
+ fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.repository),
+ nil,
+ )
+
+ if err != nil {
+ return nil, err
+ }
+
+ if request.token != nil {
+ httpRequest.Header.Add("Authorization", "Bearer "+(*request.token))
+ }
+
+ response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultHTTPClient, httpRequest)
+ if err != nil {
+ return nil, err
+ }
+
+ return &appRelease{
+ Source: releaseSourceGithub,
+ Name: request.repository,
+ Version: normalizeVersionFormat(response.TagName),
+ NotesUrl: response.HtmlUrl,
+ TimeReleased: parseRFC3339Time(response.PublishedAt),
+ Downvotes: response.Reactions.Downvotes,
+ }, nil
+}
+
+type dockerHubRepositoryTagsResponse struct {
+ Results []dockerHubRepositoryTagResponse `json:"results"`
+}
+
+type dockerHubRepositoryTagResponse struct {
+ Name string `json:"name"`
+ LastPushed string `json:"tag_last_pushed"`
+}
+
+const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s"
+const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s"
+const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags"
+const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s"
+
+func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) {
+
+ nameParts := strings.Split(request.repository, "/")
+
+ if len(nameParts) > 2 {
+ return nil, fmt.Errorf("invalid repository name: %s", request.repository)
+ } else if len(nameParts) == 1 {
+ nameParts = []string{"library", nameParts[0]}
+ }
+
+ tagParts := strings.SplitN(nameParts[1], ":", 2)
+
+ var requestURL string
+
+ if len(tagParts) == 2 {
+ requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1])
+ } else {
+ requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1])
+ }
+
+ httpRequest, err := http.NewRequest("GET", requestURL, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ if request.token != nil {
+ httpRequest.Header.Add("Authorization", "Bearer "+(*request.token))
+ }
+
+ var tag *dockerHubRepositoryTagResponse
+
+ if len(tagParts) == 1 {
+ response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultHTTPClient, httpRequest)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(response.Results) == 0 {
+ return nil, fmt.Errorf("no tags found for repository: %s", request.repository)
+ }
+
+ tag = &response.Results[0]
+ } else {
+ response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultHTTPClient, httpRequest)
+ if err != nil {
+ return nil, err
+ }
+
+ tag = &response
+ }
+
+ var repo string
+ var displayName string
+ var notesURL string
+
+ if len(tagParts) == 1 {
+ repo = nameParts[1]
+ } else {
+ repo = tagParts[0]
+ }
+
+ if nameParts[0] == "library" {
+ displayName = repo
+ notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name)
+ } else {
+ displayName = nameParts[0] + "/" + repo
+ notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name)
+ }
+
+ return &appRelease{
+ Source: releaseSourceDockerHub,
+ NotesUrl: notesURL,
+ Name: displayName,
+ Version: tag.Name,
+ TimeReleased: parseRFC3339Time(tag.LastPushed),
+ }, nil
+}
+
+type gitlabReleaseResponseJson struct {
+ TagName string `json:"tag_name"`
+ ReleasedAt string `json:"released_at"`
+ Links struct {
+ Self string `json:"self"`
+ } `json:"_links"`
+}
+
+func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) {
+ httpRequest, err := http.NewRequest(
+ "GET",
+ fmt.Sprintf(
+ "https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
+ url.QueryEscape(request.repository),
+ ),
+ nil,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if request.token != nil {
+ httpRequest.Header.Add("PRIVATE-TOKEN", *request.token)
+ }
+
+ response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultHTTPClient, httpRequest)
+ if err != nil {
+ return nil, err
+ }
+
+ return &appRelease{
+ Source: releaseSourceGitlab,
+ Name: request.repository,
+ Version: normalizeVersionFormat(response.TagName),
+ NotesUrl: response.Links.Self,
+ TimeReleased: parseRFC3339Time(response.ReleasedAt),
+ }, nil
+}
+
+type codebergReleaseResponseJson struct {
+ TagName string `json:"tag_name"`
+ PublishedAt string `json:"published_at"`
+ HtmlUrl string `json:"html_url"`
+}
+
+func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) {
+ httpRequest, err := http.NewRequest(
+ "GET",
+ fmt.Sprintf(
+ "https://codeberg.org/api/v1/repos/%s/releases/latest",
+ request.repository,
+ ),
+ nil,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultHTTPClient, httpRequest)
+ if err != nil {
+ return nil, err
+ }
+
+ return &appRelease{
+ Source: releaseSourceCodeberg,
+ Name: request.repository,
+ Version: normalizeVersionFormat(response.TagName),
+ NotesUrl: response.HtmlUrl,
+ TimeReleased: parseRFC3339Time(response.PublishedAt),
+ }, nil
+}
diff --git a/internal/feed/github.go b/internal/glance/widget-repository.go
similarity index 52%
rename from internal/feed/github.go
rename to internal/glance/widget-repository.go
index 18487f0..df1e8b7 100644
--- a/internal/feed/github.go
+++ b/internal/glance/widget-repository.go
@@ -1,72 +1,91 @@
-package feed
+package glance
import (
+ "context"
"fmt"
+ "html/template"
"net/http"
"strings"
"sync"
"time"
)
-type githubReleaseLatestResponseJson struct {
- TagName string `json:"tag_name"`
- PublishedAt string `json:"published_at"`
- HtmlUrl string `json:"html_url"`
- Reactions struct {
- Downvotes int `json:"-1"`
- } `json:"reactions"`
+var repositoryWidgetTemplate = mustParseTemplate("repository.html", "widget-base.html")
+
+type repositoryWidget struct {
+ widgetBase `yaml:",inline"`
+ RequestedRepository string `yaml:"repository"`
+ Token optionalEnvField `yaml:"token"`
+ PullRequestsLimit int `yaml:"pull-requests-limit"`
+ IssuesLimit int `yaml:"issues-limit"`
+ CommitsLimit int `yaml:"commits-limit"`
+ Repository repository `yaml:"-"`
}
-func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) {
- httpRequest, err := http.NewRequest(
- "GET",
- fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository),
- nil,
+func (widget *repositoryWidget) 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 *repositoryWidget) update(ctx context.Context) {
+ details, err := fetchRepositoryDetailsFromGithub(
+ widget.RequestedRepository,
+ string(widget.Token),
+ widget.PullRequestsLimit,
+ widget.IssuesLimit,
+ widget.CommitsLimit,
)
- if err != nil {
- return nil, err
+ if !widget.canContinueUpdateAfterHandlingErr(err) {
+ return
}
- if request.Token != nil {
- httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
- }
-
- response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
-
- if err != nil {
- return nil, err
- }
-
- return &AppRelease{
- Source: ReleaseSourceGithub,
- Name: request.Repository,
- Version: normalizeVersionFormat(response.TagName),
- NotesUrl: response.HtmlUrl,
- TimeReleased: parseRFC3339Time(response.PublishedAt),
- Downvotes: response.Reactions.Downvotes,
- }, nil
+ widget.Repository = details
}
-type GithubTicket struct {
+func (widget *repositoryWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, repositoryWidgetTemplate)
+}
+
+type repository struct {
+ Name string
+ Stars int
+ Forks int
+ OpenPullRequests int
+ PullRequests []githubTicket
+ OpenIssues int
+ Issues []githubTicket
+ LastCommits int
+ Commits []githubCommitDetails
+}
+
+type githubTicket struct {
Number int
CreatedAt time.Time
Title string
}
-type RepositoryDetails struct {
- Name string
- Stars int
- Forks int
- OpenPullRequests int
- PullRequests []GithubTicket
- OpenIssues int
- Issues []GithubTicket
- LastCommits int
- Commits []CommitDetails
+type githubCommitDetails struct {
+ Sha string
+ Author string
+ CreatedAt time.Time
+ Message string
}
-type githubRepositoryDetailsResponseJson struct {
+type githubRepositoryResponseJson struct {
Name string `json:"full_name"`
Stars int `json:"stargazers_count"`
Forks int `json:"forks_count"`
@@ -81,13 +100,6 @@ type githubTicketResponseJson struct {
} `json:"items"`
}
-type CommitDetails struct {
- Sha string
- Author string
- CreatedAt time.Time
- Message string
-}
-
type gitHubCommitResponseJson struct {
Sha string `json:"sha"`
Commit struct {
@@ -99,15 +111,15 @@ type gitHubCommitResponseJson struct {
} `json:"commit"`
}
-func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) {
- repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
+func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, maxIssues int, maxCommits int) (repository, error) {
+ repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repo), nil)
if err != nil {
- return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
+ return repository{}, fmt.Errorf("%w: could not create request with repository: %v", errNoContent, err)
}
- PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
- issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
- CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil)
+ PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repo, maxPRs), nil)
+ issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repo, maxIssues), nil)
+ CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repo, maxCommits), nil)
if token != "" {
token = fmt.Sprintf("Bearer %s", token)
@@ -117,7 +129,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
CommitsRequest.Header.Add("Authorization", token)
}
- var detailsResponse githubRepositoryDetailsResponseJson
+ var repositoryResponse githubRepositoryResponseJson
var detailsErr error
var PRsResponse githubTicketResponseJson
var PRsErr error
@@ -130,14 +142,14 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
wg.Add(1)
go (func() {
defer wg.Done()
- detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
+ repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultHTTPClient, repositoryRequest)
})()
if maxPRs > 0 {
wg.Add(1)
go (func() {
defer wg.Done()
- PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
+ PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, PRsRequest)
})()
}
@@ -145,7 +157,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
wg.Add(1)
go (func() {
defer wg.Done()
- issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
+ issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, issuesRequest)
})()
}
@@ -153,35 +165,35 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
wg.Add(1)
go (func() {
defer wg.Done()
- commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest)
+ commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultHTTPClient, CommitsRequest)
})()
}
wg.Wait()
if detailsErr != nil {
- return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
+ return repository{}, fmt.Errorf("%w: could not get repository details: %s", errNoContent, detailsErr)
}
- details := RepositoryDetails{
- Name: detailsResponse.Name,
- Stars: detailsResponse.Stars,
- Forks: detailsResponse.Forks,
- PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
- Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
- Commits: make([]CommitDetails, 0, len(commitsResponse)),
+ details := repository{
+ Name: repositoryResponse.Name,
+ Stars: repositoryResponse.Stars,
+ Forks: repositoryResponse.Forks,
+ PullRequests: make([]githubTicket, 0, len(PRsResponse.Tickets)),
+ Issues: make([]githubTicket, 0, len(issuesResponse.Tickets)),
+ Commits: make([]githubCommitDetails, 0, len(commitsResponse)),
}
err = nil
if maxPRs > 0 {
if PRsErr != nil {
- err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
+ err = fmt.Errorf("%w: could not get PRs: %s", errPartialContent, PRsErr)
} else {
details.OpenPullRequests = PRsResponse.Count
for i := range PRsResponse.Tickets {
- details.PullRequests = append(details.PullRequests, GithubTicket{
+ details.PullRequests = append(details.PullRequests, githubTicket{
Number: PRsResponse.Tickets[i].Number,
CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
Title: PRsResponse.Tickets[i].Title,
@@ -193,12 +205,12 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
if maxIssues > 0 {
if issuesErr != nil {
// TODO: fix, overwriting the previous error
- err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
+ err = fmt.Errorf("%w: could not get issues: %s", errPartialContent, issuesErr)
} else {
details.OpenIssues = issuesResponse.Count
for i := range issuesResponse.Tickets {
- details.Issues = append(details.Issues, GithubTicket{
+ details.Issues = append(details.Issues, githubTicket{
Number: issuesResponse.Tickets[i].Number,
CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
Title: issuesResponse.Tickets[i].Title,
@@ -209,10 +221,10 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
if maxCommits > 0 {
if CommitsErr != nil {
- err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr)
+ err = fmt.Errorf("%w: could not get commits: %s", errPartialContent, CommitsErr)
} else {
for i := range commitsResponse {
- details.Commits = append(details.Commits, CommitDetails{
+ details.Commits = append(details.Commits, githubCommitDetails{
Sha: commitsResponse[i].Sha,
Author: commitsResponse[i].Commit.Author.Name,
CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),
diff --git a/internal/feed/rss.go b/internal/glance/widget-rss.go
similarity index 52%
rename from internal/feed/rss.go
rename to internal/glance/widget-rss.go
index ec6ab2e..fe8a319 100644
--- a/internal/feed/rss.go
+++ b/internal/glance/widget-rss.go
@@ -1,10 +1,13 @@
-package feed
+package glance
import (
"context"
"fmt"
"html"
+ "html/template"
+ "io"
"log/slog"
+ "net/http"
"net/url"
"regexp"
"sort"
@@ -15,7 +18,87 @@ import (
gofeedext "github.com/mmcdole/gofeed/extensions"
)
-type RSSFeedItem struct {
+var (
+ rssWidgetTemplate = mustParseTemplate("rss-list.html", "widget-base.html")
+ rssWidgetDetailedListTemplate = mustParseTemplate("rss-detailed-list.html", "widget-base.html")
+ rssWidgetHorizontalCardsTemplate = mustParseTemplate("rss-horizontal-cards.html", "widget-base.html")
+ rssWidgetHorizontalCards2Template = mustParseTemplate("rss-horizontal-cards-2.html", "widget-base.html")
+)
+
+type rssWidget struct {
+ widgetBase `yaml:",inline"`
+ FeedRequests []RSSFeedRequest `yaml:"feeds"`
+ Style string `yaml:"style"`
+ ThumbnailHeight float64 `yaml:"thumbnail-height"`
+ CardHeight float64 `yaml:"card-height"`
+ Items rssFeedItemList `yaml:"-"`
+ Limit int `yaml:"limit"`
+ CollapseAfter int `yaml:"collapse-after"`
+ SingleLineTitles bool `yaml:"single-line-titles"`
+ NoItemsMessage string `yaml:"-"`
+}
+
+func (widget *rssWidget) 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 *rssWidget) update(ctx context.Context) {
+ items, err := fetchItemsFromRSSFeeds(widget.FeedRequests)
+
+ if !widget.canContinueUpdateAfterHandlingErr(err) {
+ return
+ }
+
+ if len(items) > widget.Limit {
+ items = items[:widget.Limit]
+ }
+
+ widget.Items = items
+}
+
+func (widget *rssWidget) Render() template.HTML {
+ if widget.Style == "horizontal-cards" {
+ return widget.renderTemplate(widget, rssWidgetHorizontalCardsTemplate)
+ }
+
+ if widget.Style == "horizontal-cards-2" {
+ return widget.renderTemplate(widget, rssWidgetHorizontalCards2Template)
+ }
+
+ if widget.Style == "detailed-list" {
+ return widget.renderTemplate(widget, rssWidgetDetailedListTemplate)
+ }
+
+ return widget.renderTemplate(widget, rssWidgetTemplate)
+}
+
+type rssFeedItem struct {
ChannelName string
ChannelURL string
Title string
@@ -28,7 +111,6 @@ type RSSFeedItem struct {
// doesn't cover all cases but works the vast majority of the time
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
-var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
func sanitizeFeedDescription(description string) string {
if description == "" {
@@ -57,17 +139,18 @@ func shortenFeedDescriptionLen(description string, maxLen int) string {
}
type RSSFeedRequest struct {
- Url string `yaml:"url"`
- Title string `yaml:"title"`
- HideCategories bool `yaml:"hide-categories"`
- HideDescription bool `yaml:"hide-description"`
- ItemLinkPrefix string `yaml:"item-link-prefix"`
- IsDetailed bool `yaml:"-"`
+ Url string `yaml:"url"`
+ Title string `yaml:"title"`
+ HideCategories bool `yaml:"hide-categories"`
+ HideDescription bool `yaml:"hide-description"`
+ ItemLinkPrefix string `yaml:"item-link-prefix"`
+ Headers map[string]string `yaml:"headers"`
+ IsDetailed bool `yaml:"-"`
}
-type RSSFeedItems []RSSFeedItem
+type rssFeedItemList []rssFeedItem
-func (f RSSFeedItems) SortByNewest() RSSFeedItems {
+func (f rssFeedItemList) sortByNewest() rssFeedItemList {
sort.Slice(f, func(i, j int) bool {
return f[i].PublishedAt.After(f[j].PublishedAt)
})
@@ -77,22 +160,42 @@ func (f RSSFeedItems) SortByNewest() RSSFeedItems {
var feedParser = gofeed.NewParser()
-func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
- ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
- defer cancel()
-
- feed, err := feedParser.ParseURLWithContext(request.Url, ctx)
-
+func fetchItemsFromRSSFeedTask(request RSSFeedRequest) ([]rssFeedItem, error) {
+ req, err := http.NewRequest("GET", request.Url, nil)
if err != nil {
return nil, err
}
- items := make(RSSFeedItems, 0, len(feed.Items))
+ for key, value := range request.Headers {
+ req.Header.Add(key, value)
+ }
+
+ resp, err := defaultHTTPClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.Url)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ feed, err := feedParser.ParseString(string(body))
+ if err != nil {
+ return nil, err
+ }
+
+ items := make(rssFeedItemList, 0, len(feed.Items))
for i := range feed.Items {
item := feed.Items[i]
- rssItem := RSSFeedItem{
+ rssItem := rssFeedItem{
ChannelURL: feed.Link,
}
@@ -102,7 +205,6 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
rssItem.Link = item.Link
} else {
parsedUrl, err := url.Parse(feed.Link)
-
if err != nil {
parsedUrl, err = url.Parse(request.Url)
}
@@ -210,22 +312,21 @@ func findThumbnailInItemExtensions(item *gofeed.Item) string {
return recursiveFindThumbnailInExtensions(media)
}
-func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
- job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
+func fetchItemsFromRSSFeeds(requests []RSSFeedRequest) (rssFeedItemList, error) {
+ job := newJob(fetchItemsFromRSSFeedTask, requests).withWorkers(10)
feeds, errs, err := workerPoolDo(job)
-
if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
+ return nil, fmt.Errorf("%w: %v", errNoContent, err)
}
failed := 0
- entries := make(RSSFeedItems, 0, len(feeds)*10)
+ entries := make(rssFeedItemList, 0, len(feeds)*10)
for i := range feeds {
if errs[i] != nil {
failed++
- slog.Error("failed to get rss feed", "error", errs[i], "url", requests[i].Url)
+ slog.Error("Failed to get RSS feed", "url", requests[i].Url, "error", errs[i])
continue
}
@@ -233,13 +334,13 @@ func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
}
if failed == len(requests) {
- return nil, ErrNoContent
+ return nil, errNoContent
}
- entries.SortByNewest()
+ entries.sortByNewest()
if failed > 0 {
- return entries, fmt.Errorf("%w: missing %d RSS feeds", ErrPartialContent, failed)
+ return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed)
}
return entries, nil
diff --git a/internal/widget/search.go b/internal/glance/widget-search.go
similarity index 75%
rename from internal/widget/search.go
rename to internal/glance/widget-search.go
index 19ca372..d25064a 100644
--- a/internal/widget/search.go
+++ b/internal/glance/widget-search.go
@@ -1,20 +1,20 @@
-package widget
+package glance
import (
"fmt"
"html/template"
"strings"
-
- "github.com/glanceapp/glance/internal/assets"
)
+var searchWidgetTemplate = mustParseTemplate("search.html", "widget-base.html")
+
type SearchBang struct {
Title string
Shortcut string
URL string
}
-type Search struct {
+type searchWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
SearchEngine string `yaml:"search-engine"`
@@ -34,7 +34,7 @@ var searchEngines = map[string]string{
"google": "https://www.google.com/search?q={QUERY}",
}
-func (widget *Search) Initialize() error {
+func (widget *searchWidget) initialize() error {
widget.withTitle("Search").withError(nil)
if widget.SearchEngine == "" {
@@ -49,20 +49,20 @@ func (widget *Search) Initialize() error {
for i := range widget.Bangs {
if widget.Bangs[i].Shortcut == "" {
- return fmt.Errorf("Search bang %d has no shortcut", i+1)
+ 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)
+ 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)
+ widget.cachedHTML = widget.renderTemplate(widget, searchWidgetTemplate)
return nil
}
-func (widget *Search) Render() template.HTML {
+func (widget *searchWidget) Render() template.HTML {
return widget.cachedHTML
}
diff --git a/internal/glance/widget-shared.go b/internal/glance/widget-shared.go
new file mode 100644
index 0000000..45144ac
--- /dev/null
+++ b/internal/glance/widget-shared.go
@@ -0,0 +1,64 @@
+package glance
+
+import (
+ "math"
+ "sort"
+ "time"
+)
+
+const twitchGqlEndpoint = "https://gql.twitch.tv/gql"
+const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko"
+
+var forumPostsTemplate = mustParseTemplate("forum-posts.html", "widget-base.html")
+
+type forumPost struct {
+ Title string
+ DiscussionUrl string
+ TargetUrl string
+ TargetUrlDomain string
+ ThumbnailUrl string
+ CommentCount int
+ Score int
+ Engagement float64
+ TimePosted time.Time
+ Tags []string
+ IsCrosspost bool
+}
+
+type forumPostList []forumPost
+
+const depreciatePostsOlderThanHours = 7
+const maxDepreciation = 0.9
+const maxDepreciationAfterHours = 24
+
+func (p forumPostList) calculateEngagement() {
+ var totalComments int
+ var totalScore int
+
+ for i := range p {
+ totalComments += p[i].CommentCount
+ totalScore += p[i].Score
+ }
+
+ numberOfPosts := float64(len(p))
+ averageComments := float64(totalComments) / numberOfPosts
+ averageScore := float64(totalScore) / numberOfPosts
+
+ for i := range p {
+ p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2
+
+ elapsed := time.Since(p[i].TimePosted)
+
+ if elapsed < time.Hour*depreciatePostsOlderThanHours {
+ continue
+ }
+
+ p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation
+ }
+}
+
+func (p forumPostList) sortByEngagement() {
+ sort.Slice(p, func(i, j int) bool {
+ return p[i].Engagement > p[j].Engagement
+ })
+}
diff --git a/internal/glance/widget-split-column.go b/internal/glance/widget-split-column.go
new file mode 100644
index 0000000..71747c9
--- /dev/null
+++ b/internal/glance/widget-split-column.go
@@ -0,0 +1,45 @@
+package glance
+
+import (
+ "context"
+ "html/template"
+ "time"
+)
+
+var splitColumnWidgetTemplate = mustParseTemplate("split-column.html", "widget-base.html")
+
+type splitColumnWidget struct {
+ widgetBase `yaml:",inline"`
+ containerWidgetBase `yaml:",inline"`
+ MaxColumns int `yaml:"max-columns"`
+}
+
+func (widget *splitColumnWidget) initialize() error {
+ widget.withError(nil).withTitle("Split Column").setHideHeader(true)
+
+ if err := widget.containerWidgetBase._initializeWidgets(); err != nil {
+ return err
+ }
+
+ if widget.MaxColumns < 2 {
+ widget.MaxColumns = 2
+ }
+
+ return nil
+}
+
+func (widget *splitColumnWidget) update(ctx context.Context) {
+ widget.containerWidgetBase._update(ctx)
+}
+
+func (widget *splitColumnWidget) setProviders(providers *widgetProviders) {
+ widget.containerWidgetBase._setProviders(providers)
+}
+
+func (widget *splitColumnWidget) requiresUpdate(now *time.Time) bool {
+ return widget.containerWidgetBase._requiresUpdate(now)
+}
+
+func (widget *splitColumnWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, splitColumnWidgetTemplate)
+}
diff --git a/internal/feed/twitch.go b/internal/glance/widget-twitch-channels.go
similarity index 50%
rename from internal/feed/twitch.go
rename to internal/glance/widget-twitch-channels.go
index 739d7d1..f3ab206 100644
--- a/internal/feed/twitch.go
+++ b/internal/glance/widget-twitch-channels.go
@@ -1,33 +1,69 @@
-package feed
+package glance
import (
+ "context"
"encoding/json"
- "errors"
"fmt"
+ "html/template"
"log/slog"
"net/http"
- "slices"
"sort"
"strings"
"time"
)
-type TwitchCategory struct {
- Slug string `json:"slug"`
- Name string `json:"name"`
- AvatarUrl string `json:"avatarURL"`
- ViewersCount int `json:"viewersCount"`
- Tags []struct {
- Name string `json:"tagName"`
- } `json:"tags"`
- GameReleaseDate string `json:"originalReleaseDate"`
- IsNew bool `json:"-"`
+var twitchChannelsWidgetTemplate = mustParseTemplate("twitch-channels.html", "widget-base.html")
+
+type twitchChannelsWidget struct {
+ widgetBase `yaml:",inline"`
+ ChannelsRequest []string `yaml:"channels"`
+ Channels []twitchChannel `yaml:"-"`
+ CollapseAfter int `yaml:"collapse-after"`
+ SortBy string `yaml:"sort-by"`
}
-type TwitchChannel struct {
+func (widget *twitchChannelsWidget) 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 *twitchChannelsWidget) update(ctx context.Context) {
+ channels, err := 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 *twitchChannelsWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, twitchChannelsWidgetTemplate)
+}
+
+type twitchChannel struct {
Login string
Exists bool
Name string
+ StreamTitle string
AvatarUrl string
IsLive bool
LiveSince time.Time
@@ -36,15 +72,15 @@ type TwitchChannel struct {
ViewersCount int
}
-type TwitchChannels []TwitchChannel
+type twitchChannelList []twitchChannel
-func (channels TwitchChannels) SortByViewers() {
+func (channels twitchChannelList) sortByViewers() {
sort.Slice(channels, func(i, j int) bool {
return channels[i].ViewersCount > channels[j].ViewersCount
})
}
-func (channels TwitchChannels) SortByLive() {
+func (channels twitchChannelList) sortByLive() {
sort.SliceStable(channels, func(i, j int) bool {
return channels[i].IsLive && !channels[j].IsLive
})
@@ -77,72 +113,16 @@ type twitchStreamMetadataOperationResponse struct {
Name string `json:"name"`
} `json:"game"`
} `json:"stream"`
+ LastBroadcast *struct {
+ Title string `json:"title"`
+ }
} `json:"user"`
}
-type twitchDirectoriesOperationResponse struct {
- Data struct {
- DirectoriesWithTags struct {
- Edges []struct {
- Node TwitchCategory `json:"node"`
- } `json:"edges"`
- } `json:"directoriesWithTags"`
- } `json:"data"`
-}
-
-const twitchGqlEndpoint = "https://gql.twitch.tv/gql"
-const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko"
-
-const twitchDirectoriesOperationRequestBody = `[{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}}]`
-
-func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, error) {
- reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit))
- request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
- request.Header.Add("Client-ID", twitchGqlClientId)
- response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request)
-
- if err != nil {
- return nil, err
- }
-
- if len(response) == 0 {
- return nil, errors.New("no categories could be retrieved")
- }
-
- edges := (response)[0].Data.DirectoriesWithTags.Edges
- categories := make([]TwitchCategory, 0, len(edges))
-
- for i := range edges {
- if slices.Contains(exclude, edges[i].Node.Slug) {
- continue
- }
-
- category := &edges[i].Node
- category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1)
-
- if len(category.Tags) > 2 {
- category.Tags = category.Tags[:2]
- }
-
- gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate)
-
- if err == nil {
- if time.Since(gameReleasedDate) < 14*24*time.Hour {
- category.IsNew = true
- }
- }
-
- categories = append(categories, *category)
- }
-
- if len(categories) > limit {
- categories = categories[:limit]
- }
-
- return categories, nil
-}
-
-const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]`
+const twitchChannelStatusOperationRequestBody = `[
+{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},
+{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}
+]`
// TODO: rework
// The operations for multiple channels can all be sent in a single request
@@ -150,8 +130,8 @@ const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell
// what the limit is for max operations per request and batch operations in
// multiple requests if number of channels exceeds allowed limit.
-func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
- result := TwitchChannel{
+func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) {
+ result := twitchChannel{
Login: strings.ToLower(channel),
}
@@ -159,8 +139,7 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
request.Header.Add("Client-ID", twitchGqlClientId)
- response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultClient, request)
-
+ response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultHTTPClient, request)
if err != nil {
return result, err
}
@@ -175,16 +154,12 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
for i := range response {
switch response[i].Extensions.OperationName {
case "ChannelShell":
- err = json.Unmarshal(response[i].Data, &channelShell)
-
- if err != nil {
- return result, fmt.Errorf("failed to unmarshal channel shell: %w", err)
+ if err = json.Unmarshal(response[i].Data, &channelShell); err != nil {
+ return result, fmt.Errorf("unmarshalling channel shell: %w", err)
}
case "StreamMetadata":
- err = json.Unmarshal(response[i].Data, &streamMetadata)
-
- if err != nil {
- return result, fmt.Errorf("failed to unmarshal stream metadata: %w", err)
+ if err = json.Unmarshal(response[i].Data, &streamMetadata); err != nil {
+ return result, fmt.Errorf("unmarshalling stream metadata: %w", err)
}
default:
return result, fmt.Errorf("unknown operation name: %s", response[i].Extensions.OperationName)
@@ -205,6 +180,10 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
+ if streamMetadata.UserOrNull.LastBroadcast != nil {
+ result.StreamTitle = streamMetadata.UserOrNull.LastBroadcast.Title
+ }
+
if streamMetadata.UserOrNull.Stream.Game != nil {
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
@@ -214,7 +193,7 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
if err == nil {
result.LiveSince = startedAt
} else {
- slog.Warn("failed to parse twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
+ slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
}
}
}
@@ -222,12 +201,11 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
return result, nil
}
-func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) {
- result := make(TwitchChannels, 0, len(channelLogins))
+func fetchChannelsFromTwitch(channelLogins []string) (twitchChannelList, error) {
+ result := make(twitchChannelList, 0, len(channelLogins))
job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10)
channels, errs, err := workerPoolDo(job)
-
if err != nil {
return result, err
}
@@ -237,7 +215,7 @@ func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) {
for i := range channels {
if errs[i] != nil {
failed++
- slog.Warn("failed to fetch twitch channel", "channel", channelLogins[i], "error", errs[i])
+ slog.Error("Failed to fetch Twitch channel", "channel", channelLogins[i], "error", errs[i])
continue
}
@@ -245,11 +223,11 @@ func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) {
}
if failed == len(channelLogins) {
- return result, ErrNoContent
+ return result, errNoContent
}
if failed > 0 {
- return result, fmt.Errorf("%w: failed to fetch %d channels", ErrPartialContent, failed)
+ return result, fmt.Errorf("%w: failed to fetch %d channels", errPartialContent, failed)
}
return result, nil
diff --git a/internal/glance/widget-twitch-top-games.go b/internal/glance/widget-twitch-top-games.go
new file mode 100644
index 0000000..4235bc9
--- /dev/null
+++ b/internal/glance/widget-twitch-top-games.go
@@ -0,0 +1,125 @@
+package glance
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html/template"
+ "net/http"
+ "slices"
+ "strings"
+ "time"
+)
+
+var twitchGamesWidgetTemplate = mustParseTemplate("twitch-games-list.html", "widget-base.html")
+
+type twitchGamesWidget struct {
+ widgetBase `yaml:",inline"`
+ Categories []twitchCategory `yaml:"-"`
+ Exclude []string `yaml:"exclude"`
+ Limit int `yaml:"limit"`
+ CollapseAfter int `yaml:"collapse-after"`
+}
+
+func (widget *twitchGamesWidget) 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 *twitchGamesWidget) update(ctx context.Context) {
+ categories, err := fetchTopGamesFromTwitch(widget.Exclude, widget.Limit)
+
+ if !widget.canContinueUpdateAfterHandlingErr(err) {
+ return
+ }
+
+ widget.Categories = categories
+}
+
+func (widget *twitchGamesWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, twitchGamesWidgetTemplate)
+}
+
+type twitchCategory struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ AvatarUrl string `json:"avatarURL"`
+ ViewersCount int `json:"viewersCount"`
+ Tags []struct {
+ Name string `json:"tagName"`
+ } `json:"tags"`
+ GameReleaseDate string `json:"originalReleaseDate"`
+ IsNew bool `json:"-"`
+}
+
+type twitchDirectoriesOperationResponse struct {
+ Data struct {
+ DirectoriesWithTags struct {
+ Edges []struct {
+ Node twitchCategory `json:"node"`
+ } `json:"edges"`
+ } `json:"directoriesWithTags"`
+ } `json:"data"`
+}
+
+const twitchDirectoriesOperationRequestBody = `[
+{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}}
+]`
+
+func fetchTopGamesFromTwitch(exclude []string, limit int) ([]twitchCategory, error) {
+ reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit))
+ request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
+ request.Header.Add("Client-ID", twitchGqlClientId)
+ response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultHTTPClient, request)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(response) == 0 {
+ return nil, errors.New("no categories could be retrieved")
+ }
+
+ edges := (response)[0].Data.DirectoriesWithTags.Edges
+ categories := make([]twitchCategory, 0, len(edges))
+
+ for i := range edges {
+ if slices.Contains(exclude, edges[i].Node.Slug) {
+ continue
+ }
+
+ category := &edges[i].Node
+ category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1)
+
+ if len(category.Tags) > 2 {
+ category.Tags = category.Tags[:2]
+ }
+
+ gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate)
+
+ if err == nil {
+ if time.Since(gameReleasedDate) < 14*24*time.Hour {
+ category.IsNew = true
+ }
+ }
+
+ categories = append(categories, *category)
+ }
+
+ if len(categories) > limit {
+ categories = categories[:limit]
+ }
+
+ return categories, nil
+}
diff --git a/internal/feed/requests.go b/internal/glance/widget-utils.go
similarity index 80%
rename from internal/feed/requests.go
rename to internal/glance/widget-utils.go
index 3ce5d9f..77a9d5c 100644
--- a/internal/feed/requests.go
+++ b/internal/glance/widget-utils.go
@@ -1,10 +1,11 @@
-package feed
+package glance
import (
"context"
"crypto/tls"
"encoding/json"
"encoding/xml"
+ "errors"
"fmt"
"io"
"net/http"
@@ -12,66 +13,58 @@ import (
"time"
)
+var (
+ errNoContent = errors.New("failed to retrieve any content")
+ errPartialContent = errors.New("failed to retrieve some of the content")
+)
+
const defaultClientTimeout = 5 * time.Second
-var defaultClient = &http.Client{
+var defaultHTTPClient = &http.Client{
Timeout: defaultClientTimeout,
}
-var insecureClientTransport = &http.Transport{
- TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+var defaultInsecureHTTPClient = &http.Client{
+ Timeout: defaultClientTimeout,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ },
}
-var defaultInsecureClient = &http.Client{
- Timeout: defaultClientTimeout,
- Transport: insecureClientTransport,
-}
-
-type RequestDoer interface {
+type requestDoer interface {
Do(*http.Request) (*http.Response, error)
}
-func addBrowserUserAgentHeader(request *http.Request) {
+func setBrowserUserAgentHeader(request *http.Request) {
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0")
}
-func truncateString(s string, maxLen int) string {
- asRunes := []rune(s)
-
- if len(asRunes) > maxLen {
- return string(asRunes[:maxLen])
- }
-
- return s
-}
-
-func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) {
- response, err := client.Do(request)
+func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
var result T
+ response, err := client.Do(request)
if err != nil {
return result, err
}
-
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
-
if err != nil {
return result, err
}
if response.StatusCode != http.StatusOK {
+ truncatedBody, _ := limitStringLength(string(body), 256)
+
return result, fmt.Errorf(
"unexpected status code %d for %s, response: %s",
response.StatusCode,
request.URL,
- truncateString(string(body), 256),
+ truncatedBody,
)
}
err = json.Unmarshal(body, &result)
-
if err != nil {
return result, err
}
@@ -79,40 +72,39 @@ func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T,
return result, nil
}
-func decodeJsonFromRequestTask[T any](client RequestDoer) func(*http.Request) (T, error) {
+func decodeJsonFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) {
return func(request *http.Request) (T, error) {
return decodeJsonFromRequest[T](client, request)
}
}
// TODO: tidy up, these are a copy of the above but with a line changed
-func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T, error) {
- response, err := client.Do(request)
+func decodeXmlFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
var result T
+ response, err := client.Do(request)
if err != nil {
return result, err
}
-
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
-
if err != nil {
return result, err
}
if response.StatusCode != http.StatusOK {
+ truncatedBody, _ := limitStringLength(string(body), 256)
+
return result, fmt.Errorf(
"unexpected status code %d for %s, response: %s",
response.StatusCode,
request.URL,
- truncateString(string(body), 256),
+ truncatedBody,
)
}
err = xml.Unmarshal(body, &result)
-
if err != nil {
return result, err
}
@@ -120,7 +112,7 @@ func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T,
return result, nil
}
-func decodeXmlFromRequestTask[T any](client RequestDoer) func(*http.Request) (T, error) {
+func decodeXmlFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) {
return func(request *http.Request) (T, error) {
return decodeXmlFromRequest[T](client, request)
}
diff --git a/internal/glance/widget-videos.go b/internal/glance/widget-videos.go
new file mode 100644
index 0000000..e1f3b14
--- /dev/null
+++ b/internal/glance/widget-videos.go
@@ -0,0 +1,187 @@
+package glance
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+)
+
+var (
+ videosWidgetTemplate = mustParseTemplate("videos.html", "widget-base.html", "video-card-contents.html")
+ videosWidgetGridTemplate = mustParseTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
+)
+
+type videosWidget struct {
+ widgetBase `yaml:",inline"`
+ Videos videoList `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 *videosWidget) 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 *videosWidget) update(ctx context.Context) {
+ videos, err := 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 *videosWidget) Render() template.HTML {
+ if widget.Style == "grid-cards" {
+ return widget.renderTemplate(widget, videosWidgetGridTemplate)
+ }
+
+ return widget.renderTemplate(widget, videosWidgetTemplate)
+}
+
+type youtubeFeedResponseXml struct {
+ Channel string `xml:"author>name"`
+ ChannelLink string `xml:"author>uri"`
+ Videos []struct {
+ Title string `xml:"title"`
+ Published string `xml:"published"`
+ Link struct {
+ Href string `xml:"href,attr"`
+ } `xml:"link"`
+
+ Group struct {
+ Thumbnail struct {
+ Url string `xml:"url,attr"`
+ } `xml:"http://search.yahoo.com/mrss/ thumbnail"`
+ } `xml:"http://search.yahoo.com/mrss/ group"`
+ } `xml:"entry"`
+}
+
+func parseYoutubeFeedTime(t string) time.Time {
+ parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t)
+ if err != nil {
+ return time.Now()
+ }
+
+ return parsedTime
+}
+
+type video struct {
+ ThumbnailUrl string
+ Title string
+ Url string
+ Author string
+ AuthorUrl string
+ TimePosted time.Time
+}
+
+type videoList []video
+
+func (v videoList) sortByNewest() videoList {
+ sort.Slice(v, func(i, j int) bool {
+ return v[i].TimePosted.After(v[j].TimePosted)
+ })
+
+ return v
+}
+
+func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (videoList, error) {
+ requests := make([]*http.Request, 0, len(channelIds))
+
+ for i := range channelIds {
+ var feedUrl string
+ if !includeShorts && strings.HasPrefix(channelIds[i], "UC") {
+ playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1)
+ feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
+ } else {
+ feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i]
+ }
+
+ request, _ := http.NewRequest("GET", feedUrl, nil)
+ requests = append(requests, request)
+ }
+
+ job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultHTTPClient), requests).withWorkers(30)
+
+ responses, errs, err := workerPoolDo(job)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", errNoContent, err)
+ }
+
+ videos := make(videoList, 0, len(channelIds)*15)
+
+ var failed int
+
+ for i := range responses {
+ if errs[i] != nil {
+ failed++
+ slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i])
+ continue
+ }
+
+ response := responses[i]
+
+ for j := range response.Videos {
+ v := &response.Videos[j]
+ var videoUrl string
+
+ if videoUrlTemplate == "" {
+ videoUrl = v.Link.Href
+ } else {
+ parsedUrl, err := url.Parse(v.Link.Href)
+
+ if err == nil {
+ videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v"))
+ } else {
+ videoUrl = "#"
+ }
+ }
+
+ videos = append(videos, video{
+ ThumbnailUrl: v.Group.Thumbnail.Url,
+ Title: v.Title,
+ Url: videoUrl,
+ Author: response.Channel,
+ AuthorUrl: response.ChannelLink + "/videos",
+ TimePosted: parseYoutubeFeedTime(v.Published),
+ })
+ }
+ }
+
+ if len(videos) == 0 {
+ return nil, errNoContent
+ }
+
+ videos.sortByNewest()
+
+ if failed > 0 {
+ return videos, fmt.Errorf("%w: missing videos from %d channels", errPartialContent, failed)
+ }
+
+ return videos, nil
+}
diff --git a/internal/feed/openmeteo.go b/internal/glance/widget-weather.go
similarity index 51%
rename from internal/feed/openmeteo.go
rename to internal/glance/widget-weather.go
index 2a8dfa6..9d53cd6 100644
--- a/internal/feed/openmeteo.go
+++ b/internal/glance/widget-weather.go
@@ -1,7 +1,10 @@
-package feed
+package glance
import (
+ "context"
+ "errors"
"fmt"
+ "html/template"
"math"
"net/http"
"net/url"
@@ -12,11 +15,94 @@ import (
_ "time/tzdata"
)
-type PlacesResponseJson struct {
- Results []PlaceJson
+var weatherWidgetTemplate = mustParseTemplate("weather.html", "widget-base.html")
+
+type weatherWidget 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 *openMeteoPlaceResponseJson `yaml:"-"`
+ Weather *weather `yaml:"-"`
+ TimeLabels [12]string `yaml:"-"`
}
-type PlaceJson struct {
+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 *weatherWidget) initialize() error {
+ widget.withTitle("Weather").withCacheOnTheHour()
+
+ if widget.Location == "" {
+ return fmt.Errorf("location is required")
+ }
+
+ if widget.HourFormat == "" || widget.HourFormat == "12h" {
+ widget.TimeLabels = timeLabels12h
+ } else if widget.HourFormat == "24h" {
+ widget.TimeLabels = timeLabels24h
+ } else {
+ return errors.New("hour-format must be either 12h or 24h")
+ }
+
+ if widget.Units == "" {
+ widget.Units = "metric"
+ } else if widget.Units != "metric" && widget.Units != "imperial" {
+ return errors.New("units must be either metric or imperial")
+ }
+
+ return nil
+}
+
+func (widget *weatherWidget) update(ctx context.Context) {
+ if widget.Place == nil {
+ place, err := fetchOpenMeteoPlaceFromName(widget.Location)
+ if err != nil {
+ widget.withError(err).scheduleEarlyUpdate()
+ return
+ }
+
+ widget.Place = place
+ }
+
+ weather, err := fetchWeatherForOpenMeteoPlace(widget.Place, widget.Units)
+
+ if !widget.canContinueUpdateAfterHandlingErr(err) {
+ return
+ }
+
+ widget.Weather = weather
+}
+
+func (widget *weatherWidget) Render() template.HTML {
+ return widget.renderTemplate(widget, weatherWidgetTemplate)
+}
+
+type weather struct {
+ Temperature int
+ ApparentTemperature int
+ WeatherCode int
+ CurrentColumn int
+ SunriseColumn int
+ SunsetColumn int
+ Columns []weatherColumn
+}
+
+func (w *weather) WeatherCodeAsString() string {
+ if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
+ return weatherCode
+ }
+
+ return ""
+}
+
+type openMeteoPlacesResponseJson struct {
+ Results []openMeteoPlaceResponseJson
+}
+
+type openMeteoPlaceResponseJson struct {
Name string
Area string `json:"admin1"`
Latitude float64
@@ -26,7 +112,7 @@ type PlaceJson struct {
location *time.Location
}
-type WeatherResponseJson struct {
+type openMeteoWeatherResponseJson struct {
Daily struct {
Sunrise []int64 `json:"sunrise"`
Sunset []int64 `json:"sunset"`
@@ -82,21 +168,20 @@ func parsePlaceName(name string) (string, string) {
return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1])
}
-func FetchPlaceFromName(location string) (*PlaceJson, error) {
+func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) {
location, area := parsePlaceName(location)
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location))
request, _ := http.NewRequest("GET", requestUrl, nil)
- responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request)
-
+ responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request)
if err != nil {
- return nil, fmt.Errorf("could not fetch places data: %v", err)
+ return nil, fmt.Errorf("fetching places data: %v", err)
}
if len(responseJson.Results) == 0 {
return nil, fmt.Errorf("no places found for %s", location)
}
- var place *PlaceJson
+ var place *openMeteoPlaceResponseJson
if area != "" {
area = strings.ToLower(area)
@@ -116,9 +201,8 @@ func FetchPlaceFromName(location string) (*PlaceJson, error) {
}
loc, err := time.LoadLocation(place.Timezone)
-
if err != nil {
- return nil, fmt.Errorf("could not load location: %v", err)
+ return nil, fmt.Errorf("loading location: %v", err)
}
place.location = loc
@@ -126,12 +210,7 @@ func FetchPlaceFromName(location string) (*PlaceJson, error) {
return place, nil
}
-func barIndexFromHour(h int) int {
- return h / 2
-}
-
-// TODO: bunch of spaget, refactor
-func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
+func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units string) (*weather, error) {
query := url.Values{}
var temperatureUnit string
@@ -153,17 +232,16 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
request, _ := http.NewRequest("GET", requestUrl, nil)
- responseJson, err := decodeJsonFromRequest[WeatherResponseJson](defaultClient, request)
-
+ responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultHTTPClient, request)
if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
+ return nil, fmt.Errorf("%w: %v", errNoContent, err)
}
now := time.Now().In(place.location)
bars := make([]weatherColumn, 0, 24)
- currentBar := barIndexFromHour(now.Hour())
- sunriseBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour())
- sunsetBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour()) - 1
+ currentBar := now.Hour() / 2
+ sunriseBar := (time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour()) / 2
+ sunsetBar := (time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour() - 1) / 2
if sunsetBar < 0 {
sunsetBar = 0
@@ -189,16 +267,23 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
minT := slices.Min(temperatures)
maxT := slices.Max(temperatures)
+ temperaturesRange := float64(maxT - minT)
+
for i := 0; i < 12; i++ {
bars = append(bars, weatherColumn{
Temperature: temperatures[i],
- Scale: float64(temperatures[i]-minT) / float64(maxT-minT),
HasPrecipitation: precipitations[i],
})
+
+ if temperaturesRange > 0 {
+ bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange
+ } else {
+ bars[i].Scale = 1
+ }
}
}
- return &Weather{
+ return &weather{
Temperature: int(responseJson.Current.Temperature),
ApparentTemperature: int(responseJson.Current.ApparentTemperature),
WeatherCode: responseJson.Current.WeatherCode,
@@ -208,3 +293,34 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
Columns: bars,
}, nil
}
+
+var weatherCodeTable = map[int]string{
+ 0: "Clear Sky",
+ 1: "Mainly Clear",
+ 2: "Partly Cloudy",
+ 3: "Overcast",
+ 45: "Fog",
+ 48: "Rime Fog",
+ 51: "Drizzle",
+ 53: "Drizzle",
+ 55: "Drizzle",
+ 56: "Drizzle",
+ 57: "Drizzle",
+ 61: "Rain",
+ 63: "Moderate Rain",
+ 65: "Heavy Rain",
+ 66: "Freezing Rain",
+ 67: "Freezing Rain",
+ 71: "Snow",
+ 73: "Moderate Snow",
+ 75: "Heavy Snow",
+ 77: "Snow Grains",
+ 80: "Rain",
+ 81: "Moderate Rain",
+ 82: "Heavy Rain",
+ 85: "Snow",
+ 86: "Snow",
+ 95: "Thunderstorm",
+ 96: "Thunderstorm",
+ 99: "Thunderstorm",
+}
diff --git a/internal/widget/widget.go b/internal/glance/widget.go
similarity index 65%
rename from internal/widget/widget.go
rename to internal/glance/widget.go
index c452427..7e8a618 100644
--- a/internal/widget/widget.go
+++ b/internal/glance/widget.go
@@ -1,4 +1,4 @@
-package widget
+package glance
import (
"bytes"
@@ -12,73 +12,77 @@ import (
"sync/atomic"
"time"
- "github.com/glanceapp/glance/internal/feed"
-
"gopkg.in/yaml.v3"
)
-var uniqueID atomic.Uint64
+var widgetIDCounter atomic.Uint64
-func New(widgetType string) (Widget, error) {
- var widget Widget
+func newWidget(widgetType string) (widget, error) {
+ var w widget
switch widgetType {
case "calendar":
- widget = &Calendar{}
+ w = &calendarWidget{}
case "clock":
- widget = &Clock{}
+ w = &clockWidget{}
case "weather":
- widget = &Weather{}
+ w = &weatherWidget{}
case "bookmarks":
- widget = &Bookmarks{}
+ w = &bookmarksWidget{}
case "iframe":
- widget = &IFrame{}
+ w = &iframeWidget{}
case "html":
- widget = &HTML{}
+ w = &htmlWidget{}
case "hacker-news":
- widget = &HackerNews{}
+ w = &hackerNewsWidget{}
case "releases":
- widget = &Releases{}
+ w = &releasesWidget{}
case "videos":
- widget = &Videos{}
+ w = &videosWidget{}
case "markets", "stocks":
- widget = &Markets{}
+ w = &marketsWidget{}
case "reddit":
- widget = &Reddit{}
+ w = &redditWidget{}
case "rss":
- widget = &RSS{}
+ w = &rssWidget{}
case "monitor":
- widget = &Monitor{}
+ w = &monitorWidget{}
case "twitch-top-games":
- widget = &TwitchGames{}
+ w = &twitchGamesWidget{}
case "twitch-channels":
- widget = &TwitchChannels{}
+ w = &twitchChannelsWidget{}
case "lobsters":
- widget = &Lobsters{}
+ w = &lobstersWidget{}
case "change-detection":
- widget = &ChangeDetection{}
+ w = &changeDetectionWidget{}
case "repository":
- widget = &Repository{}
+ w = &repositoryWidget{}
case "search":
- widget = &Search{}
+ w = &searchWidget{}
case "extension":
- widget = &Extension{}
+ w = &extensionWidget{}
case "group":
- widget = &Group{}
+ w = &groupWidget{}
case "dns-stats":
- widget = &DNSStats{}
+ w = &dnsStatsWidget{}
+ case "split-column":
+ w = &splitColumnWidget{}
+ case "custom-api":
+ w = &customAPIWidget{}
+ case "docker-containers":
+ w = &dockerContainersWidget{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}
- widget.SetID(uniqueID.Add(1))
+ w.setID(widgetIDCounter.Add(1))
- return widget, nil
+ return w, nil
}
-type Widgets []Widget
+type widgets []widget
-func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
+func (w *widgets) UnmarshalYAML(node *yaml.Node) error {
var nodes []yaml.Node
if err := node.Decode(&nodes); err != nil {
@@ -94,8 +98,7 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
return err
}
- widget, err := New(meta.Type)
-
+ widget, err := newWidget(meta.Type)
if err != nil {
return err
}
@@ -110,17 +113,19 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
return nil
}
-type Widget interface {
- Initialize() error
- RequiresUpdate(*time.Time) bool
- SetProviders(*Providers)
- Update(context.Context)
+type widget interface {
+ // These need to be exported because they get called in templates
Render() template.HTML
GetType() string
- GetID() uint64
- SetID(uint64)
- HandleRequest(w http.ResponseWriter, r *http.Request)
- SetHideHeader(bool)
+
+ initialize() error
+ requiresUpdate(*time.Time) bool
+ setProviders(*widgetProviders)
+ update(context.Context)
+ setID(uint64)
+ id() uint64
+ handleRequest(w http.ResponseWriter, r *http.Request)
+ setHideHeader(bool)
}
type cacheType int
@@ -132,29 +137,29 @@ const (
)
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:"-"`
+ ID uint64 `yaml:"-"`
+ Providers *widgetProviders `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
+type widgetProviders struct {
+ assetResolver func(string) string
}
-func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
+func (w *widgetBase) requiresUpdate(now *time.Time) bool {
if w.cacheType == cacheTypeInfinite {
return false
}
@@ -166,23 +171,23 @@ func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
return now.After(w.nextUpdate)
}
-func (w *widgetBase) Update(ctx context.Context) {
+func (w *widgetBase) update(ctx context.Context) {
}
-func (w *widgetBase) GetID() uint64 {
+func (w *widgetBase) id() uint64 {
return w.ID
}
-func (w *widgetBase) SetID(id uint64) {
+func (w *widgetBase) setID(id uint64) {
w.ID = id
}
-func (w *widgetBase) SetHideHeader(value bool) {
+func (w *widgetBase) setHideHeader(value bool) {
w.HideHeader = value
}
-func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
+func (widget *widgetBase) handleRequest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
}
@@ -190,19 +195,18 @@ func (w *widgetBase) GetType() string {
return w.Type
}
-func (w *widgetBase) SetProviders(providers *Providers) {
+func (w *widgetBase) setProviders(providers *widgetProviders) {
w.Providers = providers
}
-func (w *widgetBase) render(data any, t *template.Template) template.HTML {
+func (w *widgetBase) renderTemplate(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)
+ slog.Error("Failed to render template", "error", err)
// need to immediately re-render with the error,
// otherwise risk breaking the page since the widget
@@ -211,7 +215,7 @@ func (w *widgetBase) render(data any, t *template.Template) template.HTML {
err2 := t.Execute(&w.templateBuffer, data)
if err2 != nil {
- slog.Error("failed to render error within widget", "error", err2, "initial_error", err)
+ 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
@@ -288,7 +292,7 @@ func (w *widgetBase) canContinueUpdateAfterHandlingErr(err error) bool {
if err != nil {
w.scheduleEarlyUpdate()
- if !errors.Is(err, feed.ErrPartialContent) {
+ if !errors.Is(err, errPartialContent) {
w.withError(err)
w.withNotice(nil)
return false
diff --git a/internal/widget/bookmarks.go b/internal/widget/bookmarks.go
deleted file mode 100644
index 962d540..0000000
--- a/internal/widget/bookmarks.go
+++ /dev/null
@@ -1,47 +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 string `yaml:"icon"`
- IsSimpleIcon bool `yaml:"-"`
- SameTab bool `yaml:"same-tab"`
- HideArrow bool `yaml:"hide-arrow"`
- } `yaml:"links"`
- } `yaml:"groups"`
-}
-
-func (widget *Bookmarks) Initialize() error {
- widget.withTitle("Bookmarks").withError(nil)
-
- for g := range widget.Groups {
- for l := range widget.Groups[g].Links {
- if widget.Groups[g].Links[l].Icon == "" {
- continue
- }
-
- link := &widget.Groups[g].Links[l]
- link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon)
- }
- }
-
- widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate)
-
- return nil
-}
-
-func (widget *Bookmarks) Render() template.HTML {
- return widget.cachedHTML
-}
diff --git a/internal/widget/calendar.go b/internal/widget/calendar.go
deleted file mode 100644
index a126353..0000000
--- a/internal/widget/calendar.go
+++ /dev/null
@@ -1,30 +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
-}
-
-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)
-}
diff --git a/internal/widget/changedetection.go b/internal/widget/changedetection.go
deleted file mode 100644
index 26c080a..0000000
--- a/internal/widget/changedetection.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/widget/dns-stats.go b/internal/widget/dns-stats.go
deleted file mode 100644
index 91757b1..0000000
--- a/internal/widget/dns-stats.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/widget/extension.go b/internal/widget/extension.go
deleted file mode 100644
index 547bbfe..0000000
--- a/internal/widget/extension.go
+++ /dev/null
@@ -1,59 +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"`
- 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,
- 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
-}
diff --git a/internal/widget/fields.go b/internal/widget/fields.go
deleted file mode 100644
index 9ae1eda..0000000
--- a/internal/widget/fields.go
+++ /dev/null
@@ -1,168 +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
- }
-
- 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
-}
-
-func (f *OptionalEnvString) String() string {
- return string(*f)
-}
-
-func toSimpleIconIfPrefixed(icon string) (string, bool) {
- if !strings.HasPrefix(icon, "si:") {
- return icon, false
- }
-
- icon = strings.TrimPrefix(icon, "si:")
- icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
-
- return icon, true
-}
diff --git a/internal/widget/group.go b/internal/widget/group.go
deleted file mode 100644
index 9a15510..0000000
--- a/internal/widget/group.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package widget
-
-import (
- "context"
- "errors"
- "html/template"
- "sync"
- "time"
-
- "github.com/glanceapp/glance/internal/assets"
-)
-
-type Group struct {
- widgetBase `yaml:",inline"`
- Widgets Widgets `yaml:"widgets"`
-}
-
-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 allowed")
- }
-
- if err := widget.Widgets[i].Initialize(); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func (widget *Group) 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 *Group) SetProviders(providers *Providers) {
- for i := range widget.Widgets {
- widget.Widgets[i].SetProviders(providers)
- }
-}
-
-func (widget *Group) RequiresUpdate(now *time.Time) bool {
- for i := range widget.Widgets {
- if widget.Widgets[i].RequiresUpdate(now) {
- return true
- }
- }
-
- return false
-}
-
-func (widget *Group) Render() template.HTML {
- return widget.render(widget, assets.GroupTemplate)
-}
diff --git a/internal/widget/hacker-news.go b/internal/widget/hacker-news.go
deleted file mode 100644
index f2db6e3..0000000
--- a/internal/widget/hacker-news.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/widget/lobsters.go b/internal/widget/lobsters.go
deleted file mode 100644
index a783c31..0000000
--- a/internal/widget/lobsters.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/widget/markets.go b/internal/widget/markets.go
deleted file mode 100644
index 0d80973..0000000
--- a/internal/widget/markets.go
+++ /dev/null
@@ -1,46 +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()
- }
-
- widget.Markets = markets
-}
-
-func (widget *Markets) Render() template.HTML {
- return widget.render(widget, assets.MarketsTemplate)
-}
diff --git a/internal/widget/monitor.go b/internal/widget/monitor.go
deleted file mode 100644
index 06d7303..0000000
--- a/internal/widget/monitor.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package widget
-
-import (
- "context"
- "html/template"
- "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 "ok"
- }
-
- return "error"
-}
-
-type Monitor struct {
- widgetBase `yaml:",inline"`
- Sites []struct {
- *feed.SiteStatusRequest `yaml:",inline"`
- Status *feed.SiteStatus `yaml:"-"`
- Title string `yaml:"title"`
- IconUrl string `yaml:"icon"`
- IsSimpleIcon bool `yaml:"-"`
- SameTab bool `yaml:"same-tab"`
- StatusText string `yaml:"-"`
- StatusStyle string `yaml:"-"`
- } `yaml:"sites"`
- ShowFailingOnly bool `yaml:"show-failing-only"`
- HasFailing bool `yaml:"-"`
-}
-
-func (widget *Monitor) Initialize() error {
- widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
-
- for i := range widget.Sites {
- widget.Sites[i].IconUrl, widget.Sites[i].IsSimpleIcon = toSimpleIconIfPrefixed(widget.Sites[i].IconUrl)
- }
-
- 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 status.Code >= 400 || status.TimedOut || status.Error != nil {
- widget.HasFailing = true
- }
-
- 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)
-}
diff --git a/internal/widget/reddit.go b/internal/widget/reddit.go
deleted file mode 100644
index b1ddf0a..0000000
--- a/internal/widget/reddit.go
+++ /dev/null
@@ -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)
-
-}
diff --git a/internal/widget/releases.go b/internal/widget/releases.go
deleted file mode 100644
index 74b5af7..0000000
--- a/internal/widget/releases.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/widget/repository-overview.go b/internal/widget/repository-overview.go
deleted file mode 100644
index 9d4cab3..0000000
--- a/internal/widget/repository-overview.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/widget/rss.go b/internal/widget/rss.go
deleted file mode 100644
index 282b150..0000000
--- a/internal/widget/rss.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/widget/twitch-channels.go b/internal/widget/twitch-channels.go
deleted file mode 100644
index b06c986..0000000
--- a/internal/widget/twitch-channels.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/widget/twitch-top-games.go b/internal/widget/twitch-top-games.go
deleted file mode 100644
index 85933a6..0000000
--- a/internal/widget/twitch-top-games.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/widget/videos.go b/internal/widget/videos.go
deleted file mode 100644
index 8943603..0000000
--- a/internal/widget/videos.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/widget/weather.go b/internal/widget/weather.go
deleted file mode 100644
index ac207d4..0000000
--- a/internal/widget/weather.go
+++ /dev/null
@@ -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)
-}