diff --git a/README.md b/README.md index 053a2bb..27eea96 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,15 @@ blogwatcher-cli articles --all # List articles from a specific blog blogwatcher-cli articles --blog "Tech Blog" + +# Filter articles by publication date +blogwatcher-cli articles --since 2024-01-01 # Articles on or after Jan 1, 2024 (inclusive) +blogwatcher-cli articles --before 2024-01-15 # Articles before Jan 15, 2024 (exclusive) +blogwatcher-cli articles --since 2024-01-01 --before 2024-01-15 # Articles from Jan 1 up to but not including Jan 15 ``` +Date filters use the `published_date` of articles and require the format `YYYY-MM-DD`. Articles without a publication date are excluded when date filters are applied. + ### Managing Read Status ```bash diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 94d8284..1ab07f3 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -228,8 +228,20 @@ func newArticlesCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { showAll := viper.GetBool("all") + sinceStr := viper.GetString("since") + beforeStr := viper.GetString("before") + + since, err := parseDateFilter(sinceStr) + if err != nil { + return err + } + before, err := parseDateFilter(beforeStr) + if err != nil { + return err + } + return withDatabase(cmd, func(db *storage.Database) error { - articles, blogNames, err := controller.GetArticles(cmd.Context(), db, showAll, viper.GetString("blog"), viper.GetString("category")) + articles, blogNames, err := controller.GetArticles(cmd.Context(), db, showAll, viper.GetString("blog"), viper.GetString("category"), since, before) if err != nil { printError(err) return markError(err) @@ -259,6 +271,8 @@ func newArticlesCommand() *cobra.Command { cmd.Flags().BoolP("all", "a", false, "Show all articles (including read)") cmd.Flags().StringP("blog", "b", "", "Filter by blog name") cmd.Flags().StringP("category", "c", "", "Filter by category") + cmd.Flags().String("since", "", "Show articles published on or after YYYY-MM-DD") + cmd.Flags().String("before", "", "Show articles published before YYYY-MM-DD") return cmd } @@ -298,7 +312,7 @@ func newReadAllCommand() *cobra.Command { blogName := viper.GetString("blog") return withDatabase(cmd, func(db *storage.Database) error { - articles, _, err := controller.GetArticles(cmd.Context(), db, false, blogName, "") + articles, _, err := controller.GetArticles(cmd.Context(), db, false, blogName, "", nil, nil) if err != nil { printError(err) return markError(err) @@ -474,6 +488,17 @@ func parseID(value string) (int64, error) { return parsed, nil } +func parseDateFilter(dateStr string) (*time.Time, error) { + if dateStr == "" { + return nil, nil + } + parsed, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return nil, fmt.Errorf("invalid date format: %s, expected YYYY-MM-DD", dateStr) + } + return &parsed, nil +} + func confirm(prompt string) (bool, error) { reader := bufio.NewReader(os.Stdin) fmt.Printf("%s [y/N]: ", prompt) diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 39a21d3..a6fd8f0 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -7,6 +7,7 @@ import ( "io" "net/url" "strings" + "time" "github.com/JulienTant/blogwatcher-cli/internal/model" "github.com/JulienTant/blogwatcher-cli/internal/opml" @@ -99,7 +100,7 @@ func RemoveBlog(ctx context.Context, db *storage.Database, name string) error { return err } -func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogName string, category string) ([]model.Article, map[int64]string, error) { +func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogName string, category string, since *time.Time, before *time.Time) ([]model.Article, map[int64]string, error) { var blogID *int64 if blogName != "" { blog, err := db.GetBlogByName(ctx, blogName) @@ -117,7 +118,7 @@ func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogNa categoryPtr = &category } - articles, err := db.ListArticles(ctx, !showAll, blogID, categoryPtr) + articles, err := db.ListArticles(ctx, !showAll, blogID, categoryPtr, since, before) if err != nil { return nil, nil, err } @@ -163,7 +164,7 @@ func MarkAllArticlesRead(ctx context.Context, db *storage.Database, blogName str blogID = &blog.ID } - articles, err := db.ListArticles(ctx, true, blogID, nil) + articles, err := db.ListArticles(ctx, true, blogID, nil, nil, nil) if err != nil { return nil, err } diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go index dab9c69..a90f06d 100644 --- a/internal/controller/controller_test.go +++ b/internal/controller/controller_test.go @@ -122,12 +122,12 @@ func TestGetArticlesFilters(t *testing.T) { _, err = db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "Title", URL: "https://example.com/1"}) require.NoError(t, err, "add article") - articles, blogNames, err := GetArticles(ctx, db, false, "", "") + articles, blogNames, err := GetArticles(ctx, db, false, "", "", nil, nil) require.NoError(t, err, "get articles") require.Len(t, articles, 1) require.Equal(t, blog.Name, blogNames[blog.ID]) - _, _, err = GetArticles(ctx, db, false, "Missing", "") + _, _, err = GetArticles(ctx, db, false, "Missing", "", nil, nil) require.Error(t, err, "expected blog not found error") } @@ -290,13 +290,13 @@ func TestGetArticlesFilterByCategory(t *testing.T) { require.NoError(t, err, "add article") // Filter by Go - articles, _, err := GetArticles(ctx, db, false, "", "Go") + articles, _, err := GetArticles(ctx, db, false, "", "Go", nil, nil) require.NoError(t, err, "get articles by category") require.Len(t, articles, 1) require.Equal(t, "Go Post", articles[0].Title) // No filter returns all - all, _, err := GetArticles(ctx, db, false, "", "") + all, _, err := GetArticles(ctx, db, false, "", "", nil, nil) require.NoError(t, err, "get all articles") require.Len(t, all, 2) } diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 9e2208a..92ce27f 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -58,7 +58,7 @@ func TestScanBlogRSS(t *testing.T) { require.Equal(t, 2, result.NewArticles) require.Equal(t, "rss", result.Source) - articles, err := db.ListArticles(ctx, false, nil, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, articles, 2) } @@ -204,7 +204,7 @@ func TestScanBlogRSSWithCategories(t *testing.T) { require.NoError(t, scanErr) require.Equal(t, 2, result.NewArticles) - articles, err := db.ListArticles(ctx, false, nil, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, articles, 2) diff --git a/internal/storage/database.go b/internal/storage/database.go index e9da74c..3806033 100644 --- a/internal/storage/database.go +++ b/internal/storage/database.go @@ -360,7 +360,7 @@ func (db *Database) GetExistingArticleURLs(ctx context.Context, urls []string) ( return result, nil } -func (db *Database) ListArticles(ctx context.Context, unreadOnly bool, blogID *int64, category *string) ([]model.Article, error) { +func (db *Database) ListArticles(ctx context.Context, unreadOnly bool, blogID *int64, category *string, since *time.Time, before *time.Time) ([]model.Article, error) { query := sq.Select("id", "blog_id", "title", "url", "published_date", "discovered_date", "is_read", "categories"). From("articles"). OrderBy("discovered_date DESC") @@ -376,6 +376,12 @@ func (db *Database) ListArticles(ctx context.Context, unreadOnly bool, blogID *i // for exact element matching. query = query.Where("EXISTS (SELECT 1 FROM json_each(categories) WHERE LOWER(json_each.value) = LOWER(?))", *category) } + if since != nil { + query = query.Where(sq.GtOrEq{"published_date": since.Format(sqliteTimeLayout)}) + } + if before != nil { + query = query.Where(sq.Lt{"published_date": before.Format(sqliteTimeLayout)}) + } rows, err := query.RunWith(db.conn).QueryContext(ctx) if err != nil { diff --git a/internal/storage/database_test.go b/internal/storage/database_test.go index 531da09..66faed5 100644 --- a/internal/storage/database_test.go +++ b/internal/storage/database_test.go @@ -39,7 +39,7 @@ func TestDatabaseCreatesFileAndCRUD(t *testing.T) { require.NoError(t, err, "add articles bulk") require.Equal(t, 2, count) - list, err := db.ListArticles(ctx, false, nil, nil) + list, err := db.ListArticles(ctx, false, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, list, 2) @@ -190,17 +190,17 @@ func TestListArticlesFiltersAndOrdering(t *testing.T) { _, err = db.MarkArticleRead(ctx, first.ID) require.NoError(t, err, "mark read") - all, err := db.ListArticles(ctx, false, nil, nil) + all, err := db.ListArticles(ctx, false, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, all, 3) require.Equal(t, second.ID, all[0].ID, "expected newest article first") - unread, err := db.ListArticles(ctx, true, nil, nil) + unread, err := db.ListArticles(ctx, true, nil, nil, nil, nil) require.NoError(t, err, "list unread") require.Len(t, unread, 2) blogID := blogB.ID - filtered, err := db.ListArticles(ctx, false, &blogID, nil) + filtered, err := db.ListArticles(ctx, false, &blogID, nil, nil, nil) require.NoError(t, err, "list by blog") require.Len(t, filtered, 1) require.Equal(t, blogB.ID, filtered[0].BlogID) @@ -231,7 +231,7 @@ func TestBulkInsertDuplicateRollbackAndEmpty(t *testing.T) { _, err = db.AddArticlesBulk(ctx, dupArticles) require.Error(t, err, "expected bulk insert to fail on duplicate url") - articles, err := db.ListArticles(ctx, false, nil, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, articles, 1, "expected rollback on duplicate") } @@ -346,38 +346,38 @@ func TestListArticlesFilterByCategory(t *testing.T) { // Filter by "Go" - should return only the Go article cat := "Go" - goArticles, err := db.ListArticles(ctx, false, nil, &cat) + goArticles, err := db.ListArticles(ctx, false, nil, &cat, nil, nil) require.NoError(t, err, "list by category Go") require.Len(t, goArticles, 1) require.Equal(t, "Go Article", goArticles[0].Title) // Filter by "Programming" - should return both categorized articles cat = "Programming" - progArticles, err := db.ListArticles(ctx, false, nil, &cat) + progArticles, err := db.ListArticles(ctx, false, nil, &cat, nil, nil) require.NoError(t, err, "list by category Programming") require.Len(t, progArticles, 2) // No filter - should return all 3 - all, err := db.ListArticles(ctx, false, nil, nil) + all, err := db.ListArticles(ctx, false, nil, nil, nil, nil) require.NoError(t, err, "list all") require.Len(t, all, 3) // Case-insensitive match - "go" should match "Go" cat = "go" - goLower, err := db.ListArticles(ctx, false, nil, &cat) + goLower, err := db.ListArticles(ctx, false, nil, &cat, nil, nil) require.NoError(t, err, "list by category go (lowercase)") require.Len(t, goLower, 1) require.Equal(t, "Go Article", goLower[0].Title) // Case-insensitive match - "PROGRAMMING" should match "Programming" cat = "PROGRAMMING" - progUpper, err := db.ListArticles(ctx, false, nil, &cat) + progUpper, err := db.ListArticles(ctx, false, nil, &cat, nil, nil) require.NoError(t, err, "list by category PROGRAMMING (uppercase)") require.Len(t, progUpper, 2) // Empty string category should return all empty := "" - allEmpty, err := db.ListArticles(ctx, false, nil, &empty) + allEmpty, err := db.ListArticles(ctx, false, nil, &empty, nil, nil) require.NoError(t, err, "list with empty category") require.Len(t, allEmpty, 3) } @@ -398,7 +398,7 @@ func TestBulkInsertWithCategories(t *testing.T) { require.NoError(t, err, "bulk insert") require.Equal(t, 2, count) - list, err := db.ListArticles(ctx, false, nil, nil) + list, err := db.ListArticles(ctx, false, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, list, 2) @@ -410,8 +410,95 @@ func TestBulkInsertWithCategories(t *testing.T) { break } } - require.NotNil(t, withCat, "expected article with categories") - require.Equal(t, []string{"AI", "ML"}, withCat.Categories) + require.NotNil(t, withCat, "should find article with categories") + require.Len(t, withCat.Categories, 2, "should have 2 categories") + require.Equal(t, []string{"AI", "ML"}, withCat.Categories, "should match expected categories") +} + +func TestListArticlesFilterByDate(t *testing.T) { + ctx := context.Background() + tmp := t.TempDir() + path := filepath.Join(tmp, "blogwatcher-cli.db") + db, err := OpenDatabase(ctx, path) + require.NoError(t, err, "open database") + defer func() { require.NoError(t, db.Close()) }() + + blog, err := db.AddBlog(ctx, model.Blog{Name: "TestBlog", URL: "https://example.com"}) + require.NoError(t, err, "add blog") + + date1 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + date2 := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + date3 := time.Date(2024, 2, 1, 10, 0, 0, 0, time.UTC) + + _, err = db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "Article1", URL: "https://example.com/1", PublishedDate: &date1}) + require.NoError(t, err, "add article 1") + + _, err = db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "Article2", URL: "https://example.com/2", PublishedDate: &date2}) + require.NoError(t, err, "add article 2") + + _, err = db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "Article3", URL: "https://example.com/3", PublishedDate: &date3}) + require.NoError(t, err, "add article 3") + + _, err = db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "NoDate", URL: "https://example.com/nodate", PublishedDate: nil}) + require.NoError(t, err, "add article without date") + + t.Run("without filters returns all articles", func(t *testing.T) { + articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil) + require.NoError(t, err, "list articles") + require.Len(t, articles, 4, "should return all articles including no-date article") + }) + + t.Run("since filter inclusive", func(t *testing.T) { + since := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + articles, err := db.ListArticles(ctx, false, nil, nil, &since, nil) + require.NoError(t, err, "list articles with since filter") + require.Len(t, articles, 2, "should return articles on or after since date (Article2 and Article3)") + titles := []string{articles[0].Title, articles[1].Title} + require.Contains(t, titles, "Article2", "should include Article2 published on since date") + require.Contains(t, titles, "Article3", "should include Article3 after since date") + }) + + t.Run("before filter exclusive", func(t *testing.T) { + before := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, &before) + require.NoError(t, err, "list articles with before filter") + require.Len(t, articles, 1, "should return articles before date (only Article1)") + require.Equal(t, "Article1", articles[0].Title, "should only include Article1 before before-date") + }) + + t.Run("combined filters", func(t *testing.T) { + since := time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC) + before := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) + articles, err := db.ListArticles(ctx, false, nil, nil, &since, &before) + require.NoError(t, err, "list articles with combined filters") + require.Len(t, articles, 1, "should return only Article2 in range") + require.Equal(t, "Article2", articles[0].Title, "should only include Article2") + }) + + t.Run("nil published date excluded from filters", func(t *testing.T) { + since := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + articles, err := db.ListArticles(ctx, false, nil, nil, &since, nil) + require.NoError(t, err, "list articles with since filter") + require.Len(t, articles, 3, "should exclude no-date article") + + for _, article := range articles { + require.NotNil(t, article.PublishedDate, "all returned articles should have published date") + } + }) + + t.Run("after all dates", func(t *testing.T) { + since := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) + articles, err := db.ListArticles(ctx, false, nil, nil, &since, nil) + require.NoError(t, err, "list articles with since filter after all dates") + require.Empty(t, articles, "should return empty result") + }) + + t.Run("before all dates", func(t *testing.T) { + before := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, &before) + require.NoError(t, err, "list articles with before filter before all dates") + require.Empty(t, articles, "should return empty result") + }) } func openTestDB(t *testing.T) *Database {