mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
Add OPDS
This commit is contained in:
@@ -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.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -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) ?? '',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user