diff --git a/internal/glance/config-fields.go b/internal/glance/config-fields.go index 9e523c1..c89af99 100644 --- a/internal/glance/config-fields.go +++ b/internal/glance/config-fields.go @@ -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 +} diff --git a/internal/glance/templates/forum-posts.html b/internal/glance/templates/forum-posts.html index 5b65b69..c8169b4 100644 --- a/internal/glance/templates/forum-posts.html +++ b/internal/glance/templates/forum-posts.html @@ -1,6 +1,9 @@ {{ template "widget-base.html" . }} {{- define "widget-content" }} +{{ if .Filters.AllFiltered }} +

No posts match the specified filters ({{ .Filters.FilteredCount }} filtered)

+{{ else }} +{{ end }} {{- end }} diff --git a/internal/glance/templates/videos-grid.html b/internal/glance/templates/videos-grid.html index 2819fe8..4c87384 100644 --- a/internal/glance/templates/videos-grid.html +++ b/internal/glance/templates/videos-grid.html @@ -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 }} +

No videos match the specified filters ({{ .Filters.FilteredCount }} filtered)

+{{ else }}
{{ range .Videos }}
@@ -11,3 +14,4 @@ {{ end }}
{{ end }} +{{ end }} diff --git a/internal/glance/templates/videos-vertical-list.html b/internal/glance/templates/videos-vertical-list.html index cd760a4..a6639f2 100644 --- a/internal/glance/templates/videos-vertical-list.html +++ b/internal/glance/templates/videos-vertical-list.html @@ -1,6 +1,9 @@ {{ template "widget-base.html" . }} {{- define "widget-content" }} +{{ if .Filters.AllFiltered }} +

No videos match the specified filters ({{ .Filters.FilteredCount }} filtered)

+{{ else }} +{{ end }} {{- end }} diff --git a/internal/glance/templates/videos.html b/internal/glance/templates/videos.html index 16e7261..8a09b18 100644 --- a/internal/glance/templates/videos.html +++ b/internal/glance/templates/videos.html @@ -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 }} +

No videos match the specified filters ({{ .Filters.FilteredCount }} filtered)

+{{ else }} {{ end }} +{{ end }} diff --git a/internal/glance/widget-hacker-news.go b/internal/glance/widget-hacker-news.go index ad00df0..5269a85 100644 --- a/internal/glance/widget-hacker-news.go +++ b/internal/glance/widget-hacker-news.go @@ -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 } diff --git a/internal/glance/widget-lobsters.go b/internal/glance/widget-lobsters.go index 786d1df..59405a4 100644 --- a/internal/glance/widget-lobsters.go +++ b/internal/glance/widget-lobsters.go @@ -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] } diff --git a/internal/glance/widget-reddit.go b/internal/glance/widget-reddit.go index a2cb5d9..f54ad4b 100644 --- a/internal/glance/widget-reddit.go +++ b/internal/glance/widget-reddit.go @@ -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 } diff --git a/internal/glance/widget-rss.go b/internal/glance/widget-rss.go index 74fffce..c66dc16 100644 --- a/internal/glance/widget-rss.go +++ b/internal/glance/widget-rss.go @@ -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,6 +140,19 @@ 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"` diff --git a/internal/glance/widget-shared.go b/internal/glance/widget-shared.go index 45144ac..748aa61 100644 --- a/internal/glance/widget-shared.go +++ b/internal/glance/widget-shared.go @@ -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 diff --git a/internal/glance/widget-videos.go b/internal/glance/widget-videos.go index ecf77fa..569f1da 100644 --- a/internal/glance/widget-videos.go +++ b/internal/glance/widget-videos.go @@ -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