mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat(story-2.3): add OPML import/export functionality
This commit is contained in:
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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user