Browse Source

feat(filter): add `EntryDate=max-age:duration` filter

Example: `EntryDate=max-age:30d` or `EntryDate=max-age:1h`

Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h", "d".
Frédéric Guillot 9 months ago
parent
commit
e6b814199b
2 changed files with 87 additions and 2 deletions
  1. 28 0
      internal/reader/filter/filter.go
  2. 59 2
      internal/reader/filter/filter_test.go

+ 28 - 0
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)
+}

+ 59 - 2
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")
+	}
+}