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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A Go CLI tool to track blog articles, detect new posts, and manage read/unread s
- **Blog Filtering** - View articles from specific blogs
- **Duplicate Prevention** - Never tracks the same article twice
- **Colored CLI Output** - User-friendly terminal interface
- **JSON Output** - Machine-readable output for automation and agent integrations

## Installation

Expand All @@ -30,6 +31,24 @@ Pre-built binaries for Linux, macOS, and Windows are available on the [GitHub Re

## Usage

### Output Format

Human-readable text output is the default. Use `--format json` for machine-readable output in scripts or agent integrations:

```bash
blogwatcher-cli add "xkcd" https://xkcd.com --feed-url https://xkcd.com/atom.xml --format json
blogwatcher-cli blogs --format json
blogwatcher-cli scan --format json
blogwatcher-cli articles --format json
blogwatcher-cli read 42 --format json
blogwatcher-cli unread 42 --format json
blogwatcher-cli read-all --yes --format json
blogwatcher-cli remove "xkcd" --yes --format json
blogwatcher-cli import subscriptions.opml --format json
```

You can also set `BLOGWATCHER_FORMAT=json`. Commands that normally prompt for confirmation (`remove`, `read-all`) require `--yes` in JSON mode.

### Adding Blogs

```bash
Expand Down
157 changes: 157 additions & 0 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package e2e

import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
Expand Down Expand Up @@ -407,6 +408,149 @@ func TestE2E(t *testing.T) {
}
}

func TestJSONOutput(t *testing.T) {
baseURL := startTestServer(t)

for _, mode := range []string{"flags", "env"} {
t.Run(mode, func(t *testing.T) {
c := &cliOpts{
mode: mode,
dbPath: filepath.Join(t.TempDir(), "test.db"),
}

addOut := c.ok(t, []string{"add", "go-blog", baseURL + "/go/"}, map[string]string{
"feed-url": baseURL + "/go/feed.atom",
"format": "json",
})
var added struct {
OK bool `json:"ok"`
Blog struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"blog"`
}
require.NoError(t, json.Unmarshal([]byte(addOut), &added))
assert.True(t, added.OK)
assert.Equal(t, "go-blog", added.Blog.Name)

blogsOut := c.ok(t, []string{"blogs"}, map[string]string{"format": "json"})
var blogs struct {
Blogs []struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
FeedURL string `json:"feed_url"`
} `json:"blogs"`
}
require.NoError(t, json.Unmarshal([]byte(blogsOut), &blogs))
require.Len(t, blogs.Blogs, 1)
assert.Equal(t, "go-blog", blogs.Blogs[0].Name)
assert.Equal(t, baseURL+"/go/", blogs.Blogs[0].URL)
assert.Equal(t, baseURL+"/go/feed.atom", blogs.Blogs[0].FeedURL)

scanOut := c.ok(t, []string{"scan"}, map[string]string{"format": "json"})
var scan struct {
Scanned int `json:"scanned"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
TotalNewArticles int `json:"total_new_articles"`
Results []struct {
BlogName string `json:"blog_name"`
NewArticles int `json:"new_articles"`
TotalFound int `json:"total_found"`
Source string `json:"source"`
} `json:"results"`
}
require.NoError(t, json.Unmarshal([]byte(scanOut), &scan))
assert.Equal(t, 1, scan.Scanned)
assert.Equal(t, 1, scan.Succeeded)
assert.Equal(t, 0, scan.Failed)
assert.Equal(t, 3, scan.TotalNewArticles)
require.Len(t, scan.Results, 1)
assert.Equal(t, "go-blog", scan.Results[0].BlogName)
assert.Equal(t, "rss", scan.Results[0].Source)

articlesOut := c.ok(t, []string{"articles"}, map[string]string{"format": "json"})
var articles struct {
Articles []struct {
ID int64 `json:"id"`
BlogID int64 `json:"blog_id"`
Blog string `json:"blog"`
Title string `json:"title"`
URL string `json:"url"`
IsRead bool `json:"is_read"`
Categories []string `json:"categories"`
} `json:"articles"`
}
require.NoError(t, json.Unmarshal([]byte(articlesOut), &articles))
require.Len(t, articles.Articles, 3)
assert.Equal(t, "go-blog", articles.Articles[0].Blog)
assert.NotEmpty(t, articles.Articles[0].Title)
assert.NotEmpty(t, articles.Articles[0].URL)
articleID := fmt.Sprintf("%d", articles.Articles[0].ID)

readOut := c.ok(t, []string{"read", articleID}, map[string]string{"format": "json"})
var readResult struct {
OK bool `json:"ok"`
Action string `json:"action"`
ArticleID int64 `json:"article_id"`
Changed bool `json:"changed"`
Article struct {
IsRead bool `json:"is_read"`
} `json:"article"`
}
require.NoError(t, json.Unmarshal([]byte(readOut), &readResult))
assert.True(t, readResult.OK)
assert.Equal(t, "read", readResult.Action)
assert.True(t, readResult.Changed)
assert.True(t, readResult.Article.IsRead)

unreadOut := c.ok(t, []string{"unread", articleID}, map[string]string{"format": "json"})
var unreadResult struct {
OK bool `json:"ok"`
Action string `json:"action"`
Changed bool `json:"changed"`
Article struct {
IsRead bool `json:"is_read"`
} `json:"article"`
}
require.NoError(t, json.Unmarshal([]byte(unreadOut), &unreadResult))
assert.True(t, unreadResult.OK)
assert.Equal(t, "unread", unreadResult.Action)
assert.True(t, unreadResult.Changed)
assert.False(t, unreadResult.Article.IsRead)

readAllOut := c.ok(t, []string{"read-all"}, map[string]string{"yes": "", "format": "json"})
var readAll struct {
OK bool `json:"ok"`
Count int `json:"count"`
}
require.NoError(t, json.Unmarshal([]byte(readAllOut), &readAll))
assert.True(t, readAll.OK)
assert.Greater(t, readAll.Count, 0)

removeOut := c.ok(t, []string{"remove", "go-blog"}, map[string]string{"yes": "", "format": "json"})
var removed struct {
OK bool `json:"ok"`
Name string `json:"name"`
}
require.NoError(t, json.Unmarshal([]byte(removeOut), &removed))
assert.True(t, removed.OK)
assert.Equal(t, "go-blog", removed.Name)
})
}
}

func TestInvalidOutputFormat(t *testing.T) {
c := &cliOpts{
mode: "flags",
dbPath: filepath.Join(t.TempDir(), "test.db"),
}
_, stderr, code := c.run(t, []string{"blogs"}, map[string]string{"format": "xml"})
assert.NotEqual(t, 0, code)
assert.Contains(t, stderr, `invalid output format "xml": expected text or json`)
}

func TestAddBlogInvalidURL(t *testing.T) {
for _, mode := range []string{"flags", "env"} {
t.Run(mode, func(t *testing.T) {
Expand Down Expand Up @@ -465,6 +609,19 @@ func TestImportOPML(t *testing.T) {
out := c.ok(t, []string{"import", opmlPath}, nil)
checkOutput(t, "30_import_opml", out, baseURL)

jsonDB := filepath.Join(t.TempDir(), "json-test.db")
jsonCLI := &cliOpts{mode: mode, dbPath: jsonDB}
jsonOut := jsonCLI.ok(t, []string{"import", opmlPath}, map[string]string{"format": "json"})
var imported struct {
OK bool `json:"ok"`
Added int `json:"added"`
Skipped int `json:"skipped"`
}
require.NoError(t, json.Unmarshal([]byte(jsonOut), &imported))
assert.True(t, imported.OK)
assert.Equal(t, 2, imported.Added)
assert.Equal(t, 0, imported.Skipped)

// Verify blogs appear in list.
out = c.ok(t, []string{"blogs"}, nil)
checkOutput(t, "31_import_blogs_listed", out, baseURL)
Expand Down
60 changes: 56 additions & 4 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,14 @@ 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"))
blog, err := controller.AddBlog(cmd.Context(), db, name, url, viper.GetString("feed-url"), viper.GetString("scrape-selector"))
if err != nil {
printError(err)
return markError(err)
}
if isJSONOutput() {
return writeJSON(addBlogOutput{OK: true, Blog: newBlogOutput(blog)})
}
cprintf([]color.Attribute{color.FgGreen}, "Added blog '%s'\n", name)
return nil
})
Expand All @@ -76,6 +79,9 @@ func newRemoveCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
if !viper.GetBool("yes") {
if isJSONOutput() {
return fmt.Errorf("remove with --format json requires --yes")
}
confirmed, err := confirm(fmt.Sprintf("Remove blog '%s' and all its articles?", name))
if err != nil {
return err
Expand All @@ -89,6 +95,9 @@ func newRemoveCommand() *cobra.Command {
printError(err)
return markError(err)
}
if isJSONOutput() {
return writeJSON(removeBlogOutput{OK: true, Name: name})
}
cprintf([]color.Attribute{color.FgGreen}, "Removed blog '%s'\n", name)
return nil
})
Expand All @@ -108,6 +117,9 @@ func newBlogsCommand() *cobra.Command {
if err != nil {
return err
}
if isJSONOutput() {
return writeJSON(newBlogsOutput(blogs))
}
if len(blogs) == 0 {
fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.")
return nil
Expand Down Expand Up @@ -157,6 +169,9 @@ func newScanCommand() *cobra.Command {
printError(err)
return markError(err)
}
if isJSONOutput() {
return writeJSON(newScanOutput([]scanner.ScanResult{*result}))
}
if !silent {
printScanResult(*result)
}
Expand All @@ -166,10 +181,13 @@ func newScanCommand() *cobra.Command {
return err
}
if len(blogs) == 0 {
if isJSONOutput() {
return writeJSON(newScanOutput(nil))
}
fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.")
return nil
}
if !silent {
if !silent && !isJSONOutput() {
cprintf([]color.Attribute{color.FgCyan}, "Scanning %d blog(s)...\n\n", len(blogs))
}
results, err := sc.ScanAllBlogs(cmd.Context(), db, workers)
Expand All @@ -179,7 +197,7 @@ func newScanCommand() *cobra.Command {
totalNew := 0
failed := 0
for _, result := range results {
if !silent {
if !silent && !isJSONOutput() {
printScanResult(result)
}
if result.Error != "" {
Expand All @@ -188,6 +206,9 @@ func newScanCommand() *cobra.Command {
totalNew += result.NewArticles
}
}
if isJSONOutput() {
return writeJSON(newScanOutput(results))
}
if !silent {
fmt.Println()
succeeded := len(results) - failed
Expand All @@ -209,7 +230,7 @@ func newScanCommand() *cobra.Command {
}
}

if silent {
if silent && !isJSONOutput() {
fmt.Println("scan done")
}
return nil
Expand Down Expand Up @@ -240,6 +261,9 @@ func newArticlesCommand() *cobra.Command {
printError(err)
return markError(err)
}
if isJSONOutput() {
return writeJSON(newArticlesOutput(articles, blogNames))
}
if len(articles) == 0 {
if showAll {
fmt.Println("No articles found.")
Expand Down Expand Up @@ -286,6 +310,11 @@ func newReadCommand() *cobra.Command {
printError(err)
return markError(err)
}
changed := !article.IsRead
if isJSONOutput() {
article.IsRead = true
return writeJSON(articleStatusOutput{OK: true, Action: "read", ArticleID: articleID, Changed: changed, Article: newArticleOutput(article, "")})
}
if article.IsRead {
fmt.Printf("Article %d is already marked as read.\n", articleID)
} else {
Expand All @@ -312,11 +341,17 @@ func newReadAllCommand() *cobra.Command {
return markError(err)
}
if len(articles) == 0 {
if isJSONOutput() {
return writeJSON(readAllOutput{OK: true, Blog: blogName, Count: 0, Articles: []articleOutput{}})
}
cprintln([]color.Attribute{color.FgGreen}, "No unread articles to mark as read.")
return nil
}

if !viper.GetBool("yes") {
if isJSONOutput() {
return fmt.Errorf("read-all with --format json requires --yes")
}
scope := "all blogs"
if blogName != "" {
scope = fmt.Sprintf("from '%s'", blogName)
Expand All @@ -336,6 +371,15 @@ func newReadAllCommand() *cobra.Command {
return markError(err)
}

if isJSONOutput() {
out := readAllOutput{OK: true, Blog: blogName, Count: len(marked), Articles: make([]articleOutput, 0, len(marked))}
for _, article := range marked {
article.IsRead = true
out.Articles = append(out.Articles, newArticleOutput(article, ""))
}
return writeJSON(out)
}

cprintf([]color.Attribute{color.FgGreen}, "Marked %d article(s) as read\n", len(marked))
return nil
})
Expand Down Expand Up @@ -363,6 +407,11 @@ func newUnreadCommand() *cobra.Command {
printError(err)
return markError(err)
}
changed := article.IsRead
if isJSONOutput() {
article.IsRead = false
return writeJSON(articleStatusOutput{OK: true, Action: "unread", ArticleID: articleID, Changed: changed, Article: newArticleOutput(article, "")})
}
if !article.IsRead {
fmt.Printf("Article %d is already marked as unread.\n", articleID)
} else {
Expand Down Expand Up @@ -396,6 +445,9 @@ func newImportCommand() *cobra.Command {
printError(err)
return markError(err)
}
if isJSONOutput() {
return writeJSON(importOutput{OK: true, Added: added, Skipped: skipped})
}
cprintf([]color.Attribute{color.FgGreen}, "Imported %d blog(s), skipped %d duplicate(s)\n", added, skipped)
return nil
})
Expand Down
Loading