diff --git a/cmd/cleed/list.go b/cmd/cleed/list.go index 1cbea09..d66f31c 100644 --- a/cmd/cleed/list.go +++ b/cmd/cleed/list.go @@ -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 @@ -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, @@ -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) } diff --git a/cmd/cleed/list_test.go b/cmd/cleed/list_test.go index 652b60a..c1ce157 100644 --- a/cmd/cleed/list_test.go +++ b/cmd/cleed/list_test.go @@ -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(` @@ -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() @@ -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(` + + + 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) - 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 { @@ -659,13 +766,116 @@ func Test_List_ExportToOPML(t *testing.T) { assert.Equal(t, ` - test + Export from cleed/test + Mon, 01 Jan 2024 00:00:00 +0000 - - - - + + + + + + +`, 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, ` + + + Export from cleed/test + Mon, 01 Jan 2024 00:00:00 +0000 + + + + + + + + + + + + `, string(b)) } diff --git a/cmd/cleed/utils_test.go b/cmd/cleed/utils_test.go index 6097588..27b91c0 100644 --- a/cmd/cleed/utils_test.go +++ b/cmd/cleed/utils_test.go @@ -47,7 +47,7 @@ func createDefaultRSS() string { return ` RSS Feed - Feed description + RSS Feed description https://rss-feed.com/ Item 1 @@ -67,7 +67,7 @@ func createDefaultAtom() string { return ` Atom Feed - Feed description + Atom Feed description Item 1 diff --git a/internal/feed.go b/internal/feed.go index 851b680..e93047f 100644 --- a/internal/feed.go +++ b/internal/feed.go @@ -307,51 +307,97 @@ func (f *TerminalFeed) ImportFromOPML(path, list string) error { 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()) + for _, listOutline := range opml.Body.Oultines { + urls := make([]string, 0, len(listOutline.Outlines)) + for _, feedOutline := range listOutline.Outlines { + urls = append(urls, feedOutline.XMLURL) + } + if len(urls) == 0 { + continue + } + listName := list + if list == "" { + if listOutline.Text != "" { + listName = listOutline.Text + } else { + listName = "default" + } + } + err = f.storage.AddToList(urls, listName) + 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"), listName) } - 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, xml.Header) + fmt.Fprint(fo, "\n \n") + fmt.Fprint(fo, " Export from ") + xml.EscapeText(fo, []byte(f.agent)) + fmt.Fprint(fo, "\n") + fmt.Fprintf(fo, " %s\n", f.time.Now().Format(time.RFC1123Z)) + fmt.Fprint(fo, " \n \n") + + lists := make([]string, 0) + if list == "" { + lists, err = f.storage.LoadLists() + if err != nil { + return utils.NewInternalError("failed to load lists: " + err.Error()) + } + if len(lists) == 0 { + lists = append(lists, "default") + } + } else { + lists = append(lists, list) } - fmt.Fprint(fo, ` - -`) - f.printer.Printf("exported %s to %s\n", utils.Pluralize(int64(len(feeds)), "feed"), path) + + totalFeedCount := int64(0) + for i := range lists { + feeds, err := f.storage.GetFeedsFromList(lists[i]) + if err != nil { + return utils.NewInternalError("failed to list feeds: " + err.Error()) + } + if len(feeds) == 0 { + continue + } + fmt.Fprint(fo, " \n") + for _, item := range feeds { + fmt.Fprint(fo, " \n") + totalFeedCount++ + } + fmt.Fprint(fo, " \n") + } + fmt.Fprint(fo, " \n") + f.printer.Printf("exported %s from %s to %s\n", utils.Pluralize(totalFeedCount, "feed"), utils.Pluralize(int64(len(lists)), "list"), path) return nil } diff --git a/internal/utils/opml.go b/internal/utils/opml.go index 3eae90e..c606215 100644 --- a/internal/utils/opml.go +++ b/internal/utils/opml.go @@ -7,19 +7,22 @@ import ( type OPML struct { XMLName xml.Name `xml:"opml"` Version string `xml:"version,attr"` - Head Head `xml:"head"` - Body Body `xml:"body"` + Head OPMLHead `xml:"head"` + Body OPMLBody `xml:"body"` } -type Head struct { +type OPMLHead struct { Title string `xml:"title,omitempty"` } -type Body struct { - Oultines []*Outline `xml:"outline"` +type OPMLBody struct { + Text string `xml:"text,attr,omitempty"` + Oultines []*OPMLOutline `xml:"outline"` } -type Outline struct { - Outlines []*Outline `xml:"outline"` - XMLURL string `xml:"xmlUrl,attr,omitempty"` +type OPMLOutline struct { + Text string `xml:"text,attr,omitempty"` + Description string `xml:"description,attr,omitempty"` + XMLURL string `xml:"xmlUrl,attr,omitempty"` + Outlines []*OPMLOutline `xml:"outline"` }