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.
267 lines
7.0 KiB
Go
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
|
|
}
|