Skip to content

Commit

Permalink
feat: add OPML import/export functionality
Browse files Browse the repository at this point in the history
- Added `--import-from-opml` flag to import feeds from an OPML file
- Added `--export-to-opml` flag to export feeds to an OPML file
  • Loading branch information
radulucut committed Aug 28, 2024
1 parent c8b1810 commit 07b6d44
Show file tree
Hide file tree
Showing 5 changed files with 251 additions and 9 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,14 @@ cleed list mylist --remove
# Import feeds from a file
cleed list mylist --import-from-file feeds.txt

# Import feeds from an OPML file
cleed list mylist --import-from-opml feeds.opml

# Export feeds to a file
cleed list mylist --export-to-file feeds.txt

# Export feeds to an OPML file
cleed list mylist --export-to-opml feeds.opml
```

#### Configuration
Expand Down
16 changes: 16 additions & 0 deletions cmd/cleed/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,14 @@ Examples:
# Import feeds from a file
cleed list mylist --import-from-file feeds.txt
# Import feeds from an OPML file
cleed list mylist --import-from-opml feeds.opml
# Export feeds to a file
cleed list mylist --export-to-file feeds.txt
# Export feeds to an OPML file
cleed list mylist --export-to-opml feeds.opml
`,

RunE: r.RunList,
Expand All @@ -42,7 +48,9 @@ Examples:
flags.String("merge", "", "merge a list")
flags.Bool("remove", false, "remove a list")
flags.String("import-from-file", "", "import feeds from a file. Newline separated URLs")
flags.String("import-from-opml", "", "import feeds from an OPML file")
flags.String("export-to-file", "", "export feeds to a file. Newline separated URLs")
flags.String("export-to-opml", "", "export feeds to an OPML file")

r.Cmd.AddCommand(cmd)
}
Expand Down Expand Up @@ -70,5 +78,13 @@ func (r *Root) RunList(cmd *cobra.Command, args []string) error {
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.ListFeeds(args[0])
}
154 changes: 145 additions & 9 deletions cmd/cleed/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ func Test_List_ImportFromFile(t *testing.T) {
t.Fatal(err)
}

err = os.WriteFile(path.Join(listsDir, "import"),
err = os.WriteFile(path.Join(listsDir, "test"),
[]byte(fmt.Sprintf("%d %s\n%d %s\n",
defaultCurrentTime.Unix(), "https://example.com",
defaultCurrentTime.Unix()+300, "https://test.com",
Expand All @@ -444,15 +444,12 @@ func Test_List_ImportFromFile(t *testing.T) {
t.Fatal(err)
}

importFilePath := path.Join(listsDir, "test2")
importFilePath := path.Join(listsDir, "import")
err = os.WriteFile(importFilePath,
[]byte(fmt.Sprintf("%s\n%s\n%s\n%s\n",
"https://example0.com",
" https://test.com",
"# comment",
"https://example2.com",
),
), 0600)
[]byte(`https://example0.com
https://test.com
# comment
https://example2.com`), 0600)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -533,3 +530,142 @@ func Test_List_ExportToFile(t *testing.T) {
https://test.com
`, string(b))
}

func Test_List_ImportFromOPML(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",
defaultCurrentTime.Unix(), "https://example.com",
defaultCurrentTime.Unix()+300, "https://test.com",
),
), 0600)
if err != nil {
t.Fatal(err)
}

importFilePath := path.Join(listsDir, "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>
</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)

os.Args = []string{"cleed", "list", "test", "--import-from-opml", importFilePath}

err = root.Cmd.Execute()
assert.NoError(t, err)
assert.Equal(t, "added 3 feeds to list: test\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)
}

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",
defaultCurrentTime.Unix(), "https://example.com",
defaultCurrentTime.Unix()+300, "https://test.com",
),
), 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", "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())

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>test</title>
</head>
<body>
<outline text="test">
<outline xmlUrl="https://example.com"/>
<outline xmlUrl="https://test.com"/>
</outline>
</body>
</opml>`, string(b))
}
59 changes: 59 additions & 0 deletions internal/feed.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"compress/gzip"
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -296,6 +297,64 @@ func (f *TerminalFeed) ExportToFile(path, list string) error {
return nil
}

func (f *TerminalFeed) ImportFromOPML(path, list string) error {
b, err := os.ReadFile(path)
if err != nil {
return utils.NewInternalError("failed to read file: " + err.Error())
}
opml := &utils.OPML{}
err = xml.Unmarshal(b, opml)
if err != nil {
return utils.NewInternalError("failed to parse OPML: " + err.Error())
}
urls := make([]string, 0, len(opml.Body.Oultines))
if len(opml.Body.Oultines) == 0 {
return utils.NewInternalError("no feeds found in OPML")
}
outlines := opml.Body.Oultines[0].Outlines
for _, o := range outlines {
urls = append(urls, o.XMLURL)
}
err = f.storage.AddToList(urls, list)
if err != nil {
return utils.NewInternalError("failed to save feeds: " + err.Error())
}
f.printer.Printf("added %s to list: %s\n", utils.Pluralize(int64(len(urls)), "feed"), list)
return nil
}

func (f *TerminalFeed) ExportToOPML(path, list string) error {
feeds, err := f.storage.GetFeedsFromList(list)
if err != nil {
return utils.NewInternalError("failed to list feeds: " + err.Error())
}
if len(feeds) == 0 {
f.printer.Println("no feeds to export")
return nil
}
fo, err := os.Create(path)
if err != nil {
return utils.NewInternalError("failed to create file: " + err.Error())
}
defer fo.Close()
fmt.Fprintf(fo, `%s<opml version="1.0">
<head>
<title>%s</title>
</head>
<body>
<outline text="%s">
`, xml.Header, list, list)
for i := range feeds {
fmt.Fprintf(fo, ` <outline xmlUrl="%s"/>
`, feeds[i].Address)
}
fmt.Fprint(fo, ` </outline>
</body>
</opml>`)
f.printer.Printf("exported %s to %s\n", utils.Pluralize(int64(len(feeds)), "feed"), path)
return nil
}

type FeedItem struct {
Feed *gofeed.Feed
Item *gofeed.Item
Expand Down
25 changes: 25 additions & 0 deletions internal/utils/opml.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package utils

import (
"encoding/xml"
)

type OPML struct {
XMLName xml.Name `xml:"opml"`
Version string `xml:"version,attr"`
Head Head `xml:"head"`
Body Body `xml:"body"`
}

type Head struct {
Title string `xml:"title,omitempty"`
}

type Body struct {
Oultines []*Outline `xml:"outline"`
}

type Outline struct {
Outlines []*Outline `xml:"outline"`
XMLURL string `xml:"xmlUrl,attr,omitempty"`
}

0 comments on commit 07b6d44

Please sign in to comment.