feat: scaffold initial RSS reader project with Go backend and React frontend
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
package opml
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
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"`
|
||||
DateCreated string `xml:"dateCreated,omitempty"`
|
||||
DateModified string `xml:"dateModified,omitempty"`
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Outlines []Outline `xml:"outline"`
|
||||
}
|
||||
|
||||
type Outline struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type ImportedFeed struct {
|
||||
Title string
|
||||
FeedURL string
|
||||
SiteURL string
|
||||
CategoryTitle string
|
||||
}
|
||||
|
||||
func ParseOPML(data []byte) ([]ImportedFeed, error) {
|
||||
var doc OPML
|
||||
if err := xml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse OPML XML: %w", err)
|
||||
}
|
||||
|
||||
var feeds []ImportedFeed
|
||||
for _, topOutline := range doc.Body.Outlines {
|
||||
if topOutline.XMLURL != "" {
|
||||
// Top-level feed without category
|
||||
title := topOutline.Title
|
||||
if title == "" {
|
||||
title = topOutline.Text
|
||||
}
|
||||
feeds = append(feeds, ImportedFeed{
|
||||
Title: title,
|
||||
FeedURL: strings.TrimSpace(topOutline.XMLURL),
|
||||
SiteURL: strings.TrimSpace(topOutline.HTMLURL),
|
||||
CategoryTitle: "",
|
||||
})
|
||||
} else if len(topOutline.Outlines) > 0 {
|
||||
// Category folder containing feeds
|
||||
categoryTitle := topOutline.Title
|
||||
if categoryTitle == "" {
|
||||
categoryTitle = topOutline.Text
|
||||
}
|
||||
for _, child := range topOutline.Outlines {
|
||||
if child.XMLURL != "" {
|
||||
title := child.Title
|
||||
if title == "" {
|
||||
title = child.Text
|
||||
}
|
||||
feeds = append(feeds, ImportedFeed{
|
||||
Title: title,
|
||||
FeedURL: strings.TrimSpace(child.XMLURL),
|
||||
SiteURL: strings.TrimSpace(child.HTMLURL),
|
||||
CategoryTitle: strings.TrimSpace(categoryTitle),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
func GenerateOPML(feeds []models.Feed, categories []models.Category) ([]byte, error) {
|
||||
doc := OPML{
|
||||
Version: "2.0",
|
||||
Head: Head{
|
||||
Title: "RSSPipe Subscriptions",
|
||||
DateCreated: time.Now().UTC().Format(time.RFC1123),
|
||||
DateModified: time.Now().UTC().Format(time.RFC1123),
|
||||
},
|
||||
Body: Body{
|
||||
Outlines: make([]Outline, 0),
|
||||
},
|
||||
}
|
||||
|
||||
// Map category ID to slice of feeds
|
||||
catFeedMap := make(map[string][]models.Feed)
|
||||
var uncategorized []models.Feed
|
||||
|
||||
for _, f := range feeds {
|
||||
if f.CategoryID != nil && *f.CategoryID != "" {
|
||||
catFeedMap[*f.CategoryID] = append(catFeedMap[*f.CategoryID], f)
|
||||
} else {
|
||||
uncategorized = append(uncategorized, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Add categorized feeds
|
||||
for _, c := range categories {
|
||||
feedList, exists := catFeedMap[c.ID]
|
||||
if !exists || len(feedList) == 0 {
|
||||
continue
|
||||
}
|
||||
catOutline := Outline{
|
||||
Text: c.Title,
|
||||
Title: c.Title,
|
||||
Outlines: make([]Outline, 0, len(feedList)),
|
||||
}
|
||||
for _, f := range feedList {
|
||||
catOutline.Outlines = append(catOutline.Outlines, Outline{
|
||||
Text: f.Title,
|
||||
Title: f.Title,
|
||||
Type: "rss",
|
||||
XMLURL: f.FeedURL,
|
||||
HTMLURL: f.SiteURL,
|
||||
})
|
||||
}
|
||||
doc.Body.Outlines = append(doc.Body.Outlines, catOutline)
|
||||
}
|
||||
|
||||
// Add uncategorized feeds
|
||||
for _, f := range uncategorized {
|
||||
doc.Body.Outlines = append(doc.Body.Outlines, Outline{
|
||||
Text: f.Title,
|
||||
Title: f.Title,
|
||||
Type: "rss",
|
||||
XMLURL: f.FeedURL,
|
||||
HTMLURL: f.SiteURL,
|
||||
})
|
||||
}
|
||||
|
||||
xmlHeader := []byte(xml.Header)
|
||||
data, err := xml.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(xmlHeader, data...), nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package opml
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
func TestParseAndGenerateOPML(t *testing.T) {
|
||||
sampleOPML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<opml version="2.0">
|
||||
<head>
|
||||
<title>Miniflux Subscriptions</title>
|
||||
</head>
|
||||
<body>
|
||||
<outline text="Technology" title="Technology">
|
||||
<outline type="rss" text="Hacker News" title="Hacker News" xmlUrl="https://news.ycombinator.com/rss" htmlUrl="https://news.ycombinator.com/" />
|
||||
<outline type="rss" text="Go Blog" title="The Go Programming Language" xmlUrl="https://go.dev/blog/feed.atom" htmlUrl="https://go.dev/blog" />
|
||||
</outline>
|
||||
<outline type="rss" text="Ars Technica" title="Ars Technica" xmlUrl="https://feeds.arstechnica.com/arstechnica/index" htmlUrl="https://arstechnica.com" />
|
||||
</body>
|
||||
</opml>`
|
||||
|
||||
feeds, err := ParseOPML([]byte(sampleOPML))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOPML failed: %v", err)
|
||||
}
|
||||
|
||||
if len(feeds) != 3 {
|
||||
t.Fatalf("expected 3 feeds, got %d", len(feeds))
|
||||
}
|
||||
|
||||
// Verify first feed with category
|
||||
if feeds[0].Title != "Hacker News" || feeds[0].FeedURL != "https://news.ycombinator.com/rss" || feeds[0].CategoryTitle != "Technology" {
|
||||
t.Errorf("unexpected feed 0: %+v", feeds[0])
|
||||
}
|
||||
|
||||
// Verify uncategorized feed
|
||||
if feeds[2].Title != "Ars Technica" || feeds[2].FeedURL != "https://feeds.arstechnica.com/arstechnica/index" || feeds[2].CategoryTitle != "" {
|
||||
t.Errorf("unexpected feed 2: %+v", feeds[2])
|
||||
}
|
||||
|
||||
// Test Export Generation
|
||||
catID := "cat-1"
|
||||
categories := []models.Category{
|
||||
{ID: catID, Title: "Technology"},
|
||||
}
|
||||
modelFeeds := []models.Feed{
|
||||
{ID: "f-1", Title: "Hacker News", FeedURL: "https://news.ycombinator.com/rss", SiteURL: "https://news.ycombinator.com/", CategoryID: &catID},
|
||||
{ID: "f-2", Title: "Ars Technica", FeedURL: "https://feeds.arstechnica.com/arstechnica/index", SiteURL: "https://arstechnica.com"},
|
||||
}
|
||||
|
||||
exportedXML, err := GenerateOPML(modelFeeds, categories)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateOPML failed: %v", err)
|
||||
}
|
||||
|
||||
reParsed, err := ParseOPML(exportedXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to re-parse exported OPML: %v", err)
|
||||
}
|
||||
|
||||
if len(reParsed) != 2 {
|
||||
t.Fatalf("expected 2 re-parsed feeds, got %d", len(reParsed))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user