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.
This commit is contained in:
2026-07-30 12:12:33 -04:00
parent 26f695f480
commit 9920fd47b9
2 changed files with 190 additions and 17 deletions
+89 -17
View File
@@ -9,15 +9,19 @@ import (
// 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"`
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 {
@@ -64,17 +68,29 @@ type Category struct {
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{},
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{
@@ -178,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) {
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(
+101
View File
@@ -232,6 +232,107 @@ func TestNewErrorFeed(t *testing.T) {
}
}
func TestFeedSetPagination(t *testing.T) {
feed := NewFeed("urn:uuid:test-id", "Test Feed")
feed.SetPagination(1814, 50, 51)
if feed.TotalResults == nil || *feed.TotalResults != 1814 {
t.Errorf("expected TotalResults to be 1814, got %v", feed.TotalResults)
}
if feed.ItemsPerPage == nil || *feed.ItemsPerPage != 50 {
t.Errorf("expected ItemsPerPage to be 50, got %v", feed.ItemsPerPage)
}
if feed.StartIndex == nil || *feed.StartIndex != 51 {
t.Errorf("expected StartIndex to be 51, got %v", feed.StartIndex)
}
}
func TestFeedGenerateXMLPagination(t *testing.T) {
feed := NewFeed("urn:uuid:test-id", "Test Feed")
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "first")
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "previous")
feed.AddLink("http://example.com/catalog?page=2", "application/atom+xml", "self")
feed.AddLink("http://example.com/catalog?page=3", "application/atom+xml", "next")
feed.AddLink("http://example.com/catalog?page=37", "application/atom+xml", "last")
feed.SetPagination(1814, 50, 51)
output, err := feed.GenerateXML()
if err != nil {
t.Fatalf("failed to generate XML: %v", err)
}
outputStr := string(output)
requiredStrings := []string{
`xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"`,
`<opensearch:totalResults>1814</opensearch:totalResults>`,
`<opensearch:itemsPerPage>50</opensearch:itemsPerPage>`,
`<opensearch:startIndex>51</opensearch:startIndex>`,
`rel="first"`,
`rel="previous"`,
`rel="next"`,
`rel="last"`,
`page=3`,
}
for _, required := range requiredStrings {
if !contains(outputStr, required) {
t.Errorf("generated XML missing required string: %s", required)
}
}
}
func TestFeedGenerateXMLOmitsPaginationWhenUnset(t *testing.T) {
feed := NewFeed("urn:uuid:test-id", "Test Feed")
output, err := feed.GenerateXML()
if err != nil {
t.Fatalf("failed to generate XML: %v", err)
}
outputStr := string(output)
if contains(outputStr, "opensearch:totalResults") {
t.Errorf("expected no totalResults when pagination unset, but found it")
}
if contains(outputStr, "opensearch:itemsPerPage") {
t.Errorf("expected no itemsPerPage when pagination unset, but found it")
}
}
func TestNewSearchDescription(t *testing.T) {
template := "http://example.com/opds/devices/abc/search?q={searchTerms}&token=xyz"
desc := NewSearchDescription("Bookhoard", "Search the library", template)
if desc.ShortName != "Bookhoard" {
t.Errorf("expected ShortName 'Bookhoard', got '%s'", desc.ShortName)
}
if desc.Url.Template != template {
t.Errorf("expected template '%s', got '%s'", template, desc.Url.Template)
}
}
func TestSearchDescriptionGenerateXML(t *testing.T) {
template := "http://example.com/opds/devices/abc/search?q={searchTerms}"
desc := NewSearchDescription("Bookhoard", "Search the library", template)
output, err := desc.GenerateXMLString()
if err != nil {
t.Fatalf("failed to generate XML: %v", err)
}
requiredStrings := []string{
`<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">`,
`<ShortName>Bookhoard</ShortName>`,
`<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"`,
`template="http://example.com/opds/devices/abc/search?q={searchTerms}"`,
}
for _, required := range requiredStrings {
if !contains(output, required) {
t.Errorf("generated OpenSearch XML missing required string: %s", required)
}
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && indexOf(s, substr) >= 0
}