From e6b814199b7747994baf049600a9b51164e2e855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Guillot?= Date: Thu, 19 Jun 2025 17:08:57 -0700 Subject: [PATCH] feat(filter): add `EntryDate=max-age:duration` filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Example: `EntryDate=max-age:30d` or `EntryDate=max-age:1h` Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h", "d". --- internal/reader/filter/filter.go | 28 ++++++++++++ internal/reader/filter/filter_test.go | 61 ++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) 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) +} 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") + } +}