mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-17 04:07:20 +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: Feed Core Engine
|
||||||
epic-2: in-progress
|
epic-2: in-progress
|
||||||
2-1-feed-model-add-feed-by-url: done
|
2-1-feed-model-add-feed-by-url: done
|
||||||
2-2-feed-metadata-auto-discovery: review
|
2-2-feed-metadata-auto-discovery: done
|
||||||
2-3-opml-import: backlog
|
2-3-opml-import: review
|
||||||
2-4-feed-management-crud: backlog
|
2-4-feed-management-crud: backlog
|
||||||
2-5-background-feed-fetcher: backlog
|
2-5-background-feed-fetcher: backlog
|
||||||
epic-2-retrospective: optional
|
epic-2-retrospective: optional
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ func main() {
|
|||||||
r.Route("/feeds", func(r chi.Router) {
|
r.Route("/feeds", func(r chi.Router) {
|
||||||
r.Get("/", feedHandler.List)
|
r.Get("/", feedHandler.List)
|
||||||
r.Post("/", feedHandler.Add)
|
r.Post("/", feedHandler.Add)
|
||||||
|
r.Post("/import/opml", feedHandler.ImportOPML)
|
||||||
|
r.Get("/export/opml", feedHandler.ExportOPML)
|
||||||
r.Get("/{id}", feedHandler.Get)
|
r.Get("/{id}", feedHandler.Get)
|
||||||
r.Delete("/{id}", feedHandler.Delete)
|
r.Delete("/{id}", feedHandler.Delete)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/michael/flowreader/internal/opml"
|
||||||
"github.com/michael/flowreader/internal/service"
|
"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"})
|
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
|
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