diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-14 09:42:14 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-14 09:42:14 -0800 |
| commit | 6fa13a06411048f3217397f4285b3e64e7b9ee58 (patch) | |
| tree | af5ec83884bb45a10d51cf5be3ae895ebf1bcff8 /internal | |
| parent | 23947045c011e84149bc1b9d48805e57bb0bb3ba (diff) | |
| download | neko-6fa13a06411048f3217397f4285b3e64e7b9ee58.tar.gz neko-6fa13a06411048f3217397f4285b3e64e7b9ee58.tar.bz2 neko-6fa13a06411048f3217397f4285b3e64e7b9ee58.zip | |
feature: implement full OPML and Text import/export (fixing NK-r6nhj0)
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/exporter/exporter.go | 91 | ||||
| -rw-r--r-- | internal/importer/import_format_test.go | 97 | ||||
| -rw-r--r-- | internal/importer/importer.go | 113 |
3 files changed, 279 insertions, 22 deletions
diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index 9172fec..2d32fdb 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -1,39 +1,99 @@ package exporter import ( - "adammathes.com/neko/models/feed" "bytes" "encoding/json" "encoding/xml" "fmt" "html/template" + + "adammathes.com/neko/models/feed" ) +type OPML struct { + XMLName xml.Name `xml:"opml"` + Version string `xml:"version,attr"` + Head struct { + Title string `xml:"title"` + } `xml:"head"` + Body struct { + Outlines []Outline `xml:"outline"` + } `xml:"body"` +} + +type Outline struct { + XMLName xml.Name `xml:"outline"` + Text string `xml:"text,attr"` + Title string `xml:"title,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` + XMLURL string `xml:"xmlUrl,attr,omitempty"` + HTMLURL string `xml:"htmlUrl,attr,omitempty"` + Outlines []Outline `xml:"outline,omitempty"` +} + func ExportFeeds(format string) string { feeds, err := feed.All() if err != nil { - panic(err) + return "" } - s := "" switch format { case "text": + var b bytes.Buffer for _, f := range feeds { - s = s + fmt.Sprintf("%s\n", f.Url) + fmt.Fprintf(&b, "%s\n", f.Url) } + return b.String() case "opml": - s = s + fmt.Sprintf(`<opml version="2.0"><head><title>neko feeds</title></head><body>`) - s = s + fmt.Sprintf("\n") + var o OPML + o.Version = "2.0" + o.Head.Title = "neko feeds" + + // Group by category + cats := make(map[string][]*feed.Feed) + var noCat []*feed.Feed for _, f := range feeds { - b, _ := xml.Marshal(f) - s = s + fmt.Sprintf("%s\n", string(b)) + if f.Category != "" { + cats[f.Category] = append(cats[f.Category], f) + } else { + noCat = append(noCat, f) + } } - s = s + fmt.Sprintf(`</body></opml>`) + + for cat, fds := range cats { + out := Outline{Text: cat} + for _, f := range fds { + out.Outlines = append(out.Outlines, Outline{ + Text: f.Title, + Title: f.Title, + Type: "rss", + XMLURL: f.Url, + HTMLURL: f.WebUrl, + }) + } + o.Body.Outlines = append(o.Body.Outlines, out) + } + + for _, f := range noCat { + o.Body.Outlines = append(o.Body.Outlines, Outline{ + Text: f.Title, + Title: f.Title, + Type: "rss", + XMLURL: f.Url, + HTMLURL: f.WebUrl, + }) + } + + b, err := xml.MarshalIndent(o, "", " ") + if err != nil { + return "" + } + return xml.Header + string(b) case "json": - js, _ := json.Marshal(feeds) - s = fmt.Sprintf("%s\n", js) + js, _ := json.MarshalIndent(feeds, "", " ") + return string(js) case "html": htmlTemplateString := `<html> @@ -50,12 +110,15 @@ func ExportFeeds(format string) string { </html>` var bts bytes.Buffer htmlTemplate, err := template.New("feeds").Parse(htmlTemplateString) + if err != nil { + return "" + } err = htmlTemplate.Execute(&bts, feeds) if err != nil { - panic(err) + return "" } - s = bts.String() + return bts.String() } - return s + return "" } diff --git a/internal/importer/import_format_test.go b/internal/importer/import_format_test.go new file mode 100644 index 0000000..9176383 --- /dev/null +++ b/internal/importer/import_format_test.go @@ -0,0 +1,97 @@ +package importer + +import ( + "strings" + "testing" + + "path/filepath" + + "adammathes.com/neko/config" + "adammathes.com/neko/models" + "adammathes.com/neko/models/feed" +) + +func TestImportOPML(t *testing.T) { + config.Config.DBFile = filepath.Join(t.TempDir(), "test.db") + models.InitDB() + defer models.DB.Close() + + opmlContent := `<?xml version="1.0" encoding="UTF-8"?> +<opml version="2.0"> + <head> + <title>testing import</title> + </head> + <body> + <outline text="Tech"> + <outline type="rss" text="Ars Technica" title="Ars Technica" xmlUrl="https://arstechnica.com/feed/" htmlUrl="https://arstechnica.com"/> + <outline type="rss" text="The Verge" title="The Verge" xmlUrl="https://www.theverge.com/rss/index.xml" htmlUrl="https://www.theverge.com"/> + </outline> + <outline type="rss" text="XKCD" title="XKCD" xmlUrl="https://xkcd.com/rss.xml" htmlUrl="https://xkcd.com"/> + </body> +</opml>` + + err := ImportOPML(strings.NewReader(opmlContent)) + if err != nil { + t.Fatalf("ImportOPML failed: %v", err) + } + + feeds, err := feed.All() + if err != nil { + t.Fatal(err) + } + + if len(feeds) != 3 { + t.Errorf("Expected 3 feeds, got %d", len(feeds)) + } + + foundArs := false + foundXKCD := false + for _, f := range feeds { + if f.Url == "https://arstechnica.com/feed/" { + foundArs = true + if f.Category != "Tech" { + t.Errorf("Expected category 'Tech' for Ars, got %q", f.Category) + } + } + if f.Url == "https://xkcd.com/rss.xml" { + foundXKCD = true + if f.Category != "" { + t.Errorf("Expected empty category for XKCD, got %q", f.Category) + } + } + } + + if !foundArs { + t.Error("Did not find Ars Technica in imported feeds") + } + if !foundXKCD { + t.Error("Did not find XKCD in imported feeds") + } +} + +func TestImportText(t *testing.T) { + config.Config.DBFile = filepath.Join(t.TempDir(), "test.db") + models.InitDB() + defer models.DB.Close() + + textContent := ` +https://example.com/feed1 +# comment +https://example.com/feed2 + https://example.com/feed3 +` + + err := ImportText(strings.NewReader(textContent)) + if err != nil { + t.Fatalf("ImportText failed: %v", err) + } + + feeds, err := feed.All() + if err != nil { + t.Fatal(err) + } + + if len(feeds) != 3 { + t.Errorf("Expected 3 feeds, got %d", len(feeds)) + } +} diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 73a2cd8..f74ace1 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -1,12 +1,14 @@ package importer import ( - // "bufio" + "bufio" "encoding/json" - //"fmt" + "encoding/xml" + "errors" "io" "log" "os" + "strings" "adammathes.com/neko/models/feed" "adammathes.com/neko/models/item" @@ -32,15 +34,101 @@ type IDate struct { Date string `json:"$date"` } -func ImportJSON(filename string) error { +type OPML struct { + XMLName xml.Name `xml:"opml"` + Version string `xml:"version,attr"` + Head struct { + Title string `xml:"title"` + } `xml:"head"` + Body struct { + Outlines []Outline `xml:"outline"` + } `xml:"body"` +} - f, err := os.Open(filename) - if err != nil { +type Outline struct { + Text string `xml:"text,attr"` + Title string `xml:"title,attr"` + Type string `xml:"type,attr"` + XMLURL string `xml:"xmlUrl,attr"` + HTMLURL string `xml:"htmlUrl,attr"` + Category string `xml:"category,attr"` + Outlines []Outline `xml:"outline"` +} + +func ImportFeeds(format string, r io.Reader) error { + switch format { + case "opml": + return ImportOPML(r) + case "text": + return ImportText(r) + case "json": + return ImportJSONReader(r) + default: + return errors.New("unsupported import format") + } +} + +func ImportOPML(r io.Reader) error { + var o OPML + if err := xml.NewDecoder(r).Decode(&o); err != nil { return err } - defer f.Close() - dec := json.NewDecoder(f) + var walk func([]Outline, string) + walk = func(outlines []Outline, cat string) { + for _, out := range outlines { + if out.Type == "rss" || out.XMLURL != "" { + f := &feed.Feed{ + Url: out.XMLURL, + Title: out.Title, + WebUrl: out.HTMLURL, + Category: cat, + } + if f.Title == "" { + f.Title = out.Text + } + if f.Category == "" { + f.Category = out.Category + } + err := f.Create() + if err != nil { + log.Printf("error importing %s: %v", f.Url, err) + } else { + log.Printf("imported %s", f.Url) + } + } + if len(out.Outlines) > 0 { + newCat := cat + if out.XMLURL == "" && out.Text != "" { + newCat = out.Text + } + walk(out.Outlines, newCat) + } + } + } + walk(o.Body.Outlines, "") + return nil +} + +func ImportText(r io.Reader) error { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + err := feed.NewFeed(line) + if err != nil { + log.Printf("error importing %s: %v", line, err) + } else { + log.Printf("imported %s", line) + } + } + return scanner.Err() +} + +func ImportJSONReader(r io.Reader) error { + dec := json.NewDecoder(r) for { var ii IItem if err := dec.Decode(&ii); err == io.EOF { @@ -57,6 +145,15 @@ func ImportJSON(filename string) error { return nil } +func ImportJSON(filename string) error { + f, err := os.Open(filename) + if err != nil { + return err + } + defer f.Close() + return ImportJSONReader(f) +} + func InsertIItem(ii *IItem) error { var f feed.Feed @@ -67,6 +164,7 @@ func InsertIItem(ii *IItem) error { if err != nil { f.Url = ii.Feed.Url f.Title = ii.Feed.Title + f.WebUrl = ii.Feed.WebUrl err = f.Create() if err != nil { return err @@ -84,6 +182,5 @@ func InsertIItem(ii *IItem) error { } err = i.Create() - log.Printf("inserted %s\n", i.Url) return err } |
