Skip to content

Commit

Permalink
feat: enhance OPML import/export
Browse files Browse the repository at this point in the history
- allow import/export for multiple lists
- export feed title and description
- escape xml text
  • Loading branch information
radulucut committed Jan 23, 2025
1 parent 8021ecb commit b2019a6
Show file tree
Hide file tree
Showing 5 changed files with 338 additions and 69 deletions.
48 changes: 29 additions & 19 deletions cmd/cleed/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (
func (r *Root) initList() {
cmd := &cobra.Command{
Use: "list",
Short: "Show all lists or feeds in a list",
Long: `Show all lists or feeds in a list
Short: "Show all lists, feeds in a list or manage lists",
Long: `Show all lists, feeds in a list or manage lists
Examples:
# Show all lists
Expand All @@ -29,14 +29,20 @@ Examples:
# Import feeds from a file
cleed list mylist --import-from-file feeds.txt
# Import feeds from an OPML file
# Import feeds from an OPML file into a list
cleed list mylist --import-from-opml feeds.opml
# Import feeds from an OPML file into multiple lists
cleed list --import-from-opml feeds.opml
# Export feeds to a file
cleed list mylist --export-to-file feeds.txt
# Export feeds to an OPML file
# Export feeds from a list to a file
cleed list mylist --export-to-opml feeds.opml
# Export all feeds to an OPML file grouped by lists
cleed list --export-to-opml feeds.opml
`,

RunE: r.RunList,
Expand All @@ -56,35 +62,39 @@ Examples:
}

func (r *Root) RunList(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
list := ""
if len(args) > 0 {
list = args[0]
}
importFromOPML := cmd.Flag("import-from-opml").Value.String()
if importFromOPML != "" {
return r.feed.ImportFromOPML(importFromOPML, list)
}
exportToOPML := cmd.Flag("export-to-opml").Value.String()
if exportToOPML != "" {
return r.feed.ExportToOPML(exportToOPML, list)
}
if list == "" {
return r.feed.Lists()
}
rename := cmd.Flag("rename").Value.String()
if rename != "" {
return r.feed.RenameList(args[0], rename)
return r.feed.RenameList(list, rename)
}
merge := cmd.Flag("merge").Value.String()
if merge != "" {
return r.feed.MergeLists(args[0], merge)
return r.feed.MergeLists(list, merge)
}
if cmd.Flag("remove").Changed {
return r.feed.RemoveList(args[0])
return r.feed.RemoveList(list)
}
importFromFile := cmd.Flag("import-from-file").Value.String()
if importFromFile != "" {
return r.feed.ImportFromFile(importFromFile, args[0])
return r.feed.ImportFromFile(importFromFile, list)
}
exportToFile := cmd.Flag("export-to-file").Value.String()
if exportToFile != "" {
return r.feed.ExportToFile(exportToFile, args[0])
}
importFromOPML := cmd.Flag("import-from-opml").Value.String()
if importFromOPML != "" {
return r.feed.ImportFromOPML(importFromOPML, args[0])
}
exportToOPML := cmd.Flag("export-to-opml").Value.String()
if exportToOPML != "" {
return r.feed.ExportToOPML(exportToOPML, args[0])
return r.feed.ExportToFile(exportToFile, list)
}
return r.feed.ListFeeds(args[0])
return r.feed.ListFeeds(list)
}
228 changes: 219 additions & 9 deletions cmd/cleed/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ func Test_List_ImportFromOPML(t *testing.T) {
t.Fatal(err)
}

importFilePath := path.Join(listsDir, "import.opml")
importFilePath := path.Join(configDir, "import.opml")
err = os.WriteFile(importFilePath,
[]byte(`<?xml version="1.0" encoding="UTF-8"?>
<opml version="1.0">
Expand Down Expand Up @@ -606,7 +606,7 @@ func Test_List_ImportFromOPML(t *testing.T) {
}, items)
}

func Test_List_ExportToOPML(t *testing.T) {
func Test_List_ImportFromOPML_Multiple_Lists(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

Expand Down Expand Up @@ -639,18 +639,125 @@ func Test_List_ExportToOPML(t *testing.T) {
t.Fatal(err)
}

importFilePath := path.Join(configDir, "import.opml")
err = os.WriteFile(importFilePath,
[]byte(`<?xml version="1.0" encoding="UTF-8"?>
<opml version="1.0">
<head>
<title>test</title>
</head>
<body>
<outline text="test">
<outline xmlUrl="https://example1.com"/>
<outline xmlUrl="https://example2.com"/>
<outline xmlUrl="https://example3.com"/>
</outline>
<outline text="test 2">
<outline text="RSS Feed" description="RSS Feed description" xmlUrl="https://rss-feed.com/rss" />
<outline text="Atom Feed" description="Atom Feed description" xmlUrl="https://atom-feed.com/atom" />
<outline xmlUrl="https://test.com" />
</outline>
</body>
</opml>`), 0600)
if err != nil {
t.Fatal(err)
}

feed := internal.NewTerminalFeed(timeMock, printer, storage)
feed.SetAgent("cleed/test")

root, err := NewRoot("0.1.0", timeMock, printer, storage, feed)
assert.NoError(t, err)

exportPath := path.Join(listsDir, "export.opml")
os.Args = []string{"cleed", "list", "--import-from-opml", importFilePath}

err = root.Cmd.Execute()
assert.NoError(t, err)
assert.Equal(t, "added 3 feeds to list: test\nadded 3 feeds to list: test 2\n", out.String())

items, err := storage.GetFeedsFromList("test")
assert.NoError(t, err)
assert.Equal(t, []*_storage.ListItem{
{AddedAt: time.Unix(defaultCurrentTime.Unix(), 0), Address: "https://example.com"},
{AddedAt: time.Unix(defaultCurrentTime.Unix()+300, 0), Address: "https://test.com"},
{AddedAt: time.Unix(defaultCurrentTime.Unix(), 0), Address: "https://example1.com"},
{AddedAt: time.Unix(defaultCurrentTime.Unix(), 0), Address: "https://example2.com"},
{AddedAt: time.Unix(defaultCurrentTime.Unix(), 0), Address: "https://example3.com"},
}, items)

items, err = storage.GetFeedsFromList("test 2")
assert.NoError(t, err)
assert.Equal(t, []*_storage.ListItem{
{AddedAt: time.Unix(defaultCurrentTime.Unix(), 0), Address: "https://rss-feed.com/rss"},
{AddedAt: time.Unix(defaultCurrentTime.Unix(), 0), Address: "https://atom-feed.com/atom"},
{AddedAt: time.Unix(defaultCurrentTime.Unix(), 0), Address: "https://test.com"},
}, items)
}

func Test_List_ExportToOPML(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

timeMock := mocks.NewMockTime(ctrl)
timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes()

out := new(bytes.Buffer)
printer := internal.NewPrinter(nil, out, out)
storage := _storage.NewLocalStorage("cleed_test", timeMock)
defer localStorageCleanup(t, storage)

configDir, err := os.UserConfigDir()
if err != nil {
t.Fatal(err)
}

listsDir := path.Join(configDir, "cleed_test", "lists")
err = os.MkdirAll(listsDir, 0700)
if err != nil {
t.Fatal(err)
}

err = os.WriteFile(path.Join(listsDir, "test"),
[]byte(fmt.Sprintf("%d %s\n%d %s\n%d %s\n",
defaultCurrentTime.Unix(), "https://rss-feed.com/rss",
defaultCurrentTime.Unix()+300, "https://atom-feed.com/atom",
defaultCurrentTime.Unix()+500, "https://test.com",
),
), 0600)
if err != nil {
t.Fatal(err)
}

cacheDir, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
cacheDir = path.Join(cacheDir, "cleed_test")
err = os.MkdirAll(cacheDir, 0700)
if err != nil {
t.Fatal(err)
}
err = storage.SaveFeedCache(bytes.NewBufferString(createDefaultRSS()), "https://rss-feed.com/rss")
if err != nil {
t.Fatal(err)
}
err = storage.SaveFeedCache(bytes.NewBufferString(createDefaultAtom()), "https://atom-feed.com/atom")
if err != nil {
t.Fatal(err)
}

feed := internal.NewTerminalFeed(timeMock, printer, storage)
feed.SetAgent("cleed/test")

root, err := NewRoot("0.1.0", timeMock, printer, storage, feed)
assert.NoError(t, err)

exportPath := path.Join(configDir, "export.opml")
os.Args = []string{"cleed", "list", "test", "--export-to-opml", exportPath}

err = root.Cmd.Execute()
assert.NoError(t, err)
assert.Equal(t, fmt.Sprintf("exported 2 feeds to %s\n", exportPath), out.String())
assert.Equal(t, fmt.Sprintf("exported 3 feeds from 1 list to %s\n", exportPath), out.String())

b, err := os.ReadFile(exportPath)
if err != nil {
Expand All @@ -659,13 +766,116 @@ func Test_List_ExportToOPML(t *testing.T) {
assert.Equal(t, `<?xml version="1.0" encoding="UTF-8"?>
<opml version="1.0">
<head>
<title>test</title>
<title>Export from cleed/test</title>
<dateCreated>Mon, 01 Jan 2024 00:00:00 +0000</dateCreated>
</head>
<body>
<outline text="test">
<outline xmlUrl="https://example.com"/>
<outline xmlUrl="https://test.com"/>
</outline>
<outline text="test">
<outline text="RSS Feed" description="RSS Feed description" xmlUrl="https://rss-feed.com/rss" />
<outline text="Atom Feed" description="Atom Feed description" xmlUrl="https://atom-feed.com/atom" />
<outline xmlUrl="https://test.com" />
</outline>
</body>
</opml>`, string(b))
}

func Test_List_ExportToOPML_Multiple_Lists(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

timeMock := mocks.NewMockTime(ctrl)
timeMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes()

out := new(bytes.Buffer)
printer := internal.NewPrinter(nil, out, out)
storage := _storage.NewLocalStorage("cleed_test", timeMock)
defer localStorageCleanup(t, storage)

configDir, err := os.UserConfigDir()
if err != nil {
t.Fatal(err)
}

listsDir := path.Join(configDir, "cleed_test", "lists")
err = os.MkdirAll(listsDir, 0700)
if err != nil {
t.Fatal(err)
}

err = os.WriteFile(path.Join(listsDir, "test"),
[]byte(fmt.Sprintf("%d %s\n%d %s\n%d %s\n",
defaultCurrentTime.Unix(), "https://rss-feed.com/rss",
defaultCurrentTime.Unix()+300, "https://atom-feed.com/atom",
defaultCurrentTime.Unix()+500, "https://test.com",
),
), 0600)
if err != nil {
t.Fatal(err)
}

err = os.WriteFile(path.Join(listsDir, "test 2"),
[]byte(fmt.Sprintf("%d %s\n%d %s\n%d %s\n",
defaultCurrentTime.Unix(), "https://rss-feed.com/rss",
defaultCurrentTime.Unix()+300, "https://atom-feed.com/atom",
defaultCurrentTime.Unix()+500, "https://test.com",
),
), 0600)
if err != nil {
t.Fatal(err)
}

cacheDir, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
cacheDir = path.Join(cacheDir, "cleed_test")
err = os.MkdirAll(cacheDir, 0700)
if err != nil {
t.Fatal(err)
}
err = storage.SaveFeedCache(bytes.NewBufferString(createDefaultRSS()), "https://rss-feed.com/rss")
if err != nil {
t.Fatal(err)
}
err = storage.SaveFeedCache(bytes.NewBufferString(createDefaultAtom()), "https://atom-feed.com/atom")
if err != nil {
t.Fatal(err)
}

feed := internal.NewTerminalFeed(timeMock, printer, storage)
feed.SetAgent("cleed/test")

root, err := NewRoot("0.1.0", timeMock, printer, storage, feed)
assert.NoError(t, err)

exportPath := path.Join(configDir, "export.opml")
os.Args = []string{"cleed", "list", "--export-to-opml", exportPath}

err = root.Cmd.Execute()
assert.NoError(t, err)
assert.Equal(t, fmt.Sprintf("exported 6 feeds from 2 lists to %s\n", exportPath), out.String())

b, err := os.ReadFile(exportPath)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, `<?xml version="1.0" encoding="UTF-8"?>
<opml version="1.0">
<head>
<title>Export from cleed/test</title>
<dateCreated>Mon, 01 Jan 2024 00:00:00 +0000</dateCreated>
</head>
<body>
<outline text="test">
<outline text="RSS Feed" description="RSS Feed description" xmlUrl="https://rss-feed.com/rss" />
<outline text="Atom Feed" description="Atom Feed description" xmlUrl="https://atom-feed.com/atom" />
<outline xmlUrl="https://test.com" />
</outline>
<outline text="test 2">
<outline text="RSS Feed" description="RSS Feed description" xmlUrl="https://rss-feed.com/rss" />
<outline text="Atom Feed" description="Atom Feed description" xmlUrl="https://atom-feed.com/atom" />
<outline xmlUrl="https://test.com" />
</outline>
</body>
</opml>`, string(b))
}
4 changes: 2 additions & 2 deletions cmd/cleed/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func createDefaultRSS() string {
return `<rss version="2.0">
<channel>
<title>RSS Feed</title>
<description>Feed description</description>
<description>RSS Feed description</description>
<link>https://rss-feed.com/</link>
<item>
<title>Item 1</title>
Expand All @@ -67,7 +67,7 @@ func createDefaultAtom() string {
return `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Feed</title>
<subtitle>Feed description</subtitle>
<subtitle>Atom Feed description</subtitle>
<link href="https://atom-feed.com/"/>
<entry>
<title>Item 1</title>
Expand Down
Loading

0 comments on commit b2019a6

Please sign in to comment.