mirror of
https://github.com/Xevion/glance.git
synced 2025-12-10 14:07:25 -06:00
Merge branch 'release/v0.6.0' into features
This commit is contained in:
1
internal/assets/static/icons/dockerhub.svg
Normal file
1
internal/assets/static/icons/dockerhub.svg
Normal 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 |
1
internal/assets/static/icons/github.svg
Normal file
1
internal/assets/static/icons/github.svg
Normal 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 |
1
internal/assets/static/icons/gitlab.svg
Normal file
1
internal/assets/static/icons/gitlab.svg
Normal 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 |
@@ -782,6 +782,15 @@ details[open] .summary::after {
|
||||
padding-right: var(--widget-content-horizontal-padding);
|
||||
}
|
||||
|
||||
.logo:has(img) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
max-height: 2.7rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
height: 100%;
|
||||
gap: var(--header-items-gap);
|
||||
@@ -820,6 +829,13 @@ details[open] .summary::after {
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.release-source-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.market-chart {
|
||||
margin-left: auto;
|
||||
width: 6.5rem;
|
||||
@@ -997,6 +1013,7 @@ details[open] .summary::after {
|
||||
background-color: var(--color-widget-background-highlight);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.bookmarks-icon {
|
||||
@@ -1005,10 +1022,6 @@ details[open] .summary::after {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.simple-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
:root:not(.light-scheme) .simple-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
@@ -1307,6 +1320,10 @@ details[open] .summary::after {
|
||||
transition: filter 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
.monitor-site-icon.simple-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.monitor-site:hover .monitor-site-icon {
|
||||
filter: grayscale(0);
|
||||
opacity: 1;
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
<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 }}">
|
||||
<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="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" }}">
|
||||
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
|
||||
{{ block "document-head-after" . }}{{ end }}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="header-container content-bounds">
|
||||
<div class="header flex padding-inline-widget widget-content-frame">
|
||||
<!-- 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">
|
||||
{{ template "navigation-links" . }}
|
||||
</div>
|
||||
@@ -63,11 +63,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ if not .App.Config.Branding.HideFooter }}
|
||||
<div class="footer flex items-center flex-column">
|
||||
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
||||
<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 }}
|
||||
</div>
|
||||
{{ else }}
|
||||
{{ .App.Config.Branding.CustomFooter }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="mobile-navigation-offset"></div>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range $i, $release := .Releases }}
|
||||
{{ range .Releases }}
|
||||
<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">
|
||||
<li {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li>
|
||||
<li>{{ $release.Version }}</li>
|
||||
{{ if gt $release.Downvotes 3 }}
|
||||
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
||||
<li {{ dynamicRelativeTimeAttrs .TimeReleased }}></li>
|
||||
<li>{{ .Version }}</li>
|
||||
{{ if gt .Downvotes 3 }}
|
||||
<li>{{ .Downvotes | formatNumber }} ⚠</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
|
||||
</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 }}
|
||||
<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>
|
||||
|
||||
102
internal/feed/dockerhub.go
Normal file
102
internal/feed/dockerhub.go
Normal 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
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -17,85 +17,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 {
|
||||
@@ -112,6 +68,8 @@ type RepositoryDetails struct {
|
||||
PullRequests []GithubTicket
|
||||
OpenIssues int
|
||||
Issues []GithubTicket
|
||||
LastCommits int
|
||||
Commits []CommitDetails
|
||||
}
|
||||
|
||||
type githubRepositoryDetailsResponseJson struct {
|
||||
@@ -129,21 +87,40 @@ type githubTicketResponseJson struct {
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
|
||||
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
|
||||
type CommitDetails struct {
|
||||
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 {
|
||||
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)
|
||||
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 != "" {
|
||||
token = fmt.Sprintf("Bearer %s", token)
|
||||
repositoryRequest.Header.Add("Authorization", token)
|
||||
PRsRequest.Header.Add("Authorization", token)
|
||||
issuesRequest.Header.Add("Authorization", token)
|
||||
CommitsRequest.Header.Add("Authorization", token)
|
||||
}
|
||||
|
||||
var detailsResponse githubRepositoryDetailsResponseJson
|
||||
@@ -152,6 +129,8 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
|
||||
var PRsErr error
|
||||
var issuesResponse githubTicketResponseJson
|
||||
var issuesErr error
|
||||
var commitsResponse []gitHubCommitResponseJson
|
||||
var CommitsErr error
|
||||
var wg sync.WaitGroup
|
||||
|
||||
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()
|
||||
|
||||
if detailsErr != nil {
|
||||
@@ -188,6 +175,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
|
||||
Forks: detailsResponse.Forks,
|
||||
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
|
||||
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
|
||||
Commits: make([]CommitDetails, 0, len(commitsResponse)),
|
||||
}
|
||||
|
||||
err = nil
|
||||
@@ -201,7 +189,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,12 +206,27 @@ 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
54
internal/feed/gitlab.go
Normal file
54
internal/feed/gitlab.go
Normal 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
|
||||
}
|
||||
@@ -41,11 +41,13 @@ type Weather struct {
|
||||
}
|
||||
|
||||
type AppRelease struct {
|
||||
Name string
|
||||
Version string
|
||||
NotesUrl string
|
||||
TimeReleased time.Time
|
||||
Downvotes int
|
||||
Source ReleaseSource
|
||||
SourceIconURL string
|
||||
Name string
|
||||
Version string
|
||||
NotesUrl string
|
||||
TimeReleased time.Time
|
||||
Downvotes int
|
||||
}
|
||||
|
||||
type AppReleases []AppRelease
|
||||
|
||||
69
internal/feed/releases.go
Normal file
69
internal/feed/releases.go
Normal 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")
|
||||
}
|
||||
@@ -161,7 +161,11 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||
} else if url := findThumbnailInItemExtensions(item); url != "" {
|
||||
rssItem.ImageURL = url
|
||||
} 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server Server `yaml:"server"`
|
||||
Theme Theme `yaml:"theme"`
|
||||
Pages []Page `yaml:"pages"`
|
||||
Server Server `yaml:"server"`
|
||||
Theme Theme `yaml:"theme"`
|
||||
Branding Branding `yaml:"branding"`
|
||||
Pages []Page `yaml:"pages"`
|
||||
}
|
||||
|
||||
func NewConfigFromYml(contents io.Reader) (*Config, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -48,6 +49,14 @@ type Server struct {
|
||||
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 {
|
||||
Size string `yaml:"size"`
|
||||
Widgets widget.Widgets `yaml:"widgets"`
|
||||
@@ -102,6 +111,14 @@ func titleToSlug(s string) string {
|
||||
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) {
|
||||
if len(config.Pages) == 0 {
|
||||
return nil, fmt.Errorf("no pages configured")
|
||||
@@ -114,8 +131,13 @@ func NewApplication(config *Config) (*Application, error) {
|
||||
widgetByID: make(map[uint64]widget.Widget),
|
||||
}
|
||||
|
||||
app.Config.Server.AssetsHash = assets.PublicFSHash
|
||||
app.slugToPage[""] = &config.Pages[0]
|
||||
|
||||
providers := &widget.Providers{
|
||||
AssetResolver: app.AssetPath,
|
||||
}
|
||||
|
||||
for p := range config.Pages {
|
||||
if config.Pages[p].Slug == "" {
|
||||
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 {
|
||||
widget := config.Pages[p].Columns[c].Widgets[w]
|
||||
app.widgetByID[widget.GetID()] = widget
|
||||
|
||||
widget.SetProviders(providers)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,13 +158,16 @@ func NewApplication(config *Config) (*Application, error) {
|
||||
config = &app.Config
|
||||
|
||||
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
||||
config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
||||
|
||||
if config.Server.BaseURL != "" &&
|
||||
config.Theme.CustomCSSFile != "" &&
|
||||
strings.HasPrefix(config.Theme.CustomCSSFile, "/assets/") {
|
||||
config.Theme.CustomCSSFile = config.Server.BaseURL + config.Theme.CustomCSSFile
|
||||
if config.Branding.FaviconURL == "" {
|
||||
config.Branding.FaviconURL = app.AssetPath("favicon.png")
|
||||
} else {
|
||||
config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
|
||||
}
|
||||
|
||||
config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
@@ -238,8 +265,6 @@ func (a *Application) AssetPath(asset string) string {
|
||||
}
|
||||
|
||||
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 HTTPS support
|
||||
mux := http.NewServeMux()
|
||||
@@ -252,7 +277,7 @@ func (a *Application) Serve() error {
|
||||
|
||||
mux.Handle(
|
||||
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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,6 +55,12 @@ func (widget *Group) Update(ctx context.Context) {
|
||||
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 {
|
||||
for i := range widget.Widgets {
|
||||
if widget.Widgets[i].RequiresUpdate(now) {
|
||||
|
||||
@@ -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.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
|
||||
}
|
||||
|
||||
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
|
||||
@@ -43,6 +87,10 @@ func (widget *Releases) Update(ctx context.Context) {
|
||||
releases = releases[:widget.Limit]
|
||||
}
|
||||
|
||||
for i := range releases {
|
||||
releases[i].SourceIconURL = widget.Providers.AssetResolver("icons/" + string(releases[i].Source) + ".svg")
|
||||
}
|
||||
|
||||
widget.Releases = releases
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ type Repository struct {
|
||||
Token OptionalEnvString `yaml:"token"`
|
||||
PullRequestsLimit int `yaml:"pull-requests-limit"`
|
||||
IssuesLimit int `yaml:"issues-limit"`
|
||||
CommitsLimit int `yaml:"commits-limit"`
|
||||
RepositoryDetails feed.RepositoryDetails
|
||||
}
|
||||
|
||||
@@ -29,6 +30,10 @@ func (widget *Repository) Initialize() error {
|
||||
widget.IssuesLimit = 3
|
||||
}
|
||||
|
||||
if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 {
|
||||
widget.CommitsLimit = -1
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -38,6 +43,7 @@ func (widget *Repository) Update(ctx context.Context) {
|
||||
string(widget.Token),
|
||||
widget.PullRequestsLimit,
|
||||
widget.IssuesLimit,
|
||||
widget.CommitsLimit,
|
||||
)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
|
||||
@@ -113,6 +113,7 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
|
||||
type Widget interface {
|
||||
Initialize() error
|
||||
RequiresUpdate(*time.Time) bool
|
||||
SetProviders(*Providers)
|
||||
Update(context.Context)
|
||||
Render() template.HTML
|
||||
GetType() string
|
||||
@@ -132,6 +133,7 @@ const (
|
||||
|
||||
type widgetBase struct {
|
||||
ID uint64 `yaml:"-"`
|
||||
Providers *Providers `yaml:"-"`
|
||||
Type string `yaml:"type"`
|
||||
Title string `yaml:"title"`
|
||||
TitleURL string `yaml:"title-url"`
|
||||
@@ -148,6 +150,10 @@ type widgetBase struct {
|
||||
HideHeader bool `yaml:"-"`
|
||||
}
|
||||
|
||||
type Providers struct {
|
||||
AssetResolver func(string) string
|
||||
}
|
||||
|
||||
func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
|
||||
if w.cacheType == cacheTypeInfinite {
|
||||
return false
|
||||
@@ -184,6 +190,10 @@ func (w *widgetBase) GetType() string {
|
||||
return w.Type
|
||||
}
|
||||
|
||||
func (w *widgetBase) SetProviders(providers *Providers) {
|
||||
w.Providers = providers
|
||||
}
|
||||
|
||||
func (w *widgetBase) render(data any, t *template.Template) template.HTML {
|
||||
w.templateBuffer.Reset()
|
||||
err := t.Execute(&w.templateBuffer, data)
|
||||
|
||||
Reference in New Issue
Block a user