diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index a3c8f06..ef63882 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -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 diff --git a/cmd/server/main.go b/cmd/server/main.go index 1aecbec..afbe9d7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) }) diff --git a/internal/handler/feed.go b/internal/handler/feed.go index 7ce1c30..0afdfc4 100644 --- a/internal/handler/feed.go +++ b/internal/handler/feed.go @@ -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) +} diff --git a/internal/opml/opml.go b/internal/opml/opml.go new file mode 100644 index 0000000..733895c --- /dev/null +++ b/internal/opml/opml.go @@ -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 +} diff --git a/internal/service/feed.go b/internal/service/feed.go index e0e90cf..069dae6 100644 --- a/internal/service/feed.go +++ b/internal/service/feed.go @@ -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 +}