2025-02-28 11:37:07 +01:00
|
|
|
package feeds
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/xml"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"log"
|
2025-02-28 17:23:01 +01:00
|
|
|
"strings"
|
2025-02-28 11:37:07 +01:00
|
|
|
"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) {
|
2025-02-28 17:23:01 +01:00
|
|
|
// 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")
|
|
|
|
}
|
|
|
|
|
2025-02-28 11:37:07 +01:00
|
|
|
var opml OPML
|
2025-02-28 17:23:01 +01:00
|
|
|
if err := xml.Unmarshal(content, &opml); err != nil {
|
2025-02-28 11:37:07 +01:00
|
|
|
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")
|
2025-02-28 12:32:45 +01:00
|
|
|
|
2025-02-28 11:37:07 +01:00
|
|
|
if feedID != "" {
|
|
|
|
query = query.Where("feed_id = ?", feedID)
|
|
|
|
}
|
2025-02-28 12:32:45 +01:00
|
|
|
|
2025-02-28 11:37:07 +01:00
|
|
|
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"`
|
|
|
|
}
|