diff --git a/internal/opds/feed.go b/internal/opds/feed.go new file mode 100644 index 0000000..d0c1b37 --- /dev/null +++ b/internal/opds/feed.go @@ -0,0 +1,185 @@ +package opds + +import ( + "encoding/xml" + "fmt" + "time" +) + +// OPDS 1.2 Feed Structures + +type Feed struct { + XMLName xml.Name `xml:"feed"` + Xmlns string `xml:"xmlns,attr"` + OpdsNS string `xml:"xmlns:opds,attr"` + DcNS string `xml:"xmlns:dc,attr"` + ID string `xml:"id"` + Title string `xml:"title"` + Updated string `xml:"updated"` + Links []Link `xml:"link"` + Entries []Entry `xml:"entry"` +} + +type Entry struct { + ID string `xml:"id"` + Title string `xml:"dc:title"` + Creator string `xml:"dc:creator,omitempty"` + Updated string `xml:"updated"` + Summary string `xml:"summary,omitempty"` + Links []Link `xml:"link"` + Identifier *Identifier `xml:"dc:identifier,omitempty"` + Metadata []Meta `xml:"meta"` + Categories []Category `xml:"category,omitempty"` +} + +type Link struct { + Href string `xml:"href,attr"` + Type string `xml:"type,attr"` + Rel string `xml:"rel,attr,omitempty"` +} + +type Identifier struct { + XMLName xml.Name `xml:"dc:identifier"` + ID string `xml:"id,attr"` + Content string `xml:",chardata"` +} + +type Meta struct { + Property string `xml:"property,attr"` + Content string `xml:",chardata"` +} + +type Category struct { + Scheme string `xml:"scheme,attr"` + Term string `xml:"term,attr"` +} + +// NewFeed creates a new OPDS feed +func NewFeed(feedID, title string) *Feed { + now := time.Now().Format(time.RFC3339) + return &Feed{ + Xmlns: "http://www.w3.org/2005/Atom", + OpdsNS: "http://opds-spec.org/2010/", + DcNS: "http://purl.org/dc/elements/1.1/", + ID: feedID, + Title: title, + Updated: now, + Links: []Link{}, + Entries: []Entry{}, + } +} + +// AddLink adds a link to the feed +func (f *Feed) AddLink(href, linkType, rel string) { + f.Links = append(f.Links, Link{ + Href: href, + Type: linkType, + Rel: rel, + }) +} + +// AddEntry adds an entry to the feed +func (f *Feed) AddEntry(entry Entry) { + f.Entries = append(f.Entries, entry) +} + +// NewEntry creates a new OPDS entry +func NewEntry(id, title, creator, updated string) Entry { + return Entry{ + ID: id, + Title: title, + Creator: creator, + Updated: updated, + Links: []Link{}, + Metadata: []Meta{}, + } +} + +// AddAcquisitionLink adds an acquisition link to the entry +func (e *Entry) AddAcquisitionLink(href, linkType string) { + e.Links = append(e.Links, Link{ + Href: href, + Type: linkType, + Rel: "http://opds-spec.org/acquisition/open-access", + }) +} + +// AddAlternateLink adds an alternate link to the entry +func (e *Entry) AddAlternateLink(href, linkType string) { + e.Links = append(e.Links, Link{ + Href: href, + Type: linkType, + Rel: "alternate", + }) +} + +// AddLink adds a generic link to the entry +func (e *Entry) AddLink(href, linkType, rel string) { + e.Links = append(e.Links, Link{ + Href: href, + Type: linkType, + Rel: rel, + }) +} + +// SetIdentifier sets the canonical identifier +func (e *Entry) SetIdentifier(id string) { + e.Identifier = &Identifier{ + ID: "bookmann", + Content: id, + } +} + +// AddMetadata adds metadata to the entry +func (e *Entry) AddMetadata(property, content string) { + e.Metadata = append(e.Metadata, Meta{ + Property: property, + Content: content, + }) +} + +// AddCategory adds a category to the entry +func (e *Entry) AddCategory(scheme, term string) { + e.Categories = append(e.Categories, Category{ + Scheme: scheme, + Term: term, + }) +} + +// SetSummary sets the entry summary +func (e *Entry) SetSummary(summary string) { + e.Summary = summary +} + +// GenerateXML generates the OPDS XML +func (f *Feed) GenerateXML() ([]byte, error) { + output, err := xml.MarshalIndent(f, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal OPDS feed: %w", err) + } + return output, nil +} + +// GenerateXMLString generates the OPDS XML as a string +func (f *Feed) GenerateXMLString() (string, error) { + output, err := f.GenerateXML() + if err != nil { + return "", err + } + return xml.Header + string(output), nil +} + +// NewErrorFeed creates an error feed +func NewErrorFeed(message string) *Feed { + feed := NewFeed( + fmt.Sprintf("urn:uuid:error-%d", time.Now().Unix()), + "Error", + ) + feed.AddEntry(Entry{ + ID: "urn:uuid:error", + Title: "Error", + Summary: message, + Updated: feed.Updated, + }) + return feed +} diff --git a/internal/opds/feed_test.go b/internal/opds/feed_test.go new file mode 100644 index 0000000..9b2b052 --- /dev/null +++ b/internal/opds/feed_test.go @@ -0,0 +1,246 @@ +package opds + +import ( + "testing" +) + +func TestNewFeed(t *testing.T) { + feed := NewFeed("urn:uuid:test-id", "Test Feed") + + if feed.ID != "urn:uuid:test-id" { + t.Errorf("expected ID to be 'urn:uuid:test-id', got '%s'", feed.ID) + } + + if feed.Title != "Test Feed" { + t.Errorf("expected Title to be 'Test Feed', got '%s'", feed.Title) + } + + if feed.Xmlns != "http://www.w3.org/2005/Atom" { + t.Errorf("expected Xmlns to be 'http://www.w3.org/2005/Atom', got '%s'", feed.Xmlns) + } + + if feed.OpdsNS != "http://opds-spec.org/2010/" { + t.Errorf("expected OpdsNS to be 'http://opds-spec.org/2010/', got '%s'", feed.OpdsNS) + } +} + +func TestFeedAddLink(t *testing.T) { + feed := NewFeed("urn:uuid:test-id", "Test Feed") + feed.AddLink("http://example.com", "application/atom+xml", "self") + + if len(feed.Links) != 1 { + t.Fatalf("expected 1 link, got %d", len(feed.Links)) + } + + link := feed.Links[0] + if link.Href != "http://example.com" { + t.Errorf("expected Href to be 'http://example.com', got '%s'", link.Href) + } + + if link.Type != "application/atom+xml" { + t.Errorf("expected Type to be 'application/atom+xml', got '%s'", link.Type) + } + + if link.Rel != "self" { + t.Errorf("expected Rel to be 'self', got '%s'", link.Rel) + } +} + +func TestFeedAddEntry(t *testing.T) { + feed := NewFeed("urn:uuid:test-id", "Test Feed") + entry := NewEntry("urn:uuid:book-id", "Test Book", "Test Author", "2023-01-01T00:00:00Z") + feed.AddEntry(entry) + + if len(feed.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(feed.Entries)) + } + + if feed.Entries[0].ID != "urn:uuid:book-id" { + t.Errorf("expected entry ID to be 'urn:uuid:book-id', got '%s'", feed.Entries[0].ID) + } +} + +func TestNewEntry(t *testing.T) { + entry := NewEntry("urn:uuid:test-id", "Test Title", "Test Author", "2023-01-01T00:00:00Z") + + if entry.ID != "urn:uuid:test-id" { + t.Errorf("expected ID to be 'urn:uuid:test-id', got '%s'", entry.ID) + } + + if entry.Title != "Test Title" { + t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title) + } + + if entry.Creator != "Test Author" { + t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator) + } + + if entry.Updated != "2023-01-01T00:00:00Z" { + t.Errorf("expected Updated to be '2023-01-01T00:00:00Z', got '%s'", entry.Updated) + } +} + +func TestEntryAddAcquisitionLink(t *testing.T) { + entry := NewEntry("urn:uuid:test-id", "Test Title", "Test Author", "2023-01-01T00:00:00Z") + entry.AddAcquisitionLink("http://example.com/book.epub", "application/epub+zip") + + if len(entry.Links) != 1 { + t.Fatalf("expected 1 link, got %d", len(entry.Links)) + } + + link := entry.Links[0] + if link.Href != "http://example.com/book.epub" { + t.Errorf("expected Href to be 'http://example.com/book.epub', got '%s'", link.Href) + } + + if link.Rel != "http://opds-spec.org/acquisition/open-access" { + t.Errorf("expected Rel to be 'http://opds-spec.org/acquisition/open-access', got '%s'", link.Rel) + } +} + +func TestEntryAddAlternateLink(t *testing.T) { + entry := NewEntry("urn:uuid:test-id", "Test Title", "Test Author", "2023-01-01T00:00:00Z") + entry.AddAlternateLink("http://example.com/book.kepub.epub", "application/vnd.kobo+xml+zip") + + if len(entry.Links) != 1 { + t.Fatalf("expected 1 link, got %d", len(entry.Links)) + } + + link := entry.Links[0] + if link.Rel != "alternate" { + t.Errorf("expected Rel to be 'alternate', got '%s'", link.Rel) + } +} + +func TestEntrySetIdentifier(t *testing.T) { + entry := NewEntry("urn:uuid:test-id", "Test Title", "Test Author", "2023-01-01T00:00:00Z") + entry.SetIdentifier("book-uuid-123") + + if entry.Identifier == nil { + t.Fatal("expected Identifier to be set, but it was nil") + } + + if entry.Identifier.ID != "bookmann" { + t.Errorf("expected Identifier ID to be 'bookmann', got '%s'", entry.Identifier.ID) + } + + if entry.Identifier.Content != "book-uuid-123" { + t.Errorf("expected Identifier Content to be 'book-uuid-123', got '%s'", entry.Identifier.Content) + } +} + +func TestEntryAddMetadata(t *testing.T) { + entry := NewEntry("urn:uuid:test-id", "Test Title", "Test Author", "2023-01-01T00:00:00Z") + entry.AddMetadata("bookmann:sha256", "abc123...") + + if len(entry.Metadata) != 1 { + t.Fatalf("expected 1 metadata item, got %d", len(entry.Metadata)) + } + + meta := entry.Metadata[0] + if meta.Property != "bookmann:sha256" { + t.Errorf("expected Property to be 'bookmann:sha256', got '%s'", meta.Property) + } + + if meta.Content != "abc123..." { + t.Errorf("expected Content to be 'abc123...', got '%s'", meta.Content) + } +} + +func TestEntryAddCategory(t *testing.T) { + entry := NewEntry("urn:uuid:test-id", "Test Title", "Test Author", "2023-01-01T00:00:00Z") + entry.AddCategory("http://example.com/collections", "Science Fiction") + + if len(entry.Categories) != 1 { + t.Fatalf("expected 1 category, got %d", len(entry.Categories)) + } + + category := entry.Categories[0] + if category.Scheme != "http://example.com/collections" { + t.Errorf("expected Scheme to be 'http://example.com/collections', got '%s'", category.Scheme) + } + + if category.Term != "Science Fiction" { + t.Errorf("expected Term to be 'Science Fiction', got '%s'", category.Term) + } +} + +func TestEntrySetSummary(t *testing.T) { + entry := NewEntry("urn:uuid:test-id", "Test Title", "Test Author", "2023-01-01T00:00:00Z") + entry.SetSummary("This is a test book") + + if entry.Summary != "This is a test book" { + t.Errorf("expected Summary to be 'This is a test book', got '%s'", entry.Summary) + } +} + +func TestFeedGenerateXML(t *testing.T) { + feed := NewFeed("urn:uuid:test-id", "Test Feed") + feed.AddLink("http://example.com", "application/atom+xml", "self") + + entry := NewEntry("urn:uuid:book-id", "Test Book", "Test Author", "2023-01-01T00:00:00Z") + entry.AddAcquisitionLink("http://example.com/book.epub", "application/epub+zip") + entry.SetIdentifier("book-uuid-123") + entry.AddMetadata("bookmann:sha256", "abc123...") + feed.AddEntry(entry) + + output, err := feed.GenerateXML() + if err != nil { + t.Fatalf("failed to generate XML: %v", err) + } + + outputStr := string(output) + + // Check for essential OPDS elements + requiredStrings := []string{ + `urn:uuid:test-id`, + `Test Feed`, + ``, + `urn:uuid:book-id`, + `Test Book`, + `Test Author`, + `book-uuid-123`, + `abc123...`, + } + + for _, required := range requiredStrings { + if !contains(outputStr, required) { + t.Errorf("generated XML missing required string: %s", required) + } + } +} + +func TestNewErrorFeed(t *testing.T) { + feed := NewErrorFeed("Test error message") + + if feed.Title != "Error" { + t.Errorf("expected Title to be 'Error', got '%s'", feed.Title) + } + + if len(feed.Entries) != 1 { + t.Fatalf("expected 1 entry in error feed, got %d", len(feed.Entries)) + } + + if feed.Entries[0].Summary != "Test error message" { + t.Errorf("expected error message to be 'Test error message', got '%s'", feed.Entries[0].Summary) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && indexOf(s, substr) >= 0 +} + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +}