diff --git a/README.md b/README.md
index 7fa7070..0814f21 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/cmd/cleed/list.go b/cmd/cleed/list.go
index 7399656..1cbea09 100644
--- a/cmd/cleed/list.go
+++ b/cmd/cleed/list.go
@@ -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,
@@ -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)
}
@@ -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])
}
diff --git a/cmd/cleed/list_test.go b/cmd/cleed/list_test.go
index 69d5c6d..1f50145 100644
--- a/cmd/cleed/list_test.go
+++ b/cmd/cleed/list_test.go
@@ -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",
@@ -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)
}
@@ -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(`
+
+
+ test
+
+
+
+
+
+
+
+
+`), 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, `
+
+
+ test
+
+
+
+
+
+
+
+`, string(b))
+}
diff --git a/internal/feed.go b/internal/feed.go
index b291dd8..c51e282 100644
--- a/internal/feed.go
+++ b/internal/feed.go
@@ -4,6 +4,7 @@ import (
"bufio"
"compress/gzip"
"context"
+ "encoding/xml"
"fmt"
"io"
"net/http"
@@ -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
+
+ %s
+
+
+
+`, xml.Header, list, list)
+ for i := range feeds {
+ fmt.Fprintf(fo, `
+`, feeds[i].Address)
+ }
+ fmt.Fprint(fo, `
+
+`)
+ 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
diff --git a/internal/utils/opml.go b/internal/utils/opml.go
new file mode 100644
index 0000000..3eae90e
--- /dev/null
+++ b/internal/utils/opml.go
@@ -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"`
+}