Update change detection

This commit is contained in:
Svilen Markov 2024-05-30 22:53:59 +01:00
parent 6cad5a8efb
commit 00a93e466d
8 changed files with 140 additions and 84 deletions

View File

@ -22,7 +22,7 @@ var (
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html") RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html") RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html") ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
ChangesTemplate = compileTemplate("changes.html", "widget-base.html") ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
VideosGridTemplate = compileTemplate("videos-grid.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") StocksTemplate = compileTemplate("stocks.html", "widget-base.html")

View File

@ -0,0 +1,17 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .ChangeDetections }}
<li>
<a class="size-h4 block text-truncate color-highlight" href="{{ .URL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .LastChanged }}></li>
<li class="shrink min-width-0"><a class="visited-indicator" href="{{ .DiffURL }}" target="_blank" rel="noreferrer">diff:{{ .PreviousHash }}</a></li>
</ul>
</li>
{{ else }}
<li>No watches configured</li>
{{ end}}
</ul>
{{ end }}

View File

@ -1,18 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible">
{{ range $i, $watch := .ChangeDetections }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $watch.URL }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
<ul class="list-horizontal-text">
<li title="{{ $watch.LastChanged | formatTime }}" {{ dynamicRelativeTimeAttrs $watch.LastChanged }}>{{ $watch.LastChanged | relativeTime }}</li>
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ $watch.DiffURL }}" target="_blank" rel="noreferrer">diff: {{ $watch.DiffDisplay | }}</a></li>
</ul>
</li>
{{ end }}
</ul>
{{ if gt (len .ChangeDetections) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }}

View File

@ -4,37 +4,70 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"sort"
"strings" "strings"
"time" "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 { type changeDetectionResponseJson struct {
Name string `json:"title"` Title string `json:"title"`
URL string `json:"url"` URL string `json:"url"`
LastChanged int `json:"last_changed"` LastChanged int64 `json:"last_changed"`
UUID string `json:"uuid"` DateCreated int64 `json:"date_created"`
PreviousHash string `json:"previous_md5"`
} }
func parseLastChangeTime(t int) time.Time { func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
parsedTime := time.Unix(int64(t), 0) request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
return parsedTime
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 FetchLatestDetectedChanges(request_url string, watches []string, token string) (ChangeWatches, error) { func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
changeWatches := make(ChangeWatches, 0, len(watches)) watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
if request_url == "" { if len(requestedWatchIDs) == 0 {
request_url = "https://www.changedetection.io" return watches, nil
} }
if len(watches) == 0 { requests := make([]*http.Request, len(requestedWatchIDs))
return changeWatches, nil
}
requests := make([]*http.Request, len(watches)) for i, repository := range requestedWatchIDs {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch/%s", instanceURL, repository), nil)
for i, repository := range watches {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch/%s", request_url, repository), nil)
if token != "" { if token != "" {
request.Header.Add("x-api-key", token) request.Header.Add("x-api-key", token)
@ -56,30 +89,51 @@ func FetchLatestDetectedChanges(request_url string, watches []string, token stri
for i := range responses { for i := range responses {
if errs[i] != nil { if errs[i] != nil {
failed++ failed++
slog.Error("Failed to fetch or parse change detections", "error", errs[i], "url", requests[i].URL) slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL)
continue continue
} }
watch := responses[i] watchJson := responses[i]
changeWatches = append(changeWatches, ChangeWatch{ watch := ChangeDetectionWatch{
Name: watch.Name, URL: watchJson.URL,
URL: watch.URL, DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
LastChanged: parseLastChangeTime(watch.LastChanged), }
DiffURL: request_url + "/diff/" + watch.UUID,
DiffDisplay: strings.Split(watch.UUID, "-")[len(strings.Split(watch.UUID, "-"))-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(changeWatches) == 0 { if len(watches) == 0 {
return nil, ErrNoContent return nil, ErrNoContent
} }
changeWatches.SortByNewest() watches.SortByNewest()
if failed > 0 { if failed > 0 {
return changeWatches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed) return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
} }
return changeWatches, nil return watches, nil
} }

View File

@ -48,16 +48,6 @@ type AppRelease struct {
type AppReleases []AppRelease type AppReleases []AppRelease
type ChangeWatch struct {
Name string
URL string
LastChanged time.Time
DiffURL string
DiffDisplay string
}
type ChangeWatches []ChangeWatch
type Video struct { type Video struct {
ThumbnailUrl string ThumbnailUrl string
Title string Title string
@ -212,14 +202,6 @@ func (r AppReleases) SortByNewest() AppReleases {
return r return r
} }
func (r ChangeWatches) SortByNewest() ChangeWatches {
sort.Slice(r, func(i, j int) bool {
return r[i].LastChanged.After(r[j].LastChanged)
})
return r
}
func (v Videos) SortByNewest() Videos { func (v Videos) SortByNewest() Videos {
sort.Slice(v, func(i, j int) bool { sort.Slice(v, func(i, j int) bool {
return v[i].TimePosted.After(v[j].TimePosted) return v[i].TimePosted.After(v[j].TimePosted)

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"regexp"
"slices" "slices"
"strings" "strings"
) )
@ -77,3 +78,9 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
return values return values
} }
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
func stripURLScheme(url string) string {
return urlSchemePattern.ReplaceAllString(url, "")
}

View File

@ -9,18 +9,18 @@ import (
"github.com/glanceapp/glance/internal/feed" "github.com/glanceapp/glance/internal/feed"
) )
type ChangeDetections struct { type ChangeDetection struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
ChangeDetections feed.ChangeWatches `yaml:"-"` ChangeDetections feed.ChangeDetectionWatches `yaml:"-"`
RequestURL string `yaml:"request_url"` WatchUUIDs []string `yaml:"watches"`
Watches []string `yaml:"watches"` InstanceURL string `yaml:"instance-url"`
Token OptionalEnvString `yaml:"token"` Token OptionalEnvString `yaml:"token"`
Limit int `yaml:"limit"` Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"` CollapseAfter int `yaml:"collapse-after"`
} }
func (widget *ChangeDetections) Initialize() error { func (widget *ChangeDetection) Initialize() error {
widget.withTitle("Changes").withCacheDuration(2 * time.Hour) widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
if widget.Limit <= 0 { if widget.Limit <= 0 {
widget.Limit = 10 widget.Limit = 10
@ -30,11 +30,25 @@ func (widget *ChangeDetections) Initialize() error {
widget.CollapseAfter = 5 widget.CollapseAfter = 5
} }
if widget.InstanceURL == "" {
widget.InstanceURL = "https://www.changedetection.io"
}
return nil return nil
} }
func (widget *ChangeDetections) Update(ctx context.Context) { func (widget *ChangeDetection) Update(ctx context.Context) {
watches, err := feed.FetchLatestDetectedChanges(widget.RequestURL, widget.Watches, string(widget.Token)) 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) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
@ -47,6 +61,6 @@ func (widget *ChangeDetections) Update(ctx context.Context) {
widget.ChangeDetections = watches widget.ChangeDetections = watches
} }
func (widget *ChangeDetections) Render() template.HTML { func (widget *ChangeDetection) Render() template.HTML {
return widget.render(widget, assets.ChangesTemplate) return widget.render(widget, assets.ChangeDetectionTemplate)
} }

View File

@ -43,12 +43,12 @@ func New(widgetType string) (Widget, error) {
return &TwitchGames{}, nil return &TwitchGames{}, nil
case "twitch-channels": case "twitch-channels":
return &TwitchChannels{}, nil return &TwitchChannels{}, nil
case "changes": case "change-detection":
return &ChangeDetections{}, nil return &ChangeDetection{}, nil
case "repository": case "repository":
return &Repository{}, nil return &Repository{}, nil
default: default:
return nil, fmt.Errorf("unknown widget type: %s found", widgetType) return nil, fmt.Errorf("unknown widget type: %s", widgetType)
} }
} }