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 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 }