diff --git a/docs/configuration.md b/docs/configuration.md
index bc87d30..7c416e9 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1105,11 +1105,13 @@ Example:
```yaml
- type: releases
+ show-source-icon: true
repositories:
- - immich-app/immich
- go-gitea/gitea
- - dani-garcia/vaultwarden
- jellyfin/jellyfin
+ - glanceapp/glance
+ - gitlab:fdroid/fdroidclient
+ - dockerhub:gotify/server
```
Preview:
@@ -1121,12 +1123,23 @@ Preview:
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| repositories | array | yes | |
+| show-source-icon | boolean | no | false | |
| token | string | no | |
+| gitlab-token | string | no | |
| limit | integer | no | 10 |
| collapse-after | integer | no | 5 |
##### `repositories`
-A list of repositores for which to fetch the latest release for. Only the name/repo is required, not the full URL.
+A list of repositores to fetch the latest release for. Only the name/repo is required, not the full URL. A prefix can be specified for repositories hosted elsewhere such as GitLab and Docker Hub. Example:
+
+```yaml
+repositories:
+ - gitlab:inkscape/inkscape
+ - dockerhub:glanceapp/glance
+```
+
+##### `show-source-icon`
+Shows an icon of the source (GitHub/GitLab/Docker Hub) next to the repository name when set to `true`.
##### `token`
Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if you're tracking lots of repositories or your cache time is low. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here.
@@ -1151,6 +1164,9 @@ and then use it in your `glance.yml` like this:
This way you can safely check your `glance.yml` in version control without exposing the token.
+##### `gitlab-token`
+Same as the above but used when fetching GitLab releases.
+
##### `limit`
The maximum number of releases to show.
diff --git a/docs/images/releases-widget-preview.png b/docs/images/releases-widget-preview.png
index 47acfd0..ec712bb 100644
Binary files a/docs/images/releases-widget-preview.png and b/docs/images/releases-widget-preview.png differ
diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css
index af94e64..081c64a 100644
--- a/internal/assets/static/main.css
+++ b/internal/assets/static/main.css
@@ -543,8 +543,6 @@ kbd:active {
@container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
-
-
.widget-error-header {
display: flex;
align-items: center;
@@ -707,6 +705,12 @@ kbd:active {
color: var(--color-text-highlight);
}
+.release-source-icon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+
.market-chart {
margin-left: auto;
width: 6.5rem;
diff --git a/internal/assets/templates/releases.html b/internal/assets/templates/releases.html
index d6a8974..13e6a0d 100644
--- a/internal/assets/templates/releases.html
+++ b/internal/assets/templates/releases.html
@@ -2,14 +2,28 @@
{{ define "widget-content" }}
- {{ range $i, $release := .Releases }}
+ {{ range .Releases }}
- {{ .Name }}
+
+
{{ .Name }}
+ {{ if $.ShowSourceIcon }}
+ {{/* TODO: add the icons as assets and link to them here instead of hardcoding */}}
+
+ {{ if eq .Source "github" }}
+
+ {{ else if eq .Source "gitlab" }}
+
+ {{ else if eq .Source "dockerhub" }}
+
+ {{ end }}
+
+ {{ end }}
+
-
- {{ $release.Version }}
- {{ if gt $release.Downvotes 3 }}
- {{ $release.Downvotes | formatNumber }} ⚠
+
+ {{ .Version }}
+ {{ if gt .Downvotes 3 }}
+ {{ .Downvotes | formatNumber }} ⚠
{{ end }}
diff --git a/internal/feed/dockerhub.go b/internal/feed/dockerhub.go
new file mode 100644
index 0000000..45e67b7
--- /dev/null
+++ b/internal/feed/dockerhub.go
@@ -0,0 +1,58 @@
+package feed
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+)
+
+type dockerHubRepositoryTagsResponse struct {
+ Results []struct {
+ Name string `json:"name"`
+ LastPushed string `json:"tag_last_pushed"`
+ } `json:"results"`
+}
+
+const dockerHubReleaseNotesURLFormat = "https://hub.docker.com/r/%s/tags?name=%s"
+
+func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) {
+ parts := strings.Split(request.Repository, "/")
+
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
+ }
+
+ httpRequest, err := http.NewRequest(
+ "GET",
+ fmt.Sprintf("https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags", parts[0], parts[1]),
+ nil,
+ )
+
+ if err != nil {
+ return nil, err
+ }
+
+ if request.Token != nil {
+ httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
+ }
+
+ response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, 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]
+
+ return &AppRelease{
+ Source: ReleaseSourceDockerHub,
+ NotesUrl: fmt.Sprintf(dockerHubReleaseNotesURLFormat, request.Repository, tag.Name),
+ Name: request.Repository,
+ Version: tag.Name,
+ TimeReleased: parseRFC3339Time(tag.LastPushed),
+ }, nil
+}
diff --git a/internal/feed/github.go b/internal/feed/github.go
index 4d7dc73..8adbfd8 100644
--- a/internal/feed/github.go
+++ b/internal/feed/github.go
@@ -2,7 +2,6 @@ package feed
import (
"fmt"
- "log/slog"
"net/http"
"sync"
"time"
@@ -17,85 +16,41 @@ type githubReleaseLatestResponseJson struct {
} `json:"reactions"`
}
-func parseGithubTime(t string) time.Time {
- parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
-
- if err != nil {
- return time.Now()
- }
-
- return parsedTime
-}
-
-func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
- appReleases := make(AppReleases, 0, len(repositories))
-
- if len(repositories) == 0 {
- return appReleases, nil
- }
-
- requests := make([]*http.Request, len(repositories))
-
- for i, repository := range repositories {
- request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository), nil)
-
- if token != "" {
- request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
- }
-
- requests[i] = request
- }
-
- task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](defaultClient)
- job := newJob(task, requests).withWorkers(15)
- responses, errs, err := workerPoolDo(job)
+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
}
- var failed int
-
- for i := range responses {
- if errs[i] != nil {
- failed++
- slog.Error("Failed to fetch or parse github release", "error", errs[i], "url", requests[i].URL)
- continue
- }
-
- liveRelease := &responses[i]
-
- if liveRelease == nil {
- slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
- continue
- }
-
- version := liveRelease.TagName
-
- if version[0] != 'v' {
- version = "v" + version
- }
-
- appReleases = append(appReleases, AppRelease{
- Name: repositories[i],
- Version: version,
- NotesUrl: liveRelease.HtmlUrl,
- TimeReleased: parseGithubTime(liveRelease.PublishedAt),
- Downvotes: liveRelease.Reactions.Downvotes,
- })
+ if request.Token != nil {
+ httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
}
- if len(appReleases) == 0 {
- return nil, ErrNoContent
+ response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
+
+ if err != nil {
+ return nil, err
}
- appReleases.SortByNewest()
+ version := response.TagName
- if failed > 0 {
- return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
+ if len(version) > 0 && version[0] != 'v' {
+ version = "v" + version
}
- return appReleases, nil
+ return &AppRelease{
+ Source: ReleaseSourceGithub,
+ Name: request.Repository,
+ Version: version,
+ NotesUrl: response.HtmlUrl,
+ TimeReleased: parseRFC3339Time(response.PublishedAt),
+ Downvotes: response.Reactions.Downvotes,
+ }, nil
}
type GithubTicket struct {
@@ -201,7 +156,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
for i := range PRsResponse.Tickets {
details.PullRequests = append(details.PullRequests, GithubTicket{
Number: PRsResponse.Tickets[i].Number,
- CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
+ CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
Title: PRsResponse.Tickets[i].Title,
})
}
@@ -218,7 +173,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
for i := range issuesResponse.Tickets {
details.Issues = append(details.Issues, GithubTicket{
Number: issuesResponse.Tickets[i].Number,
- CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
+ CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
Title: issuesResponse.Tickets[i].Title,
})
}
diff --git a/internal/feed/gitlab.go b/internal/feed/gitlab.go
new file mode 100644
index 0000000..4e0c1e8
--- /dev/null
+++ b/internal/feed/gitlab.go
@@ -0,0 +1,54 @@
+package feed
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+)
+
+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](defaultClient, httpRequest)
+
+ if err != nil {
+ return nil, err
+ }
+
+ version := response.TagName
+
+ if len(version) > 0 && version[0] != 'v' {
+ version = "v" + version
+ }
+
+ return &AppRelease{
+ Source: ReleaseSourceGitlab,
+ Name: request.Repository,
+ Version: version,
+ NotesUrl: response.Links.Self,
+ TimeReleased: parseRFC3339Time(response.ReleasedAt),
+ }, nil
+}
diff --git a/internal/feed/primitives.go b/internal/feed/primitives.go
index 7371983..4b623ec 100644
--- a/internal/feed/primitives.go
+++ b/internal/feed/primitives.go
@@ -41,6 +41,7 @@ type Weather struct {
}
type AppRelease struct {
+ Source ReleaseSource
Name string
Version string
NotesUrl string
diff --git a/internal/feed/releases.go b/internal/feed/releases.go
new file mode 100644
index 0000000..516801e
--- /dev/null
+++ b/internal/feed/releases.go
@@ -0,0 +1,69 @@
+package feed
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+)
+
+type ReleaseSource string
+
+const (
+ ReleaseSourceGithub ReleaseSource = "github"
+ ReleaseSourceGitlab ReleaseSource = "gitlab"
+ ReleaseSourceDockerHub ReleaseSource = "dockerhub"
+)
+
+type ReleaseRequest struct {
+ Source ReleaseSource
+ Repository string
+ Token *string
+}
+
+func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
+ job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
+ results, errs, err := workerPoolDo(job)
+
+ if err != nil {
+ return nil, err
+ }
+
+ var failed int
+
+ releases := make(AppReleases, 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 ReleaseSourceGithub:
+ return fetchLatestGithubRelease(request)
+ case ReleaseSourceGitlab:
+ return fetchLatestGitLabRelease(request)
+ case ReleaseSourceDockerHub:
+ return fetchLatestDockerHubRelease(request)
+ }
+
+ return nil, errors.New("unsupported source")
+}
diff --git a/internal/feed/utils.go b/internal/feed/utils.go
index 16c376b..f86b497 100644
--- a/internal/feed/utils.go
+++ b/internal/feed/utils.go
@@ -7,6 +7,7 @@ import (
"regexp"
"slices"
"strings"
+ "time"
)
var (
@@ -79,7 +80,6 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
return values
}
-
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
func stripURLScheme(url string) string {
@@ -95,3 +95,13 @@ func limitStringLength(s string, max int) (string, bool) {
return s, false
}
+
+func parseRFC3339Time(t string) time.Time {
+ parsed, err := time.Parse(time.RFC3339, t)
+
+ if err != nil {
+ return time.Now()
+ }
+
+ return parsed
+}
diff --git a/internal/widget/fields.go b/internal/widget/fields.go
index cbbfce2..9ae1eda 100644
--- a/internal/widget/fields.go
+++ b/internal/widget/fields.go
@@ -152,6 +152,10 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
return nil
}
+func (f *OptionalEnvString) String() string {
+ return string(*f)
+}
+
func toSimpleIconIfPrefixed(icon string) (string, bool) {
if !strings.HasPrefix(icon, "si:") {
return icon, false
diff --git a/internal/widget/releases.go b/internal/widget/releases.go
index 77fe103..c824ed6 100644
--- a/internal/widget/releases.go
+++ b/internal/widget/releases.go
@@ -2,7 +2,9 @@ package widget
import (
"context"
+ "errors"
"html/template"
+ "strings"
"time"
"github.com/glanceapp/glance/internal/assets"
@@ -10,12 +12,15 @@ import (
)
type Releases struct {
- widgetBase `yaml:",inline"`
- Releases feed.AppReleases `yaml:"-"`
- Repositories []string `yaml:"repositories"`
- Token OptionalEnvString `yaml:"token"`
- Limit int `yaml:"limit"`
- CollapseAfter int `yaml:"collapse-after"`
+ 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 {
@@ -29,11 +34,50 @@ func (widget *Releases) Initialize() error {
widget.CollapseAfter = 5
}
+ var tokenAsString = widget.Token.String()
+ var gitLabTokenAsString = widget.GitLabToken.String()
+
+ for _, repository := range widget.Repositories {
+ parts := strings.Split(repository, ":")
+ 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 {
+ 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.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token))
+ releases, err := feed.FetchLatestReleases(widget.releaseRequests)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return