Browse Source

Add support for OPML files with several nested outlines

Frédéric Guillot 3 years ago
parent
commit
f0a698c6fe
3 changed files with 94 additions and 54 deletions
  1. 17 35
      reader/opml/opml.go
  2. 17 1
      reader/opml/parser.go
  3. 60 18
      reader/opml/parser_test.go

+ 17 - 35
reader/opml/opml.go

@@ -6,36 +6,21 @@ package opml // import "miniflux.app/reader/opml"
 
 import (
 	"encoding/xml"
+	"strings"
 )
 
 // Specs: http://opml.org/spec2.opml
 type opmlDocument struct {
-	XMLName  xml.Name      `xml:"opml"`
-	Version  string        `xml:"version,attr"`
-	Header   opmlHeader    `xml:"head"`
-	Outlines []opmlOutline `xml:"body>outline"`
+	XMLName  xml.Name              `xml:"opml"`
+	Version  string                `xml:"version,attr"`
+	Header   opmlHeader            `xml:"head"`
+	Outlines opmlOutlineCollection `xml:"body>outline"`
 }
 
 func NewOPMLDocument() *opmlDocument {
 	return &opmlDocument{}
 }
 
-func (o *opmlDocument) GetSubscriptionList() SubcriptionList {
-	var subscriptions SubcriptionList
-	for _, outline := range o.Outlines {
-		if len(outline.Outlines) > 0 {
-			for _, element := range outline.Outlines {
-				// outline.Text is only available in OPML v2.
-				subscriptions = element.Append(subscriptions, outline.Text)
-			}
-		} else {
-			subscriptions = outline.Append(subscriptions, "")
-		}
-	}
-
-	return subscriptions
-}
-
 type opmlHeader struct {
 	Title       string `xml:"title,omitempty"`
 	DateCreated string `xml:"dateCreated,omitempty"`
@@ -43,11 +28,15 @@ type opmlHeader struct {
 }
 
 type opmlOutline struct {
-	Title    string        `xml:"title,attr,omitempty"`
-	Text     string        `xml:"text,attr"`
-	FeedURL  string        `xml:"xmlUrl,attr,omitempty"`
-	SiteURL  string        `xml:"htmlUrl,attr,omitempty"`
-	Outlines []opmlOutline `xml:"outline,omitempty"`
+	Title    string                `xml:"title,attr,omitempty"`
+	Text     string                `xml:"text,attr"`
+	FeedURL  string                `xml:"xmlUrl,attr,omitempty"`
+	SiteURL  string                `xml:"htmlUrl,attr,omitempty"`
+	Outlines opmlOutlineCollection `xml:"outline,omitempty"`
+}
+
+func (o *opmlOutline) IsSubscription() bool {
+	return strings.TrimSpace(o.FeedURL) != ""
 }
 
 func (o *opmlOutline) GetTitle() string {
@@ -78,15 +67,8 @@ func (o *opmlOutline) GetSiteURL() string {
 	return o.FeedURL
 }
 
-func (o *opmlOutline) Append(subscriptions SubcriptionList, category string) SubcriptionList {
-	if o.FeedURL != "" {
-		subscriptions = append(subscriptions, &Subcription{
-			Title:        o.GetTitle(),
-			FeedURL:      o.FeedURL,
-			SiteURL:      o.GetSiteURL(),
-			CategoryName: category,
-		})
-	}
+type opmlOutlineCollection []opmlOutline
 
-	return subscriptions
+func (o opmlOutlineCollection) HasChildren() bool {
+	return len(o) > 0
 }

+ 17 - 1
reader/opml/parser.go

@@ -25,5 +25,21 @@ func Parse(data io.Reader) (SubcriptionList, *errors.LocalizedError) {
 		return nil, errors.NewLocalizedError("Unable to parse OPML file: %q", err)
 	}
 
-	return opmlDocument.GetSubscriptionList(), nil
+	return getSubscriptionsFromOutlines(opmlDocument.Outlines, ""), nil
+}
+
+func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category string) (subscriptions SubcriptionList) {
+	for _, outline := range outlines {
+		if outline.IsSubscription() {
+			subscriptions = append(subscriptions, &Subcription{
+				Title:        outline.GetTitle(),
+				FeedURL:      outline.FeedURL,
+				SiteURL:      outline.GetSiteURL(),
+				CategoryName: category,
+			})
+		} else if outline.Outlines.HasChildren() {
+			subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.Text)...)
+		}
+	}
+	return subscriptions
 }

+ 60 - 18
reader/opml/parser_test.go

@@ -38,15 +38,15 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
 
 	subscriptions, err := Parse(bytes.NewBufferString(data))
 	if err != nil {
-		t.Error(err)
+		t.Fatal(err)
 	}
 
 	if len(subscriptions) != 13 {
-		t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 13)
+		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 13)
 	}
 
 	if !subscriptions[0].Equals(expected[0]) {
-		t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[0], expected[0])
+		t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[0], expected[0])
 	}
 }
 
@@ -75,16 +75,16 @@ func TestParseOpmlWithCategories(t *testing.T) {
 
 	subscriptions, err := Parse(bytes.NewBufferString(data))
 	if err != nil {
-		t.Error(err)
+		t.Fatal(err)
 	}
 
 	if len(subscriptions) != 3 {
-		t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
+		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
 	}
 
 	for i := 0; i < len(subscriptions); i++ {
 		if !subscriptions[i].Equals(expected[i]) {
-			t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
+			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
 }
@@ -108,16 +108,16 @@ func TestParseOpmlWithEmptyTitleAndEmptySiteURL(t *testing.T) {
 
 	subscriptions, err := Parse(bytes.NewBufferString(data))
 	if err != nil {
-		t.Error(err)
+		t.Fatal(err)
 	}
 
 	if len(subscriptions) != 2 {
-		t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
+		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
 	}
 
 	for i := 0; i < len(subscriptions); i++ {
 		if !subscriptions[i].Equals(expected[i]) {
-			t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
+			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
 }
@@ -146,16 +146,16 @@ func TestParseOpmlVersion1(t *testing.T) {
 
 	subscriptions, err := Parse(bytes.NewBufferString(data))
 	if err != nil {
-		t.Error(err)
+		t.Fatal(err)
 	}
 
 	if len(subscriptions) != 2 {
-		t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
+		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
 	}
 
 	for i := 0; i < len(subscriptions); i++ {
 		if !subscriptions[i].Equals(expected[i]) {
-			t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
+			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
 }
@@ -180,16 +180,58 @@ func TestParseOpmlVersion1WithoutOuterOutline(t *testing.T) {
 
 	subscriptions, err := Parse(bytes.NewBufferString(data))
 	if err != nil {
-		t.Error(err)
+		t.Fatal(err)
 	}
 
 	if len(subscriptions) != 2 {
-		t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
+		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
 	}
 
 	for i := 0; i < len(subscriptions); i++ {
 		if !subscriptions[i].Equals(expected[i]) {
-			t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
+			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
+		}
+	}
+}
+
+func TestParseOpmlVersion1WithSeveralNestedOutlines(t *testing.T) {
+	data := `<?xml version="1.0"?>
+	<opml xmlns:rssowl="http://www.rssowl.org" version="1.1">
+		<head>
+			<title>RSSOwl Subscriptions</title>
+			<dateCreated>星期二, 26 四月 2022 00:12:04 CST</dateCreated>
+		</head>
+		<body>
+			<outline text="My Feeds" rssowl:isSet="true" rssowl:id="7">
+				<outline text="Some Category" rssowl:isSet="false" rssowl:id="55">
+					<outline type="rss" title="Feed 1" xmlUrl="http://example.org/feed1/" htmlUrl="http://example.org/1"></outline>
+					<outline type="rss" title="Feed 2" xmlUrl="http://example.org/feed2/" htmlUrl="http://example.org/2"></outline>
+				</outline>
+				<outline text="Another Category" rssowl:isSet="false" rssowl:id="87">
+					<outline type="rss" title="Feed 3" xmlUrl="http://example.org/feed3/" htmlUrl="http://example.org/3"></outline>
+				</outline>
+			</outline>
+		</body>
+	</opml>
+	`
+
+	var expected SubcriptionList
+	expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: "Some Category"})
+	expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: "Some Category"})
+	expected = append(expected, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed3/", SiteURL: "http://example.org/3", CategoryName: "Another Category"})
+
+	subscriptions, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(subscriptions) != 3 {
+		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
+	}
+
+	for i := 0; i < len(subscriptions); i++ {
+		if !subscriptions[i].Equals(expected[i]) {
+			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
 }
@@ -213,16 +255,16 @@ func TestParseOpmlWithInvalidCharacterEntity(t *testing.T) {
 
 	subscriptions, err := Parse(bytes.NewBufferString(data))
 	if err != nil {
-		t.Error(err)
+		t.Fatal(err)
 	}
 
 	if len(subscriptions) != 1 {
-		t.Errorf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 1)
+		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 1)
 	}
 
 	for i := 0; i < len(subscriptions); i++ {
 		if !subscriptions[i].Equals(expected[i]) {
-			t.Errorf(`Subscription are different: "%v" vs "%v"`, subscriptions[i], expected[i])
+			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
 }