Compare commits

...

7 Commits

Author SHA1 Message Date
Svilen Markov b3b86b3483 Update error message 2025-09-06 13:47:06 +07:00
Svilen Markov 9656f85b0a Merge branch 'feature/filters' into dev 2025-09-06 13:44:28 +07:00
Svilen Markov a7132946a3 Add filters for rss and videos widgets 2025-09-06 13:42:27 +07:00
Svilen Markov 7d7017cc57 Merge branch 'widget/trending-repositories' into dev 2025-09-06 12:50:55 +07:00
Svilen Markov c791ff09d9 Fix relative rss item thumbnail links #813 2025-09-06 12:42:34 +07:00
Svilen Markov ad60d52264 Add filters 2025-07-26 14:56:02 +07:00
Svilen Markov 841e106e8e Add trending repositories widget 2025-07-25 17:27:04 +07:00
20 changed files with 717 additions and 47 deletions

@ -34,6 +34,8 @@
- [DNS Stats](#dns-stats)
- [Server Stats](#server-stats)
- [Repository](#repository)
<!-- TODO: add docs -->
- [Trending Repositories](#trending-repositories)
- [Bookmarks](#bookmarks)
- [Calendar](#calendar)
- [Calendar (legacy)](#calendar-legacy)

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

@ -8,6 +8,7 @@ require (
github.com/shirou/gopsutil/v4 v4.25.5
github.com/tidwall/gjson v1.18.0
golang.org/x/crypto v0.39.0
golang.org/x/net v0.41.0
golang.org/x/text v0.26.0
gopkg.in/yaml.v3 v3.0.1
)
@ -28,6 +29,5 @@ require (
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.33.0 // indirect
)

@ -33,8 +33,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -61,8 +59,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -79,8 +75,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -124,8 +118,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

@ -98,39 +98,54 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
return nil
}
var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d|w|mo|y)$`)
type durationField time.Duration
func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
var value string
errorLine := fmt.Sprintf("line %d:", node.Line)
if err := node.Decode(&value); err != nil {
return err
}
func parseDurationValue(value string) (time.Duration, error) {
matches := durationFieldPattern.FindStringSubmatch(value)
if len(matches) != 3 {
return fmt.Errorf("%s invalid duration format for value `%s`", errorLine, value)
return 0, fmt.Errorf("invalid format for value `%s`, must be a number followed by one of: s, m, h, d, w, mo, y", value)
}
duration, err := strconv.Atoi(matches[1])
if err != nil {
return fmt.Errorf("%s invalid duration value: %s", errorLine, matches[1])
return 0, err
}
switch matches[2] {
case "s":
*d = durationField(time.Duration(duration) * time.Second)
return time.Duration(duration) * time.Second, nil
case "m":
*d = durationField(time.Duration(duration) * time.Minute)
return time.Duration(duration) * time.Minute, nil
case "h":
*d = durationField(time.Duration(duration) * time.Hour)
return time.Duration(duration) * time.Hour, nil
case "d":
*d = durationField(time.Duration(duration) * 24 * time.Hour)
return time.Duration(duration) * 24 * time.Hour, nil
case "w":
return time.Duration(duration) * 7 * 24 * time.Hour, nil
case "mo":
return time.Duration(duration) * 30 * 24 * time.Hour, nil
case "y":
return time.Duration(duration) * 365 * 24 * time.Hour, nil
default:
return 0, fmt.Errorf("unknown duration unit: %s", matches[2])
}
}
func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
parsedDuration, err := parseDurationValue(value)
if err != nil {
return fmt.Errorf("line %d: %w", node.Line, err)
}
*d = durationField(parsedDuration)
return nil
}
@ -444,3 +459,263 @@ func (s *sortableFields[T]) Apply(data []T) {
return 0
})
}
type filterableData interface {
filterableField(field string) any
}
type filterableFields[T filterableData] struct {
filters []func(T) bool
FilteredCount int `yaml:"-"`
AllFiltered bool `yaml:"-"`
}
func (f *filterableFields[T]) Apply(items []T) []T {
if len(f.filters) == 0 {
f.FilteredCount = 0
f.AllFiltered = false
return items
}
filtered := make([]T, 0, len(items))
for _, item := range items {
include := true
for _, shouldInclude := range f.filters {
if !shouldInclude(item) {
include = false
break
}
}
if include {
filtered = append(filtered, item)
}
}
f.FilteredCount = len(items) - len(filtered)
f.AllFiltered = f.FilteredCount == len(items)
return filtered
}
func (f *filterableFields[T]) UnmarshalYAML(node *yaml.Node) error {
untypedFilters := make(map[string]any)
if err := node.Decode(&untypedFilters); err != nil {
return errors.New("filters must be defined as an object where each key is the name of a field")
}
rawFilters := make(map[string][]string)
for key, value := range untypedFilters {
rawFilters[key] = []string{}
switch vt := value.(type) {
case string:
rawFilters[key] = append(rawFilters[key], vt)
case []any:
for _, item := range vt {
if str, ok := item.(string); ok {
rawFilters[key] = append(rawFilters[key], str)
} else {
return fmt.Errorf("filter value in array for %s must be a string, got %T", key, item)
}
}
case nil:
continue // skip empty filters
default:
return fmt.Errorf("filter value for %s must be a string or an array, got %T", key, value)
}
}
makeStringFilter := func(key string, values []string) (func(T) bool, error) {
parsedFilters := []func(string) bool{}
for _, value := range values {
value, negative := strings.CutPrefix(value, "!")
if value == "" {
return nil, errors.New("value is empty")
}
if pattern, ok := strings.CutPrefix(value, "re:"); ok {
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("value `%s`: %w", value, err)
}
parsedFilters = append(parsedFilters, func(s string) bool {
return negative != re.MatchString(s)
})
continue
}
value = strings.ToLower(value)
parsedFilters = append(parsedFilters, func(s string) bool {
return negative != strings.Contains(strings.ToLower(s), value)
})
}
return func(item T) bool {
value, ok := item.filterableField(key).(string)
if !ok {
return false
}
for i := range parsedFilters {
if !parsedFilters[i](value) {
return false
}
}
return true
}, nil
}
makeIntFilter := func(key string, values []string) (func(T) bool, error) {
parsedFilters := []func(int) bool{}
parseNumber := func(value string) (int, error) {
var multiplier int
if strings.HasSuffix(value, "k") {
multiplier = 1_000
value = strings.TrimSuffix(value, "k")
} else if strings.HasSuffix(value, "m") {
multiplier = 1_000_000
value = strings.TrimSuffix(value, "m")
} else {
multiplier = 1
}
num, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("invalid number format for key %s: %w", key, err)
}
return num * multiplier, nil
}
for _, value := range values {
if number, ok := strings.CutPrefix(value, "<"); ok {
num, err := parseNumber(number)
if err != nil {
return nil, err
}
parsedFilters = append(parsedFilters, func(v int) bool {
return v < num
})
} else if number, ok := strings.CutPrefix(value, ">"); ok {
num, err := parseNumber(number)
if err != nil {
return nil, err
}
parsedFilters = append(parsedFilters, func(v int) bool {
return v > num
})
} else {
num, err := parseNumber(value)
if err != nil {
return nil, err
}
parsedFilters = append(parsedFilters, func(v int) bool {
return v == num
})
}
}
return func(item T) bool {
value, ok := item.filterableField(key).(int)
if !ok {
return false
}
for i := range parsedFilters {
if !parsedFilters[i](value) {
return false
}
}
return true
}, nil
}
makeTimeFilter := func(key string, values []string) (func(T) bool, error) {
parsedFilters := []func(time.Time) bool{}
for _, value := range values {
if number, ok := strings.CutPrefix(value, "<"); ok {
duration, err := parseDurationValue(number)
if err != nil {
return nil, err
}
parsedFilters = append(parsedFilters, func(t time.Time) bool {
return time.Since(t) < duration
})
} else if number, ok := strings.CutPrefix(value, ">"); ok {
duration, err := parseDurationValue(number)
if err != nil {
return nil, err
}
parsedFilters = append(parsedFilters, func(t time.Time) bool {
return time.Since(t) > duration
})
} else {
return nil, fmt.Errorf("invalid time filter format for value `%s`", value)
}
}
return func(item T) bool {
value, ok := item.filterableField(key).(time.Time)
if !ok {
return false
}
for i := range parsedFilters {
if !parsedFilters[i](value) {
return false
}
}
return true
}, nil
}
var data T
for key, values := range rawFilters {
if len(values) == 0 {
continue
}
value := data.filterableField(key)
if value == nil {
return fmt.Errorf("filter with key `%s` is not supported", key)
}
var filter func(T) bool
var err error
switch v := value.(type) {
case string:
filter, err = makeStringFilter(key, values)
case int:
filter, err = makeIntFilter(key, values)
case time.Time:
filter, err = makeTimeFilter(key, values)
default:
return fmt.Errorf("unsupported filter type for key %s: %T", key, v)
}
if err != nil {
return fmt.Errorf("failed to create filter for key %s: %w", key, err)
}
f.filters = append(f.filters, filter)
}
return nil
}

@ -111,6 +111,7 @@ func serveApp(configPath string) error {
errStr := strings.ReplaceAll(err.Error(), "\n", "")
errStr = sequentialWhitespacePattern.ReplaceAllString(errStr, " ")
errStr = strings.ReplaceAll(errStr, "!!seq", "array")
errStr = strings.ReplaceAll(errStr, "!!str", "string")
log.Printf("Config has errors: %v", errStr)
printConfigLinesNearErrorIfAvailable(err, newContents)

@ -0,0 +1,161 @@
package glance
import (
"strings"
"golang.org/x/net/html"
)
type searchableNode html.Node
func (n *searchableNode) findFirst(tag string, attrs ...string) *searchableNode {
if tag == "" || n == nil {
return nil
}
if len(attrs)%2 != 0 {
panic("attributes must be in key-value pairs")
}
if n.matches(tag, attrs...) {
return n
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
if child.Type != html.ElementNode {
continue
}
if found := (*searchableNode)(child).findFirst(tag, attrs...); found != nil {
return found
}
}
return nil
}
func (n *searchableNode) nthChild(index int) *searchableNode {
if n == nil || index < 0 {
return nil
}
if index == 0 {
return n
}
count := 0
for child := n.FirstChild; child != nil; child = child.NextSibling {
if child.Type != html.ElementNode {
continue
}
count++
if count == index {
return (*searchableNode)(child)
}
}
return nil
}
func (n *searchableNode) findFirstChild(tag string, attrs ...string) *searchableNode {
if tag == "" || n == nil {
return nil
}
if len(attrs)%2 != 0 {
panic("attributes must be in key-value pairs")
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
if child.Type != html.ElementNode {
continue
}
if child.Type == html.ElementNode && (*searchableNode)(child).matches(tag, attrs...) {
return (*searchableNode)(child)
}
}
return nil
}
func (n *searchableNode) findAll(tag string, attrs ...string) []*searchableNode {
if tag == "" || n == nil {
return nil
}
if len(attrs)%2 != 0 {
panic("attributes must be in key-value pairs")
}
var results []*searchableNode
if n.matches(tag, attrs...) {
results = append(results, n)
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
if child.Type != html.ElementNode {
continue
}
results = append(results, (*searchableNode)(child).findAll(tag, attrs...)...)
}
return results
}
func (n *searchableNode) matches(tag string, attrs ...string) bool {
if tag == "" || n == nil {
return false
}
if len(attrs)%2 != 0 {
panic("attributes must be in key-value pairs")
}
if n.Data != tag {
return false
}
for i := 0; i < len(attrs); i += 2 {
key := attrs[i]
value := attrs[i+1]
found := false
for _, attr := range n.Attr {
if attr.Key == key && attr.Val == value {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func (n *searchableNode) text() string {
if n == nil {
return ""
}
if n.Type == html.TextNode {
return strings.TrimSpace(n.Data)
}
var builder strings.Builder
for child := n.FirstChild; child != nil; child = child.NextSibling {
switch child.Type {
case html.TextNode:
builder.WriteString(strings.TrimSpace(child.Data))
case html.ElementNode:
builder.WriteString((*searchableNode)(child).text())
}
}
return builder.String()
}

@ -1,6 +1,9 @@
{{ template "widget-base.html" . }}
{{- define "widget-content" }}
{{ if .Filters.AllFiltered }}
<p>No posts match the specified filters ({{ .Filters.FilteredCount }} filtered)</p>
{{ else }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{- range .Posts }}
<li>
@ -46,4 +49,5 @@
</li>
{{- end }}
</ul>
{{ end }}
{{- end }}

@ -0,0 +1,22 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Repositories }}
<li>
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="https://github.com/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Slug }}</a>
{{ if .Description}}
<p class="text-truncate-2-lines margin-top-3">{{ .Description }}</p>
{{ end }}
<ul class="list-horizontal-text margin-top-3">
{{ if .Language }}
<li>{{ .Language }}</li>
{{ end }}
<li>{{ .Stars | formatNumber }} stars</li>
</ul>
</li>
{{ else }}
<p>No repositories found.</p>
{{ end }}
</ul>
{{ end }}

@ -1,8 +1,11 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content-classes" }}{{ if not .Filters.AllFiltered }}widget-content-frameless{{ end }}{{ end }}
{{ define "widget-content" }}
{{ if .Filters.AllFiltered }}
<p>No videos match the specified filters ({{ .Filters.FilteredCount }} filtered)</p>
{{ else }}
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
{{ range .Videos }}
<div class="card widget-content-frame thumbnail-parent">
@ -11,3 +14,4 @@
{{ end }}
</div>
{{ end }}
{{ end }}

@ -1,6 +1,9 @@
{{ template "widget-base.html" . }}
{{- define "widget-content" }}
{{ if .Filters.AllFiltered }}
<p>No videos match the specified filters ({{ .Filters.FilteredCount }} filtered)</p>
{{ else }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{- range .Videos }}
<li class="flex thumbnail-parent gap-10 items-center">
@ -17,4 +20,5 @@
</li>
{{- end }}
</ul>
{{ end }}
{{- end }}

@ -1,8 +1,11 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content-classes" }}{{ if not .Filters.AllFiltered }}widget-content-frameless{{ end }}{{ end }}
{{ define "widget-content" }}
{{ if .Filters.AllFiltered }}
<p>No videos match the specified filters ({{ .Filters.FilteredCount }} filtered)</p>
{{ else }}
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container">
{{ range .Videos }}
@ -13,3 +16,4 @@
</div>
</div>
{{ end }}
{{ end }}

@ -20,6 +20,8 @@ type hackerNewsWidget struct {
CollapseAfter int `yaml:"collapse-after"`
CommentsUrlTemplate string `yaml:"comments-url-template"`
ShowThumbnails bool `yaml:"-"`
Filters filterableFields[forumPost] `yaml:"filters"`
}
func (widget *hackerNewsWidget) initialize() error {
@ -45,20 +47,21 @@ func (widget *hackerNewsWidget) initialize() error {
func (widget *hackerNewsWidget) update(ctx context.Context) {
posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.ExtraSortBy == "engagement" {
posts.calculateEngagement()
posts.sortByEngagement()
}
posts = widget.Filters.Apply(posts)
if widget.Limit < len(posts) {
posts = posts[:widget.Limit]
}
if widget.ExtraSortBy == "engagement" {
posts.calculateEngagement()
posts.sortByEngagement()
}
widget.Posts = posts
}

@ -18,6 +18,8 @@ type lobstersWidget struct {
SortBy string `yaml:"sort-by"`
Tags []string `yaml:"tags"`
ShowThumbnails bool `yaml:"-"`
Filters filterableFields[forumPost] `yaml:"filters"`
}
func (widget *lobstersWidget) initialize() error {
@ -51,6 +53,8 @@ func (widget *lobstersWidget) update(ctx context.Context) {
return
}
posts = widget.Filters.Apply(posts)
if widget.Limit < len(posts) {
posts = posts[:widget.Limit]
}

@ -35,6 +35,8 @@ type redditWidget struct {
CollapseAfter int `yaml:"collapse-after"`
RequestURLTemplate string `yaml:"request-url-template"`
Filters filterableFields[forumPost] `yaml:"filters"`
AppAuth struct {
Name string `yaml:"name"`
ID string `yaml:"id"`
@ -97,15 +99,17 @@ func (widget *redditWidget) update(ctx context.Context) {
return
}
if len(posts) > widget.Limit {
posts = posts[:widget.Limit]
}
posts = widget.Filters.Apply(posts)
if widget.ExtraSortBy == "engagement" {
posts.calculateEngagement()
posts.sortByEngagement()
}
if len(posts) > widget.Limit {
posts = posts[:widget.Limit]
}
widget.Posts = posts
}

@ -44,6 +44,8 @@ type rssWidget struct {
cachedFeedsMutex sync.Mutex
cachedFeeds map[string]*cachedRSSFeed `yaml:"-"`
Filters filterableFields[rssFeedItem] `yaml:"filters"`
}
func (widget *rssWidget) initialize() error {
@ -71,13 +73,14 @@ func (widget *rssWidget) initialize() error {
}
}
widget.NoItemsMessage = "No items were returned from the feeds."
widget.cachedFeeds = make(map[string]*cachedRSSFeed)
return nil
}
func (widget *rssWidget) update(ctx context.Context) {
widget.NoItemsMessage = "No items were returned from the feeds."
items, err := widget.fetchItemsFromFeeds()
if !widget.canContinueUpdateAfterHandlingErr(err) {
@ -88,6 +91,15 @@ func (widget *rssWidget) update(ctx context.Context) {
items.sortByNewest()
}
items = widget.Filters.Apply(items)
if widget.Filters.AllFiltered {
widget.NoItemsMessage = fmt.Sprintf(
"No items match the specified filters (%d filtered)",
widget.Filters.FilteredCount,
)
}
if len(items) > widget.Limit {
items = items[:widget.Limit]
}
@ -128,15 +140,29 @@ type rssFeedItem struct {
PublishedAt time.Time
}
func (i rssFeedItem) filterableField(field string) any {
switch field {
case "title":
return i.Title
case "description":
return i.Description
case "posted":
return i.PublishedAt
default:
return nil
}
}
type rssFeedRequest struct {
URL string `yaml:"url"`
Title string `yaml:"title"`
HideCategories bool `yaml:"hide-categories"`
HideDescription bool `yaml:"hide-description"`
Limit int `yaml:"limit"`
ItemLinkPrefix string `yaml:"item-link-prefix"`
Headers map[string]string `yaml:"headers"`
IsDetailed bool `yaml:"-"`
URL string `yaml:"url"`
Title string `yaml:"title"`
HideCategories bool `yaml:"hide-categories"`
HideDescription bool `yaml:"hide-description"`
Limit int `yaml:"limit"`
ItemLinkPrefix string `yaml:"item-link-prefix"`
ThumbnailLinkPrefix string `yaml:"thumbnail-link-prefix"`
Headers map[string]string `yaml:"headers"`
IsDetailed bool `yaml:"-"`
}
type rssFeedItemList []rssFeedItem
@ -321,6 +347,24 @@ func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFe
}
}
// For some reason gofeed sometimes converts absolute URLs to relative ones, so we need to fix
// that here and provide the user with an option to override the prefix in case we get it wrong
if strings.HasPrefix(rssItem.ImageURL, "/") {
prefix := strings.TrimPrefix(feed.Link, "/")
if request.ThumbnailLinkPrefix != "" {
prefix = strings.TrimPrefix(request.ThumbnailLinkPrefix, "/")
} else if prefix == "" {
parsed, err := url.Parse(request.URL)
if err != nil {
prefix = request.URL
} else {
prefix = parsed.Scheme + "://" + parsed.Host
}
}
rssItem.ImageURL = strings.TrimRight(prefix, "/") + rssItem.ImageURL
}
if item.PublishedParsed != nil {
rssItem.PublishedAt = *item.PublishedParsed
} else {

@ -27,6 +27,21 @@ type forumPost struct {
type forumPostList []forumPost
func (p forumPost) filterableField(field string) any {
switch field {
case "title":
return p.Title
case "comments":
return p.CommentCount
case "points":
return p.Score
case "posted":
return p.TimePosted
default:
return nil
}
}
const depreciatePostsOlderThanHours = 7
const maxDepreciation = 0.9
const maxDepreciationAfterHours = 24

@ -0,0 +1,113 @@
package glance
import (
"context"
"fmt"
"html/template"
"slices"
"strconv"
"strings"
"time"
"golang.org/x/net/html"
)
var trendingRepositoriesWidgetTemplate = mustParseTemplate("trending-repositories.html", "widget-base.html")
type trendingRepositoriesWidget struct {
widgetBase `yaml:",inline"`
Repositories []trendingRepository `yaml:"-"`
Language string `yaml:"language"`
DateRange string `yaml:"date-range"`
CollapseAfter int `yaml:"collapse-after"`
}
func (widget *trendingRepositoriesWidget) initialize() error {
widget.withTitle("Trending Repositories").withCacheDuration(8 * time.Hour)
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 4
}
if !slices.Contains([]string{"daily", "weekly", "monthly"}, widget.DateRange) {
widget.DateRange = "daily"
}
return nil
}
func (widget *trendingRepositoriesWidget) update(ctx context.Context) {
repositories, err := widget.fetchTrendingRepositories()
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.Repositories = repositories
}
func (widget *trendingRepositoriesWidget) Render() template.HTML {
return widget.renderTemplate(widget, trendingRepositoriesWidgetTemplate)
}
type trendingRepository struct {
Slug string
Description string
Language string
Stars int
}
func (widget *trendingRepositoriesWidget) fetchTrendingRepositories() ([]trendingRepository, error) {
url := fmt.Sprintf("https://github.com/trending/%s?since=%s", widget.Language, widget.DateRange)
response, err := defaultHTTPClient.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch trending repositories: %w", err)
}
defer response.Body.Close()
if response.StatusCode != 200 {
return nil, fmt.Errorf("unexpected status code %d for %s", response.StatusCode, url)
}
parsedDoc, err := html.Parse(response.Body)
if err != nil {
return nil, fmt.Errorf("failed to parse HTML response: %w", err)
}
doc := (*searchableNode)(parsedDoc)
repositories := make([]trendingRepository, 0, 15)
repoElems := doc.
findFirst("main").
findFirst("div", "class", "Box").
findAll("article", "class", "Box-row")
for _, repoElem := range repoElems {
nameElem := repoElem.findFirstChild("h2").findFirst("a", "class", "Link")
name := strings.ReplaceAll(nameElem.text(), " ", "")
description := repoElem.findFirstChild("p").text()
metaElem := repoElem.findFirstChild("div", "class", "f6 color-fg-muted mt-2")
language := metaElem.findFirst("span", "itemprop", "programmingLanguage").text()
starsIndex := 2
if language == "" {
starsIndex = 1
}
starsText := metaElem.nthChild(starsIndex).text()
starsText = strings.ReplaceAll(starsText, ",", "")
stars, _ := strconv.Atoi(starsText)
repositories = append(repositories, trendingRepository{
Slug: name,
Description: description,
Language: language,
Stars: stars,
})
}
return repositories, nil
}

@ -32,6 +32,8 @@ type videosWidget struct {
Limit int `yaml:"limit"`
IncludeShorts bool `yaml:"include-shorts"`
SortBy string `yaml:"sort-by"`
Filters filterableFields[video] `yaml:"filters"`
}
func (widget *videosWidget) initialize() error {
@ -71,6 +73,8 @@ func (widget *videosWidget) update(ctx context.Context) {
return
}
videos = widget.Filters.Apply(videos)
if len(videos) > widget.Limit {
videos = videos[:widget.Limit]
}
@ -131,6 +135,19 @@ type video struct {
TimeUpdated time.Time
}
func (v video) filterableField(field string) any {
switch field {
case "title":
return v.Title
case "posted":
return v.TimePosted
case "updated":
return v.TimeUpdated
default:
return nil
}
}
type videoList []video
func (v videoList) sortByPosted() videoList {
@ -154,9 +171,8 @@ func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate
for i := range channelOrPlaylistIDs {
var feedUrl string
if strings.HasPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix) {
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" +
strings.TrimPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix)
if after, ok := strings.CutPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix); ok {
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + after
} else if !includeShorts && strings.HasPrefix(channelOrPlaylistIDs[i], "UC") {
playlistId := strings.Replace(channelOrPlaylistIDs[i], "UC", "UULF", 1)
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId

@ -83,6 +83,8 @@ func newWidget(widgetType string) (widget, error) {
w = &torrentsWidget{}
case "to-do":
w = &todoWidget{}
case "trending-repositories":
w = &trendingRepositoriesWidget{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}