From 9920fd47b9c44543ab27274f3da87799b704d66f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 30 Jul 2026 12:12:33 -0400 Subject: [PATCH] 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 , and , 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. --- internal/opds/feed.go | 106 +++++++++++++++++++++++++++++++------ internal/opds/feed_test.go | 101 +++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 17 deletions(-) diff --git a/internal/opds/feed.go b/internal/opds/feed.go index 44cb16d..a893e2d 100644 --- a/internal/opds/feed.go +++ b/internal/opds/feed.go @@ -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 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( diff --git a/internal/opds/feed_test.go b/internal/opds/feed_test.go index 2e7081c..991d87e 100644 --- a/internal/opds/feed_test.go +++ b/internal/opds/feed_test.go @@ -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/"`, + `1814`, + `50`, + `51`, + `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{ + ``, + `Bookhoard`, + `= len(substr) && indexOf(s, substr) >= 0 }