diff --git a/README.md b/README.md index 18ecf3da..a612b3d6 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Feedsmith aims to fully support all major feed formats and namespaces in complet | [Source](https://feedsmith.dev/reference/namespaces/source) | `` | RSS | ✅ | ✅ | | [blogChannel](https://feedsmith.dev/reference/namespaces/blogchannel) | `` | RSS | ✅ | ✅ | | [YouTube](https://feedsmith.dev/reference/namespaces/yt) | `` | Atom | ✅ | ✅ | +| [OPDS](https://feedsmith.dev/reference/namespaces/opds) | `` | Atom | ✅ | ✅ | | [W3C Basic Geo](https://feedsmith.dev/reference/namespaces/geo) | `` | RSS, Atom | ✅ | ✅ | | [GeoRSS Simple](https://feedsmith.dev/reference/namespaces/georss) | `` | RSS, Atom, RDF | ✅ | ✅ | | [RDF](https://feedsmith.dev/reference/namespaces/rdf) | `` | RDF | ✅ | ✅ | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 3ac27bb1..44c95eaa 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -136,6 +136,7 @@ export default defineConfig({ { text: 'Source', link: '/reference/namespaces/source' }, { text: 'blogChannel', link: '/reference/namespaces/blogchannel' }, { text: 'YouTube', link: '/reference/namespaces/yt' }, + { text: 'OPDS', link: '/reference/namespaces/opds' }, { text: 'W3C Basic Geo', link: '/reference/namespaces/geo' }, { text: 'GeoRSS Simple', link: '/reference/namespaces/georss' }, { text: 'RDF', link: '/reference/namespaces/rdf' }, diff --git a/docs/index.md b/docs/index.md index ae4db6a0..d98a9545 100644 --- a/docs/index.md +++ b/docs/index.md @@ -96,6 +96,7 @@ Feedsmith aims to fully support all major feed formats and namespaces in complet | [Source](/reference/namespaces/source) | `` | RSS | ✅ | ✅ | | [blogChannel](/reference/namespaces/blogchannel) | `` | RSS | ✅ | ✅ | | [YouTube](/reference/namespaces/yt) | `` | Atom | ✅ | ✅ | +| [OPDS](/reference/namespaces/opds) | `` | Atom | ✅ | ✅ | | [W3C Basic Geo](/reference/namespaces/geo) | `` | RSS, Atom | ✅ | ✅ | | [GeoRSS Simple](/reference/namespaces/georss) | `` | RSS, Atom, RDF | ✅ | ✅ | | [RDF](/reference/namespaces/rdf) | `` | RDF | ✅ | ✅ | diff --git a/docs/reference/feeds/atom.md b/docs/reference/feeds/atom.md index c43ac3fc..b416adf5 100644 --- a/docs/reference/feeds/atom.md +++ b/docs/reference/feeds/atom.md @@ -38,6 +38,7 @@ Atom is a syndication format based on XML that provides a robust framework for w Pingback, Trackback, YouTube, + OPDS, W3C Basic Geo, GeoRSS Simple diff --git a/docs/reference/namespaces/opds.md b/docs/reference/namespaces/opds.md new file mode 100644 index 00000000..71942fee --- /dev/null +++ b/docs/reference/namespaces/opds.md @@ -0,0 +1,36 @@ +# OPDS Namespace Reference + +The OPDS (Open Publication Distribution System) namespace extends Atom links with catalog-specific metadata for digital publication distribution, including pricing, acquisition methods, and faceted navigation. + + + + + + + + + + + + + + + + + + + + + + + + +
Namespace URIhttp://opds-spec.org/2010/catalog
SpecificationOPDS Catalog 1.2
Prefix<opds:*>
Available inAtom
Propertyopds (on Link)
+ +## Types + +<<< @/../src/namespaces/opds/common/types.ts#reference + +## Related + +- **[Parsing Namespaces](/parsing/namespaces)** - How namespace parsing works diff --git a/src/common/config.ts b/src/common/config.ts index 444f3d44..d7c7e55b 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -52,6 +52,10 @@ import { stopNodes as mediaStopNodes, uris as mediaUris, } from '../namespaces/media/common/config.js' +import { + stopNodes as opdsStopNodes, + uris as opdsUris, +} from '../namespaces/opds/common/config.js' import { stopNodes as opensearchStopNodes, uris as opensearchUris, @@ -165,6 +169,7 @@ export const namespaceUris = { trackback: trackbackUris, prism: prismUris, acast: acastUris, + opds: opdsUris, } export const namespacePrefixes = Object.entries(namespaceUris).reduce( @@ -196,6 +201,7 @@ export const namespaceStopNodes = [ ...googleplayStopNodes, ...itunesStopNodes, ...mediaStopNodes, + ...opdsStopNodes, ...opensearchStopNodes, ...pingbackStopNodes, ...podcastStopNodes, diff --git a/src/feeds/atom/common/types.ts b/src/feeds/atom/common/types.ts index e78397ab..0ff400f7 100644 --- a/src/feeds/atom/common/types.ts +++ b/src/feeds/atom/common/types.ts @@ -16,6 +16,7 @@ import type { GeoRssNs } from '../../../namespaces/georss/common/types.js' import type { GooglePlayNs } from '../../../namespaces/googleplay/common/types.js' import type { ItunesNs } from '../../../namespaces/itunes/common/types.js' import type { MediaNs } from '../../../namespaces/media/common/types.js' +import type { OpdsNs } from '../../../namespaces/opds/common/types.js' import type { OpenSearchNs } from '../../../namespaces/opensearch/common/types.js' import type { PingbackNs } from '../../../namespaces/pingback/common/types.js' import type { PscNs } from '../../../namespaces/psc/common/types.js' @@ -48,6 +49,7 @@ export namespace Atom { title?: string length?: number thr?: ThrNs.Link + opds?: OpdsNs.Link } export type Person = { diff --git a/src/feeds/atom/generate/index.test.ts b/src/feeds/atom/generate/index.test.ts index 362e9597..3f21c4b1 100644 --- a/src/feeds/atom/generate/index.test.ts +++ b/src/feeds/atom/generate/index.test.ts @@ -1363,6 +1363,190 @@ describe('generate with lenient mode', () => { +` + + expect(generate(value)).toEqual(expected) + }) + + it('should generate Atom feed with OPDS catalog entry', () => { + const value = { + id: 'urn:uuid:example-catalog', + title: 'Example OPDS Catalog', + updated: new Date('2024-01-15T12:00:00Z'), + entries: [ + { + id: 'urn:isbn:9780000000001', + title: 'Example Book', + updated: new Date('2024-01-15T12:00:00Z'), + links: [ + { + href: 'https://example.com/book.epub', + rel: 'http://opds-spec.org/acquisition/buy', + type: 'application/epub+zip', + opds: { + prices: [{ value: 9.99, currencyCode: 'USD' }], + }, + }, + { + href: 'https://example.com/cover.jpg', + rel: 'http://opds-spec.org/image', + type: 'image/jpeg', + }, + ], + }, + ], + } + const expected = ` + + urn:uuid:example-catalog + Example OPDS Catalog + 2024-01-15T12:00:00.000Z + + urn:isbn:9780000000001 + + 9.99 + + + Example Book + 2024-01-15T12:00:00.000Z + + +` + + expect(generate(value)).toEqual(expected) + }) + + it('should generate Atom feed with OPDS faceted navigation', () => { + const value = { + id: 'urn:uuid:catalog-facets', + title: 'Catalog with Facets', + updated: new Date('2024-01-15T12:00:00Z'), + links: [ + { + href: 'https://example.com/catalog?sort=author', + rel: 'http://opds-spec.org/facet', + opds: { + facetGroup: 'Sort', + activeFacet: true, + }, + }, + { + href: 'https://example.com/catalog?sort=title', + rel: 'http://opds-spec.org/facet', + opds: { + facetGroup: 'Sort', + activeFacet: false, + }, + }, + ], + } + const expected = ` + + urn:uuid:catalog-facets + + + Catalog with Facets + 2024-01-15T12:00:00.000Z + +` + + expect(generate(value)).toEqual(expected) + }) + + it('should generate Atom feed with OPDS library lending extensions', () => { + const value = { + id: 'urn:uuid:library-catalog', + title: 'Library Catalog', + updated: new Date('2024-01-15T12:00:00Z'), + entries: [ + { + id: 'urn:isbn:9780000000003', + title: 'Borrowable Book', + updated: new Date('2024-01-15T12:00:00Z'), + links: [ + { + href: 'https://example.com/borrow', + rel: 'http://opds-spec.org/acquisition/borrow', + type: 'application/atom+xml;type=entry;profile=opds-catalog', + opds: { + availability: { + status: 'unavailable', + since: new Date('2024-01-01T00:00:00Z'), + until: new Date('2024-06-30T23:59:59Z'), + }, + holds: { total: 5, position: 2 }, + copies: { total: 3, available: 1 }, + }, + }, + ], + }, + ], + } + const expected = ` + + urn:uuid:library-catalog + Library Catalog + 2024-01-15T12:00:00.000Z + + urn:isbn:9780000000003 + + + + + + Borrowable Book + 2024-01-15T12:00:00.000Z + + +` + + expect(generate(value)).toEqual(expected) + }) + + it('should generate Atom feed with OPDS indirect acquisition', () => { + const value = { + id: 'urn:uuid:catalog-indirect', + title: 'Catalog with Indirect Acquisition', + updated: new Date('2024-01-15T12:00:00Z'), + entries: [ + { + id: 'urn:isbn:9780000000002', + title: 'Book via Checkout', + updated: new Date('2024-01-15T12:00:00Z'), + links: [ + { + href: 'https://example.com/checkout', + rel: 'http://opds-spec.org/acquisition', + type: 'text/html', + opds: { + indirectAcquisitions: [ + { + type: 'application/epub+zip', + indirectAcquisitions: [{ type: 'application/x-mobipocket-ebook' }], + }, + ], + }, + }, + ], + }, + ], + } + const expected = ` + + urn:uuid:catalog-indirect + Catalog with Indirect Acquisition + 2024-01-15T12:00:00.000Z + + urn:isbn:9780000000002 + + + + + + Book via Checkout + 2024-01-15T12:00:00.000Z + + ` expect(generate(value)).toEqual(expected) diff --git a/src/feeds/atom/generate/utils.test.ts b/src/feeds/atom/generate/utils.test.ts index 01b1e9d3..036ce952 100644 --- a/src/feeds/atom/generate/utils.test.ts +++ b/src/feeds/atom/generate/utils.test.ts @@ -143,6 +143,104 @@ describe('generateLink', () => { expect(generateLink(value)).toEqual(expected) }) + + it('should generate link with OPDS acquisition properties', () => { + const value = { + href: 'https://example.com/book.epub', + rel: 'http://opds-spec.org/acquisition/buy', + type: 'application/epub+zip', + opds: { + prices: [{ value: 9.99, currencyCode: 'USD' }], + }, + } + const expected = { + '@href': 'https://example.com/book.epub', + '@rel': 'http://opds-spec.org/acquisition/buy', + '@type': 'application/epub+zip', + 'opds:price': [{ '#text': 9.99, '@currencycode': 'USD' }], + } + + expect(generateLink(value)).toEqual(expected) + }) + + it('should generate link with OPDS indirect acquisition', () => { + const value = { + href: 'https://example.com/checkout', + rel: 'http://opds-spec.org/acquisition', + type: 'text/html', + opds: { + indirectAcquisitions: [ + { + type: 'application/epub+zip', + indirectAcquisitions: [{ type: 'application/x-mobipocket-ebook' }], + }, + ], + }, + } + const expected = { + '@href': 'https://example.com/checkout', + '@rel': 'http://opds-spec.org/acquisition', + '@type': 'text/html', + 'opds:indirectAcquisition': [ + { + '@type': 'application/epub+zip', + 'opds:indirectAcquisition': [{ '@type': 'application/x-mobipocket-ebook' }], + }, + ], + } + + expect(generateLink(value)).toEqual(expected) + }) + + it('should generate link with OPDS library lending extensions', () => { + const value = { + href: 'https://example.com/borrow', + rel: 'http://opds-spec.org/acquisition/borrow', + type: 'application/atom+xml;type=entry;profile=opds-catalog', + opds: { + availability: { + status: 'unavailable', + since: '2024-01-01T00:00:00Z', + until: '2024-06-30T23:59:59Z', + }, + holds: { total: 5, position: 2 }, + copies: { total: 3, available: 1 }, + }, + } + const expected = { + '@href': 'https://example.com/borrow', + '@rel': 'http://opds-spec.org/acquisition/borrow', + '@type': 'application/atom+xml;type=entry;profile=opds-catalog', + 'opds:availability': { + '@status': 'unavailable', + '@since': '2024-01-01T00:00:00.000Z', + '@until': '2024-06-30T23:59:59.000Z', + }, + 'opds:holds': { '@total': 5, '@position': 2 }, + 'opds:copies': { '@total': 3, '@available': 1 }, + } + + expect(generateLink(value)).toEqual(expected) + }) + + it('should generate link with OPDS facet attributes', () => { + const value = { + href: 'https://example.com/catalog?sort=author', + rel: 'http://opds-spec.org/facet', + opds: { + facetGroup: 'Sort', + activeFacet: true, + }, + } + const expected = { + '@href': 'https://example.com/catalog?sort=author', + '@rel': 'http://opds-spec.org/facet', + '@opds:facetGroup': 'Sort', + '@opds:activeFacet': true, + } + + expect(generateLink(value)).toEqual(expected) + }) }) describe('generatePerson', () => { diff --git a/src/feeds/atom/generate/utils.ts b/src/feeds/atom/generate/utils.ts index 0f156d33..6e9eacbb 100644 --- a/src/feeds/atom/generate/utils.ts +++ b/src/feeds/atom/generate/utils.ts @@ -32,6 +32,7 @@ import { generateItem as generateItunesItem, } from '../../../namespaces/itunes/generate/utils.js' import { generateItemOrFeed as generateMediaItemOrFeed } from '../../../namespaces/media/generate/utils.js' +import { generateLink as generateOpdsLink } from '../../../namespaces/opds/generate/utils.js' import { generateFeed as generateOpenSearchFeed } from '../../../namespaces/opensearch/generate/utils.js' import { generateFeed as generatePingbackFeed, @@ -73,6 +74,7 @@ export const generateLink: GenerateUtil> = (link) => { '@title': generatePlainString(link.title), '@length': generateNumber(link.length), ...generateThrLink(link.thr), + ...generateOpdsLink(link.opds), } return trimObject(value) diff --git a/src/feeds/atom/parse/config.ts b/src/feeds/atom/parse/config.ts index 2b59d321..cbc14561 100644 --- a/src/feeds/atom/parse/config.ts +++ b/src/feeds/atom/parse/config.ts @@ -21,7 +21,10 @@ export const stopNodes = [ 'feed.generator', 'feed.icon', 'feed.id', - 'feed.link', + // Intentionally NOT a stop node. OPDS places child elements (opds:price, + // opds:indirectAcquisition, …) inside , so the parser must + // traverse into link rather than treat its contents as raw text. + // 'feed.link', 'feed.logo', 'feed.rights', 'feed.subtitle', @@ -40,7 +43,8 @@ export const stopNodes = [ 'feed.entry.contributor.url', // Atom 0.3. 'feed.entry.contributor.email', 'feed.entry.id', - 'feed.entry.link', + // Same reason as feed.link above. + // 'feed.entry.link', 'feed.entry.published', 'feed.entry.issued', // Atom 0.3. 'feed.entry.created', // Atom 0.3. @@ -57,7 +61,8 @@ export const stopNodes = [ 'feed.entry.source.generator', 'feed.entry.source.icon', 'feed.entry.source.id', - 'feed.entry.source.link', + // Same reason as feed.link above. + // 'feed.entry.source.link', 'feed.entry.source.logo', 'feed.entry.source.rights', 'feed.entry.source.subtitle', diff --git a/src/feeds/atom/parse/index.test.ts b/src/feeds/atom/parse/index.test.ts index e15a78eb..a038f4df 100644 --- a/src/feeds/atom/parse/index.test.ts +++ b/src/feeds/atom/parse/index.test.ts @@ -1181,6 +1181,194 @@ describe('parse', () => { }) }) + it('should correctly parse Atom feed with OPDS catalog entry', () => { + const value = ` + + + Example OPDS Catalog + urn:uuid:example-catalog + 2024-01-15T12:00:00Z + + Example Book + urn:isbn:9780000000001 + 2024-01-15T12:00:00Z + + 9.99 + + + + + ` + const expected = { + title: 'Example OPDS Catalog', + id: 'urn:uuid:example-catalog', + updated: '2024-01-15T12:00:00Z', + entries: [ + { + title: 'Example Book', + id: 'urn:isbn:9780000000001', + updated: '2024-01-15T12:00:00Z', + links: [ + { + href: 'https://example.com/book.epub', + rel: 'http://opds-spec.org/acquisition/buy', + type: 'application/epub+zip', + opds: { + prices: [{ value: 9.99, currencyCode: 'USD' }], + }, + }, + { + href: 'https://example.com/cover.jpg', + rel: 'http://opds-spec.org/image', + type: 'image/jpeg', + }, + ], + }, + ], + } + + expect(parse(value)).toEqual(expected) + }) + + it('should correctly parse Atom feed with OPDS faceted navigation', () => { + const value = ` + + + Catalog with Facets + urn:uuid:catalog-facets + 2024-01-15T12:00:00Z + + + + ` + const expected = { + title: 'Catalog with Facets', + id: 'urn:uuid:catalog-facets', + updated: '2024-01-15T12:00:00Z', + links: [ + { + href: 'https://example.com/catalog?sort=author', + rel: 'http://opds-spec.org/facet', + opds: { + facetGroup: 'Sort', + activeFacet: true, + }, + }, + { + href: 'https://example.com/catalog?sort=title', + rel: 'http://opds-spec.org/facet', + opds: { + facetGroup: 'Sort', + activeFacet: false, + }, + }, + ], + } + + expect(parse(value)).toEqual(expected) + }) + + it('should correctly parse Atom feed with OPDS indirect acquisition', () => { + const value = ` + + + Catalog with Indirect Acquisition + urn:uuid:catalog-indirect + 2024-01-15T12:00:00Z + + Book via Checkout + urn:isbn:9780000000002 + 2024-01-15T12:00:00Z + + + + + + + + ` + const expected = { + title: 'Catalog with Indirect Acquisition', + id: 'urn:uuid:catalog-indirect', + updated: '2024-01-15T12:00:00Z', + entries: [ + { + title: 'Book via Checkout', + id: 'urn:isbn:9780000000002', + updated: '2024-01-15T12:00:00Z', + links: [ + { + href: 'https://example.com/checkout', + rel: 'http://opds-spec.org/acquisition', + type: 'text/html', + opds: { + indirectAcquisitions: [ + { + type: 'application/epub+zip', + indirectAcquisitions: [{ type: 'application/x-mobipocket-ebook' }], + }, + ], + }, + }, + ], + }, + ], + } + + expect(parse(value)).toEqual(expected) + }) + + it('should correctly parse Atom feed with OPDS library lending extensions', () => { + const value = ` + + + Library Catalog + urn:uuid:library-catalog + 2024-01-15T12:00:00Z + + Borrowable Book + urn:isbn:9780000000003 + 2024-01-15T12:00:00Z + + + + + + + + ` + const expected = { + title: 'Library Catalog', + id: 'urn:uuid:library-catalog', + updated: '2024-01-15T12:00:00Z', + entries: [ + { + title: 'Borrowable Book', + id: 'urn:isbn:9780000000003', + updated: '2024-01-15T12:00:00Z', + links: [ + { + href: 'https://example.com/borrow', + rel: 'http://opds-spec.org/acquisition/borrow', + type: 'application/atom+xml;type=entry;profile=opds-catalog', + opds: { + availability: { + status: 'unavailable', + since: '2024-01-01T00:00:00Z', + until: '2024-06-30T23:59:59Z', + }, + holds: { total: 5, position: 2 }, + copies: { total: 3, available: 1 }, + }, + }, + ], + }, + ], + } + + expect(parse(value)).toEqual(expected) + }) + // Edge cases and quirks observed in feeds found in the wild. describe('real world feeds', () => { describe('character encoding', () => { diff --git a/src/feeds/atom/parse/utils.test.ts b/src/feeds/atom/parse/utils.test.ts index db6292c6..bb5a8996 100644 --- a/src/feeds/atom/parse/utils.test.ts +++ b/src/feeds/atom/parse/utils.test.ts @@ -209,6 +209,112 @@ describe('parseLink', () => { expect(parseLink(null)).toBeUndefined() expect(parseLink([])).toBeUndefined() }) + + it('should parse link with OPDS prices', () => { + const value = { + '@href': 'https://example.com/book.epub', + '@rel': 'http://opds-spec.org/acquisition/buy', + '@type': 'application/epub+zip', + '@xmlns:opds': 'http://opds-spec.org/2010/catalog', + 'opds:price': [ + { '#text': '9.99', '@currencycode': 'USD' }, + { '#text': '8.99', '@currencycode': 'EUR' }, + ], + } + const expected = { + href: 'https://example.com/book.epub', + rel: 'http://opds-spec.org/acquisition/buy', + type: 'application/epub+zip', + opds: { + prices: [ + { value: 9.99, currencyCode: 'USD' }, + { value: 8.99, currencyCode: 'EUR' }, + ], + }, + } + + expect(parseLink(value)).toEqual(expected) + }) + + it('should parse link with OPDS indirect acquisition', () => { + const value = { + '@href': 'https://example.com/loan', + '@rel': 'http://opds-spec.org/acquisition/borrow', + '@type': 'application/atom+xml;type=entry;profile=opds-catalog', + '@xmlns:opds': 'http://opds-spec.org/2010/catalog', + 'opds:indirectacquisition': { + '@type': 'application/vnd.adobe.adept+xml', + 'opds:indirectacquisition': { + '@type': 'application/epub+zip', + }, + }, + } + const expected = { + href: 'https://example.com/loan', + rel: 'http://opds-spec.org/acquisition/borrow', + type: 'application/atom+xml;type=entry;profile=opds-catalog', + opds: { + indirectAcquisitions: [ + { + type: 'application/vnd.adobe.adept+xml', + indirectAcquisitions: [{ type: 'application/epub+zip' }], + }, + ], + }, + } + + expect(parseLink(value)).toEqual(expected) + }) + + it('should parse link with OPDS facet attributes', () => { + const value = { + '@href': 'https://example.com/catalog?sort=new', + '@rel': 'http://opds-spec.org/facet', + '@title': 'New Releases', + '@xmlns:opds': 'http://opds-spec.org/2010/catalog', + '@opds:facetgroup': 'Sort By', + '@opds:activefacet': 'true', + } + const expected = { + href: 'https://example.com/catalog?sort=new', + rel: 'http://opds-spec.org/facet', + title: 'New Releases', + opds: { + facetGroup: 'Sort By', + activeFacet: true, + }, + } + + expect(parseLink(value)).toEqual(expected) + }) + + it('should parse link with complete OPDS properties', () => { + const value = { + '@href': 'https://example.com/book.epub', + '@rel': 'http://opds-spec.org/acquisition/buy', + '@type': 'application/epub+zip', + '@xmlns:opds': 'http://opds-spec.org/2010/catalog', + 'opds:price': { '#text': '14.99', '@currencycode': 'USD' }, + 'opds:indirectacquisition': { + '@type': 'application/vnd.adobe.adept+xml', + }, + '@opds:facetgroup': 'Price', + '@opds:activefacet': 'false', + } + const expected = { + href: 'https://example.com/book.epub', + rel: 'http://opds-spec.org/acquisition/buy', + type: 'application/epub+zip', + opds: { + prices: [{ value: 14.99, currencyCode: 'USD' }], + indirectAcquisitions: [{ type: 'application/vnd.adobe.adept+xml' }], + facetGroup: 'Price', + activeFacet: false, + }, + } + + expect(parseLink(value)).toEqual(expected) + }) }) describe('retrievePersonUri', () => { diff --git a/src/feeds/atom/parse/utils.ts b/src/feeds/atom/parse/utils.ts index 2444bcc8..89190c6d 100644 --- a/src/feeds/atom/parse/utils.ts +++ b/src/feeds/atom/parse/utils.ts @@ -31,6 +31,7 @@ import { retrieveItem as retrieveItunesItem, } from '../../../namespaces/itunes/parse/utils.js' import { retrieveItemOrFeed as retrieveMediaItemOrFeed } from '../../../namespaces/media/parse/utils.js' +import { retrieveLink as retrieveOpdsLink } from '../../../namespaces/opds/parse/utils.js' import { retrieveFeed as retrieveOpenSearchFeed } from '../../../namespaces/opensearch/parse/utils.js' import { retrieveFeed as retrievePingbackFeed, @@ -76,6 +77,7 @@ export const parseLink: ParsePartialUtil> = (value) => { title: parseString(value['@title']), length: parseNumber(value['@length']), thr: namespaces.has('thr') ? retrieveThrLink(value) : undefined, + opds: namespaces.has('opds') ? retrieveOpdsLink(value) : undefined, } return trimObject(link) diff --git a/src/feeds/atom/references/atom-ns.json b/src/feeds/atom/references/atom-ns.json index b5093cbb..5358c035 100644 --- a/src/feeds/atom/references/atom-ns.json +++ b/src/feeds/atom/references/atom-ns.json @@ -801,6 +801,59 @@ "count": 2, "updated": "2025-05-10T16:45:00Z" } + }, + { + "href": "https://example.com/book.epub", + "rel": "http://opds-spec.org/acquisition/buy", + "type": "application/epub+zip", + "opds": { + "prices": [ + { "value": 9.99, "currencyCode": "USD" }, + { "value": 8.49, "currencyCode": "EUR" } + ] + } + }, + { + "href": "https://example.com/borrow", + "rel": "http://opds-spec.org/acquisition/borrow", + "type": "application/atom+xml;type=entry;profile=opds-catalog", + "opds": { + "indirectAcquisitions": [ + { + "type": "application/vnd.adobe.adept+xml", + "indirectAcquisitions": [{ "type": "application/epub+zip" }] + } + ] + } + }, + { + "href": "https://example.com/catalog?sort=new", + "rel": "http://opds-spec.org/facet", + "title": "New Releases", + "opds": { + "facetGroup": "Sort By", + "activeFacet": true + } + }, + { + "href": "https://example.com/borrow/123", + "rel": "http://opds-spec.org/acquisition/borrow", + "type": "application/atom+xml;type=entry;profile=opds-catalog", + "opds": { + "availability": { + "status": "available", + "since": "2024-01-01T00:00:00Z", + "until": "2024-12-31T23:59:59Z" + }, + "holds": { + "total": 5, + "position": 2 + }, + "copies": { + "total": 3, + "available": 1 + } + } } ] } diff --git a/src/feeds/atom/references/atom-ns.xml b/src/feeds/atom/references/atom-ns.xml index dd82aaab..5bfc23a1 100644 --- a/src/feeds/atom/references/atom-ns.xml +++ b/src/feeds/atom/references/atom-ns.xml @@ -19,6 +19,7 @@ xmlns:trackback="http://madskills.com/public/xml/rss/module/trackback/" xmlns:admin="http://webns.net/mvcb/" xmlns:yt="http://www.youtube.com/xml/schemas/2015" + xmlns:opds="http://opds-spec.org/2010/catalog" xmlns:georss="http://www.georss.org/georss" xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" @@ -578,5 +579,22 @@ dQw4w9WgXcQ UCuAXFkgsw1L7xaCfnd5JJOw + + + + 9.99 + 8.49 + + + + + + + + + + + + diff --git a/src/namespaces/opds/common/config.ts b/src/namespaces/opds/common/config.ts new file mode 100644 index 00000000..abe67dfa --- /dev/null +++ b/src/namespaces/opds/common/config.ts @@ -0,0 +1,15 @@ +export const uris = [ + 'http://opds-spec.org/2010/catalog', // Official URI. + 'https://opds-spec.org/2010/catalog', + 'http://opds-spec.org/2010/catalog/', + 'https://opds-spec.org/2010/catalog/', +] + +export const stopNodes = [ + '*.opds:price', + // Not a stop node because it supports recursive nesting that requires parser traversal. + // '*.opds:indirectacquisition', + '*.opds:availability', + '*.opds:holds', + '*.opds:copies', +] diff --git a/src/namespaces/opds/common/types.ts b/src/namespaces/opds/common/types.ts new file mode 100644 index 00000000..79c324b7 --- /dev/null +++ b/src/namespaces/opds/common/types.ts @@ -0,0 +1,44 @@ +import type { DateLike } from '../../../common/types.js' + +// #region reference +export namespace OpdsNs { + export type Link = { + prices?: Array + indirectAcquisitions?: Array + facetGroup?: string + activeFacet?: boolean + availability?: Availability + holds?: Holds + copies?: Copies + } + + export type Price = { + value: number + currencyCode: string + } + + export type IndirectAcquisition = { + type: string + indirectAcquisitions?: Array + } + + // Unofficial extension for Library lending: availability status of a resource. + export type Availability = { + status: string + since?: TDate + until?: TDate + } + + // Unofficial extension for Library lending: hold queue information. + export type Holds = { + total?: number + position?: number + } + + // Unofficial extension for Library lending: copy availability information. + export type Copies = { + total?: number + available?: number + } +} +// #endregion reference diff --git a/src/namespaces/opds/generate/utils.test.ts b/src/namespaces/opds/generate/utils.test.ts new file mode 100644 index 00000000..2b14ab38 --- /dev/null +++ b/src/namespaces/opds/generate/utils.test.ts @@ -0,0 +1,594 @@ +import { describe, expect, it } from 'bun:test' +import { + generateAvailability, + generateCopies, + generateHolds, + generateIndirectAcquisition, + generateLink, + generatePrice, +} from './utils.js' + +describe('generatePrice', () => { + it('should generate price with all properties', () => { + const value = { + value: 9.99, + currencyCode: 'USD', + } + const expected = { + '#text': 9.99, + '@currencycode': 'USD', + } + + expect(generatePrice(value)).toEqual(expected) + }) + + it('should generate price with zero value', () => { + const value = { + value: 0, + currencyCode: 'USD', + } + const expected = { + '#text': 0, + '@currencycode': 'USD', + } + + expect(generatePrice(value)).toEqual(expected) + }) + + it('should generate price with various currency codes', () => { + const values = [ + { value: 10.0, currencyCode: 'GBP' }, + { value: 1000, currencyCode: 'JPY' }, + { value: 25.5, currencyCode: 'CAD' }, + ] + const expected = [ + { '#text': 10.0, '@currencycode': 'GBP' }, + { '#text': 1000, '@currencycode': 'JPY' }, + { '#text': 25.5, '@currencycode': 'CAD' }, + ] + + expect(generatePrice(values[0])).toEqual(expected[0]) + expect(generatePrice(values[1])).toEqual(expected[1]) + expect(generatePrice(values[2])).toEqual(expected[2]) + }) + + it('should return undefined when currency code is missing', () => { + const value = { + value: 9.99, + } + + // @ts-expect-error: This is for testing purposes. + expect(generatePrice(value)).toBeUndefined() + }) + + it('should return undefined when value is missing', () => { + const value = { + currencyCode: 'USD', + } + + // @ts-expect-error: This is for testing purposes. + expect(generatePrice(value)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + const value = {} + + // @ts-expect-error: This is for testing purposes. + expect(generatePrice(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + // @ts-expect-error: This is for testing purposes. + expect(generatePrice('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generatePrice(123)).toBeUndefined() + expect(generatePrice(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generatePrice(null)).toBeUndefined() + }) +}) + +describe('generateIndirectAcquisition', () => { + it('should generate indirect acquisition with type only', () => { + const value = { + type: 'application/epub+zip', + } + const expected = { + '@type': 'application/epub+zip', + } + + expect(generateIndirectAcquisition(value)).toEqual(expected) + }) + + it('should generate indirect acquisition with nested acquisitions', () => { + const value = { + type: 'application/epub+zip', + indirectAcquisitions: [ + { type: 'application/x-mobipocket-ebook' }, + { type: 'application/pdf' }, + ], + } + const expected = { + '@type': 'application/epub+zip', + 'opds:indirectAcquisition': [ + { '@type': 'application/x-mobipocket-ebook' }, + { '@type': 'application/pdf' }, + ], + } + + expect(generateIndirectAcquisition(value)).toEqual(expected) + }) + + it('should generate deeply nested indirect acquisitions', () => { + const value = { + type: 'text/html', + indirectAcquisitions: [ + { + type: 'application/epub+zip', + indirectAcquisitions: [{ type: 'application/x-mobipocket-ebook' }], + }, + ], + } + const expected = { + '@type': 'text/html', + 'opds:indirectAcquisition': [ + { + '@type': 'application/epub+zip', + 'opds:indirectAcquisition': [{ '@type': 'application/x-mobipocket-ebook' }], + }, + ], + } + + expect(generateIndirectAcquisition(value)).toEqual(expected) + }) + + it('should generate multiple levels of nested indirect acquisitions', () => { + const value = { + type: 'text/html', + indirectAcquisitions: [ + { + type: 'application/atom+xml', + indirectAcquisitions: [ + { + type: 'application/epub+zip', + indirectAcquisitions: [ + { + type: 'application/x-mobipocket-ebook', + indirectAcquisitions: [{ type: 'application/pdf' }], + }, + ], + }, + ], + }, + ], + } + const expected = { + '@type': 'text/html', + 'opds:indirectAcquisition': [ + { + '@type': 'application/atom+xml', + 'opds:indirectAcquisition': [ + { + '@type': 'application/epub+zip', + 'opds:indirectAcquisition': [ + { + '@type': 'application/x-mobipocket-ebook', + 'opds:indirectAcquisition': [{ '@type': 'application/pdf' }], + }, + ], + }, + ], + }, + ], + } + + expect(generateIndirectAcquisition(value)).toEqual(expected) + }) + + it('should filter out invalid nested acquisitions', () => { + const value = { + type: 'application/epub+zip', + indirectAcquisitions: [ + { type: 'application/pdf' }, + {}, // Missing type. + { type: 'application/x-mobipocket-ebook' }, + ], + } + const expected = { + '@type': 'application/epub+zip', + 'opds:indirectAcquisition': [ + { '@type': 'application/pdf' }, + { '@type': 'application/x-mobipocket-ebook' }, + ], + } + + // @ts-expect-error: This is for testing purposes. + expect(generateIndirectAcquisition(value)).toEqual(expected) + }) + + it('should return undefined when type is missing', () => { + const value = { + indirectAcquisitions: [{ type: 'application/pdf' }], + } + + // @ts-expect-error: This is for testing purposes. + expect(generateIndirectAcquisition(value)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + const value = {} + + // @ts-expect-error: This is for testing purposes. + expect(generateIndirectAcquisition(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + // @ts-expect-error: This is for testing purposes. + expect(generateIndirectAcquisition('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateIndirectAcquisition(123)).toBeUndefined() + expect(generateIndirectAcquisition(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateIndirectAcquisition(null)).toBeUndefined() + }) +}) + +describe('generateAvailability', () => { + it('should generate availability with all properties', () => { + const value = { + status: 'available', + since: '2023-01-01T00:00:00Z', + until: '2023-12-31T23:59:59Z', + } + const expected = { + '@status': 'available', + '@since': '2023-01-01T00:00:00.000Z', + '@until': '2023-12-31T23:59:59.000Z', + } + + expect(generateAvailability(value)).toEqual(expected) + }) + + it('should generate availability with status only', () => { + const value = { + status: 'unavailable', + } + const expected = { + '@status': 'unavailable', + } + + expect(generateAvailability(value)).toEqual(expected) + }) + + it('should generate availability with status and since', () => { + const value = { + status: 'reserved', + since: '2023-06-15T10:00:00Z', + } + const expected = { + '@status': 'reserved', + '@since': '2023-06-15T10:00:00.000Z', + } + + expect(generateAvailability(value)).toEqual(expected) + }) + + it('should generate availability with status and until', () => { + const value = { + status: 'ready', + until: '2023-07-01T00:00:00Z', + } + const expected = { + '@status': 'ready', + '@until': '2023-07-01T00:00:00.000Z', + } + + expect(generateAvailability(value)).toEqual(expected) + }) + + it('should generate availability with Date objects', () => { + const value = { + status: 'available', + since: new Date('2023-01-01T00:00:00Z'), + until: new Date('2023-12-31T23:59:59Z'), + } + const expected = { + '@status': 'available', + '@since': '2023-01-01T00:00:00.000Z', + '@until': '2023-12-31T23:59:59.000Z', + } + + expect(generateAvailability(value)).toEqual(expected) + }) + + it('should return undefined when status is missing', () => { + const value = { + since: '2023-01-01T00:00:00Z', + until: '2023-12-31T23:59:59Z', + } + + // @ts-expect-error: This is for testing purposes. + expect(generateAvailability(value)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + const value = {} + + // @ts-expect-error: This is for testing purposes. + expect(generateAvailability(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + // @ts-expect-error: This is for testing purposes. + expect(generateAvailability('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateAvailability(123)).toBeUndefined() + expect(generateAvailability(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateAvailability(null)).toBeUndefined() + }) +}) + +describe('generateHolds', () => { + it('should generate holds with all properties', () => { + const value = { + total: 10, + position: 3, + } + const expected = { + '@total': 10, + '@position': 3, + } + + expect(generateHolds(value)).toEqual(expected) + }) + + it('should generate holds with total only', () => { + const value = { + total: 5, + } + const expected = { + '@total': 5, + } + + expect(generateHolds(value)).toEqual(expected) + }) + + it('should generate holds with position only', () => { + const value = { + position: 2, + } + const expected = { + '@position': 2, + } + + expect(generateHolds(value)).toEqual(expected) + }) + + it('should generate holds with zero values', () => { + const value = { + total: 0, + position: 0, + } + const expected = { + '@total': 0, + '@position': 0, + } + + expect(generateHolds(value)).toEqual(expected) + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(generateHolds(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + // @ts-expect-error: This is for testing purposes. + expect(generateHolds('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateHolds(123)).toBeUndefined() + expect(generateHolds(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateHolds(null)).toBeUndefined() + }) +}) + +describe('generateCopies', () => { + it('should generate copies with all properties', () => { + const value = { + total: 20, + available: 5, + } + const expected = { + '@total': 20, + '@available': 5, + } + + expect(generateCopies(value)).toEqual(expected) + }) + + it('should generate copies with total only', () => { + const value = { + total: 10, + } + const expected = { + '@total': 10, + } + + expect(generateCopies(value)).toEqual(expected) + }) + + it('should generate copies with available only', () => { + const value = { + available: 3, + } + const expected = { + '@available': 3, + } + + expect(generateCopies(value)).toEqual(expected) + }) + + it('should generate copies with zero values', () => { + const value = { + total: 5, + available: 0, + } + const expected = { + '@total': 5, + '@available': 0, + } + + expect(generateCopies(value)).toEqual(expected) + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(generateCopies(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + // @ts-expect-error: This is for testing purposes. + expect(generateCopies('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateCopies(123)).toBeUndefined() + expect(generateCopies(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateCopies(null)).toBeUndefined() + }) +}) + +describe('generateLink', () => { + it('should generate link with all OPDS properties', () => { + const value = { + prices: [ + { value: 9.99, currencyCode: 'USD' }, + { value: 8.99, currencyCode: 'EUR' }, + ], + indirectAcquisitions: [{ type: 'application/epub+zip' }], + facetGroup: 'Price', + activeFacet: true, + availability: { status: 'available' }, + holds: { total: 5, position: 2 }, + copies: { total: 10, available: 3 }, + } + const expected = { + 'opds:price': [ + { '#text': 9.99, '@currencycode': 'USD' }, + { '#text': 8.99, '@currencycode': 'EUR' }, + ], + 'opds:indirectAcquisition': [{ '@type': 'application/epub+zip' }], + '@opds:facetGroup': 'Price', + '@opds:activeFacet': true, + 'opds:availability': { '@status': 'available' }, + 'opds:holds': { '@total': 5, '@position': 2 }, + 'opds:copies': { '@total': 10, '@available': 3 }, + } + + expect(generateLink(value)).toEqual(expected) + }) + + it('should generate link with prices only', () => { + const value = { + prices: [{ value: 9.99, currencyCode: 'USD' }], + } + const expected = { + 'opds:price': [{ '#text': 9.99, '@currencycode': 'USD' }], + } + + expect(generateLink(value)).toEqual(expected) + }) + + it('should generate link with indirect acquisitions only', () => { + const value = { + indirectAcquisitions: [ + { + type: 'application/epub+zip', + indirectAcquisitions: [{ type: 'application/pdf' }], + }, + ], + } + const expected = { + 'opds:indirectAcquisition': [ + { + '@type': 'application/epub+zip', + 'opds:indirectAcquisition': [{ '@type': 'application/pdf' }], + }, + ], + } + + expect(generateLink(value)).toEqual(expected) + }) + + it('should generate link with facet attributes only', () => { + const value = { + facetGroup: 'Sort', + activeFacet: true, + } + const expected = { + '@opds:facetGroup': 'Sort', + '@opds:activeFacet': true, + } + + expect(generateLink(value)).toEqual(expected) + }) + + it('should generate link with activeFacet as false', () => { + const value = { + facetGroup: 'Author', + activeFacet: false, + } + const expected = { + '@opds:facetGroup': 'Author', + '@opds:activeFacet': false, + } + + expect(generateLink(value)).toEqual(expected) + }) + + it('should generate link with facetGroup only', () => { + const value = { + facetGroup: 'Category', + } + const expected = { + '@opds:facetGroup': 'Category', + } + + expect(generateLink(value)).toEqual(expected) + }) + + it('should filter out invalid prices', () => { + const value = { + prices: [ + { value: 9.99, currencyCode: 'USD' }, + { value: 5.99 }, // Missing currency code. + { currencyCode: 'EUR' }, // Missing value. + ], + } + const expected = { + 'opds:price': [{ '#text': 9.99, '@currencycode': 'USD' }], + } + + // @ts-expect-error: This is for testing purposes. + expect(generateLink(value)).toEqual(expected) + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(generateLink(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + // @ts-expect-error: This is for testing purposes. + expect(generateLink('string')).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateLink(123)).toBeUndefined() + expect(generateLink(undefined)).toBeUndefined() + // @ts-expect-error: This is for testing purposes. + expect(generateLink(null)).toBeUndefined() + }) +}) diff --git a/src/namespaces/opds/generate/utils.ts b/src/namespaces/opds/generate/utils.ts new file mode 100644 index 00000000..165c1d37 --- /dev/null +++ b/src/namespaces/opds/generate/utils.ts @@ -0,0 +1,112 @@ +import type { DateLike, GenerateUtil } from '../../../common/types.js' +import { + generateBoolean, + generateNumber, + generatePlainString, + generateRfc3339Date, + isObject, + trimArray, + trimObject, +} from '../../../common/utils.js' +import type { OpdsNs } from '../common/types.js' + +export const generatePrice: GenerateUtil = (price) => { + if (!isObject(price)) { + return + } + + if (price.value === undefined || price.currencyCode === undefined) { + return + } + + const value = { + '#text': generateNumber(price.value), + '@currencycode': generatePlainString(price.currencyCode), + } + + return trimObject(value) +} + +export const generateIndirectAcquisition: GenerateUtil = ( + indirectAcquisition, +) => { + if (!isObject(indirectAcquisition)) { + return + } + + if (indirectAcquisition.type === undefined) { + return + } + + const value = { + '@type': generatePlainString(indirectAcquisition.type), + 'opds:indirectAcquisition': trimArray( + indirectAcquisition.indirectAcquisitions, + generateIndirectAcquisition, + ), + } + + return trimObject(value) +} + +export const generateAvailability: GenerateUtil> = (availability) => { + if (!isObject(availability)) { + return + } + + if (availability.status === undefined) { + return + } + + const value = { + '@status': generatePlainString(availability.status), + '@since': generateRfc3339Date(availability.since), + '@until': generateRfc3339Date(availability.until), + } + + return trimObject(value) +} + +export const generateHolds: GenerateUtil = (holds) => { + if (!isObject(holds)) { + return + } + + const value = { + '@total': generateNumber(holds.total), + '@position': generateNumber(holds.position), + } + + return trimObject(value) +} + +export const generateCopies: GenerateUtil = (copies) => { + if (!isObject(copies)) { + return + } + + const value = { + '@total': generateNumber(copies.total), + '@available': generateNumber(copies.available), + } + + return trimObject(value) +} + +export const generateLink: GenerateUtil> = (link) => { + if (!isObject(link)) { + return + } + + const value = { + 'opds:price': trimArray(link.prices, generatePrice), + 'opds:indirectAcquisition': trimArray(link.indirectAcquisitions, generateIndirectAcquisition), + '@opds:facetGroup': generatePlainString(link.facetGroup), + '@opds:activeFacet': generateBoolean(link.activeFacet), + 'opds:availability': generateAvailability(link.availability), + 'opds:holds': generateHolds(link.holds), + 'opds:copies': generateCopies(link.copies), + } + + return trimObject(value) +} diff --git a/src/namespaces/opds/parse/utils.test.ts b/src/namespaces/opds/parse/utils.test.ts new file mode 100644 index 00000000..286564d9 --- /dev/null +++ b/src/namespaces/opds/parse/utils.test.ts @@ -0,0 +1,622 @@ +import { describe, expect, it } from 'bun:test' +import { + parseAvailability, + parseCopies, + parseHolds, + parseIndirectAcquisition, + parsePrice, + retrieveLink, +} from './utils.js' + +describe('parsePrice', () => { + it('should parse complete price with value and currency code', () => { + const value = { + '#text': '9.99', + '@currencycode': 'USD', + } + const expected = { + value: 9.99, + currencyCode: 'USD', + } + + expect(parsePrice(value)).toEqual(expected) + }) + + it('should parse price with numeric value', () => { + const value = { + '#text': 14.99, + '@currencycode': 'EUR', + } + const expected = { + value: 14.99, + currencyCode: 'EUR', + } + + expect(parsePrice(value)).toEqual(expected) + }) + + it('should parse price with zero value', () => { + const value = { + '#text': '0', + '@currencycode': 'USD', + } + const expected = { + value: 0, + currencyCode: 'USD', + } + + expect(parsePrice(value)).toEqual(expected) + }) + + it('should parse price with various currency codes', () => { + const values = [ + { '#text': '10.00', '@currencycode': 'GBP' }, + { '#text': '1000', '@currencycode': 'JPY' }, + { '#text': '25.50', '@currencycode': 'CAD' }, + ] + const expected = [ + { value: 10.0, currencyCode: 'GBP' }, + { value: 1000, currencyCode: 'JPY' }, + { value: 25.5, currencyCode: 'CAD' }, + ] + + expect(parsePrice(values[0])).toEqual(expected[0]) + expect(parsePrice(values[1])).toEqual(expected[1]) + expect(parsePrice(values[2])).toEqual(expected[2]) + }) + + it('should parse price without #text wrapper', () => { + const value = { + '@currencycode': 'USD', + } + + expect(parsePrice(value)).toBeUndefined() + }) + + it('should return undefined when currency code is missing', () => { + const value = { + '#text': '9.99', + } + + expect(parsePrice(value)).toBeUndefined() + }) + + it('should return undefined when value is missing', () => { + const value = { + '@currencycode': 'USD', + } + + expect(parsePrice(value)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(parsePrice(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + expect(parsePrice('not an object')).toBeUndefined() + expect(parsePrice(undefined)).toBeUndefined() + expect(parsePrice(null)).toBeUndefined() + expect(parsePrice([])).toBeUndefined() + expect(parsePrice(123)).toBeUndefined() + }) +}) + +describe('parseIndirectAcquisition', () => { + it('should parse indirect acquisition with type only', () => { + const value = { + '@type': 'application/epub+zip', + } + const expected = { + type: 'application/epub+zip', + } + + expect(parseIndirectAcquisition(value)).toEqual(expected) + }) + + it('should parse indirect acquisition with nested acquisitions', () => { + const value = { + '@type': 'application/epub+zip', + 'opds:indirectacquisition': [ + { '@type': 'application/x-mobipocket-ebook' }, + { '@type': 'application/pdf' }, + ], + } + const expected = { + type: 'application/epub+zip', + indirectAcquisitions: [ + { type: 'application/x-mobipocket-ebook' }, + { type: 'application/pdf' }, + ], + } + + expect(parseIndirectAcquisition(value)).toEqual(expected) + }) + + it('should parse deeply nested indirect acquisitions', () => { + const value = { + '@type': 'text/html', + 'opds:indirectacquisition': { + '@type': 'application/epub+zip', + 'opds:indirectacquisition': { + '@type': 'application/x-mobipocket-ebook', + }, + }, + } + const expected = { + type: 'text/html', + indirectAcquisitions: [ + { + type: 'application/epub+zip', + indirectAcquisitions: [{ type: 'application/x-mobipocket-ebook' }], + }, + ], + } + + expect(parseIndirectAcquisition(value)).toEqual(expected) + }) + + it('should parse multiple levels of nested indirect acquisitions', () => { + const value = { + '@type': 'text/html', + 'opds:indirectacquisition': { + '@type': 'application/atom+xml', + 'opds:indirectacquisition': { + '@type': 'application/epub+zip', + 'opds:indirectacquisition': { + '@type': 'application/x-mobipocket-ebook', + 'opds:indirectacquisition': { + '@type': 'application/pdf', + }, + }, + }, + }, + } + const expected = { + type: 'text/html', + indirectAcquisitions: [ + { + type: 'application/atom+xml', + indirectAcquisitions: [ + { + type: 'application/epub+zip', + indirectAcquisitions: [ + { + type: 'application/x-mobipocket-ebook', + indirectAcquisitions: [{ type: 'application/pdf' }], + }, + ], + }, + ], + }, + ], + } + + expect(parseIndirectAcquisition(value)).toEqual(expected) + }) + + it('should filter out invalid nested acquisitions', () => { + const value = { + '@type': 'application/epub+zip', + 'opds:indirectacquisition': [ + { '@type': 'application/pdf' }, + {}, // Missing type. + { '@type': 'application/x-mobipocket-ebook' }, + ], + } + const expected = { + type: 'application/epub+zip', + indirectAcquisitions: [ + { type: 'application/pdf' }, + { type: 'application/x-mobipocket-ebook' }, + ], + } + + expect(parseIndirectAcquisition(value)).toEqual(expected) + }) + + it('should return undefined when type is missing', () => { + const value = { + 'opds:indirectacquisition': [{ '@type': 'application/pdf' }], + } + + expect(parseIndirectAcquisition(value)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(parseIndirectAcquisition(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + expect(parseIndirectAcquisition('not an object')).toBeUndefined() + expect(parseIndirectAcquisition(undefined)).toBeUndefined() + expect(parseIndirectAcquisition(null)).toBeUndefined() + expect(parseIndirectAcquisition([])).toBeUndefined() + expect(parseIndirectAcquisition(123)).toBeUndefined() + }) +}) + +describe('parseAvailability', () => { + it('should parse availability with all properties', () => { + const value = { + '@status': 'available', + '@since': '2023-01-01T00:00:00Z', + '@until': '2023-12-31T23:59:59Z', + } + const expected = { + status: 'available', + since: '2023-01-01T00:00:00Z', + until: '2023-12-31T23:59:59Z', + } + + expect(parseAvailability(value)).toEqual(expected) + }) + + it('should parse availability with status only', () => { + const value = { + '@status': 'unavailable', + } + const expected = { + status: 'unavailable', + } + + expect(parseAvailability(value)).toEqual(expected) + }) + + it('should parse availability with status and since', () => { + const value = { + '@status': 'reserved', + '@since': '2023-06-15T10:00:00Z', + } + const expected = { + status: 'reserved', + since: '2023-06-15T10:00:00Z', + } + + expect(parseAvailability(value)).toEqual(expected) + }) + + it('should parse availability with status and until', () => { + const value = { + '@status': 'ready', + '@until': '2023-07-01T00:00:00Z', + } + const expected = { + status: 'ready', + until: '2023-07-01T00:00:00Z', + } + + expect(parseAvailability(value)).toEqual(expected) + }) + + it('should return undefined when status is missing', () => { + const value = { + '@since': '2023-01-01T00:00:00Z', + '@until': '2023-12-31T23:59:59Z', + } + + expect(parseAvailability(value)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(parseAvailability(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + expect(parseAvailability('not an object')).toBeUndefined() + expect(parseAvailability(undefined)).toBeUndefined() + expect(parseAvailability(null)).toBeUndefined() + expect(parseAvailability([])).toBeUndefined() + expect(parseAvailability(123)).toBeUndefined() + }) +}) + +describe('parseHolds', () => { + it('should parse holds with all properties', () => { + const value = { + '@total': '10', + '@position': '3', + } + const expected = { + total: 10, + position: 3, + } + + expect(parseHolds(value)).toEqual(expected) + }) + + it('should parse holds with total only', () => { + const value = { + '@total': '5', + } + const expected = { + total: 5, + } + + expect(parseHolds(value)).toEqual(expected) + }) + + it('should parse holds with position only', () => { + const value = { + '@position': '2', + } + const expected = { + position: 2, + } + + expect(parseHolds(value)).toEqual(expected) + }) + + it('should parse holds with numeric values', () => { + const value = { + '@total': 15, + '@position': 7, + } + const expected = { + total: 15, + position: 7, + } + + expect(parseHolds(value)).toEqual(expected) + }) + + it('should parse holds with zero values', () => { + const value = { + '@total': '0', + '@position': '0', + } + const expected = { + total: 0, + position: 0, + } + + expect(parseHolds(value)).toEqual(expected) + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(parseHolds(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + expect(parseHolds('not an object')).toBeUndefined() + expect(parseHolds(undefined)).toBeUndefined() + expect(parseHolds(null)).toBeUndefined() + expect(parseHolds([])).toBeUndefined() + expect(parseHolds(123)).toBeUndefined() + }) +}) + +describe('parseCopies', () => { + it('should parse copies with all properties', () => { + const value = { + '@total': '20', + '@available': '5', + } + const expected = { + total: 20, + available: 5, + } + + expect(parseCopies(value)).toEqual(expected) + }) + + it('should parse copies with total only', () => { + const value = { + '@total': '10', + } + const expected = { + total: 10, + } + + expect(parseCopies(value)).toEqual(expected) + }) + + it('should parse copies with available only', () => { + const value = { + '@available': '3', + } + const expected = { + available: 3, + } + + expect(parseCopies(value)).toEqual(expected) + }) + + it('should parse copies with numeric values', () => { + const value = { + '@total': 25, + '@available': 12, + } + const expected = { + total: 25, + available: 12, + } + + expect(parseCopies(value)).toEqual(expected) + }) + + it('should parse copies with zero values', () => { + const value = { + '@total': '5', + '@available': '0', + } + const expected = { + total: 5, + available: 0, + } + + expect(parseCopies(value)).toEqual(expected) + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(parseCopies(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + expect(parseCopies('not an object')).toBeUndefined() + expect(parseCopies(undefined)).toBeUndefined() + expect(parseCopies(null)).toBeUndefined() + expect(parseCopies([])).toBeUndefined() + expect(parseCopies(123)).toBeUndefined() + }) +}) + +describe('retrieveLink', () => { + it('should parse link with all OPDS properties', () => { + const value = { + 'opds:price': [ + { '#text': '9.99', '@currencycode': 'USD' }, + { '#text': '8.99', '@currencycode': 'EUR' }, + ], + 'opds:indirectacquisition': [{ '@type': 'application/epub+zip' }], + '@opds:facetgroup': 'Price', + '@opds:activefacet': 'true', + 'opds:availability': { '@status': 'available' }, + 'opds:holds': { '@total': '5', '@position': '2' }, + 'opds:copies': { '@total': '10', '@available': '3' }, + } + const expected = { + prices: [ + { value: 9.99, currencyCode: 'USD' }, + { value: 8.99, currencyCode: 'EUR' }, + ], + indirectAcquisitions: [{ type: 'application/epub+zip' }], + facetGroup: 'Price', + activeFacet: true, + availability: { status: 'available' }, + holds: { total: 5, position: 2 }, + copies: { total: 10, available: 3 }, + } + + expect(retrieveLink(value)).toEqual(expected) + }) + + it('should parse link with prices only', () => { + const value = { + 'opds:price': { '#text': '9.99', '@currencycode': 'USD' }, + } + const expected = { + prices: [{ value: 9.99, currencyCode: 'USD' }], + } + + expect(retrieveLink(value)).toEqual(expected) + }) + + it('should parse link with indirect acquisitions only', () => { + const value = { + 'opds:indirectacquisition': { + '@type': 'application/epub+zip', + 'opds:indirectacquisition': { '@type': 'application/pdf' }, + }, + } + const expected = { + indirectAcquisitions: [ + { + type: 'application/epub+zip', + indirectAcquisitions: [{ type: 'application/pdf' }], + }, + ], + } + + expect(retrieveLink(value)).toEqual(expected) + }) + + it('should parse link with facet attributes only', () => { + const value = { + '@opds:facetgroup': 'Sort', + '@opds:activefacet': 'true', + } + const expected = { + facetGroup: 'Sort', + activeFacet: true, + } + + expect(retrieveLink(value)).toEqual(expected) + }) + + it('should parse activeFacet as false', () => { + const value = { + '@opds:facetgroup': 'Author', + '@opds:activefacet': 'false', + } + const expected = { + facetGroup: 'Author', + activeFacet: false, + } + + expect(retrieveLink(value)).toEqual(expected) + }) + + it('should parse link with facetGroup only', () => { + const value = { + '@opds:facetgroup': 'Category', + } + const expected = { + facetGroup: 'Category', + } + + expect(retrieveLink(value)).toEqual(expected) + }) + + it('should filter out invalid prices', () => { + const value = { + 'opds:price': [ + { '#text': '9.99', '@currencycode': 'USD' }, + { '#text': '5.99' }, // Missing currency code. + { '@currencycode': 'EUR' }, // Missing value. + ], + } + const expected = { + prices: [{ value: 9.99, currencyCode: 'USD' }], + } + + expect(retrieveLink(value)).toEqual(expected) + }) + + it('should handle objects with mixed valid and invalid properties', () => { + const value = { + 'opds:price': { '#text': '9.99', '@currencycode': 'USD' }, + '@opds:facetgroup': 'Price', + '@invalid': 'property', + } + const expected = { + prices: [{ value: 9.99, currencyCode: 'USD' }], + facetGroup: 'Price', + } + + expect(retrieveLink(value)).toEqual(expected) + }) + + it('should return undefined for empty object', () => { + const value = {} + + expect(retrieveLink(value)).toBeUndefined() + }) + + it('should return undefined for objects with only unrelated properties', () => { + const value = { + '@unrelated': 'property', + random: 'value', + } + + expect(retrieveLink(value)).toBeUndefined() + }) + + it('should return undefined for non-object input', () => { + expect(retrieveLink('not an object')).toBeUndefined() + expect(retrieveLink(undefined)).toBeUndefined() + expect(retrieveLink(null)).toBeUndefined() + expect(retrieveLink([])).toBeUndefined() + expect(retrieveLink(123)).toBeUndefined() + }) +}) diff --git a/src/namespaces/opds/parse/utils.ts b/src/namespaces/opds/parse/utils.ts new file mode 100644 index 00000000..9d8e095d --- /dev/null +++ b/src/namespaces/opds/parse/utils.ts @@ -0,0 +1,114 @@ +import type { ParsePartialUtil } from '../../../common/types.js' +import { + isObject, + parseArrayOf, + parseBoolean, + parseDate, + parseNumber, + parseSingularOf, + parseString, + retrieveText, + trimObject, +} from '../../../common/utils.js' +import type { OpdsNs } from '../common/types.js' + +export const parsePrice: ParsePartialUtil = (value) => { + if (!isObject(value)) { + return + } + + const priceValue = parseNumber(retrieveText(value)) + const currencyCode = parseString(value['@currencycode']) + + if (priceValue === undefined || currencyCode === undefined) { + return + } + + return { + value: priceValue, + currencyCode, + } +} + +export const parseIndirectAcquisition: ParsePartialUtil = (value) => { + if (!isObject(value)) { + return + } + + const type = parseString(value['@type']) + + if (type === undefined) { + return + } + + const indirectAcquisition = { + type, + indirectAcquisitions: parseArrayOf(value['opds:indirectacquisition'], parseIndirectAcquisition), + } + + return trimObject(indirectAcquisition) as OpdsNs.IndirectAcquisition +} + +export const parseAvailability: ParsePartialUtil> = (value) => { + if (!isObject(value)) { + return + } + + const status = parseString(value['@status']) + + if (status === undefined) { + return + } + + const availability = { + status, + since: parseDate(value['@since']), + until: parseDate(value['@until']), + } + + return trimObject(availability) as OpdsNs.Availability +} + +export const parseHolds: ParsePartialUtil = (value) => { + if (!isObject(value)) { + return + } + + const holds = { + total: parseNumber(value['@total']), + position: parseNumber(value['@position']), + } + + return trimObject(holds) +} + +export const parseCopies: ParsePartialUtil = (value) => { + if (!isObject(value)) { + return + } + + const copies = { + total: parseNumber(value['@total']), + available: parseNumber(value['@available']), + } + + return trimObject(copies) +} + +export const retrieveLink: ParsePartialUtil> = (value) => { + if (!isObject(value)) { + return + } + + const link = { + prices: parseArrayOf(value['opds:price'], parsePrice), + indirectAcquisitions: parseArrayOf(value['opds:indirectacquisition'], parseIndirectAcquisition), + facetGroup: parseString(value['@opds:facetgroup']), + activeFacet: parseBoolean(value['@opds:activefacet']), + availability: parseSingularOf(value['opds:availability'], parseAvailability), + holds: parseSingularOf(value['opds:holds'], parseHolds), + copies: parseSingularOf(value['opds:copies'], parseCopies), + } + + return trimObject(link) +}