Skip to content
49 changes: 49 additions & 0 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,55 @@ func TestE2E(t *testing.T) {
// ── Remove nonexistent ──
_, stderr = c.fail(t, []string{"remove", "nope"}, map[string]string{"yes": ""})
checkOutput(t, "23_remove_nonexistent", stderr, baseURL)

// ── Add grouped blogs ──
out = c.ok(t, []string{"add", "go-blog-2", baseURL + "/rust/"}, map[string]string{
"scrape-selector": ".post-list td a[href]",
"group": "Team A",
})
checkOutput(t, "24_add_with_group", out, baseURL)

c.ok(t, []string{"add", "go-blog-3", baseURL + "/nowhere/"}, map[string]string{
"group": "Team B",
})

// ── Blogs list shows Group line ──
out = c.ok(t, []string{"blogs"}, nil)
checkOutput(t, "25_blogs_shows_group", out, baseURL)

// ── Blogs filtered by group, case-insensitive ──
out = c.ok(t, []string{"blogs"}, map[string]string{"group": "team a"})
checkOutput(t, "26_blogs_filter_group", out, baseURL)

// ── Blogs filtered by nonexistent group ──
out = c.ok(t, []string{"blogs"}, map[string]string{"group": "nonexistent"})
checkOutput(t, "27_blogs_filter_group_no_match", out, baseURL)

// ── Scan filtered by group ──
out = c.ok(t, []string{"scan"}, map[string]string{"group": "Team A"})
checkOutput(t, "28_scan_filter_group", out, baseURL)

// ── Scan filtered by nonexistent group ──
out = c.ok(t, []string{"scan"}, map[string]string{"group": "nonexistent"})
checkOutput(t, "29_scan_filter_group_no_match", out, baseURL)

// ── Articles filtered by group ──
out = c.ok(t, []string{"articles"}, map[string]string{"group": "Team A"})
checkOutput(t, "30_articles_filter_group", out, baseURL)

// ── Group filter returns every blog in the group, not just the first match ──
c.ok(t, []string{"add", "go-blog-4", baseURL + "/nowhere2/"}, map[string]string{
"group": "Team C",
})
c.ok(t, []string{"add", "go-blog-5", baseURL + "/nowhere3/"}, map[string]string{
"group": "Team C",
})
out = c.ok(t, []string{"blogs"}, map[string]string{"group": "Team C"})
checkOutput(t, "31_blogs_filter_group_multi", out, baseURL)

// ── Group filter is an exact match, not a prefix match ──
out = c.ok(t, []string{"blogs"}, map[string]string{"group": "Team"})
checkOutput(t, "32_blogs_filter_group_prefix_no_match", out, baseURL)
})
}
}
Expand Down
1 change: 1 addition & 0 deletions e2e/expected/24_add_with_group.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added blog 'go-blog-2'
21 changes: 21 additions & 0 deletions e2e/expected/25_blogs_shows_group.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Tracked blogs (4):

github-blog
URL: {{SERVER}}/github/
Feed: {{SERVER}}/github/feed/
Last scanned: {{TIMESTAMP}}

go-blog
URL: {{SERVER}}/go/
Feed: {{SERVER}}/go/feed.atom
Last scanned: {{TIMESTAMP}}

go-blog-2
URL: {{SERVER}}/rust/
Selector: .post-list td a[href]
Group: Team A

go-blog-3
URL: {{SERVER}}/nowhere/
Group: Team B

7 changes: 7 additions & 0 deletions e2e/expected/26_blogs_filter_group.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Tracked blogs (1):

go-blog-2
URL: {{SERVER}}/rust/
Selector: .post-list td a[href]
Group: Team A

1 change: 1 addition & 0 deletions e2e/expected/27_blogs_filter_group_no_match.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
No blogs found in group 'nonexistent'.
6 changes: 6 additions & 0 deletions e2e/expected/28_scan_filter_group.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Scanning 1 blog(s)...

go-blog-2
Source: HTML | Found: 5 | New: 5

Found 5 new article(s) total!
1 change: 1 addition & 0 deletions e2e/expected/29_scan_filter_group_no_match.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
No blogs found in group 'nonexistent'.
21 changes: 21 additions & 0 deletions e2e/expected/30_articles_filter_group.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Unread articles (5):

[ID] [new] Announcing Rust 1.94.0
Blog: go-blog-2
URL: https://blog.rust-lang.org/2026/03/05/Rust-1.94.0/

[ID] [new] Announcing Rust 1.94.1
Blog: go-blog-2
URL: https://blog.rust-lang.org/2026/03/26/1.94.1-release/

[ID] [new] Security advisory for Cargo
Blog: go-blog-2
URL: https://blog.rust-lang.org/2026/03/21/cve-2026-33056/

[ID] [new] What we heard about Rust's challenges
Blog: go-blog-2
URL: https://blog.rust-lang.org/2026/03/20/rust-challenges/

[ID] [new] docs.rs: building fewer targets by default
Blog: go-blog-2
URL: https://blog.rust-lang.org/2026/04/04/docsrs-only-default-targets/
10 changes: 10 additions & 0 deletions e2e/expected/31_blogs_filter_group_multi.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Tracked blogs (2):

go-blog-4
URL: {{SERVER}}/nowhere2/
Group: Team C

go-blog-5
URL: {{SERVER}}/nowhere3/
Group: Team C

1 change: 1 addition & 0 deletions e2e/expected/32_blogs_filter_group_prefix_no_match.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
No blogs found in group 'Team'.
40 changes: 32 additions & 8 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func newAddCommand() *cobra.Command {
name := args[0]
url := args[1]
return withDatabase(cmd, func(db *storage.Database) error {
_, err := controller.AddBlog(cmd.Context(), db, name, url, viper.GetString("feed-url"), viper.GetString("scrape-selector"))
_, err := controller.AddBlog(cmd.Context(), db, name, url, viper.GetString("feed-url"), viper.GetString("scrape-selector"), viper.GetString("group"))
if err != nil {
printError(err)
return markError(err)
Expand All @@ -65,6 +65,7 @@ func newAddCommand() *cobra.Command {
}
cmd.Flags().String("feed-url", "", "RSS/Atom feed URL (auto-discovered if not provided)")
cmd.Flags().String("scrape-selector", "", "CSS selector for HTML scraping fallback")
cmd.Flags().StringP("group", "g", "", "Group name for organizing this blog")
return cmd
}

Expand Down Expand Up @@ -104,12 +105,17 @@ func newBlogsCommand() *cobra.Command {
Short: "List all tracked blogs.",
RunE: func(cmd *cobra.Command, args []string) error {
return withDatabase(cmd, func(db *storage.Database) error {
blogs, err := db.ListBlogs(cmd.Context())
group := viper.GetString("group")
blogs, err := db.ListBlogs(cmd.Context(), stringPtrOrNil(group))
if err != nil {
return err
}
if len(blogs) == 0 {
fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.")
if group != "" {
fmt.Printf("No blogs found in group '%s'.\n", group)
} else {
fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.")
}
return nil
}
cprintf([]color.Attribute{color.FgCyan, color.Bold}, "Tracked blogs (%d):\n\n", len(blogs))
Expand All @@ -122,6 +128,9 @@ func newBlogsCommand() *cobra.Command {
if blog.ScrapeSelector != "" {
fmt.Printf(" Selector: %s\n", blog.ScrapeSelector)
}
if blog.Group != "" {
fmt.Printf(" Group: %s\n", blog.Group)
}
if blog.LastScanned != nil {
fmt.Printf(" Last scanned: %s\n", blog.LastScanned.Format("2006-01-02 15:04"))
}
Expand All @@ -131,6 +140,7 @@ func newBlogsCommand() *cobra.Command {
})
},
}
cmd.Flags().StringP("group", "g", "", "Filter by group name")
return cmd
}

Expand Down Expand Up @@ -161,18 +171,23 @@ func newScanCommand() *cobra.Command {
printScanResult(*result)
}
} else {
blogs, err := db.ListBlogs(cmd.Context())
groupName := viper.GetString("group")
blogs, err := db.ListBlogs(cmd.Context(), stringPtrOrNil(groupName))
if err != nil {
return err
}
if len(blogs) == 0 {
fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.")
if groupName != "" {
fmt.Printf("No blogs found in group '%s'.\n", groupName)
} else {
fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.")
}
return nil
}
if !silent {
cprintf([]color.Attribute{color.FgCyan}, "Scanning %d blog(s)...\n\n", len(blogs))
}
results, err := sc.ScanAllBlogs(cmd.Context(), db, workers)
results, err := sc.ScanAllBlogs(cmd.Context(), db, workers, groupName)
if err != nil {
return err
}
Expand Down Expand Up @@ -218,6 +233,7 @@ func newScanCommand() *cobra.Command {
}
cmd.Flags().BoolP("silent", "s", false, "Only output 'scan done' when complete")
cmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers when scanning all blogs")
cmd.Flags().StringP("group", "g", "", "Only scan blogs in this group")
return cmd
}

Expand All @@ -235,7 +251,7 @@ func newArticlesCommand() *cobra.Command {
}

return withDatabase(cmd, func(db *storage.Database) error {
articles, blogNames, err := controller.GetArticles(cmd.Context(), db, showAll, viper.GetString("blog"), viper.GetString("category"), since, before)
articles, blogNames, err := controller.GetArticles(cmd.Context(), db, showAll, viper.GetString("blog"), viper.GetString("category"), viper.GetString("group"), since, before)
if err != nil {
printError(err)
return markError(err)
Expand Down Expand Up @@ -265,6 +281,7 @@ 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().StringP("group", "g", "", "Filter by group name")
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 @@ -306,7 +323,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, "", nil, nil)
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 +491,13 @@ func csprintf(attrs []color.Attribute, format string, a ...any) string {
return color.New(attrs...).Sprintf(format, a...)
}

func stringPtrOrNil(s string) *string {
if s == "" {
return nil
}
return &s
}

func parseID(value string) (int64, error) {
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
Expand Down
12 changes: 12 additions & 0 deletions internal/cli/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ func TestParseDateFilter(t *testing.T) {
})
}

func TestStringPtrOrNil(t *testing.T) {
t.Run("empty string returns nil", func(t *testing.T) {
assert.Nil(t, stringPtrOrNil(""))
})

t.Run("non-empty string returns pointer to value", func(t *testing.T) {
got := stringPtrOrNil("Team A")
require.NotNil(t, got)
assert.Equal(t, "Team A", *got)
})
}

func TestParseDateRange(t *testing.T) {
t.Run("both empty returns nils", func(t *testing.T) {
since, before, err := parseDateRange("", "")
Expand Down
18 changes: 12 additions & 6 deletions internal/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func validateHTTPURL(s string) error {
return nil
}

func AddBlog(ctx context.Context, db *storage.Database, name string, urlStr string, feedURL string, scrapeSelector string) (model.Blog, error) {
func AddBlog(ctx context.Context, db *storage.Database, name string, urlStr string, feedURL string, scrapeSelector string, group string) (model.Blog, error) {
if err := validateHTTPURL(urlStr); err != nil {
return model.Blog{}, err
}
Expand All @@ -84,6 +84,7 @@ func AddBlog(ctx context.Context, db *storage.Database, name string, urlStr stri
URL: urlStr,
FeedURL: feedURL,
ScrapeSelector: scrapeSelector,
Group: group,
}
return db.AddBlog(ctx, blog)
}
Expand All @@ -100,7 +101,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, since *time.Time, before *time.Time) ([]model.Article, map[int64]string, error) {
func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogName string, category string, group 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 @@ -118,11 +119,16 @@ func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogNa
categoryPtr = &category
}

articles, err := db.ListArticles(ctx, !showAll, blogID, categoryPtr, since, before)
var groupPtr *string
if group != "" {
groupPtr = &group
}

articles, err := db.ListArticles(ctx, !showAll, blogID, categoryPtr, groupPtr, since, before)
if err != nil {
return nil, nil, err
}
blogs, err := db.ListBlogs(ctx)
blogs, err := db.ListBlogs(ctx, nil)
if err != nil {
return nil, nil, err
}
Expand Down Expand Up @@ -164,7 +170,7 @@ func MarkAllArticlesRead(ctx context.Context, db *storage.Database, blogName str
blogID = &blog.ID
}

articles, err := db.ListArticles(ctx, true, blogID, nil, nil, nil)
articles, err := db.ListArticles(ctx, true, blogID, nil, nil, nil, nil)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -193,7 +199,7 @@ func ImportOPML(ctx context.Context, db *storage.Database, r io.Reader) (added i
if title == "" {
title = siteURL
}
_, err := AddBlog(ctx, db, title, siteURL, feed.FeedURL, "")
_, err := AddBlog(ctx, db, title, siteURL, feed.FeedURL, "", "")
if err != nil {
var alreadyExists BlogAlreadyExistsError
var invalidURL InvalidURLError
Expand Down
Loading