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
81 changes: 73 additions & 8 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"bufio"
"context"
"fmt"
"net/http"
"os"
Expand Down Expand Up @@ -234,14 +235,43 @@ func newArticlesCommand() *cobra.Command {
return markError(err)
}

limit := viper.GetInt("limit")
if limit < 0 {
err := fmt.Errorf("invalid --limit: %d (must be >= 0)", limit)
printError(err)
return markError(err)
}
if limit > storage.MaxListLimit {
err := fmt.Errorf("invalid --limit: %d (maximum is %d)", limit, storage.MaxListLimit)
printError(err)
return markError(err)
}

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)
filter := storage.ArticleFilter{
UnreadOnly: !showAll,
Category: stringPtr(viper.GetString("category")),
Since: since,
Before: before,
Search: viper.GetString("search"),
Limit: limit,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

blogID, err := resolveBlogID(cmd.Context(), db, viper.GetString("blog"))
if err != nil {
return err
}
filter.BlogID = blogID

articles, blogNames, err := controller.GetArticles(cmd.Context(), db, filter)
if err != nil {
printError(err)
return markError(err)
}
if len(articles) == 0 {
if showAll {
if viper.GetString("search") != "" {
cprintf([]color.Attribute{color.FgYellow}, "No articles matching '%s'.\n", viper.GetString("search"))
} else if showAll {
fmt.Println("No articles found.")
} else {
cprintln([]color.Attribute{color.FgGreen}, "No unread articles!")
Expand All @@ -253,6 +283,9 @@ func newArticlesCommand() *cobra.Command {
if showAll {
label = "All articles"
}
if viper.GetString("search") != "" {
label = fmt.Sprintf("Search results for '%s'", viper.GetString("search"))
}
cprintf([]color.Attribute{color.FgCyan, color.Bold}, "%s (%d):\n\n", label, len(articles))
for _, article := range articles {
printArticle(article, blogNames[article.BlogID])
Expand All @@ -267,6 +300,8 @@ func newArticlesCommand() *cobra.Command {
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")
cmd.Flags().StringP("search", "s", "", "Search articles by title or content (FTS5 full-text search)")
cmd.Flags().IntP("limit", "n", 20, "Maximum number of articles to return")
return cmd
}

Expand Down Expand Up @@ -303,10 +338,17 @@ func newReadAllCommand() *cobra.Command {
Use: "read-all",
Short: "Mark all unread articles as read.",
RunE: func(cmd *cobra.Command, args []string) error {
blogName := viper.GetString("blog")

return withDatabase(cmd, func(db *storage.Database) error {
articles, _, err := controller.GetArticles(cmd.Context(), db, false, blogName, "", nil, nil)
filter := storage.ArticleFilter{
UnreadOnly: true,
}
blogID, err := resolveBlogID(cmd.Context(), db, viper.GetString("blog"))
if err != nil {
return err
}
filter.BlogID = blogID

articles, _, err := controller.GetArticles(cmd.Context(), db, filter)
if err != nil {
printError(err)
return markError(err)
Expand All @@ -318,8 +360,8 @@ func newReadAllCommand() *cobra.Command {

if !viper.GetBool("yes") {
scope := "all blogs"
if blogName != "" {
scope = fmt.Sprintf("from '%s'", blogName)
if blog := viper.GetString("blog"); blog != "" {
scope = fmt.Sprintf("from '%s'", blog)
}
confirmed, err := confirm(fmt.Sprintf("Mark %d article(s) %s as read?", len(articles), scope))
if err != nil {
Expand All @@ -330,7 +372,7 @@ func newReadAllCommand() *cobra.Command {
}
}

marked, err := controller.MarkAllArticlesRead(cmd.Context(), db, blogName)
marked, err := controller.MarkAllArticlesRead(cmd.Context(), db, filter)
if err != nil {
printError(err)
return markError(err)
Expand Down Expand Up @@ -508,6 +550,29 @@ func parseDateRange(sinceStr, beforeStr string) (*time.Time, *time.Time, error)
return since, before, nil
}

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

func resolveBlogID(ctx context.Context, db *storage.Database, blogName string) (*int64, error) {
if blogName == "" {
return nil, nil
}
blog, err := db.GetBlogByName(ctx, blogName)
if err != nil {
return nil, err
}
if blog == nil {
err := fmt.Errorf("blog '%s' not found", blogName)
printError(err)
return nil, markError(err)
}
return &blog.ID, nil
}

func confirm(prompt string) (bool, error) {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [y/N]: ", prompt)
Expand Down
67 changes: 28 additions & 39 deletions internal/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"io"
"net/url"
"strings"
"time"

"github.com/JulienTant/blogwatcher-cli/internal/model"
"github.com/JulienTant/blogwatcher-cli/internal/opml"
Expand Down Expand Up @@ -100,38 +99,33 @@ 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) {
var blogID *int64
if blogName != "" {
blog, err := db.GetBlogByName(ctx, blogName)
if err != nil {
return nil, nil, err
}
if blog == nil {
return nil, nil, BlogNotFoundError{Name: blogName}
}
blogID = &blog.ID
}

var categoryPtr *string
if category != "" {
categoryPtr = &category
func GetArticles(ctx context.Context, db *storage.Database, filter storage.ArticleFilter) ([]model.Article, map[int64]string, error) {
articles, err := db.ListArticles(ctx, filter)
if err != nil {
return nil, nil, err
}

articles, err := db.ListArticles(ctx, !showAll, blogID, categoryPtr, since, before)
blogNames, err := getBlogNames(ctx, db, articles)
if err != nil {
return nil, nil, err
}

return articles, blogNames, nil
}

func getBlogNames(ctx context.Context, db *storage.Database, articles []model.Article) (map[int64]string, error) {
if len(articles) == 0 {
return nil, nil
}
blogs, err := db.ListBlogs(ctx)
if err != nil {
return nil, nil, err
return nil, err
}
blogNames := make(map[int64]string)
for _, blog := range blogs {
blogNames[blog.ID] = blog.Name
}

return articles, blogNames, nil
return blogNames, nil
}

func MarkArticleRead(ctx context.Context, db *storage.Database, articleID int64) (model.Article, error) {
Expand All @@ -151,29 +145,24 @@ func MarkArticleRead(ctx context.Context, db *storage.Database, articleID int64)
return *article, nil
}

func MarkAllArticlesRead(ctx context.Context, db *storage.Database, blogName string) ([]model.Article, error) {
var blogID *int64
if blogName != "" {
blog, err := db.GetBlogByName(ctx, blogName)
if err != nil {
return nil, err
}
if blog == nil {
return nil, BlogNotFoundError{Name: blogName}
}
blogID = &blog.ID
}
func MarkAllArticlesRead(ctx context.Context, db *storage.Database, filter storage.ArticleFilter) ([]model.Article, error) {
filter.UnreadOnly = true

articles, err := db.ListArticles(ctx, true, blogID, nil, nil, nil)
articles, err := db.ListArticles(ctx, filter)
if err != nil {
return nil, err
}
if len(articles) == 0 {
return articles, nil
}

for _, article := range articles {
_, err := db.MarkArticleRead(ctx, article.ID)
if err != nil {
return nil, err
}
ids := make([]int64, len(articles))
for i, a := range articles {
ids[i] = a.ID
}

if err := db.MarkArticlesRead(ctx, ids); err != nil {
return nil, err
}

return articles, nil
Expand Down
10 changes: 4 additions & 6 deletions internal/controller/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,10 @@ 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, "", "", nil, nil)
articles, blogNames, err := GetArticles(ctx, db, storage.ArticleFilter{})
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", "", nil, nil)
require.Error(t, err, "expected blog not found error")
}

func TestImportOPML(t *testing.T) {
Expand Down Expand Up @@ -290,13 +287,14 @@ func TestGetArticlesFilterByCategory(t *testing.T) {
require.NoError(t, err, "add article")

// Filter by Go
articles, _, err := GetArticles(ctx, db, false, "", "Go", nil, nil)
cat := "Go"
articles, _, err := GetArticles(ctx, db, storage.ArticleFilter{Category: &cat})
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, "", "", nil, nil)
all, _, err := GetArticles(ctx, db, storage.ArticleFilter{})
require.NoError(t, err, "get all articles")
require.Len(t, all, 2)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ type Article struct {
DiscoveredDate *time.Time
IsRead bool
Categories []string
Description string
Content string
}
4 changes: 4 additions & 0 deletions internal/rss/rss.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type FeedArticle struct {
URL string
PublishedDate *time.Time
Categories []string
Description string
Content string
}

type FeedParseError struct {
Expand Down Expand Up @@ -76,6 +78,8 @@ func (f *Fetcher) ParseFeed(ctx context.Context, feedURL string) ([]FeedArticle,
URL: link,
PublishedDate: pickPublishedDate(item),
Categories: item.Categories,
Description: item.Description,
Content: item.Content,
})
}

Expand Down
14 changes: 3 additions & 11 deletions internal/scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"os"
"time"

"github.com/cenkalti/backoff/v5"
Expand Down Expand Up @@ -209,17 +208,8 @@ func (s *Scanner) ScanAllBlogs(ctx context.Context, db *storage.Database, worker

for i := 0; i < workers; i++ {
g.Go(func() error {
workerDB, openErr := storage.OpenDatabase(gctx, db.Path())
if openErr != nil {
return openErr
}
defer func() {
if closeErr := workerDB.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr, "close: %v\n", closeErr)
}
}()
for item := range jobs {
result, scanErr := s.ScanBlog(gctx, workerDB, item.Blog)
result, scanErr := s.ScanBlog(gctx, db, item.Blog)
if scanErr != nil {
if isFatalScanError(scanErr) {
return fmt.Errorf("scan %s: %w", item.Blog.Name, scanErr)
Expand Down Expand Up @@ -277,6 +267,8 @@ func convertFeedArticles(blogID int64, articles []rss.FeedArticle) []model.Artic
PublishedDate: article.PublishedDate,
IsRead: false,
Categories: article.Categories,
Description: article.Description,
Content: article.Content,
})
}
return result
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, nil, nil)
articles, err := db.ListArticles(ctx, storage.ArticleFilter{})
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, nil, nil)
articles, err := db.ListArticles(ctx, storage.ArticleFilter{})
require.NoError(t, err, "list articles")
require.Len(t, articles, 2)

Expand Down
Loading