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"` +}