Merge branch 'release/v0.6.0' into features

This commit is contained in:
Svilen Markov 2024-08-29 20:06:40 +01:00
commit 37164070d2
24 changed files with 580 additions and 113 deletions

View File

@ -3,6 +3,7 @@
- [Intro](#intro) - [Intro](#intro)
- [Preconfigured page](#preconfigured-page) - [Preconfigured page](#preconfigured-page)
- [Server](#server) - [Server](#server)
- [Branding](#branding)
- [Theme](#theme) - [Theme](#theme)
- [Themes](#themes) - [Themes](#themes)
- [Pages & Columns](#pages--columns) - [Pages & Columns](#pages--columns)
@ -174,6 +175,42 @@ To be able to point to an asset from your assets path, use the `/assets/` path l
icon: /assets/gitea-icon.png icon: /assets/gitea-icon.png
``` ```
## Branding
You can adjust the various parts of the branding through a top level `branding` property. Example:
```yaml
branding:
custom-footer: |
<p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p>
logo-url: /assets/logo.png
favicon-url: /assets/logo.png
```
### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| hide-footer | bool | no | false |
| custom-footer | string | no | |
| logo-text | string | no | G |
| logo-url | string | no | |
| favicon-url | string | no | |
#### `hide-footer`
Hides the footer when set to `true`.
#### `custom-footer`
Specify custom HTML to use for the footer.
#### `logo-text`
Specify custom text to use instead of the "G" found in the navigation.
#### `logo-url`
Specify a URL to a custom image to use instead of the "G" found in the navigation. If both `logo-text` and `logo-url` are set, only `logo-url` will be used.
#### `favicon-url`
Specify a URL to a custom image to use for the favicon.
## Theme ## Theme
Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers. Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers.
@ -1063,17 +1100,19 @@ Whether to ignore invalid/self-signed certificates.
Whether to open the link in the same or a new tab. Whether to open the link in the same or a new tab.
### Releases ### Releases
Display a list of releases for specific repositories on Github. Draft releases and prereleases will not be shown. Display a list of latest releases for specific repositories on Github, GitLab or Docker Hub.
Example: Example:
```yaml ```yaml
- type: releases - type: releases
show-source-icon: true
repositories: repositories:
- immich-app/immich
- go-gitea/gitea - go-gitea/gitea
- dani-garcia/vaultwarden
- jellyfin/jellyfin - jellyfin/jellyfin
- glanceapp/glance
- gitlab:fdroid/fdroidclient
- dockerhub:gotify/server
``` ```
Preview: Preview:
@ -1085,12 +1124,41 @@ Preview:
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| repositories | array | yes | | | repositories | array | yes | |
| show-source-icon | boolean | no | false | |
| token | string | no | | | token | string | no | |
| gitlab-token | string | no | |
| limit | integer | no | 10 | | limit | integer | no | 10 |
| collapse-after | integer | no | 5 | | collapse-after | integer | no | 5 |
##### `repositories` ##### `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
```
Official images on Docker Hub can be specified by ommiting the owner:
```yaml
repositories:
- dockerhub:nginx
- dockerhub:node
- dockerhub:alpine
```
You can also specify specific tags for Docker Hub images:
```yaml
repositories:
- dockerhub:nginx:latest
- dockerhub:nginx:stable-alpine
```
##### `show-source-icon`
Shows an icon of the source (GitHub/GitLab/Docker Hub) next to the repository name when set to `true`.
##### `token` ##### `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. 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.
@ -1115,6 +1183,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. 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` ##### `limit`
The maximum number of releases to show. The maximum number of releases to show.
@ -1181,6 +1252,7 @@ Example:
repository: glanceapp/glance repository: glanceapp/glance
pull-requests-limit: 5 pull-requests-limit: 5
issues-limit: 3 issues-limit: 3
commits-limit: 3
``` ```
Preview: Preview:
@ -1195,6 +1267,7 @@ Preview:
| token | string | no | | | token | string | no | |
| pull-requests-limit | integer | no | 3 | | pull-requests-limit | integer | no | 3 |
| issues-limit | integer | no | 3 | | issues-limit | integer | no | 3 |
| commits-limit | integer | no | -1 |
##### `repository` ##### `repository`
The owner and repository name that will have their information displayed. The owner and repository name that will have their information displayed.
@ -1208,6 +1281,9 @@ The maximum number of latest open pull requests to show. Set to `-1` to not show
##### `issues-limit` ##### `issues-limit`
The maximum number of latest open issues to show. Set to `-1` to not show any. The maximum number of latest open issues to show. Set to `-1` to not show any.
##### `commits-limit`
The maximum number of lastest commits to show from the default branch. Set to `-1` to not show any.
### Bookmarks ### Bookmarks
Display a list of links which can be grouped. Display a list of links which can be grouped.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 802 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/></svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@ -782,6 +782,15 @@ details[open] .summary::after {
padding-right: var(--widget-content-horizontal-padding); padding-right: var(--widget-content-horizontal-padding);
} }
.logo:has(img) {
display: flex;
align-items: center;
}
.logo img {
max-height: 2.7rem;
}
.nav { .nav {
height: 100%; height: 100%;
gap: var(--header-items-gap); gap: var(--header-items-gap);
@ -820,6 +829,13 @@ details[open] .summary::after {
color: var(--color-text-highlight); color: var(--color-text-highlight);
} }
.release-source-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.4;
}
.market-chart { .market-chart {
margin-left: auto; margin-left: auto;
width: 6.5rem; width: 6.5rem;
@ -997,6 +1013,7 @@ details[open] .summary::after {
background-color: var(--color-widget-background-highlight); background-color: var(--color-widget-background-highlight);
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: 0.5rem; padding: 0.5rem;
opacity: 0.7;
} }
.bookmarks-icon { .bookmarks-icon {
@ -1005,10 +1022,6 @@ details[open] .summary::after {
opacity: 0.8; opacity: 0.8;
} }
.simple-icon {
opacity: 0.7;
}
:root:not(.light-scheme) .simple-icon { :root:not(.light-scheme) .simple-icon {
filter: invert(1); filter: invert(1);
} }
@ -1307,6 +1320,10 @@ details[open] .summary::after {
transition: filter 0.3s, opacity 0.3s; transition: filter 0.3s, opacity 0.3s;
} }
.monitor-site-icon.simple-icon {
opacity: 0.7;
}
.monitor-site:hover .monitor-site-icon { .monitor-site:hover .monitor-site-icon {
filter: grayscale(0); filter: grayscale(0);
opacity: 1; opacity: 1;

View File

@ -12,9 +12,8 @@
<meta name="apple-mobile-web-app-title" content="Glance"> <meta name="apple-mobile-web-app-title" content="Glance">
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}"> <meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}"> <link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
<link rel="icon" type="image/png" sizes="50x50" href="{{ .App.AssetPath "favicon.png" }}">
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}"> <link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
<link rel="icon" type="image/png" href="{{ .App.AssetPath "favicon.png" }}" /> <link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}"> <link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script> <script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
{{ block "document-head-after" . }}{{ end }} {{ block "document-head-after" . }}{{ end }}

View File

@ -32,7 +32,7 @@
<div class="header-container content-bounds"> <div class="header-container content-bounds">
<div class="header flex padding-inline-widget widget-content-frame"> <div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo --> <!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo">G</div> <div class="logo">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
<div class="nav flex grow"> <div class="nav flex grow">
{{ template "navigation-links" . }} {{ template "navigation-links" . }}
</div> </div>
@ -63,11 +63,17 @@
</div> </div>
</div> </div>
{{ if not .App.Config.Branding.HideFooter }}
<div class="footer flex items-center flex-column"> <div class="footer flex items-center flex-column">
{{ if eq "" .App.Config.Branding.CustomFooter }}
<div> <div>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }} <a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div> </div>
{{ else }}
{{ .App.Config.Branding.CustomFooter }}
{{ end }}
</div> </div>
{{ end }}
<div class="mobile-navigation-offset"></div> <div class="mobile-navigation-offset"></div>
</div> </div>

View File

@ -2,14 +2,19 @@
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}"> <ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $release := .Releases }} {{ range .Releases }}
<li> <li>
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a> <div class="flex items-center gap-10">
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if $.ShowSourceIcon }}
<img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
{{ end }}
</div>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li> <li {{ dynamicRelativeTimeAttrs .TimeReleased }}></li>
<li>{{ $release.Version }}</li> <li>{{ .Version }}</li>
{{ if gt $release.Downvotes 3 }} {{ if gt .Downvotes 3 }}
<li>{{ $release.Downvotes | formatNumber }} ⚠</li> <li>{{ .Downvotes | formatNumber }} ⚠</li>
{{ end }} {{ end }}
</ul> </ul>
</li> </li>

View File

@ -7,6 +7,23 @@
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li> <li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
</ul> </ul>
{{ if gt (len .RepositoryDetails.Commits) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.Commits }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.Commits }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .RepositoryDetails.PullRequests) 0 }} {{ if gt (len .RepositoryDetails.PullRequests) 0 }}
<hr class="margin-block-10"> <hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a> <a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>

102
internal/feed/dockerhub.go Normal file
View File

@ -0,0 +1,102 @@
package feed
import (
"fmt"
"net/http"
"strings"
)
type dockerHubRepositoryTagsResponse struct {
Results []dockerHubRepositoryTagResponse `json:"results"`
}
type dockerHubRepositoryTagResponse struct {
Name string `json:"name"`
LastPushed string `json:"tag_last_pushed"`
}
const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s"
const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s"
const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags"
const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s"
func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) {
nameParts := strings.Split(request.Repository, "/")
if len(nameParts) > 2 {
return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
} else if len(nameParts) == 1 {
nameParts = []string{"library", nameParts[0]}
}
tagParts := strings.SplitN(nameParts[1], ":", 2)
var requestURL string
if len(tagParts) == 2 {
requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1])
} else {
requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1])
}
httpRequest, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
if request.Token != nil {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
}
var tag *dockerHubRepositoryTagResponse
if len(tagParts) == 1 {
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]
} else {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest)
if err != nil {
return nil, err
}
tag = &response
}
var repo string
var displayName string
var notesURL string
if len(tagParts) == 1 {
repo = nameParts[1]
} else {
repo = tagParts[0]
}
if nameParts[0] == "library" {
displayName = repo
notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name)
} else {
displayName = nameParts[0] + "/" + repo
notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name)
}
return &AppRelease{
Source: ReleaseSourceDockerHub,
NotesUrl: notesURL,
Name: displayName,
Version: tag.Name,
TimeReleased: parseRFC3339Time(tag.LastPushed),
}, nil
}

View File

@ -2,8 +2,8 @@ package feed
import ( import (
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
) )
@ -17,85 +17,41 @@ type githubReleaseLatestResponseJson struct {
} `json:"reactions"` } `json:"reactions"`
} }
func parseGithubTime(t string) time.Time { func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) {
parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t) httpRequest, err := http.NewRequest(
"GET",
if err != nil { fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository),
return time.Now() nil,
} )
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)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var failed int if request.Token != nil {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
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 len(appReleases) == 0 { response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
return nil, ErrNoContent
if err != nil {
return nil, err
} }
appReleases.SortByNewest() version := response.TagName
if failed > 0 { if len(version) > 0 && version[0] != 'v' {
return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed) 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 { type GithubTicket struct {
@ -112,6 +68,8 @@ type RepositoryDetails struct {
PullRequests []GithubTicket PullRequests []GithubTicket
OpenIssues int OpenIssues int
Issues []GithubTicket Issues []GithubTicket
LastCommits int
Commits []CommitDetails
} }
type githubRepositoryDetailsResponseJson struct { type githubRepositoryDetailsResponseJson struct {
@ -129,21 +87,40 @@ type githubTicketResponseJson struct {
} `json:"items"` } `json:"items"`
} }
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) { type CommitDetails struct {
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil) Sha string
Author string
CreatedAt time.Time
Message string
}
type gitHubCommitResponseJson struct {
Sha string `json:"sha"`
Commit struct {
Author struct {
Name string `json:"name"`
Date string `json:"date"`
} `json:"author"`
Message string `json:"message"`
} `json:"commit"`
}
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) {
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
if err != nil { if err != nil {
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err) return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
} }
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil) PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil) issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil)
if token != "" { if token != "" {
token = fmt.Sprintf("Bearer %s", token) token = fmt.Sprintf("Bearer %s", token)
repositoryRequest.Header.Add("Authorization", token) repositoryRequest.Header.Add("Authorization", token)
PRsRequest.Header.Add("Authorization", token) PRsRequest.Header.Add("Authorization", token)
issuesRequest.Header.Add("Authorization", token) issuesRequest.Header.Add("Authorization", token)
CommitsRequest.Header.Add("Authorization", token)
} }
var detailsResponse githubRepositoryDetailsResponseJson var detailsResponse githubRepositoryDetailsResponseJson
@ -152,6 +129,8 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
var PRsErr error var PRsErr error
var issuesResponse githubTicketResponseJson var issuesResponse githubTicketResponseJson
var issuesErr error var issuesErr error
var commitsResponse []gitHubCommitResponseJson
var CommitsErr error
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
@ -176,6 +155,14 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
})() })()
} }
if maxCommits > 0 {
wg.Add(1)
go (func() {
defer wg.Done()
commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest)
})()
}
wg.Wait() wg.Wait()
if detailsErr != nil { if detailsErr != nil {
@ -188,6 +175,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
Forks: detailsResponse.Forks, Forks: detailsResponse.Forks,
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)), PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)), Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
Commits: make([]CommitDetails, 0, len(commitsResponse)),
} }
err = nil err = nil
@ -201,7 +189,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
for i := range PRsResponse.Tickets { for i := range PRsResponse.Tickets {
details.PullRequests = append(details.PullRequests, GithubTicket{ details.PullRequests = append(details.PullRequests, GithubTicket{
Number: PRsResponse.Tickets[i].Number, Number: PRsResponse.Tickets[i].Number,
CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt), CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
Title: PRsResponse.Tickets[i].Title, Title: PRsResponse.Tickets[i].Title,
}) })
} }
@ -218,12 +206,27 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
for i := range issuesResponse.Tickets { for i := range issuesResponse.Tickets {
details.Issues = append(details.Issues, GithubTicket{ details.Issues = append(details.Issues, GithubTicket{
Number: issuesResponse.Tickets[i].Number, Number: issuesResponse.Tickets[i].Number,
CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt), CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
Title: issuesResponse.Tickets[i].Title, Title: issuesResponse.Tickets[i].Title,
}) })
} }
} }
} }
if maxCommits > 0 {
if CommitsErr != nil {
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr)
} else {
for i := range commitsResponse {
details.Commits = append(details.Commits, CommitDetails{
Sha: commitsResponse[i].Sha,
Author: commitsResponse[i].Commit.Author.Name,
CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),
Message: strings.SplitN(commitsResponse[i].Commit.Message, "\n\n", 2)[0],
})
}
}
}
return details, err return details, err
} }

54
internal/feed/gitlab.go Normal file
View File

@ -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
}

View File

@ -41,11 +41,13 @@ type Weather struct {
} }
type AppRelease struct { type AppRelease struct {
Name string Source ReleaseSource
Version string SourceIconURL string
NotesUrl string Name string
TimeReleased time.Time Version string
Downvotes int NotesUrl string
TimeReleased time.Time
Downvotes int
} }
type AppReleases []AppRelease type AppReleases []AppRelease

69
internal/feed/releases.go Normal file
View File

@ -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")
}

View File

@ -161,7 +161,11 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
} else if url := findThumbnailInItemExtensions(item); url != "" { } else if url := findThumbnailInItemExtensions(item); url != "" {
rssItem.ImageURL = url rssItem.ImageURL = url
} else if feed.Image != nil { } else if feed.Image != nil {
rssItem.ImageURL = feed.Image.URL if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' {
rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL
} else {
rssItem.ImageURL = feed.Image.URL
}
} }
if item.PublishedParsed != nil { if item.PublishedParsed != nil {

View File

@ -7,6 +7,7 @@ import (
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"time"
) )
var ( var (
@ -79,7 +80,6 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
return values return values
} }
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`) var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
func stripURLScheme(url string) string { func stripURLScheme(url string) string {
@ -95,3 +95,13 @@ func limitStringLength(s string, max int) (string, bool) {
return s, false return s, false
} }
func parseRFC3339Time(t string) time.Time {
parsed, err := time.Parse(time.RFC3339, t)
if err != nil {
return time.Now()
}
return parsed
}

View File

@ -8,9 +8,10 @@ import (
) )
type Config struct { type Config struct {
Server Server `yaml:"server"` Server Server `yaml:"server"`
Theme Theme `yaml:"theme"` Theme Theme `yaml:"theme"`
Pages []Page `yaml:"pages"` Branding Branding `yaml:"branding"`
Pages []Page `yaml:"pages"`
} }
func NewConfigFromYml(contents io.Reader) (*Config, error) { func NewConfigFromYml(contents io.Reader) (*Config, error) {

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"path/filepath" "path/filepath"
@ -48,6 +49,14 @@ type Server struct {
StartedAt time.Time `yaml:"-"` // used in custom css file StartedAt time.Time `yaml:"-"` // used in custom css file
} }
type Branding struct {
HideFooter bool `yaml:"hide-footer"`
CustomFooter template.HTML `yaml:"custom-footer"`
LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"`
}
type Column struct { type Column struct {
Size string `yaml:"size"` Size string `yaml:"size"`
Widgets widget.Widgets `yaml:"widgets"` Widgets widget.Widgets `yaml:"widgets"`
@ -102,6 +111,14 @@ func titleToSlug(s string) string {
return s return s
} }
func (a *Application) TransformUserDefinedAssetPath(path string) string {
if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path
}
return path
}
func NewApplication(config *Config) (*Application, error) { func NewApplication(config *Config) (*Application, error) {
if len(config.Pages) == 0 { if len(config.Pages) == 0 {
return nil, fmt.Errorf("no pages configured") return nil, fmt.Errorf("no pages configured")
@ -114,8 +131,13 @@ func NewApplication(config *Config) (*Application, error) {
widgetByID: make(map[uint64]widget.Widget), widgetByID: make(map[uint64]widget.Widget),
} }
app.Config.Server.AssetsHash = assets.PublicFSHash
app.slugToPage[""] = &config.Pages[0] app.slugToPage[""] = &config.Pages[0]
providers := &widget.Providers{
AssetResolver: app.AssetPath,
}
for p := range config.Pages { for p := range config.Pages {
if config.Pages[p].Slug == "" { if config.Pages[p].Slug == "" {
config.Pages[p].Slug = titleToSlug(config.Pages[p].Title) config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
@ -127,6 +149,8 @@ func NewApplication(config *Config) (*Application, error) {
for w := range config.Pages[p].Columns[c].Widgets { for w := range config.Pages[p].Columns[c].Widgets {
widget := config.Pages[p].Columns[c].Widgets[w] widget := config.Pages[p].Columns[c].Widgets[w]
app.widgetByID[widget.GetID()] = widget app.widgetByID[widget.GetID()] = widget
widget.SetProviders(providers)
} }
} }
} }
@ -134,13 +158,16 @@ func NewApplication(config *Config) (*Application, error) {
config = &app.Config config = &app.Config
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
if config.Server.BaseURL != "" && if config.Branding.FaviconURL == "" {
config.Theme.CustomCSSFile != "" && config.Branding.FaviconURL = app.AssetPath("favicon.png")
strings.HasPrefix(config.Theme.CustomCSSFile, "/assets/") { } else {
config.Theme.CustomCSSFile = config.Server.BaseURL + config.Theme.CustomCSSFile config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
} }
config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
return app, nil return app, nil
} }
@ -238,8 +265,6 @@ func (a *Application) AssetPath(asset string) string {
} }
func (a *Application) Serve() error { func (a *Application) Serve() error {
a.Config.Server.AssetsHash = assets.PublicFSHash
// TODO: add gzip support, static files must have their gzipped contents cached // TODO: add gzip support, static files must have their gzipped contents cached
// TODO: add HTTPS support // TODO: add HTTPS support
mux := http.NewServeMux() mux := http.NewServeMux()
@ -252,7 +277,7 @@ func (a *Application) Serve() error {
mux.Handle( mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash), fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 8*time.Hour)), http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
) )
if a.Config.Server.AssetsPath != "" { if a.Config.Server.AssetsPath != "" {

View File

@ -152,6 +152,10 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
return nil return nil
} }
func (f *OptionalEnvString) String() string {
return string(*f)
}
func toSimpleIconIfPrefixed(icon string) (string, bool) { func toSimpleIconIfPrefixed(icon string) (string, bool) {
if !strings.HasPrefix(icon, "si:") { if !strings.HasPrefix(icon, "si:") {
return icon, false return icon, false

View File

@ -55,6 +55,12 @@ func (widget *Group) Update(ctx context.Context) {
wg.Wait() wg.Wait()
} }
func (widget *Group) SetProviders(providers *Providers) {
for i := range widget.Widgets {
widget.Widgets[i].SetProviders(providers)
}
}
func (widget *Group) RequiresUpdate(now *time.Time) bool { func (widget *Group) RequiresUpdate(now *time.Time) bool {
for i := range widget.Widgets { for i := range widget.Widgets {
if widget.Widgets[i].RequiresUpdate(now) { if widget.Widgets[i].RequiresUpdate(now) {

View File

@ -2,7 +2,9 @@ package widget
import ( import (
"context" "context"
"errors"
"html/template" "html/template"
"strings"
"time" "time"
"github.com/glanceapp/glance/internal/assets" "github.com/glanceapp/glance/internal/assets"
@ -10,12 +12,15 @@ import (
) )
type Releases struct { type Releases struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
Releases feed.AppReleases `yaml:"-"` Releases feed.AppReleases `yaml:"-"`
Repositories []string `yaml:"repositories"` releaseRequests []*feed.ReleaseRequest `yaml:"-"`
Token OptionalEnvString `yaml:"token"` Repositories []string `yaml:"repositories"`
Limit int `yaml:"limit"` Token OptionalEnvString `yaml:"token"`
CollapseAfter int `yaml:"collapse-after"` 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 { func (widget *Releases) Initialize() error {
@ -29,11 +34,50 @@ func (widget *Releases) Initialize() error {
widget.CollapseAfter = 5 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,
}
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 return nil
} }
func (widget *Releases) Update(ctx context.Context) { 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) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
@ -43,6 +87,10 @@ func (widget *Releases) Update(ctx context.Context) {
releases = 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 widget.Releases = releases
} }

View File

@ -15,6 +15,7 @@ type Repository struct {
Token OptionalEnvString `yaml:"token"` Token OptionalEnvString `yaml:"token"`
PullRequestsLimit int `yaml:"pull-requests-limit"` PullRequestsLimit int `yaml:"pull-requests-limit"`
IssuesLimit int `yaml:"issues-limit"` IssuesLimit int `yaml:"issues-limit"`
CommitsLimit int `yaml:"commits-limit"`
RepositoryDetails feed.RepositoryDetails RepositoryDetails feed.RepositoryDetails
} }
@ -29,6 +30,10 @@ func (widget *Repository) Initialize() error {
widget.IssuesLimit = 3 widget.IssuesLimit = 3
} }
if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 {
widget.CommitsLimit = -1
}
return nil return nil
} }
@ -38,6 +43,7 @@ func (widget *Repository) Update(ctx context.Context) {
string(widget.Token), string(widget.Token),
widget.PullRequestsLimit, widget.PullRequestsLimit,
widget.IssuesLimit, widget.IssuesLimit,
widget.CommitsLimit,
) )
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {

View File

@ -113,6 +113,7 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
type Widget interface { type Widget interface {
Initialize() error Initialize() error
RequiresUpdate(*time.Time) bool RequiresUpdate(*time.Time) bool
SetProviders(*Providers)
Update(context.Context) Update(context.Context)
Render() template.HTML Render() template.HTML
GetType() string GetType() string
@ -132,6 +133,7 @@ const (
type widgetBase struct { type widgetBase struct {
ID uint64 `yaml:"-"` ID uint64 `yaml:"-"`
Providers *Providers `yaml:"-"`
Type string `yaml:"type"` Type string `yaml:"type"`
Title string `yaml:"title"` Title string `yaml:"title"`
TitleURL string `yaml:"title-url"` TitleURL string `yaml:"title-url"`
@ -148,6 +150,10 @@ type widgetBase struct {
HideHeader bool `yaml:"-"` HideHeader bool `yaml:"-"`
} }
type Providers struct {
AssetResolver func(string) string
}
func (w *widgetBase) RequiresUpdate(now *time.Time) bool { func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
if w.cacheType == cacheTypeInfinite { if w.cacheType == cacheTypeInfinite {
return false return false
@ -184,6 +190,10 @@ func (w *widgetBase) GetType() string {
return w.Type return w.Type
} }
func (w *widgetBase) SetProviders(providers *Providers) {
w.Providers = providers
}
func (w *widgetBase) render(data any, t *template.Template) template.HTML { func (w *widgetBase) render(data any, t *template.Template) template.HTML {
w.templateBuffer.Reset() w.templateBuffer.Reset()
err := t.Execute(&w.templateBuffer, data) err := t.Execute(&w.templateBuffer, data)