Update releases widget

This commit is contained in:
Svilen Markov 2025-01-17 20:14:07 +00:00
parent 3076593021
commit 0ce706e02e
4 changed files with 115 additions and 245 deletions

View file

@ -1332,6 +1332,19 @@ repositories:
- dockerhub:nginx:stable-alpine
```
To include prereleases you can specify the repository as an object and use the `include-prereleases` property:
**Note: This feature is currently only available for GitHub repositories.**
```yaml
repositories:
- gitlab:inkscape/inkscape
- repository: glanceapp/glance
include-prereleases: true
- codeberg:redict/redict
```
##### `show-source-icon`
Shows an icon of the source (GitHub/GitLab/Codeberg/Docker Hub) next to the repository name when set to `true`.

View file

@ -1,73 +0,0 @@
package feed
import (
"errors"
"fmt"
"log/slog"
)
type ReleaseSource string
const (
ReleaseSourceCodeberg ReleaseSource = "codeberg"
ReleaseSourceGithub ReleaseSource = "github"
ReleaseSourceGitlab ReleaseSource = "gitlab"
ReleaseSourceDockerHub ReleaseSource = "dockerhub"
)
type ReleaseRequest struct {
Source ReleaseSource
Repository string
Token *string
IncludeGithubPreReleases bool
}
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 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")
}

View file

@ -11,20 +11,21 @@ import (
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
)
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 string `yaml:"token"`
GitLabToken string `yaml:"gitlab-token"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
ShowSourceIcon bool `yaml:"show-source-icon"`
widgetBase `yaml:",inline"`
Releases appReleaseList `yaml:"-"`
Repositories []*releaseRequest `yaml:"repositories"`
Token string `yaml:"token"`
GitLabToken string `yaml:"gitlab-token"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
ShowSourceIcon bool `yaml:"show-source-icon"`
}
func (widget *releasesWidget) initialize() error {
@ -38,51 +39,21 @@ func (widget *releasesWidget) initialize() error {
widget.CollapseAfter = 5
}
for _, repository := range widget.Repositories {
parts := strings.SplitN(repository, ":", 2)
var request *releaseRequest
if len(parts) == 1 {
request = &releaseRequest{
source: releaseSourceGithub,
repository: repository,
}
for i := range widget.Repositories {
r := widget.Repositories[i]
if widget.Token != "" {
request.token = &widget.Token
}
} else if len(parts) == 2 {
if parts[0] == string(releaseSourceGitlab) {
request = &releaseRequest{
source: releaseSourceGitlab,
repository: parts[1],
}
if widget.GitLabToken != "" {
request.token = &widget.GitLabToken
}
} 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])
}
if r.source == releaseSourceGithub && widget.Token != "" {
r.token = &widget.Token
} else if r.source == releaseSourceGitlab && widget.GitLabToken != "" {
r.token = &widget.GitLabToken
}
widget.releaseRequests = append(widget.releaseRequests, request)
}
return nil
}
func (widget *releasesWidget) update(ctx context.Context) {
releases, err := fetchLatestReleases(widget.releaseRequests)
releases, err := fetchLatestReleases(widget.Repositories)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
@ -133,9 +104,53 @@ func (r appReleaseList) sortByNewest() appReleaseList {
}
type releaseRequest struct {
source releaseSource
repository string
token *string
IncludePreleases bool `yaml:"include-prereleases"`
Repository string `yaml:"repository"`
source releaseSource
token *string
}
func (r *releaseRequest) UnmarshalYAML(node *yaml.Node) error {
type releaseRequestAlias releaseRequest
alias := (*releaseRequestAlias)(r)
var repository string
if err := node.Decode(&repository); err != nil {
if err := node.Decode(alias); err != nil {
return fmt.Errorf("could not umarshal repository into string or struct: %v", err)
}
}
if r.Repository == "" {
if repository == "" {
return errors.New("repository is required")
} else {
r.Repository = repository
}
}
parts := strings.SplitN(repository, ":", 2)
if len(parts) == 1 {
r.source = releaseSourceGithub
} else if len(parts) == 2 {
r.Repository = parts[1]
switch parts[0] {
case string(releaseSourceGithub):
r.source = releaseSourceGithub
case string(releaseSourceGitlab):
r.source = releaseSourceGitlab
case string(releaseSourceDockerHub):
r.source = releaseSourceDockerHub
case string(releaseSourceCodeberg):
r.source = releaseSourceCodeberg
default:
return errors.New("invalid source")
}
}
return nil
}
func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) {
@ -152,7 +167,7 @@ func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) {
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])
slog.Error("Failed to fetch release", "source", requests[i].source, "repository", requests[i].Repository, "error", errs[i])
continue
}
@ -187,7 +202,7 @@ func fetchLatestReleaseTask(request *releaseRequest) (*appRelease, error) {
return nil, errors.New("unsupported source")
}
type githubReleaseLatestResponseJson struct {
type githubReleaseResponseJson struct {
TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"`
HtmlUrl string `json:"html_url"`
@ -197,12 +212,17 @@ type githubReleaseLatestResponseJson struct {
}
func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.repository),
nil,
)
var requestURL string
if !request.IncludePreleases {
requestURL = fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository)
} else {
requestURL = fmt.Sprintf("https://api.github.com/repos/%s/releases", request.Repository)
}
fmt.Println(requestURL)
httpRequest, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
@ -211,14 +231,29 @@ func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.token))
}
response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultHTTPClient, httpRequest)
if err != nil {
return nil, err
var response githubReleaseResponseJson
if !request.IncludePreleases {
response, err = decodeJsonFromRequest[githubReleaseResponseJson](defaultHTTPClient, httpRequest)
if err != nil {
return nil, err
}
} else {
responses, err := decodeJsonFromRequest[[]githubReleaseResponseJson](defaultHTTPClient, httpRequest)
if err != nil {
return nil, err
}
if len(responses) == 0 {
return nil, fmt.Errorf("no releases found for repository %s", request.Repository)
}
response = responses[0]
}
return &appRelease{
Source: releaseSourceGithub,
Name: request.repository,
Name: request.Repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.HtmlUrl,
TimeReleased: parseRFC3339Time(response.PublishedAt),
@ -242,10 +277,10 @@ const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/r
func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) {
nameParts := strings.Split(request.repository, "/")
nameParts := strings.Split(request.Repository, "/")
if len(nameParts) > 2 {
return nil, fmt.Errorf("invalid repository name: %s", request.repository)
return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
} else if len(nameParts) == 1 {
nameParts = []string{"library", nameParts[0]}
}
@ -278,7 +313,7 @@ func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) {
}
if len(response.Results) == 0 {
return nil, fmt.Errorf("no tags found for repository: %s", request.repository)
return nil, fmt.Errorf("no tags found for repository: %s", request.Repository)
}
tag = &response.Results[0]
@ -331,7 +366,7 @@ func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) {
"GET",
fmt.Sprintf(
"https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
url.QueryEscape(request.repository),
url.QueryEscape(request.Repository),
),
nil,
)
@ -350,7 +385,7 @@ func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) {
return &appRelease{
Source: releaseSourceGitlab,
Name: request.repository,
Name: request.Repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.Links.Self,
TimeReleased: parseRFC3339Time(response.ReleasedAt),
@ -368,7 +403,7 @@ func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) {
"GET",
fmt.Sprintf(
"https://codeberg.org/api/v1/repos/%s/releases/latest",
request.repository,
request.Repository,
),
nil,
)
@ -383,7 +418,7 @@ func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) {
return &appRelease{
Source: releaseSourceCodeberg,
Name: request.repository,
Name: request.Repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.HtmlUrl,
TimeReleased: parseRFC3339Time(response.PublishedAt),

View file

@ -1,105 +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"`
IncludeGithubPreReleases bool `yaml:"include-github-prereleases"`
}
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,
IncludeGithubPreReleases: widget.IncludeGithubPreReleases,
}
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)
}