diff --git a/internal/assets/templates.go b/internal/assets/templates.go
index dcba7a8..6ff1c89 100644
--- a/internal/assets/templates.go
+++ b/internal/assets/templates.go
@@ -23,6 +23,7 @@ var (
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
+ ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
diff --git a/internal/assets/templates/change-detection.html b/internal/assets/templates/change-detection.html
new file mode 100644
index 0000000..22b7a18
--- /dev/null
+++ b/internal/assets/templates/change-detection.html
@@ -0,0 +1,17 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+
+ {{ range .ChangeDetections }}
+ -
+ {{ .Title }}
+
+
+ {{ else }}
+ - No watches configured
+ {{ end}}
+
+{{ end }}
diff --git a/internal/feed/changedetection.go b/internal/feed/changedetection.go
new file mode 100644
index 0000000..793416d
--- /dev/null
+++ b/internal/feed/changedetection.go
@@ -0,0 +1,139 @@
+package feed
+
+import (
+ "fmt"
+ "log/slog"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+)
+
+type ChangeDetectionWatch struct {
+ Title string
+ URL string
+ LastChanged time.Time
+ DiffURL string
+ PreviousHash string
+}
+
+type ChangeDetectionWatches []ChangeDetectionWatch
+
+func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
+ sort.Slice(r, func(i, j int) bool {
+ return r[i].LastChanged.After(r[j].LastChanged)
+ })
+
+ return r
+}
+
+type changeDetectionResponseJson struct {
+ Title string `json:"title"`
+ URL string `json:"url"`
+ LastChanged int64 `json:"last_changed"`
+ DateCreated int64 `json:"date_created"`
+ PreviousHash string `json:"previous_md5"`
+}
+
+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)
+
+ if err != nil {
+ return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
+ }
+
+ uuids := make([]string, 0, len(uuidsMap))
+
+ for uuid := range uuidsMap {
+ uuids = append(uuids, uuid)
+ }
+
+ return uuids, nil
+}
+
+func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
+ watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
+
+ if len(requestedWatchIDs) == 0 {
+ return watches, nil
+ }
+
+ requests := make([]*http.Request, len(requestedWatchIDs))
+
+ for i, repository := range requestedWatchIDs {
+ request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch/%s", instanceURL, repository), nil)
+
+ if token != "" {
+ request.Header.Add("x-api-key", token)
+ }
+
+ requests[i] = request
+ }
+
+ task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
+ job := newJob(task, requests).withWorkers(15)
+ responses, errs, err := workerPoolDo(job)
+
+ if err != nil {
+ return nil, err
+ }
+
+ var failed int
+
+ 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)
+ continue
+ }
+
+ watchJson := responses[i]
+
+ watch := ChangeDetectionWatch{
+ URL: watchJson.URL,
+ DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
+ }
+
+ if watchJson.LastChanged == 0 {
+ watch.LastChanged = time.Unix(watchJson.DateCreated, 0)
+ } else {
+ watch.LastChanged = time.Unix(watchJson.LastChanged, 0)
+ }
+
+ if watchJson.Title != "" {
+ watch.Title = watchJson.Title
+ } else {
+ watch.Title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.")
+ }
+
+ if watchJson.PreviousHash != "" {
+ var hashLength = 8
+
+ if len(watchJson.PreviousHash) < hashLength {
+ hashLength = len(watchJson.PreviousHash)
+ }
+
+ watch.PreviousHash = watchJson.PreviousHash[0:hashLength]
+ }
+
+ watches = append(watches, watch)
+ }
+
+ if len(watches) == 0 {
+ return nil, ErrNoContent
+ }
+
+ watches.SortByNewest()
+
+ if failed > 0 {
+ return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
+ }
+
+ return watches, nil
+}
diff --git a/internal/feed/utils.go b/internal/feed/utils.go
index 6ea475f..16c376b 100644
--- a/internal/feed/utils.go
+++ b/internal/feed/utils.go
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/url"
+ "regexp"
"slices"
"strings"
)
@@ -78,6 +79,13 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
return values
}
+
+var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
+
+func stripURLScheme(url string) string {
+ return urlSchemePattern.ReplaceAllString(url, "")
+}
+
func limitStringLength(s string, max int) (string, bool) {
asRunes := []rune(s)
diff --git a/internal/widget/changedetection.go b/internal/widget/changedetection.go
new file mode 100644
index 0000000..26c080a
--- /dev/null
+++ b/internal/widget/changedetection.go
@@ -0,0 +1,66 @@
+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/widget.go b/internal/widget/widget.go
index aff6bf3..8af7964 100644
--- a/internal/widget/widget.go
+++ b/internal/widget/widget.go
@@ -45,6 +45,8 @@ func New(widgetType string) (Widget, error) {
return &TwitchGames{}, nil
case "twitch-channels":
return &TwitchChannels{}, nil
+ case "change-detection":
+ return &ChangeDetection{}, nil
case "repository":
return &Repository{}, nil
case "search":