quicknotes/feeds/service.go

364 lines
8.6 KiB
Go
Raw Normal View History

package feeds
import (
"encoding/xml"
"fmt"
"io"
"log"
"strings"
"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) {
// Read the content into a buffer so we can inspect it if there's an error
content, err := io.ReadAll(opmlContent)
if err != nil {
return nil, fmt.Errorf("failed to read OPML content: %w", err)
}
// Check if the content looks like XML
trimmedContent := strings.TrimSpace(string(content))
if !strings.HasPrefix(trimmedContent, "<?xml") && !strings.HasPrefix(trimmedContent, "<opml") {
// Try to provide a helpful error message
if strings.HasPrefix(trimmedContent, "<!DOCTYPE") || strings.HasPrefix(trimmedContent, "<html") {
return nil, fmt.Errorf("received HTML instead of OPML XML. Please check that the file is a valid OPML file")
}
return nil, fmt.Errorf("content does not appear to be valid OPML XML")
}
var opml OPML
if err := xml.Unmarshal(content, &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"`
}