aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-14 09:42:14 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-14 09:42:14 -0800
commit6fa13a06411048f3217397f4285b3e64e7b9ee58 (patch)
treeaf5ec83884bb45a10d51cf5be3ae895ebf1bcff8 /internal
parent23947045c011e84149bc1b9d48805e57bb0bb3ba (diff)
downloadneko-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.go91
-rw-r--r--internal/importer/import_format_test.go97
-rw-r--r--internal/importer/importer.go113
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
}