Files
bookhoard/internal/opds/feed.go
T
john-okeefe 9920fd47b9 feat(opds): add OpenSearch pagination metadata and search description
Extend the OPDS feed model so clients can page through large catalogs and
discover how to search them.

Feed changes:
- Add the OpenSearch namespace (xmlns:opensearch) to all feeds.
- Add optional TotalResults/ItemsPerPage/StartIndex fields, serialized as
  <opensearch:totalResults>, <opensearch:itemsPerPage> and
  <opensearch:startIndex>, plus a SetPagination helper.
- Add OpenSearchDescription/OpenSearchUrl types and a NewSearchDescription
  constructor with GenerateXML/GenerateXMLString. This produces the
  OpenSearch description document (application/opensearchdescription+xml)
  that OPDS clients like KOReader fetch to learn the {searchTerms} search
  URL template.

These are building blocks; the handlers are wired up in a follow-up commit.

Tests cover SetPagination, omission when unset, XML emission of the
paging metadata, and OpenSearch description generation/serialization.
2026-07-30 12:12:33 -04:00

267 lines
7.0 KiB
Go

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"`
OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
ID string `xml:"id"`
Title string `xml:"title"`
Updated string `xml:"updated"`
Links []Link `xml:"link"`
TotalResults *int `xml:"opensearch:totalResults,omitempty"`
ItemsPerPage *int `xml:"opensearch:itemsPerPage,omitempty"`
StartIndex *int `xml:"opensearch:startIndex,omitempty"`
Entries []Entry `xml:"entry"`
}
type Entry struct {
ID string `xml:"id"`
Title string `xml:"title"`
Author *Author `xml:"author,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 Author struct {
XMLName xml.Name `xml:"author"`
Name string `xml:"name"`
URI string `xml:"uri,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/",
OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
ID: feedID,
Title: title,
Updated: now,
Links: []Link{},
Entries: []Entry{},
}
}
// SetPagination populates the OpenSearch paging metadata (totalResults,
// itemsPerPage, startIndex). startIndex is 1-based to match the page model.
func (f *Feed) SetPagination(totalResults, itemsPerPage, startIndex int) {
tr := totalResults
ipp := itemsPerPage
si := startIndex
f.TotalResults = &tr
f.ItemsPerPage = &ipp
f.StartIndex = &si
}
// 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 {
e := Entry{
ID: id,
Title: title,
Updated: updated,
Links: []Link{},
Metadata: []Meta{},
}
if creator != "" {
e.Author = &Author{Name: creator}
}
return e
}
// 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: "bookhoard",
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
}
// OpenSearchUrl is a single <Url> element in an OpenSearch description.
type OpenSearchUrl struct {
XMLName xml.Name `xml:"Url"`
Type string `xml:"type,attr"`
Template string `xml:"template,attr"`
}
// OpenSearchDescription is an OpenSearch description document used by OPDS
// clients (e.g. KOReader) to discover how to perform catalog searches. Clients
// fetch this document at the catalog's rel="search" link, then substitute
// {searchTerms} in the Url template to execute a query.
type OpenSearchDescription struct {
XMLName xml.Name `xml:"OpenSearchDescription"`
Xmlns string `xml:"xmlns,attr"`
ShortName string `xml:"ShortName"`
Description string `xml:"Description"`
InputEncoding string `xml:"InputEncoding"`
OutputEncoding string `xml:"OutputEncoding"`
Url OpenSearchUrl `xml:"Url"`
}
// NewSearchDescription creates an OpenSearch description document whose Url
// template points clients back to the search results endpoint. The template
// must contain the {searchTerms} placeholder.
func NewSearchDescription(shortName, description, template string) *OpenSearchDescription {
return &OpenSearchDescription{
Xmlns: "http://a9.com/-/spec/opensearch/1.1/",
ShortName: shortName,
Description: description,
InputEncoding: "UTF-8",
OutputEncoding: "UTF-8",
Url: OpenSearchUrl{
Type: "application/atom+xml;profile=opds-catalog;kind=acquisition",
Template: template,
},
}
}
// GenerateXML generates the OpenSearch description XML
func (d *OpenSearchDescription) GenerateXML() ([]byte, error) {
output, err := xml.MarshalIndent(d, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal OpenSearch description: %w", err)
}
return output, nil
}
// GenerateXMLString generates the OpenSearch description XML as a string
func (d *OpenSearchDescription) GenerateXMLString() (string, error) {
output, err := d.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
}