From ad5ec4d0e902fbf6ab064bcd8204d0ad41fc02ac Mon Sep 17 00:00:00 2001 From: John Factotum <50942278+johnfactotum@users.noreply.github.com> Date: Sat, 16 Dec 2023 14:45:05 +0800 Subject: [PATCH] Add OPDS --- README.md | 27 +++++ opds.js | 282 ++++++++++++++++++++++++++++++++++++++++++++++++ uri-template.js | 52 +++++++++ 3 files changed, 361 insertions(+) create mode 100644 opds.js create mode 100644 uri-template.js diff --git a/README.md b/README.md index b8ce927..6163a26 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,33 @@ The TTS module doesn't directly handle speech output. Rather, its methods return The SSML attributes `ssml:ph` and `ssml:alphabet` are supported. There's no support for PLS and CSS Speech. +### OPDS + +The `opds.js` module can be used to implement OPDS clients. It can convert OPDS 1.x documents to OPDS 2.0: + +- `getFeed(doc)`: converts an OPDS 1.x feed to OPDS 2.0. The argument must be a DOM Document object. You need to use a `DOMParser` to obtain a Document first if you have a string. +- `getPublication(entry)`: converts a OPDS 1.x entry in acquisition feeds to an OPDS 2.0 publication. The argument must be a DOM Element object. + +It exports the following symbols for properties unsupported by OPDS 2.0: +- `SYMBOL.SUMMARY`: used on navigation links to represent the summary/content (see https://github.com/opds-community/drafts/issues/51) +- `SYMBOL.CONTENT`: used on publications to represent the content/description and its type. This is mainly for preserving the type info for XHTML. The value of this property is an object whose properties are: + - `.type`: either "text", "html", or "xhtml" + - `.value`: the value of the content + +There are also two functions that can be used to implement search forms: + +- `getOpenSearch(doc)`: for OpenSearch. The argument is a DOM Document object of an OpenSearch search document. +- `getSearch(link)` for templated search in OPDS 2.0. The argument must be an OPDS 2.0 Link object. Note that this function will import `uri-template.js`. + +These two functions return an object that implements the following interface: +- `.metadata`: an object with the string properties `title` and `description` +- `.params`: an array, representing the search parameters, whose elements are objects whose properties are + - `ns`: a string; the namespace of the parameter + - `name`: a string; the name of the parameter + - `required`: a boolean, whether the parameter is required + - `value`: a string; the default value of the parameter +- `.search(map)`: a function, whose argument is a `Map` whose values are `Map`s (i.e. a two-dimensional map). The first key is the namespace of the search parameter. For non-namespaced parameters, the first key must be `null`. The second key is the parameter's name. Returns a string representing the URL of the search results. + ### Supported Browsers The main use of the library is for use in [Foliate](https://github.com/johnfactotum/foliate), which uses WebKitGTK. As such it's the only engine that has been tested extensively. But it should also work in Chromium and Firefox. diff --git a/opds.js b/opds.js new file mode 100644 index 0000000..433b014 --- /dev/null +++ b/opds.js @@ -0,0 +1,282 @@ +const NS = { + ATOM: 'http://www.w3.org/2005/Atom', + OPDS: 'http://opds-spec.org/2010/catalog', + THR: 'http://purl.org/syndication/thread/1.0', + DC: 'http://purl.org/dc/elements/1.1/', + DCTERMS: 'http://purl.org/dc/terms/', +} + +const MIME = { + ATOM: 'application/atom+xml', + OPDS2: 'application/opds+json', +} + +export const REL = { + ACQ: 'http://opds-spec.org/acquisition', + FACET: 'http://opds-spec.org/facet', + GROUP: 'http://opds-spec.org/group', + COVER: [ + 'http://opds-spec.org/image', + 'http://opds-spec.org/cover', + ], + THUMBNAIL: [ + 'http://opds-spec.org/image/thumbnail', + 'http://opds-spec.org/thumbnail', + ], +} + +export const SYMBOL = { + SUMMARY: Symbol('summary'), + CONTENT: Symbol('content'), +} + +const FACET_GROUP = Symbol('facetGroup') + +const groupByArray = (arr, f) => { + const map = new Map() + if (arr) for (const el of arr) { + const keys = f(el) + for (const key of [keys].flat()) { + const group = map.get(key) + if (group) group.push(el) + else map.set(key, [el]) + } + } + return map +} + +// https://www.rfc-editor.org/rfc/rfc7231#section-3.1.1 +const parseMediaType = str => { + if (!str) return null + const [mediaType, ...ps] = str.split(/ *; */) + return { + mediaType: mediaType.toLowerCase(), + parameters: Object.fromEntries(ps.map(p => { + const [name, val] = p.split('=') + return [name.toLowerCase(), val?.replace(/(^"|"$)/g, '')] + })), + } +} + +export const isOPDSCatalog = str => { + const parsed = parseMediaType(str) + if (!parsed) return false + const { mediaType, parameters } = parsed + if (mediaType === MIME.OPDS2) return true + return mediaType === MIME.ATOM && parameters.profile?.toLowerCase() === 'opds-catalog' +} + +// ignore the namespace if it doesn't appear in document at all +const useNS = (doc, ns) => + doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns) ? ns : null + +const filterNS = ns => ns + ? name => el => el.namespaceURI === ns && el.localName === name + : name => el => el.localName === name + +const getContent = el => { + if (!el) return + const type = el.getAttribute('type') ?? 'text' + const value = type === 'xhtml' ? el.innerHTML + : type === 'html' ? el.textContent + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + : el.textContent + return { value, type } +} + +const getTextContent = el => { + const content = getContent(el) + if (content?.type === 'text') return content?.value +} + +const getSummary = (a, b) => getTextContent(a) ?? getTextContent(b) + +const getPrice = link => { + const price = link.getElementsByTagNameNS(NS.OPDS, 'price')[0] + return price ? { + currency: price.getAttribute('currencycode'), + value: price.textContent, + } : null +} + +const getIndirectAcquisition = el => { + const ia = el.getElementsByTagNameNS(NS.OPDS, 'indirectAcquisition')[0] + if (!ia) return [] + return [{ type: ia.getAttribute('type') }, ...getIndirectAcquisition(ia)] +} + +const getLink = link => { + const obj = { + rel: link.getAttribute('rel')?.split(/ +/), + href: link.getAttribute('href'), + type: link.getAttribute('type'), + title: link.getAttribute('title'), + properties: { + price: getPrice(link), + indirectAcquisition: getIndirectAcquisition(link), + numberOfItems: link.getAttributeNS(NS.THR, 'count'), + }, + [FACET_GROUP]: link.getAttributeNS(NS.OPDS, 'facetGroup'), + } + if (link.getAttributeNS(NS.OPDS, 'activeFacet') === 'true') + obj.rel = [obj.rel ?? []].flat().concat('self') + return obj +} + +export const getPublication = entry => { + const filter = filterNS(useNS(entry.ownerDocument, NS.ATOM)) + const children = Array.from(entry.children) + const filterDCEL = filterNS(NS.DC) + const filterDCTERMS = filterNS(NS.DCTERMS) + const filterDC = x => { + const a = filterDCEL(x), b = filterDCTERMS(x) + return y => a(y) || b(y) + } + const links = children.filter(filter('link')).map(getLink) + const linksByRel = groupByArray(links, link => link.rel) + return { + metadata: { + title: children.find(filter('title'))?.textContent ?? '', + author: children.filter(filter('author')).map(person => { + const NS = person.namespaceURI + const uri = person.getElementsByTagNameNS(NS, 'uri')[0]?.textContent + return { + name: person.getElementsByTagNameNS(NS, 'name')[0]?.textContent ?? '', + links: uri ? [{ href: uri }] : [], + } + }), + publisher: children.find(filterDC('publisher'))?.textContent, + published: (children.find(filterDCTERMS('issued')) + ?? children.find(filterDC('date')))?.textContent, + language: children.find(filterDC('language'))?.textContent, + identifier: children.find(filterDC('identifier'))?.textContent, + subject: children.filter(filter('category')).map(category => ({ + name: category.getAttribute('label'), + code: category.getAttribute('term'), + })), + [SYMBOL.CONTENT]: getContent(children.find(filter('content')) + ?? children.find(filter('summary'))), + }, + links, + images: REL.COVER.concat(REL.THUMBNAIL) + .map(R => linksByRel.get(R)?.[0]).filter(x => x), + } +} + +export const getFeed = doc => { + const ns = useNS(doc, NS.ATOM) + const filter = filterNS(ns) + const children = Array.from(doc.documentElement.children) + const entries = children.filter(filter('entry')) + const links = children.filter(filter('link')).map(getLink) + const linksByRel = groupByArray(links, link => link.rel) + + const groupedItems = new Map([[null, []]]) + const groupLinkMap = new Map() + for (const entry of entries) { + const children = Array.from(entry.children) + const links = children.filter(filter('link')).map(getLink) + const linksByRel = groupByArray(links, link => link.rel) + const isPub = [...linksByRel.keys()] + .some(rel => rel?.startsWith(REL.ACQ) || rel === 'preview') + + const groupLinks = linksByRel.get(REL.GROUP) ?? linksByRel.get('collection') + const groupLink = groupLinks?.length + ? groupLinks.find(link => groupedItems.has(link.href)) ?? groupLinks[0] : null + if (groupLink && !groupLinkMap.has(groupLink.href)) + groupLinkMap.set(groupLink.href, groupLink) + + const item = isPub + ? getPublication(entry) + : Object.assign(links.find(link => isOPDSCatalog(link.type)) ?? links[0] ?? {}, { + title: children.find(filter('title'))?.textContent, + [SYMBOL.SUMMARY]: getSummary(children.find(filter('summary')), + children.find(filter('content'))), + }) + + const arr = groupedItems.get(groupLink?.href ?? null) + if (arr) arr.push(item) + else groupedItems.set(groupLink.href, [item]) + } + const [items, ...groups] = Array.from(groupedItems, ([key, items]) => { + const itemsKey = items[0]?.metadata ? 'publications' : 'navigation' + if (key == null) return { [itemsKey]: items } + const link = groupLinkMap.get(key) + return { + metadata: { + title: link.title, + numberOfItems: link.properties.numberOfItems, + }, + links: [{ rel: 'self', href: link.href, type: link.type }], + [itemsKey]: items, + } + }) + return { + metadata: { + title: children.find(filter('title'))?.textContent, + subtitle: children.find(filter('subtitle'))?.textContent, + }, + links, + ...items, + groups, + facets: Array.from( + groupByArray(linksByRel.get(REL.FACET) ?? [], link => link[FACET_GROUP]), + ([facet, links]) => ({ metadata: { title: facet }, links })), + } +} + +export const getSearch = async link => { + const { replace, getVariables } = await import('./uri-template.js') + return { + metadata: { + title: link.title, + }, + search: map => replace(link.href, map.get(null)), + params: Array.from(getVariables(link.href), name => ({ name })), + } +} + +export const getOpenSearch = doc => { + const defaultNS = doc.documentElement.namespaceURI + const filter = filterNS(defaultNS) + const children = Array.from(doc.documentElement.children) + + const $$urls = children.filter(filter('Url')) + const $url = $$urls.find(url => isOPDSCatalog(url.getAttribute('type'))) ?? $$urls[0] + if (!$url) throw new Error('document must contain at least one Url element') + + const regex = /{(?:([^}]+?):)?(.+?)(\?)?}/g + const defaultMap = new Map([ + ['count', '100'], + ['startIndex', $url.getAttribute('indexOffset') ?? '0'], + ['startPage', $url.getAttribute('pageOffset') ?? '0'], + ['language', '*'], + ['inputEncoding', 'UTF-8'], + ['outputEncoding', 'UTF-8'], + ]) + + const template = $url.getAttribute('template') + return { + metadata: { + title: (children.find(filter('LongName')) ?? children.find(filter('ShortName')))?.textContent, + description: children.find(filter('Description'))?.textContent, + }, + search: map => template.replace(regex, (_, prefix, param) => { + const namespace = prefix ? $url.lookupNamespaceURI(prefix) : null + const ns = namespace === defaultNS ? null : namespace + const val = map.get(ns)?.get(param) + return encodeURIComponent(val ? val : (!ns ? defaultMap.get(param) ?? '' : '')) + }), + params: Array.from(template.matchAll(regex), ([, prefix, param, optional]) => { + const namespace = prefix ? $url.lookupNamespaceURI(prefix) : null + const ns = namespace === defaultNS ? null : namespace + return { + ns, name: param, + required: !optional, + value: ns && ns !== defaultNS ? '' : defaultMap.get(param) ?? '', + } + }), + } +} diff --git a/uri-template.js b/uri-template.js new file mode 100644 index 0000000..d66bbf7 --- /dev/null +++ b/uri-template.js @@ -0,0 +1,52 @@ +// URI Template: https://datatracker.ietf.org/doc/html/rfc6570 + +const regex = /{([+#./;?&])?([^}]+?)}/g +const varspecRegex = /(.+?)(\*|:[1-9]\d{0,3})?$/ + +const table = { + undefined: { first: '', sep: ',' }, + '+': { first: '', sep: ',', allowReserved: true }, + '.': { first: '.', sep: '.' }, + '/': { first: '/', sep: '/' }, + ';': { first: ';', sep: ';', named: true, ifemp: '' }, + '?': { first: '?', sep: '&', named: true, ifemp: '=' }, + '&': { first: '&', sep: '&', named: true, ifemp: '=' }, + '#': { first: '&', sep: '&', allowReserved: true }, +} + +// 2.4.1 Prefix Values, "Note that this numbering is in characters, not octets" +const prefix = (maxLength, str) => { + let result = '' + for (const char of str) { + const newResult = char + if (newResult.length > maxLength) return result + else result = newResult + } + return result +} + +export const replace = (str, map) => str.replace(regex, (_, operator, variableList) => { + const { first, sep, named, ifemp, allowReserved } = table[operator] + // TODO: this isn't spec compliant + const encode = allowReserved ? encodeURI : encodeURIComponent + const values = variableList.split(',').map(varspec => { + const match = varspec.match(varspecRegex) + if (!match) return + const [, name, modifier] = match + let value = map.get(name) + if (modifier?.startsWith(':')) { + const maxLength = parseInt(modifier.slice(1)) + value = prefix(maxLength, value) + } + return [name, value ? encode(value) : null] + }) + if (!values.filter(([, value]) => value).length) return '' + return first + values + .map(([name, value]) => value + ? (named ? name + (value ? '=' + value : ifemp) : value) : '') + .filter(x => x).join(sep) +}) + +export const getVariables = str => new Set(Array.from(str.matchAll(regex), + ([,, variableList]) => variableList.split(',') + .map(varspec => varspec.match(varspecRegex)?.[1])).flat())