Repository: miniflux/v2 (language: go)

Issue:
 Option to Filter Out Future-Dated Entries
- [x] I have read this document: https://miniflux.app/opinionated.html#feature-request

## Description:
Currently, these future-dated articles are automatically pinned to the top of the feed, which can be disruptive to the reading experience.

## Current Behavior:
- Articles with publication dates set in the future are automatically pinned to the top of the feed
- Users have no way to hide or filter these articles
- This behavior can clutter the feed and make it harder to find current content

## Proposed Solution:

Add a new filter option in the feed settings that allows users to:
- Show all articles (current behavior)
- Hide future-dated articles
Optionally, show future-dated articles but without forcing them to the top

This feature would be particularly helpful for feeds from sources that regularly publish articles with future dates (like scheduled posts or upcoming events).

Would it be possible to consider adding this functionality in a future release?


Discussion / hints:
This sounds like an issue in the feeds to be honest: websites publishing scheduled posts in advance are doing scheduled posting wrong, and entries for future events should either be published when the events is happening or have a publication date that isn't the date of the event. 

Do you have specific examples?
@jvoisin I occasionally encounter feeds that don't follow the time rules, and their entries stay pinned at the top. This can affect the reading experience when it happens.
I think adding this filter option would make Miniflux more flexible in handling these cases. Instead of enforcing one behavior, users could choose how they want to handle future-dated entries based on their needs and the specific feeds they follow. This way, those who want to see future entries can keep them, while others can choose to hide them.
Again, this does sound like an issue with the feeds, and should be fixed there, so that it can benefit everyone using the feed, and not only miniflux' users.
While I agree that fixing this at the feed source would be ideal, as end users we often don't have control over how feeds are implemented. Adding this filter option would be a practical solution for Miniflux users dealing with these feeds in the real world. It's similar to how Miniflux already provides options to handle other feed inconsistencies, making the reader more robust and user-friendly.
@Sevichecc has a good point. Miniflux already has a lot of workarounds and features to deal with some inconvenient feeds (block/allow/rewrite rules...).

Fix patch:
diff --git a/internal/reader/processor/processor.go b/internal/reader/processor/processor.go
index 0bdf0f61d48..3c824b666e0 100644
--- a/internal/reader/processor/processor.go
+++ b/internal/reader/processor/processor.go
@@ -10,6 +10,9 @@ import (
 	"strings"
 	"time"
 
+	"github.com/tdewolff/minify/v2"
+	"github.com/tdewolff/minify/v2/html"
+
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/metric"
 	"miniflux.app/v2/internal/model"
@@ -20,9 +23,6 @@ import (
 	"miniflux.app/v2/internal/reader/scraper"
 	"miniflux.app/v2/internal/reader/urlcleaner"
 	"miniflux.app/v2/internal/storage"
-
-	"github.com/tdewolff/minify/v2"
-	"github.com/tdewolff/minify/v2/html"
 )
 
 var customReplaceRuleRegex = regexp.MustCompile(`rewrite\("([^"]+)"\|"([^"]+)"\)`)
@@ -141,6 +141,9 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool
 
 			var match bool
 			switch parts[0] {
+			case "EntryDate":
+				datePattern := parts[1]
+				match = isDateMatchingPattern(entry.Date, datePattern)
 			case "EntryTitle":
 				match, _ = regexp.MatchString(parts[1], entry.Title)
 			case "EntryURL":
@@ -211,6 +214,9 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool
 
 			var match bool
 			switch parts[0] {
+			case "EntryDate":
+				datePattern := parts[1]
+				match = isDateMatchingPattern(entry.Date, datePattern)
 			case "EntryTitle":
 				match, _ = regexp.MatchString(parts[1], entry.Title)
 			case "EntryURL":
@@ -462,3 +468,44 @@ func minifyEntryContent(entryContent string) string {
 
 	return entryContent
 }
+
+func isDateMatchingPattern(entryDate time.Time, pattern string) bool {
+	if pattern == "future" {
+		return entryDate.After(time.Now())
+	}
+
+	parts := strings.SplitN(pattern, ":", 2)
+	if len(parts) != 2 {
+		return false
+	}
+
+	operator := parts[0]
+	dateStr := parts[1]
+
+	switch operator {
+	case "before":
+		targetDate, err := time.Parse("2006-01-02", dateStr)
+		if err != nil {
+			return false
+		}
+		return entryDate.Before(targetDate)
+	case "after":
+		targetDate, err := time.Parse("2006-01-02", dateStr)
+		if err != nil {
+			return false
+		}
+		return entryDate.After(targetDate)
+	case "between":
+		dates := strings.Split(dateStr, ",")
+		if len(dates) != 2 {
+			return false
+		}
+		startDate, err1 := time.Parse("2006-01-02", dates[0])
+		endDate, err2 := time.Parse("2006-01-02", dates[1])
+		if err1 != nil || err2 != nil {
+			return false
+		}
+		return entryDate.After(startDate) && entryDate.Before(endDate)
+	}
+	return false
+}
diff --git a/internal/validator/user.go b/internal/validator/user.go
index b461f912643..a7e05edb5cc 100644
--- a/internal/validator/user.go
+++ b/internal/validator/user.go
@@ -219,7 +219,7 @@ func validateMediaPlaybackRate(mediaPlaybackRate float64) *locale.LocalizedError
 
 func isValidFilterRules(filterEntryRules string, filterType string) *locale.LocalizedError {
 	// Valid Format: FieldName=RegEx\nFieldName=RegEx...
-	fieldNames := []string{"EntryTitle", "EntryURL", "EntryCommentsURL", "EntryContent", "EntryAuthor", "EntryTag"}
+	fieldNames := []string{"EntryTitle", "EntryURL", "EntryCommentsURL", "EntryContent", "EntryAuthor", "EntryTag", "EntryDate"}
 
 	rules := strings.Split(filterEntryRules, "\n")
 	for i, rule := range rules {


Test patch:
diff --git a/internal/reader/processor/processor_test.go b/internal/reader/processor/processor_test.go
index 2a594a4aec4..9e2283666c7 100644
--- a/internal/reader/processor/processor_test.go
+++ b/internal/reader/processor/processor_test.go
@@ -75,6 +75,12 @@ func TestAllowEntries(t *testing.T) {
 		{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Example", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, true},
 		{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)example"}, true},
 		{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{KeepFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, false},
+		{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Now().Add(24 * time.Hour)}, &model.User{KeepFilterEntryRules: "EntryDate=future"}, true},
+		{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Now().Add(-24 * time.Hour)}, &model.User{KeepFilterEntryRules: "EntryDate=future"}, false},
+		{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 14, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=before:2024-03-15"}, true},
+		{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 16, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=after:2024-03-15"}, true},
+		{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 3, 10, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-01,2024-03-15"}, true},
+		{&model.Feed{ID: 1, BlocklistRules: ""}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-01,2024-03-15"}, false},
 	}
 
 	for _, tc := range scenarios {