Add filters

This commit is contained in:
Svilen Markov
2025-07-26 14:56:02 +01:00
parent 9720ebe9b1
commit ad60d52264
6 changed files with 334 additions and 28 deletions

View File

@ -2,6 +2,7 @@ package glance
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
@ -94,39 +95,54 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
return nil 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 type durationField time.Duration
func parseDurationValue(value string) (time.Duration, error) {
matches := durationFieldPattern.FindStringSubmatch(value)
if len(matches) != 3 {
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 0, err
}
switch matches[2] {
case "s":
return time.Duration(duration) * time.Second, nil
case "m":
return time.Duration(duration) * time.Minute, nil
case "h":
return time.Duration(duration) * time.Hour, nil
case "d":
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 { func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
var value string var value string
errorLine := fmt.Sprintf("line %d:", node.Line)
if err := node.Decode(&value); err != nil { if err := node.Decode(&value); err != nil {
return err return err
} }
matches := durationFieldPattern.FindStringSubmatch(value) parsedDuration, err := parseDurationValue(value)
if len(matches) != 3 {
return fmt.Errorf("%s invalid duration format for value `%s`", errorLine, value)
}
duration, err := strconv.Atoi(matches[1])
if err != nil { if err != nil {
return fmt.Errorf("%s invalid duration value: %s", errorLine, matches[1]) return fmt.Errorf("line %d: %w", node.Line, err)
} }
switch matches[2] { *d = durationField(parsedDuration)
case "s":
*d = durationField(time.Duration(duration) * time.Second)
case "m":
*d = durationField(time.Duration(duration) * time.Minute)
case "h":
*d = durationField(time.Duration(duration) * time.Hour)
case "d":
*d = durationField(time.Duration(duration) * 24 * time.Hour)
}
return nil return nil
} }
@ -292,3 +308,263 @@ func (q *queryParametersField) toQueryString() string {
return query.Encode() return query.Encode()
} }
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
}

View File

@ -1,6 +1,9 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{- define "widget-content" }} {{- 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 }}"> <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{- range .Posts }} {{- range .Posts }}
<li> <li>
@ -46,4 +49,5 @@
</li> </li>
{{- end }} {{- end }}
</ul> </ul>
{{ end }}
{{- end }} {{- end }}

View File

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

View File

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

View File

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

View File

@ -27,6 +27,21 @@ type forumPost struct {
type forumPostList []forumPost 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 depreciatePostsOlderThanHours = 7
const maxDepreciation = 0.9 const maxDepreciation = 0.9
const maxDepreciationAfterHours = 24 const maxDepreciationAfterHours = 24