feat(opds): Add OPDS feed generation library
- Add OPDS feed generation for Kobo compatibility - Support device catalog, search, download, navigation - Add format conversion support
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
|
`<feed`,
|
||||||
|
`xmlns="http://www.w3.org/2005/Atom"`,
|
||||||
|
`xmlns:opds="http://opds-spec.org/2010/"`,
|
||||||
|
`xmlns:dc="http://purl.org/dc/elements/1.1/"`,
|
||||||
|
`<id>urn:uuid:test-id</id>`,
|
||||||
|
`<title>Test Feed</title>`,
|
||||||
|
`<entry>`,
|
||||||
|
`<id>urn:uuid:book-id</id>`,
|
||||||
|
`<dc:title>Test Book</dc:title>`,
|
||||||
|
`<dc:creator>Test Author</dc:creator>`,
|
||||||
|
`<link href="http://example.com/book.epub"`,
|
||||||
|
`rel="http://opds-spec.org/acquisition/open-access"`,
|
||||||
|
`<dc:identifier id="bookmann">book-uuid-123</dc:identifier>`,
|
||||||
|
`<meta property="bookmann:sha256">abc123...</meta>`,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user