diff --git a/apps/readest-app/src/__tests__/app/opds/opds-publication.test.ts b/apps/readest-app/src/__tests__/app/opds/opds-publication.test.ts new file mode 100644 index 00000000..3aca2667 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/opds/opds-publication.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; + +// opdsPublication -> opdsUtils -> opdsReq pulls in the tauri http plugin and the +// environment helpers at module load. Stub them so the pure parsing utilities +// can be imported in jsdom. foliate-js is intentionally NOT mocked so the real +// getPublication parses Atom entries. +vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); +vi.mock('@/services/environment', () => ({ + isWebAppPlatform: vi.fn(() => false), + isTauriAppPlatform: vi.fn(() => false), + getAPIBaseUrl: () => '/api', + getNodeAPIBaseUrl: () => '/node-api', +})); + +import { SYMBOL, type OPDSPublication } from '@/types/opds'; +import { + getPublicationDetailHref, + parsePublicationDocument, +} from '@/app/opds/utils/opdsPublication'; + +const pubWithLinks = (links: OPDSPublication['links']): OPDSPublication => ({ + metadata: { title: 'T' }, + links, + images: [], +}); + +describe('getPublicationDetailHref', () => { + it('finds an OPDS 2.0 publication self link', () => { + const pub = pubWithLinks([ + { + rel: 'self', + href: 'http://example.org/publication.json', + type: 'application/opds-publication+json', + }, + ]); + expect(getPublicationDetailHref(pub)).toEqual({ + href: 'http://example.org/publication.json', + type: 'application/opds-publication+json', + }); + }); + + it('finds an Atom entry self link (type=entry)', () => { + const pub = pubWithLinks([ + { + rel: ['self'], + href: '/entry/1', + type: 'application/atom+xml;type=entry;profile=opds-catalog', + }, + ]); + expect(getPublicationDetailHref(pub)?.href).toBe('/entry/1'); + }); + + it('ignores a self link that is not a publication document', () => { + const pub = pubWithLinks([ + { rel: 'self', href: '/feed', type: 'application/atom+xml;profile=opds-catalog' }, + ]); + expect(getPublicationDetailHref(pub)).toBeUndefined(); + }); + + it('ignores acquisition and cover links', () => { + const pub = pubWithLinks([ + { rel: 'http://opds-spec.org/acquisition', href: '/book.epub', type: 'application/epub+zip' }, + { rel: 'http://opds-spec.org/image', href: '/cover.jpg', type: 'image/jpeg' }, + ]); + expect(getPublicationDetailHref(pub)).toBeUndefined(); + }); + + it('returns undefined when there are no links', () => { + expect(getPublicationDetailHref(pubWithLinks([]))).toBeUndefined(); + }); +}); + +describe('parsePublicationDocument', () => { + it('parses an OPDS 2.0 JSON publication and resolves relative hrefs', () => { + const json = JSON.stringify({ + metadata: { + title: 'Full Title', + description: 'A complete description only present in the publication document.', + publisher: 'ACME', + }, + links: [ + { + rel: 'http://opds-spec.org/acquisition', + href: 'book.epub', + type: 'application/epub+zip', + }, + ], + images: [{ href: 'cover.jpg', type: 'image/jpeg' }], + }); + const pub = parsePublicationDocument(json, 'http://example.org/pubs/1.json'); + expect(pub).not.toBeNull(); + expect(pub!.metadata.description).toContain('complete description'); + expect(pub!.metadata.publisher).toBe('ACME'); + expect(pub!.links[0]!.href).toBe('http://example.org/pubs/book.epub'); + expect(pub!.images[0]!.href).toBe('http://example.org/pubs/cover.jpg'); + }); + + it('parses an Atom entry document and absolutizes acquisition links', () => { + const xml = ` + + Atom Full Title + A full summary. + Tor + + `; + const pub = parsePublicationDocument(xml, 'http://example.org/entry/'); + expect(pub).not.toBeNull(); + expect(pub!.metadata.title).toBe('Atom Full Title'); + expect(pub!.metadata[SYMBOL.CONTENT]?.value).toBe('A full summary.'); + expect(pub!.links[0]!.href).toBe('http://example.org/entry/download/book.epub'); + }); + + it('returns null for an XML feed (not a single entry)', () => { + const xml = `Feed`; + expect(parsePublicationDocument(xml, 'http://example.org/feed')).toBeNull(); + }); + + it('returns null for non-publication JSON', () => { + expect(parsePublicationDocument('{"foo":1}', 'http://example.org/x')).toBeNull(); + }); + + it('returns null for unparseable content', () => { + expect(parsePublicationDocument('not json or xml', 'http://example.org/x')).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx b/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx index 2c115cd9..de0a2158 100644 --- a/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx +++ b/apps/readest-app/src/__tests__/app/opds/publication-view.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Book } from '@/types/book'; import { REL, type OPDSPublication } from '@/types/opds'; import { PublicationView } from '@/app/opds/components/PublicationView'; +import { parsePublicationDocument } from '@/app/opds/utils/opdsPublication'; import { DropdownProvider } from '@/context/DropdownContext'; const navigateToReader = vi.hoisted(() => vi.fn()); @@ -146,6 +147,99 @@ describe('PublicationView', () => { ); }); + it('renders an HTML description (OPDS 2.0 JSON) as markup, not literal tags', () => { + // OPDS 2.0 JSON publications carry the description as a plain `description` + // string that some catalogs (e.g. pglaf/Gutenberg) fill with HTML. It must + // render as markup, not show literal

/ tags (readest issue #4749). + const htmlDescPublication: OPDSPublication = { + metadata: { + title: 'HTML Desc', + description: '

Creators: Alice

A great book.

', + }, + links: [], + images: [], + }; + + const { container } = render( + + new URL(href, base).toString()} + onDownload={vi.fn(async () => null)} + onNavigate={vi.fn()} + onGenerateCachedImageUrl={vi.fn(async (url: string) => url)} + /> + , + ); + + expect(container.querySelector('strong')?.textContent).toBe('great'); + expect(container.textContent).not.toContain('

'); + expect(container.textContent).toContain('Creators: Alice'); + }); + + // End-to-end for readest issue #4749: a feed lists this book with only a + // `rel="self"` publication link (no download, no description). Dereferencing + // that link yields the document below; rendering it must surface the + // acquisition button, the HTML description as markup, and the metadata — the + // same outcome Thorium produces. + it('renders a fetched OPDS 2.0 publication document with downloads and HTML description', () => { + const document = JSON.stringify({ + metadata: { + title: 'Weeds used in medicine', + author: { name: 'Henkel, Alice' }, + publisher: 'Washington: Government printing office, 1904.', + language: 'en', + description: '

Creators: Alice Henkel

A practical bulletin.

', + }, + links: [ + { + rel: 'self', + href: '/opds/publications?id=76922', + type: 'application/opds-publication+json', + }, + { + rel: 'http://opds-spec.org/acquisition/open-access', + href: 'https://www.gutenberg.org/cache/epub/76922/pg76922-images-3.epub', + type: 'application/epub+zip', + title: 'EPUB3', + }, + ], + images: [ + { + href: 'https://www.gutenberg.org/cache/epub/76922/pg76922.cover.medium.jpg', + type: 'image/jpeg', + }, + ], + }); + const resolved = parsePublicationDocument( + document, + 'https://opds-test.pglaf.org/opds/publications?id=76922', + ); + expect(resolved).not.toBeNull(); + + const { container } = render( + + new URL(href, base).toString()} + onDownload={vi.fn(async () => null)} + onNavigate={vi.fn()} + onGenerateCachedImageUrl={vi.fn(async (url: string) => url)} + /> + , + ); + + // Acquisition link from the fetched document drives the download button. + expect(screen.getByRole('button', { name: 'Open Access' })).toBeTruthy(); + // HTML description renders as markup, not literal tags. + expect(container.querySelector('strong')?.textContent).toBe('practical'); + expect(container.textContent).not.toContain('

'); + // Metadata only present in the fetched document is shown. + expect(screen.getByText('Washington: Government printing office, 1904.')).toBeTruthy(); + }); + it('renders a tag without an OPDS link as plain, non-clickable text', () => { render( diff --git a/apps/readest-app/src/app/opds/components/PublicationView.tsx b/apps/readest-app/src/app/opds/components/PublicationView.tsx index 24731c2f..5cadfc30 100644 --- a/apps/readest-app/src/app/opds/components/PublicationView.tsx +++ b/apps/readest-app/src/app/opds/components/PublicationView.tsx @@ -175,7 +175,14 @@ export function PublicationView({ const content = publication.metadata?.[SYMBOL.CONTENT] || publication.metadata?.content; const description = publication.metadata?.description; - const descriptionHtml = useMemo(() => getOPDSDescriptionHtml(content), [content]); + // OPDS 2.0 JSON keeps the summary in the plain `description` string (no typed + // ), and catalogs like pglaf/Gutenberg fill it with HTML. Fall back + // to it so that markup renders as markup instead of literal tags; the helper + // sanitizes either source (readest issue #4749). + const descriptionHtml = useMemo( + () => getOPDSDescriptionHtml(content ?? description), + [content, description], + ); return (

diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx index dae5db59..d5362444 100644 --- a/apps/readest-app/src/app/opds/page.tsx +++ b/apps/readest-app/src/app/opds/page.tsx @@ -42,6 +42,7 @@ import { needsProxy, probeFilename, } from './utils/opdsReq'; +import { getPublicationDetailHref, parsePublicationDocument } from './utils/opdsPublication'; import { ImportError } from '@/services/errors'; import { READEST_OPDS_USER_AGENT } from '@/services/constants'; import { findBookByOPDSSources, upsertOPDSSourceMapping } from '@/services/opds/sourceMap'; @@ -474,13 +475,78 @@ export default function BrowserPage() { [_, state, handleNavigate, addToHistory], ); - const publication = + const basePublication = selectedPublication && state.feed ? state.feed.groups?.[selectedPublication.groupIndex]?.publications?.[ selectedPublication.itemIndex ] || state.feed.publications?.[selectedPublication.itemIndex] : state.publication; + const fetchPublicationDocument = useCallback( + async (url: string): Promise => { + try { + const useProxy = isWebAppPlatform(); + const username = usernameRef.current || ''; + const password = passwordRef.current || ''; + const customHeaders = customHeadersRef.current; + const res = await fetchWithAuth(url, username, password, useProxy, {}, customHeaders); + if (!res.ok) return null; + const text = await res.text(); + return parsePublicationDocument(text, res.url); + } catch (e) { + console.warn('Failed to load OPDS publication document:', e); + return null; + } + }, + [], + ); + + // When a publication is listed in summary form but advertises a `rel="self"` + // link to its full document, dereference it and show the richer metadata + // (description, publisher, subjects, language) — readest issue #4749, matching + // what Thorium does. The summary renders immediately and is upgraded in place + // once the document loads; `source` ties the resolved record to the exact + // base publication it enriches so a stale fetch can't bleed into the next one. + const [detailPublication, setDetailPublication] = useState<{ + source: OPDSPublication; + resolved: OPDSPublication; + } | null>(null); + + useEffect(() => { + // Only enrich feed-selected summaries; a directly-loaded entry document + // (state.publication, no selection) is already the full record. + const detailLink = + selectedPublication && basePublication + ? getPublicationDetailHref(basePublication) + : undefined; + if (!basePublication || !detailLink) { + setDetailPublication(null); + return; + } + let cancelled = false; + const url = resolveURL(detailLink.href, state.baseURL); + void fetchPublicationDocument(url).then((resolved) => { + if (!cancelled && resolved) { + setDetailPublication({ source: basePublication, resolved }); + } + }); + return () => { + cancelled = true; + }; + }, [selectedPublication, basePublication, state.baseURL, fetchPublicationDocument]); + + const publication = useMemo(() => { + if (!basePublication || detailPublication?.source !== basePublication) return basePublication; + const { resolved } = detailPublication; + // Prefer the full document's metadata; keep the feed's links/cover when the + // document omits them so downloads and the cover never regress. + return { + metadata: resolved.metadata, + links: resolved.links?.length ? resolved.links : basePublication.links, + images: resolved.images?.length ? resolved.images : basePublication.images, + }; + }, [basePublication, detailPublication]); + const handleDownload = useCallback( async ( href: string, diff --git a/apps/readest-app/src/app/opds/utils/opdsPublication.ts b/apps/readest-app/src/app/opds/utils/opdsPublication.ts new file mode 100644 index 00000000..cb2f490c --- /dev/null +++ b/apps/readest-app/src/app/opds/utils/opdsPublication.ts @@ -0,0 +1,72 @@ +import { getPublication } from 'foliate-js/opds.js'; +import type { OPDSBaseLink, OPDSPublication } from '@/types/opds'; +import { looksLikeXMLContent, MIME, parseMediaType, parseOPDSXML, resolveURL } from './opdsUtils'; + +// Media type of a standalone OPDS 2.0 publication document. +const OPDS_PUBLICATION_JSON = 'application/opds-publication+json'; + +/** + * Find a publication's canonical document link (`rel="self"`) that dereferences + * to a *full* OPDS publication record. OPDS feeds frequently list publications + * in summary form (title + cover + acquisition links) and carry the complete + * metadata — description, publisher, subjects, language — only in the standalone + * publication document this link points at (readest issue #4749, matching what + * Thorium shows). Returns its href/type, or undefined when the publication has + * no such link. + * + * Recognized document types: OPDS 2.0 JSON (`application/opds-publication+json`) + * and an Atom catalog entry (`application/atom+xml;type=entry`). + */ +export const getPublicationDetailHref = ( + publication: OPDSPublication, +): { href: string; type?: string } | undefined => { + for (const link of publication.links ?? []) { + const rels = Array.isArray(link.rel) ? link.rel : [link.rel ?? '']; + if (!link.href || !rels.includes('self')) continue; + const parsed = parseMediaType(link.type); + if (!parsed) continue; + const { mediaType, parameters } = parsed; + if (mediaType === OPDS_PUBLICATION_JSON) return { href: link.href, type: link.type }; + if (mediaType === MIME.ATOM && parameters['type'] === 'entry') { + return { href: link.href, type: link.type }; + } + } + return undefined; +}; + +const absolutizeLinks = ( + links: T[] | undefined, + docURL: string, +): T[] | undefined => + links?.map((link) => (link.href ? { ...link, href: resolveURL(link.href, docURL) } : link)); + +/** + * Parse a fetched OPDS publication document (the body behind a detail link, see + * getPublicationDetailHref) into an OPDSPublication. Supports OPDS 2.0 JSON and + * Atom entry XML. Link and image hrefs are resolved to absolute URLs against the + * document's own URL so the detail view keeps resolving downloads and the cover + * correctly even though it carries the original feed's base URL. Returns null + * when the body is not a recognizable single publication. + */ +export const parsePublicationDocument = (text: string, docURL: string): OPDSPublication | null => { + let publication: OPDSPublication | null = null; + if (looksLikeXMLContent(text)) { + const doc = parseOPDSXML(text); + if (doc.documentElement?.localName !== 'entry') return null; + publication = getPublication(doc.documentElement) as OPDSPublication; + } else { + let json: unknown; + try { + json = JSON.parse(text); + } catch { + return null; + } + if (!json || typeof json !== 'object' || !('metadata' in json)) return null; + publication = json as OPDSPublication; + } + return { + metadata: publication.metadata, + links: absolutizeLinks(publication.links, docURL) ?? [], + images: absolutizeLinks(publication.images, docURL) ?? [], + }; +};