Repository: miniflux/v2

Issue:
[Feature]: Filter out past entry dates
### Feature Summary

Enrich the EntryDate filter options with `past` option

### What problem does this feature solve?

Related to: https://github.com/miniflux/v2/issues/2971 and https://miniflux.app/docs/rules.html#feed-filtering-rules

I want to propose adding `past` in the `EntryDate` filter options. Reason being that sometimes there are feeds issues that cause me to suddenly see hundred of past entries (and already read) reappear in some feeds likely because their ID has changed. When this happens I want to edit the feed filter options to ensure I will not see past entries appear again. 


### Proposed Solution

reference: https://miniflux.app/docs/rules.html#feed-filtering-rules

### Date Patterns
The EntryDate field supports the following date patterns:

...
`past` - Match entries with past publication dates.
...

**Pros**
- I no longer have to re-read/mark-as-read those past entries.
- I don't have to keep a friction-prone Blocking rule like `EntryDate=before:YYYY-MM-DD`

**Downside**:
- if the feed has been unresponsive for a while, Miniflux risk skipping this entry as well. I'm not sure if Miniflux could be smart enough to take into consideration the last time the feed was accessible and match the `EntryDate=past` with more care in such cases.

### Alternatives Considered

Using blocking rules like `EntryDate=before:YYYY-MM-DD` works but only for a period :-) and force to remember the date format and  to encode especially on a mobile 😆 

### Additional Context

_No response_

### Checklist

- [x] I have searched existing issues to ensure this feature hasn't been requested before.
- [x] I understand that feature requests are not guaranteed to be implemented.

Discussion:

Solution patch:
diff --git a/internal/reader/filter/filter.go b/internal/reader/filter/filter.go
index 5376ac78..7d8de1b7 100644
--- a/internal/reader/filter/filter.go
+++ b/internal/reader/filter/filter.go
@@ -7,6 +7,7 @@ import (
 	"log/slog"
 	"regexp"
 	"slices"
+	"strconv"
 	"strings"
 	"time"
 
@@ -183,6 +184,13 @@ func isDateMatchingPattern(pattern string, entryDate time.Time) bool {
 			return false
 		}
 		return entryDate.After(startDate) && entryDate.Before(endDate)
+	case "max-age":
+		duration, err := parseDuration(inputDate)
+		if err != nil {
+			return false
+		}
+		cutoffDate := time.Now().Add(-duration)
+		return entryDate.Before(cutoffDate)
 	}
 	return false
 }
@@ -195,3 +203,23 @@ func containsRegexPattern(pattern string, entries []string) bool {
 	}
 	return false
 }
+
+func parseDuration(duration string) (time.Duration, error) {
+	// Handle common duration formats like "30d", "7d", "1h", "1m", etc.
+	// Go's time.ParseDuration doesn't support days, so we handle them manually
+	if strings.HasSuffix(duration, "d") {
+		daysStr := strings.TrimSuffix(duration, "d")
+		days := 0
+		if daysStr != "" {
+			var err error
+			days, err = strconv.Atoi(daysStr)
+			if err != nil {
+				return 0, err
+			}
+		}
+		return time.Duration(days) * 24 * time.Hour, nil
+	}
+
+	// For other durations (hours, minutes, seconds), use Go's built-in parser
+	return time.ParseDuration(duration)
+}



Test patch:
diff --git a/internal/reader/filter/filter_test.go b/internal/reader/filter/filter_test.go
index b21a78b1..4d2aa0aa 100644
--- a/internal/reader/filter/filter_test.go
+++ b/internal/reader/filter/filter_test.go
@@ -40,6 +40,9 @@ func TestBlockingEntries(t *testing.T) {
 		{&model.Feed{ID: 1}, &model.Entry{Author: "Different", Tags: []string{"example", "something else"}}, &model.User{BlockFilterEntryRules: "EntryAuthor=(?i)example\nEntryTag=(?i)Test"}, false},
 		{&model.Feed{ID: 1}, &model.Entry{Author: "Different", Tags: []string{"example", "test"}}, &model.User{BlockFilterEntryRules: "EntryAuthor\nEntryTag=(?i)Test"}, true},
 		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 3, 14, 0, 0, 0, 0, time.UTC)}, &model.User{BlockFilterEntryRules: "EntryDate=before:2024-03-15"}, true},
+		// Test max-age filter
+		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, &model.User{BlockFilterEntryRules: "EntryDate=max-age:30d"}, true},      // Entry from Jan 1, 2024 is definitely older than 30 days
+		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, &model.User{BlockFilterEntryRules: "EntryDate=max-age:invalid"}, false}, // Invalid duration format
 		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 3, 14, 0, 0, 0, 0, time.UTC)}, &model.User{BlockFilterEntryRules: "UnknownRuleType=test"}, false},
 		{&model.Feed{ID: 1, BlockFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, &model.Entry{URL: "https://example.com", Title: "Some Example"}, &model.User{}, true},
 		// Test cases for merged user and feed BlockFilterEntryRules
@@ -97,8 +100,11 @@ func TestAllowEntries(t *testing.T) {
 		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:invalid-date,2024-03-15"}, false}, // invalid date format
 		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-15,invalid-date"}, false}, // invalid date format
 		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=between:2024-03-15"}, false},              // missing second date in range
-		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=abcd"}, false},                            // no colon in rule value
-		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=unknown:2024-03-15"}, false},              // unknown rule type
+		// Test max-age filter
+		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=max-age:30d"}, true},          // Entry from Jan 1, 2024 is definitely older than 30 days
+		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=max-age:invalid"}, false},     // Invalid duration format
+		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=abcd"}, false},               // no colon in rule value
+		{&model.Feed{ID: 1}, &model.Entry{Date: time.Date(2024, 2, 28, 0, 0, 0, 0, time.UTC)}, &model.User{KeepFilterEntryRules: "EntryDate=unknown:2024-03-15"}, false}, // unknown rule type
 		{&model.Feed{ID: 1, KeepFilterEntryRules: "EntryURL=(?i)example\nEntryTitle=(?i)Test"}, &model.Entry{URL: "https://example.com", Title: "Some Example"}, &model.User{}, true},
 		// Test cases for merged user and feed KeepFilterEntryRules
 		{&model.Feed{ID: 1, KeepFilterEntryRules: "EntryURL=(?i)website"}, &model.Entry{URL: "https://example.com", Title: "Some Title"}, &model.User{KeepFilterEntryRules: "EntryTitle=(?i)title"}, true},    // User rule matches
@@ -114,3 +120,54 @@ func TestAllowEntries(t *testing.T) {
 		}
 	}
 }
+
+func TestParseDuration(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected time.Duration
+		err      bool
+	}{
+		{"30d", 30 * 24 * time.Hour, false},
+		{"1h", time.Hour, false},
+		{"2m", 2 * time.Minute, false},
+		{"invalid", 0, true},
+		{"5x", 0, true}, // Invalid unit
+	}
+
+	for _, test := range tests {
+		result, err := parseDuration(test.input)
+		if (err != nil) != test.err {
+			t.Errorf("parseDuration(%q) error = %v, expected error: %v", test.input, err, test.err)
+			continue
+		}
+		if result != test.expected {
+			t.Errorf("parseDuration(%q) = %v, expected %v", test.input, result, test.expected)
+		}
+	}
+}
+
+func TestMaxAgeFilter(t *testing.T) {
+	now := time.Now()
+	oldEntry := &model.Entry{
+		Title: "Old Entry",
+		Date:  now.Add(-48 * time.Hour), // 48 hours ago
+	}
+	newEntry := &model.Entry{
+		Title: "New Entry",
+		Date:  now.Add(-30 * time.Minute), // 30 minutes ago
+	}
+
+	// Test blocking old entries
+	feed := &model.Feed{ID: 1}
+	user := &model.User{BlockFilterEntryRules: "EntryDate=max-age:1d"}
+
+	// Old entry should be blocked (48 hours > 1 day is true)
+	if !IsBlockedEntry(feed, oldEntry, user) {
+		t.Error("Expected old entry to be blocked with max-age:1d")
+	}
+
+	// New entry should not be blocked
+	if IsBlockedEntry(feed, newEntry, user) {
+		t.Error("Expected new entry to not be blocked with max-age:1d")
+	}
+}