Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions internal/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"net/url"
"strings"
"time"

"github.com/JulienTant/blogwatcher-cli/internal/model"
"github.com/JulienTant/blogwatcher-cli/internal/opml"
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
8 changes: 4 additions & 4 deletions internal/controller/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/scanner/scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion internal/storage/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

rows, err := query.RunWith(db.conn).QueryContext(ctx)
if err != nil {
Expand Down
115 changes: 101 additions & 14 deletions internal/storage/database_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)

Expand All @@ -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 {
Expand Down