UNPKG

foliate-js

Version:
288 lines (260 loc) 10.5 kB
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('&lt;', '<') .replaceAll('&gt;', '>') .replaceAll('&amp;', '&') : 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 } const getPerson = 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 }] : [], } } 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(getPerson), contributor: children.filter(filter('contributor')).map(getPerson), 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'), scheme: category.getAttribute('scheme'), })), rights: children.find(filter('rights'))?.textContent ?? '', [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) ?? '', } }), } }