feat(story-2.3): add OPML import/export functionality

This commit is contained in:
Michael committed 2026-02-04 14:58:30 +01:00
1 parent 12f0c6879f
commit 20589ee55b
5 files changed
+286 -2

No files matched your search

@@ -46,8 +46,8 @@ development_status:
# Epic 2: Feed Core Engine
epic-2: in-progress
2-1-feed-model-add-feed-by-url: done
2-2-feed-metadata-auto-discovery: review
2-3-opml-import: backlog
2-2-feed-metadata-auto-discovery: done
2-3-opml-import: review
2-4-feed-management-crud: backlog
2-5-background-feed-fetcher: backlog
epic-2-retrospective: optional
+2
View File
@@ -92,6 +92,8 @@ func main() {
r.Route("/feeds", func(r chi.Router) {
r.Get("/", feedHandler.List)
r.Post("/", feedHandler.Add)
r.Post("/import/opml", feedHandler.ImportOPML)
r.Get("/export/opml", feedHandler.ExportOPML)
r.Get("/{id}", feedHandler.Get)
r.Delete("/{id}", feedHandler.Delete)
})
+85
View File
@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/michael/flowreader/internal/opml"
"github.com/michael/flowreader/internal/service"
)
@@ -146,3 +147,87 @@ func (h *FeedHandler) Delete(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"message": "Feed deleted"})
}
// ImportOPML handles POST /api/v1/feeds/import/opml
func (h *FeedHandler) ImportOPML(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, err.Error())
return
}
// Parse multipart form (max 10MB)
if err := r.ParseMultipartForm(10 << 20); err != nil {
respondError(w, http.StatusBadRequest, "Invalid form data")
return
}
file, _, err := r.FormFile("file")
if err != nil {
respondError(w, http.StatusBadRequest, "No file provided")
return
}
defer file.Close()
// Parse OPML
feeds, err := opml.Parse(file)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid OPML file: "+err.Error())
return
}
// Convert to service format
var opmlFeeds []service.OPMLFeedInfo
for _, f := range feeds {
opmlFeeds = append(opmlFeeds, service.OPMLFeedInfo{
URL: f.URL,
Title: f.Title,
SiteURL: f.SiteURL,
})
}
// Import feeds
result, err := h.feedService.ImportOPML(userID, opmlFeeds)
if err != nil {
respondError(w, http.StatusInternalServerError, "Import failed")
return
}
respondJSON(w, http.StatusOK, result)
}
// ExportOPML handles GET /api/v1/feeds/export/opml
func (h *FeedHandler) ExportOPML(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, err.Error())
return
}
feeds, err := h.feedService.GetUserFeeds(userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get feeds")
return
}
// Convert to OPML format
var opmlFeeds []opml.FeedInfo
for _, f := range feeds {
opmlFeeds = append(opmlFeeds, opml.FeedInfo{
URL: f.URL,
Title: f.Title,
SiteURL: f.SiteURL,
})
}
// Generate OPML
data, err := opml.Generate("FlowReader Feeds", opmlFeeds)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate OPML")
return
}
w.Header().Set("Content-Type", "application/xml")
w.Header().Set("Content-Disposition", "attachment; filename=flowreader-feeds.opml")
w.Write(data)
}
+130
View File
@@ -0,0 +1,130 @@
// Package opml provides OPML file parsing for feed import/export.
package opml
import (
"encoding/xml"
"fmt"
"io"
"strings"
)
// OPML represents the root OPML document.
type OPML struct {
XMLName xml.Name `xml:"opml"`
Version string `xml:"version,attr"`
Head Head `xml:"head"`
Body Body `xml:"body"`
}
// Head contains OPML metadata.
type Head struct {
Title string `xml:"title"`
DateCreated string `xml:"dateCreated,omitempty"`
}
// Body contains the outline elements.
type Body struct {
Outlines []Outline `xml:"outline"`
}
// Outline represents an OPML outline element (feed or folder).
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"`
Description string `xml:"description,attr"`
Category string `xml:"category,attr"`
Outlines []Outline `xml:"outline"` // Nested outlines (folders)
}
// FeedInfo contains extracted feed information.
type FeedInfo struct {
URL string
Title string
SiteURL string
Description string
Category string
}
// Parse reads an OPML file and extracts feed information.
func Parse(r io.Reader) ([]FeedInfo, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("reading OPML: %w", err)
}
var opml OPML
if err := xml.Unmarshal(data, &opml); err != nil {
return nil, fmt.Errorf("parsing OPML: %w", err)
}
var feeds []FeedInfo
extractFeeds(opml.Body.Outlines, "", &feeds)
return feeds, nil
}
// extractFeeds recursively extracts feeds from outlines.
func extractFeeds(outlines []Outline, category string, feeds *[]FeedInfo) {
for _, outline := range outlines {
// If it has nested outlines, it's a folder
if len(outline.Outlines) > 0 {
folderName := outline.Text
if folderName == "" {
folderName = outline.Title
}
extractFeeds(outline.Outlines, folderName, feeds)
continue
}
// If it has an XML URL, it's a feed
if outline.XMLURL != "" {
title := outline.Title
if title == "" {
title = outline.Text
}
cat := category
if outline.Category != "" {
cat = outline.Category
}
*feeds = append(*feeds, FeedInfo{
URL: strings.TrimSpace(outline.XMLURL),
Title: strings.TrimSpace(title),
SiteURL: strings.TrimSpace(outline.HTMLURL),
Description: strings.TrimSpace(outline.Description),
Category: strings.TrimSpace(cat),
})
}
}
}
// Generate creates an OPML document from feeds.
func Generate(title string, feeds []FeedInfo) ([]byte, error) {
opml := OPML{
Version: "2.0",
Head: Head{
Title: title,
},
}
for _, feed := range feeds {
opml.Body.Outlines = append(opml.Body.Outlines, Outline{
Text: feed.Title,
Title: feed.Title,
Type: "rss",
XMLURL: feed.URL,
HTMLURL: feed.SiteURL,
})
}
output, err := xml.MarshalIndent(opml, "", " ")
if err != nil {
return nil, fmt.Errorf("generating OPML: %w", err)
}
return append([]byte(xml.Header), output...), nil
}
+67
View File
@@ -128,3 +128,70 @@ func (s *FeedService) DeleteFeed(feedID, userID uuid.UUID) error {
return nil
}
// ImportOPMLResult contains the result of an OPML import.
type ImportOPMLResult struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors []string `json:"errors,omitempty"`
}
// ImportOPML imports feeds from an OPML file.
func (s *FeedService) ImportOPML(userID uuid.UUID, opmlFeeds []OPMLFeedInfo) (*ImportOPMLResult, error) {
result := &ImportOPMLResult{}
for _, opmlFeed := range opmlFeeds {
// Validate URL
_, err := url.ParseRequestURI(opmlFeed.URL)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Invalid URL: %s", opmlFeed.URL))
result.Skipped++
continue
}
// Check if already exists
existing, err := s.feedRepo.GetByURL(userID, opmlFeed.URL)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Error checking %s: %v", opmlFeed.URL, err))
result.Skipped++
continue
}
if existing != nil {
result.Skipped++
continue
}
// Create feed with title from OPML
now := time.Now()
feed := &domain.Feed{
ID: uuid.New(),
UserID: userID,
URL: opmlFeed.URL,
Title: opmlFeed.Title,
SiteURL: opmlFeed.SiteURL,
CreatedAt: now,
UpdatedAt: now,
}
if feed.Title == "" {
feed.Title = opmlFeed.URL
}
if err := s.feedRepo.Create(feed); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Error creating %s: %v", opmlFeed.URL, err))
result.Skipped++
continue
}
result.Imported++
}
return result, nil
}
// OPMLFeedInfo contains feed info from OPML parsing.
type OPMLFeedInfo struct {
URL string
Title string
SiteURL string
}