+
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 100%
rename from internal/assets/templates/widget-base.html
rename to internal/glance/templates/widget-base.html
diff --git a/internal/feed/utils.go b/internal/glance/utils.go
similarity index 56%
rename from internal/feed/utils.go
rename to internal/glance/utils.go
index a6e3f8d..9600031 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,33 @@ 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
+}
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 += "...