mirror of
https://github.com/Xevion/glance.git
synced 2025-12-09 10:07:24 -06:00
Merge branch 'release/v0.6.0' into github-commit
This commit is contained in:
@@ -2,7 +2,6 @@ package widget
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
@@ -34,11 +33,8 @@ func (widget *Bookmarks) Initialize() error {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(widget.Groups[g].Links[l].Icon, "si:") {
|
||||
icon := strings.TrimPrefix(widget.Groups[g].Links[l].Icon, "si:")
|
||||
widget.Groups[g].Links[l].IsSimpleIcon = true
|
||||
widget.Groups[g].Links[l].Icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
|
||||
}
|
||||
link := &widget.Groups[g].Links[l]
|
||||
link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
66
internal/widget/changedetection.go
Normal file
66
internal/widget/changedetection.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type ChangeDetection struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
ChangeDetections feed.ChangeDetectionWatches `yaml:"-"`
|
||||
WatchUUIDs []string `yaml:"watches"`
|
||||
InstanceURL string `yaml:"instance-url"`
|
||||
Token OptionalEnvString `yaml:"token"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
}
|
||||
|
||||
func (widget *ChangeDetection) Initialize() error {
|
||||
widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 10
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
if widget.InstanceURL == "" {
|
||||
widget.InstanceURL = "https://www.changedetection.io"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *ChangeDetection) Update(ctx context.Context) {
|
||||
if len(widget.WatchUUIDs) == 0 {
|
||||
uuids, err := feed.FetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.WatchUUIDs = uuids
|
||||
}
|
||||
|
||||
watches, err := feed.FetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token))
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(watches) > widget.Limit {
|
||||
watches = watches[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.ChangeDetections = watches
|
||||
}
|
||||
|
||||
func (widget *ChangeDetection) Render() template.HTML {
|
||||
return widget.render(widget, assets.ChangeDetectionTemplate)
|
||||
}
|
||||
50
internal/widget/clock.go
Normal file
50
internal/widget/clock.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type Clock struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
HourFormat string `yaml:"hour-format"`
|
||||
Timezones []struct {
|
||||
Timezone string `yaml:"timezone"`
|
||||
Label string `yaml:"label"`
|
||||
} `yaml:"timezones"`
|
||||
}
|
||||
|
||||
func (widget *Clock) Initialize() error {
|
||||
widget.withTitle("Clock").withError(nil)
|
||||
|
||||
if widget.HourFormat == "" {
|
||||
widget.HourFormat = "24h"
|
||||
} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
|
||||
return errors.New("invalid hour format for clock widget, must be either 12h or 24h")
|
||||
}
|
||||
|
||||
for t := range widget.Timezones {
|
||||
if widget.Timezones[t].Timezone == "" {
|
||||
return errors.New("missing timezone value for clock widget")
|
||||
}
|
||||
|
||||
_, err := time.LoadLocation(widget.Timezones[t].Timezone)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
|
||||
}
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Clock) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
||||
59
internal/widget/extension.go
Normal file
59
internal/widget/extension.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Extension struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
URL string `yaml:"url"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
Extension feed.Extension `yaml:"-"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *Extension) Initialize() error {
|
||||
widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
|
||||
|
||||
if widget.URL == "" {
|
||||
return errors.New("no extension URL specified")
|
||||
}
|
||||
|
||||
_, err := url.Parse(widget.URL)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Extension) Update(ctx context.Context) {
|
||||
extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
|
||||
URL: widget.URL,
|
||||
Parameters: widget.Parameters,
|
||||
AllowHtml: widget.AllowHtml,
|
||||
})
|
||||
|
||||
widget.canContinueUpdateAfterHandlingErr(err)
|
||||
|
||||
widget.Extension = extension
|
||||
|
||||
if extension.Title != "" {
|
||||
widget.Title = extension.Title
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.render(widget, assets.ExtensionTemplate)
|
||||
}
|
||||
|
||||
func (widget *Extension) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -150,3 +151,18 @@ 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
|
||||
}
|
||||
|
||||
icon = strings.TrimPrefix(icon, "si:")
|
||||
icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
|
||||
|
||||
return icon, true
|
||||
}
|
||||
|
||||
76
internal/widget/group.go
Normal file
76
internal/widget/group.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Widgets Widgets `yaml:"widgets"`
|
||||
}
|
||||
|
||||
func (widget *Group) Initialize() error {
|
||||
widget.withError(nil)
|
||||
widget.HideHeader = true
|
||||
|
||||
for i := range widget.Widgets {
|
||||
widget.Widgets[i].SetHideHeader(true)
|
||||
|
||||
if widget.Widgets[i].GetType() == "group" {
|
||||
return errors.New("nested groups are not allowed")
|
||||
}
|
||||
|
||||
if err := widget.Widgets[i].Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Group) Update(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
now := time.Now()
|
||||
|
||||
for w := range widget.Widgets {
|
||||
widget := widget.Widgets[w]
|
||||
|
||||
if !widget.RequiresUpdate(&now) {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
widget.Update(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
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) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (widget *Group) Render() template.HTML {
|
||||
return widget.render(widget, assets.GroupTemplate)
|
||||
}
|
||||
@@ -21,7 +21,10 @@ type HackerNews struct {
|
||||
}
|
||||
|
||||
func (widget *HackerNews) Initialize() error {
|
||||
widget.withTitle("Hacker News").withCacheDuration(30 * time.Minute)
|
||||
widget.
|
||||
withTitle("Hacker News").
|
||||
withTitleURL("https://news.ycombinator.com/").
|
||||
withCacheDuration(30 * time.Minute)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 15
|
||||
|
||||
20
internal/widget/html.go
Normal file
20
internal/widget/html.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
)
|
||||
|
||||
type HTML struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Source template.HTML `yaml:"source"`
|
||||
}
|
||||
|
||||
func (widget *HTML) Initialize() error {
|
||||
widget.withTitle("").withError(nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *HTML) Render() template.HTML {
|
||||
return widget.Source
|
||||
}
|
||||
64
internal/widget/lobsters.go
Normal file
64
internal/widget/lobsters.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
type Lobsters struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Posts feed.ForumPosts `yaml:"-"`
|
||||
InstanceURL string `yaml:"instance-url"`
|
||||
CustomURL string `yaml:"custom-url"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
SortBy string `yaml:"sort-by"`
|
||||
Tags []string `yaml:"tags"`
|
||||
ShowThumbnails bool `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *Lobsters) Initialize() error {
|
||||
widget.withTitle("Lobsters").withCacheDuration(time.Hour)
|
||||
|
||||
if widget.InstanceURL == "" {
|
||||
widget.withTitleURL("https://lobste.rs")
|
||||
} else {
|
||||
widget.withTitleURL(widget.InstanceURL)
|
||||
}
|
||||
|
||||
if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
|
||||
widget.SortBy = "hot"
|
||||
}
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 15
|
||||
}
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Lobsters) Update(ctx context.Context) {
|
||||
posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if widget.Limit < len(posts) {
|
||||
posts = posts[:widget.Limit]
|
||||
}
|
||||
|
||||
widget.Posts = posts
|
||||
}
|
||||
|
||||
func (widget *Lobsters) Render() template.HTML {
|
||||
return widget.render(widget, assets.ForumPostsTemplate)
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package widget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -37,22 +35,23 @@ func statusCodeToText(status int) string {
|
||||
|
||||
func statusCodeToStyle(status int) string {
|
||||
if status == 200 {
|
||||
return "good"
|
||||
return "ok"
|
||||
}
|
||||
|
||||
return "bad"
|
||||
return "error"
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Sites []struct {
|
||||
Title string `yaml:"title"`
|
||||
Url OptionalEnvString `yaml:"url"`
|
||||
IconUrl string `yaml:"icon"`
|
||||
SameTab bool `yaml:"same-tab"`
|
||||
Status *feed.SiteStatus `yaml:"-"`
|
||||
StatusText string `yaml:"-"`
|
||||
StatusStyle string `yaml:"-"`
|
||||
*feed.SiteStatusRequest `yaml:",inline"`
|
||||
Status *feed.SiteStatus `yaml:"-"`
|
||||
Title string `yaml:"title"`
|
||||
IconUrl string `yaml:"icon"`
|
||||
IsSimpleIcon bool `yaml:"-"`
|
||||
SameTab bool `yaml:"same-tab"`
|
||||
StatusText string `yaml:"-"`
|
||||
StatusStyle string `yaml:"-"`
|
||||
} `yaml:"sites"`
|
||||
Style string `yaml:"style"`
|
||||
}
|
||||
@@ -60,25 +59,21 @@ type Monitor struct {
|
||||
func (widget *Monitor) Initialize() error {
|
||||
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
|
||||
|
||||
for i := range widget.Sites {
|
||||
widget.Sites[i].IconUrl, widget.Sites[i].IsSimpleIcon = toSimpleIconIfPrefixed(widget.Sites[i].IconUrl)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Monitor) Update(ctx context.Context) {
|
||||
requests := make([]*http.Request, len(widget.Sites))
|
||||
requests := make([]*feed.SiteStatusRequest, len(widget.Sites))
|
||||
|
||||
for i := range widget.Sites {
|
||||
request, err := http.NewRequest("GET", string(widget.Sites[i].Url), nil)
|
||||
|
||||
if err != nil {
|
||||
message := fmt.Errorf("failed to create http request for %s: %s", widget.Sites[i].Url, err)
|
||||
widget.withNotice(message)
|
||||
continue
|
||||
}
|
||||
|
||||
requests[i] = request
|
||||
requests[i] = widget.Sites[i].SiteStatusRequest
|
||||
}
|
||||
|
||||
statuses, err := feed.FetchStatusesForRequests(requests)
|
||||
statuses, err := feed.FetchStatusForSites(requests)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
|
||||
@@ -17,6 +17,7 @@ type Reddit struct {
|
||||
Subreddit string `yaml:"subreddit"`
|
||||
Style string `yaml:"style"`
|
||||
ShowThumbnails bool `yaml:"show-thumbnails"`
|
||||
ShowFlairs bool `yaml:"show-flairs"`
|
||||
SortBy string `yaml:"sort-by"`
|
||||
TopPeriod string `yaml:"top-period"`
|
||||
Search string `yaml:"search"`
|
||||
@@ -54,7 +55,10 @@ func (widget *Reddit) Initialize() error {
|
||||
}
|
||||
}
|
||||
|
||||
widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute)
|
||||
widget.
|
||||
withTitle("/r/" + widget.Subreddit).
|
||||
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
|
||||
withCacheDuration(30 * time.Minute)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -84,6 +88,7 @@ func (widget *Reddit) Update(ctx context.Context) {
|
||||
widget.Search,
|
||||
widget.CommentsUrlTemplate,
|
||||
widget.RequestUrlTemplate,
|
||||
widget.ShowFlairs,
|
||||
)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ type RSS struct {
|
||||
Items feed.RSSFeedItems `yaml:"-"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
NoItemsMessage string `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *RSS) Initialize() error {
|
||||
@@ -39,6 +40,14 @@ func (widget *RSS) Initialize() error {
|
||||
widget.CardHeight = 0
|
||||
}
|
||||
|
||||
if widget.Style == "detailed-list" {
|
||||
for i := range widget.FeedRequests {
|
||||
widget.FeedRequests[i].IsDetailed = true
|
||||
}
|
||||
}
|
||||
|
||||
widget.NoItemsMessage = "No items were returned from the feeds."
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -65,5 +74,9 @@ func (widget *RSS) Render() template.HTML {
|
||||
return widget.render(widget, assets.RSSHorizontalCards2Template)
|
||||
}
|
||||
|
||||
if widget.Style == "detailed-list" {
|
||||
return widget.render(widget, assets.RSSDetailedListTemplate)
|
||||
}
|
||||
|
||||
return widget.render(widget, assets.RSSListTemplate)
|
||||
}
|
||||
|
||||
68
internal/widget/search.go
Normal file
68
internal/widget/search.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type SearchBang struct {
|
||||
Title string
|
||||
Shortcut string
|
||||
URL string
|
||||
}
|
||||
|
||||
type Search struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
SearchEngine string `yaml:"search-engine"`
|
||||
Bangs []SearchBang `yaml:"bangs"`
|
||||
NewTab bool `yaml:"new-tab"`
|
||||
Autofocus bool `yaml:"autofocus"`
|
||||
}
|
||||
|
||||
func convertSearchUrl(url string) string {
|
||||
// Go's template is being stubborn and continues to escape the curlies in the
|
||||
// URL regardless of what the type of the variable is so this is my way around it
|
||||
return strings.ReplaceAll(url, "{QUERY}", "!QUERY!")
|
||||
}
|
||||
|
||||
var searchEngines = map[string]string{
|
||||
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
|
||||
"google": "https://www.google.com/search?q={QUERY}",
|
||||
}
|
||||
|
||||
func (widget *Search) Initialize() error {
|
||||
widget.withTitle("Search").withError(nil)
|
||||
|
||||
if widget.SearchEngine == "" {
|
||||
widget.SearchEngine = "duckduckgo"
|
||||
}
|
||||
|
||||
if url, ok := searchEngines[widget.SearchEngine]; ok {
|
||||
widget.SearchEngine = url
|
||||
}
|
||||
|
||||
widget.SearchEngine = convertSearchUrl(widget.SearchEngine)
|
||||
|
||||
for i := range widget.Bangs {
|
||||
if widget.Bangs[i].Shortcut == "" {
|
||||
return fmt.Errorf("Search bang %d has no shortcut", i+1)
|
||||
}
|
||||
|
||||
if widget.Bangs[i].URL == "" {
|
||||
return fmt.Errorf("Search bang %d has no URL", i+1)
|
||||
}
|
||||
|
||||
widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL)
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.render(widget, assets.SearchTemplate)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Search) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
||||
@@ -9,34 +9,39 @@ import (
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
)
|
||||
|
||||
// TODO: rename to Markets at some point
|
||||
type Stocks struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Stocks feed.Stocks `yaml:"stocks"`
|
||||
Sort string `yaml:"sort-by"`
|
||||
Style string `yaml:"style"`
|
||||
type Markets struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
StocksRequests []feed.MarketRequest `yaml:"stocks"`
|
||||
MarketRequests []feed.MarketRequest `yaml:"markets"`
|
||||
Sort string `yaml:"sort-by"`
|
||||
Style string `yaml:"style"`
|
||||
Markets feed.Markets `yaml:"-"`
|
||||
}
|
||||
|
||||
func (widget *Stocks) Initialize() error {
|
||||
widget.withTitle("Stocks").withCacheDuration(time.Hour)
|
||||
func (widget *Markets) Initialize() error {
|
||||
widget.withTitle("Markets").withCacheDuration(time.Hour)
|
||||
|
||||
if len(widget.MarketRequests) == 0 {
|
||||
widget.MarketRequests = widget.StocksRequests
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Stocks) Update(ctx context.Context) {
|
||||
stocks, err := feed.FetchStocksDataFromYahoo(widget.Stocks)
|
||||
func (widget *Markets) Update(ctx context.Context) {
|
||||
markets, err := feed.FetchMarketsDataFromYahoo(widget.MarketRequests)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if widget.Sort == "absolute-change" {
|
||||
stocks.SortByAbsChange()
|
||||
markets.SortByAbsChange()
|
||||
}
|
||||
|
||||
widget.Stocks = stocks
|
||||
widget.Markets = markets
|
||||
}
|
||||
|
||||
func (widget *Stocks) Render() template.HTML {
|
||||
return widget.render(widget, assets.StocksTemplate)
|
||||
func (widget *Markets) Render() template.HTML {
|
||||
return widget.render(widget, assets.MarketsTemplate)
|
||||
}
|
||||
|
||||
@@ -14,15 +14,23 @@ type TwitchChannels struct {
|
||||
ChannelsRequest []string `yaml:"channels"`
|
||||
Channels []feed.TwitchChannel `yaml:"-"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
SortBy string `yaml:"sort-by"`
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Initialize() error {
|
||||
widget.withTitle("Twitch Channels").withCacheDuration(time.Minute * 10)
|
||||
widget.
|
||||
withTitle("Twitch Channels").
|
||||
withTitleURL("https://www.twitch.tv/directory/following").
|
||||
withCacheDuration(time.Minute * 10)
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
}
|
||||
|
||||
if widget.SortBy != "viewers" && widget.SortBy != "live" {
|
||||
widget.SortBy = "viewers"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -33,7 +41,12 @@ func (widget *TwitchChannels) Update(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
channels.SortByViewers()
|
||||
if widget.SortBy == "viewers" {
|
||||
channels.SortByViewers()
|
||||
} else if widget.SortBy == "live" {
|
||||
channels.SortByLive()
|
||||
}
|
||||
|
||||
widget.Channels = channels
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ type TwitchGames struct {
|
||||
}
|
||||
|
||||
func (widget *TwitchGames) Initialize() error {
|
||||
widget.withTitle("Top games on Twitch").withCacheDuration(time.Minute * 10)
|
||||
widget.
|
||||
withTitle("Top games on Twitch").
|
||||
withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT").
|
||||
withCacheDuration(time.Minute * 10)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 10
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
)
|
||||
|
||||
type Videos struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Videos feed.Videos `yaml:"-"`
|
||||
VideoUrlTemplate string `yaml:"video-url-template"`
|
||||
Style string `yaml:"style"`
|
||||
Channels []string `yaml:"channels"`
|
||||
Limit int `yaml:"limit"`
|
||||
widgetBase `yaml:",inline"`
|
||||
Videos feed.Videos `yaml:"-"`
|
||||
VideoUrlTemplate string `yaml:"video-url-template"`
|
||||
Style string `yaml:"style"`
|
||||
CollapseAfterRows int `yaml:"collapse-after-rows"`
|
||||
Channels []string `yaml:"channels"`
|
||||
Limit int `yaml:"limit"`
|
||||
IncludeShorts bool `yaml:"include-shorts"`
|
||||
}
|
||||
|
||||
func (widget *Videos) Initialize() error {
|
||||
@@ -25,11 +27,15 @@ func (widget *Videos) Initialize() error {
|
||||
widget.Limit = 25
|
||||
}
|
||||
|
||||
if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 {
|
||||
widget.CollapseAfterRows = 4
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Videos) Update(ctx context.Context) {
|
||||
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate)
|
||||
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
|
||||
@@ -14,17 +14,30 @@ type Weather struct {
|
||||
Location string `yaml:"location"`
|
||||
ShowAreaName bool `yaml:"show-area-name"`
|
||||
HideLocation bool `yaml:"hide-location"`
|
||||
HourFormat string `yaml:"hour-format"`
|
||||
Units string `yaml:"units"`
|
||||
Place *feed.PlaceJson `yaml:"-"`
|
||||
Weather *feed.Weather `yaml:"-"`
|
||||
TimeLabels [12]string `yaml:"-"`
|
||||
}
|
||||
|
||||
var timeLabels = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
|
||||
var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
|
||||
var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"}
|
||||
|
||||
func (widget *Weather) Initialize() error {
|
||||
widget.withTitle("Weather").withCacheOnTheHour()
|
||||
widget.TimeLabels = timeLabels
|
||||
|
||||
if widget.Location == "" {
|
||||
return fmt.Errorf("location must be specified for weather widget")
|
||||
}
|
||||
|
||||
if widget.HourFormat == "" || widget.HourFormat == "12h" {
|
||||
widget.TimeLabels = timeLabels12h
|
||||
} else if widget.HourFormat == "24h" {
|
||||
widget.TimeLabels = timeLabels24h
|
||||
} else {
|
||||
return fmt.Errorf("invalid hour format '%s' for weather widget, must be either 12h or 24h", widget.HourFormat)
|
||||
}
|
||||
|
||||
if widget.Units == "" {
|
||||
widget.Units = "metric"
|
||||
@@ -32,18 +45,21 @@ func (widget *Weather) Initialize() error {
|
||||
return fmt.Errorf("invalid units '%s' for weather, must be either metric or imperial", widget.Units)
|
||||
}
|
||||
|
||||
place, err := feed.FetchPlaceFromName(widget.Location)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching data for %s: %v", widget.Location, err)
|
||||
}
|
||||
|
||||
widget.Place = place
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Weather) Update(ctx context.Context) {
|
||||
if widget.Place == nil {
|
||||
place, err := feed.FetchPlaceFromName(widget.Location)
|
||||
|
||||
if err != nil {
|
||||
widget.withError(err).scheduleEarlyUpdate()
|
||||
return
|
||||
}
|
||||
|
||||
widget.Place = place
|
||||
}
|
||||
|
||||
weather, err := feed.FetchWeatherForPlace(widget.Place, widget.Units)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/feed"
|
||||
@@ -15,39 +17,61 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var uniqueID atomic.Uint64
|
||||
|
||||
func New(widgetType string) (Widget, error) {
|
||||
var widget Widget
|
||||
|
||||
switch widgetType {
|
||||
case "calendar":
|
||||
return &Calendar{}, nil
|
||||
widget = &Calendar{}
|
||||
case "clock":
|
||||
widget = &Clock{}
|
||||
case "weather":
|
||||
return &Weather{}, nil
|
||||
widget = &Weather{}
|
||||
case "bookmarks":
|
||||
return &Bookmarks{}, nil
|
||||
widget = &Bookmarks{}
|
||||
case "iframe":
|
||||
return &IFrame{}, nil
|
||||
widget = &IFrame{}
|
||||
case "html":
|
||||
widget = &HTML{}
|
||||
case "hacker-news":
|
||||
return &HackerNews{}, nil
|
||||
widget = &HackerNews{}
|
||||
case "releases":
|
||||
return &Releases{}, nil
|
||||
widget = &Releases{}
|
||||
case "videos":
|
||||
return &Videos{}, nil
|
||||
case "stocks":
|
||||
return &Stocks{}, nil
|
||||
widget = &Videos{}
|
||||
case "markets", "stocks":
|
||||
widget = &Markets{}
|
||||
case "reddit":
|
||||
return &Reddit{}, nil
|
||||
widget = &Reddit{}
|
||||
case "rss":
|
||||
return &RSS{}, nil
|
||||
widget = &RSS{}
|
||||
case "monitor":
|
||||
return &Monitor{}, nil
|
||||
widget = &Monitor{}
|
||||
case "twitch-top-games":
|
||||
return &TwitchGames{}, nil
|
||||
widget = &TwitchGames{}
|
||||
case "twitch-channels":
|
||||
return &TwitchChannels{}, nil
|
||||
widget = &TwitchChannels{}
|
||||
case "lobsters":
|
||||
widget = &Lobsters{}
|
||||
case "change-detection":
|
||||
widget = &ChangeDetection{}
|
||||
case "repository":
|
||||
return &Repository{}, nil
|
||||
widget = &Repository{}
|
||||
case "search":
|
||||
widget = &Search{}
|
||||
case "extension":
|
||||
widget = &Extension{}
|
||||
case "group":
|
||||
widget = &Group{}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||
}
|
||||
|
||||
widget.SetID(uniqueID.Add(1))
|
||||
|
||||
return widget, nil
|
||||
}
|
||||
|
||||
type Widgets []Widget
|
||||
@@ -78,10 +102,6 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = widget.Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*w = append(*w, widget)
|
||||
}
|
||||
|
||||
@@ -91,9 +111,14 @@ 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
|
||||
GetID() uint64
|
||||
SetID(uint64)
|
||||
HandleRequest(w http.ResponseWriter, r *http.Request)
|
||||
SetHideHeader(bool)
|
||||
}
|
||||
|
||||
type cacheType int
|
||||
@@ -105,8 +130,12 @@ const (
|
||||
)
|
||||
|
||||
type widgetBase struct {
|
||||
ID uint64 `yaml:"-"`
|
||||
Providers *Providers `yaml:"-"`
|
||||
Type string `yaml:"type"`
|
||||
Title string `yaml:"title"`
|
||||
TitleURL string `yaml:"title-url"`
|
||||
CSSClass string `yaml:"css-class"`
|
||||
CustomCacheDuration DurationField `yaml:"cache"`
|
||||
ContentAvailable bool `yaml:"-"`
|
||||
Error error `yaml:"-"`
|
||||
@@ -116,6 +145,11 @@ type widgetBase struct {
|
||||
cacheType cacheType `yaml:"-"`
|
||||
nextUpdate time.Time `yaml:"-"`
|
||||
updateRetriedTimes int `yaml:"-"`
|
||||
HideHeader bool `yaml:"-"`
|
||||
}
|
||||
|
||||
type Providers struct {
|
||||
AssetResolver func(string) string
|
||||
}
|
||||
|
||||
func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
|
||||
@@ -134,10 +168,30 @@ func (w *widgetBase) Update(ctx context.Context) {
|
||||
|
||||
}
|
||||
|
||||
func (w *widgetBase) GetID() uint64 {
|
||||
return w.ID
|
||||
}
|
||||
|
||||
func (w *widgetBase) SetID(id uint64) {
|
||||
w.ID = id
|
||||
}
|
||||
|
||||
func (w *widgetBase) SetHideHeader(value bool) {
|
||||
w.HideHeader = value
|
||||
}
|
||||
|
||||
func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not implemented", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -173,6 +227,14 @@ func (w *widgetBase) withTitle(title string) *widgetBase {
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *widgetBase) withTitleURL(titleURL string) *widgetBase {
|
||||
if w.TitleURL == "" {
|
||||
w.TitleURL = titleURL
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
|
||||
w.cacheType = cacheTypeDuration
|
||||
|
||||
|
||||
Reference in New Issue
Block a user