package feeds import ( "encoding/xml" "fmt" "io" "log" "time" "github.com/go-co-op/gocron" "github.com/go-shiori/go-readability" "github.com/mmcdole/gofeed" "gorm.io/gorm" ) // Service handles feed operations type Service struct { db *gorm.DB parser *gofeed.Parser cron *gocron.Scheduler } // NewService creates a new feed service func NewService(db *gorm.DB) *Service { s := &Service{ db: db, parser: gofeed.NewParser(), cron: gocron.NewScheduler(time.UTC), } // Start the scheduler _, err := s.cron.Every(1).Hour().Do(s.RefreshAllFeeds) if err != nil { log.Printf("Error scheduling feed refresh: %v", err) } s.cron.StartAsync() return s } // AddFeed adds a new feed func (s *Service) AddFeed(url string) (*Feed, error) { // Check if feed already exists var existingFeed Feed if result := s.db.Where("url = ?", url).First(&existingFeed); result.Error == nil { return &existingFeed, nil } // Fetch and parse the feed feed, err := s.parser.ParseURL(url) if err != nil { return nil, fmt.Errorf("failed to parse feed: %w", err) } // Create new feed newFeed := Feed{ Title: feed.Title, URL: url, Description: feed.Description, SiteURL: feed.Link, ImageURL: s.extractFeedImage(feed), LastFetched: time.Now(), CreatedAt: time.Now(), UpdatedAt: time.Now(), } // Save feed to database if err := s.db.Create(&newFeed).Error; err != nil { return nil, fmt.Errorf("failed to save feed: %w", err) } // Process feed entries s.processFeedEntries(&newFeed, feed) return &newFeed, nil } // ImportOPML imports feeds from an OPML file func (s *Service) ImportOPML(opmlContent io.Reader) ([]Feed, error) { var opml OPML if err := xml.NewDecoder(opmlContent).Decode(&opml); err != nil { return nil, fmt.Errorf("failed to parse OPML: %w", err) } var feeds []Feed for _, outline := range opml.Body.Outlines { if outline.XMLURL != "" { feed, err := s.AddFeed(outline.XMLURL) if err != nil { continue // Skip feeds that fail to parse } feeds = append(feeds, *feed) } // Process nested outlines for _, nestedOutline := range outline.Outlines { if nestedOutline.XMLURL != "" { feed, err := s.AddFeed(nestedOutline.XMLURL) if err != nil { continue } feeds = append(feeds, *feed) } } } return feeds, nil } // GetFeeds returns all feeds func (s *Service) GetFeeds() ([]Feed, error) { var feeds []Feed if err := s.db.Order("title").Find(&feeds).Error; err != nil { return nil, err } return feeds, nil } // GetFeed returns a feed by ID func (s *Service) GetFeed(id string) (*Feed, error) { var feed Feed if err := s.db.First(&feed, "id = ?", id).Error; err != nil { return nil, err } return &feed, nil } // DeleteFeed deletes a feed by ID func (s *Service) DeleteFeed(id string) error { // Delete associated entries first if err := s.db.Delete(&Entry{}, "feed_id = ?", id).Error; err != nil { return err } // Delete the feed if err := s.db.Delete(&Feed{}, "id = ?", id).Error; err != nil { return err } return nil } // RefreshFeed refreshes a single feed func (s *Service) RefreshFeed(id string) error { var feed Feed if err := s.db.First(&feed, "id = ?", id).Error; err != nil { return err } // Fetch and parse the feed parsedFeed, err := s.parser.ParseURL(feed.URL) if err != nil { return fmt.Errorf("failed to parse feed: %w", err) } // Update feed metadata feed.Title = parsedFeed.Title feed.Description = parsedFeed.Description feed.SiteURL = parsedFeed.Link feed.ImageURL = s.extractFeedImage(parsedFeed) feed.LastFetched = time.Now() feed.UpdatedAt = time.Now() // Save updated feed if err := s.db.Save(&feed).Error; err != nil { return fmt.Errorf("failed to update feed: %w", err) } // Process feed entries s.processFeedEntries(&feed, parsedFeed) return nil } // RefreshAllFeeds refreshes all feeds func (s *Service) RefreshAllFeeds() error { var feeds []Feed if err := s.db.Find(&feeds).Error; err != nil { return err } for _, feed := range feeds { // Ignore errors for individual feeds to continue with others _ = s.RefreshFeed(feed.ID) } return nil } // GetEntries returns entries for all feeds or a specific feed func (s *Service) GetEntries(feedID string, unreadOnly bool) ([]Entry, error) { query := s.db.Order("published desc") if feedID != "" { query = query.Where("feed_id = ?", feedID) } if unreadOnly { query = query.Where("read_at IS NULL") } var entries []Entry if err := query.Find(&entries).Error; err != nil { return nil, err } return entries, nil } // GetEntry returns an entry by ID func (s *Service) GetEntry(id string) (*Entry, error) { var entry Entry if err := s.db.First(&entry, "id = ?", id).Error; err != nil { return nil, err } return &entry, nil } // MarkEntryAsRead marks an entry as read func (s *Service) MarkEntryAsRead(id string) error { return s.db.Model(&Entry{}).Where("id = ?", id).Update("read_at", time.Now()).Error } // MarkAllEntriesAsRead marks all entries as read, optionally filtered by feed ID func (s *Service) MarkAllEntriesAsRead(feedID string) error { query := s.db.Model(&Entry{}).Where("read_at IS NULL") if feedID != "" { query = query.Where("feed_id = ?", feedID) } return query.Update("read_at", time.Now()).Error } // FetchFullContent fetches and parses the full content of an entry func (s *Service) FetchFullContent(id string) error { var entry Entry if err := s.db.First(&entry, "id = ?", id).Error; err != nil { return err } // Skip if already has full content if entry.FullContent != "" { return nil } // Fetch and parse the article article, err := readability.FromURL(entry.URL, 30*time.Second) if err != nil { return fmt.Errorf("failed to fetch article: %w", err) } // Update entry with full content entry.FullContent = article.Content entry.UpdatedAt = time.Now() return s.db.Save(&entry).Error } // Helper function to process feed entries func (s *Service) processFeedEntries(feed *Feed, parsedFeed *gofeed.Feed) { for _, item := range parsedFeed.Items { // Skip items without links if item.Link == "" { continue } // Check if entry already exists var existingEntry Entry result := s.db.Where("url = ?", item.Link).First(&existingEntry) if result.Error == nil { // Update existing entry if needed if item.UpdatedParsed != nil && existingEntry.Updated.Before(*item.UpdatedParsed) { existingEntry.Title = item.Title existingEntry.Summary = item.Description existingEntry.Content = item.Content existingEntry.Updated = *item.UpdatedParsed existingEntry.UpdatedAt = time.Now() s.db.Save(&existingEntry) } continue } // Create new entry published := time.Now() if item.PublishedParsed != nil { published = *item.PublishedParsed } updated := published if item.UpdatedParsed != nil { updated = *item.UpdatedParsed } author := "" if item.Author != nil { author = item.Author.Name } newEntry := Entry{ FeedID: feed.ID, Title: item.Title, URL: item.Link, Content: item.Content, Summary: item.Description, Author: author, Published: published, Updated: updated, CreatedAt: time.Now(), UpdatedAt: time.Now(), } s.db.Create(&newEntry) } } // Helper function to extract feed image func (s *Service) extractFeedImage(feed *gofeed.Feed) string { if feed.Image != nil && feed.Image.URL != "" { return feed.Image.URL } return "" } // OPML represents an OPML file structure type OPML struct { XMLName xml.Name `xml:"opml"` Version string `xml:"version,attr"` Head struct { Title string `xml:"title"` } `xml:"head"` Body struct { Outlines []Outline `xml:"outline"` } `xml:"body"` } // Outline represents an OPML outline element 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"` Outlines []Outline `xml:"outline"` }