forked from akai/readest
OPDS 2.0 feeds may list a publication in summary form (title, cover, and only a rel="self" link of type application/opds-publication+json), serving the full record (acquisition links, description, publisher, subjects) only when the client follows that link on click, as Thorium does. Readest ignored it, so such books showed no download option and no description. Add opdsPublication.ts with getPublicationDetailHref and parsePublicationDocument (OPDS 2.0 JSON and Atom entry, absolutizing link/image hrefs), and dereference the link in the OPDS page when a feed-selected summary advertises one, upgrading the detail view in place once it loads. Also render an OPDS 2.0 JSON HTML description as sanitized markup instead of literal tags by falling back from the typed content to the plain description string in getOPDSDescriptionHtml. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = `<?xml version="1.0"?>
|
||||
<entry xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<title>Atom Full Title</title>
|
||||
<summary>A full summary.</summary>
|
||||
<dc:publisher>Tor</dc:publisher>
|
||||
<link rel="http://opds-spec.org/acquisition" href="download/book.epub" type="application/epub+zip"/>
|
||||
</entry>`;
|
||||
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 xmlns="http://www.w3.org/2005/Atom"><title>Feed</title></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();
|
||||
});
|
||||
});
|
||||
@@ -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 <p>/<strong> tags (readest issue #4749).
|
||||
const htmlDescPublication: OPDSPublication = {
|
||||
metadata: {
|
||||
title: 'HTML Desc',
|
||||
description: '<p>Creators: Alice</p><p>A <strong>great</strong> book.</p>',
|
||||
},
|
||||
links: [],
|
||||
images: [],
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<DropdownProvider>
|
||||
<PublicationView
|
||||
publication={htmlDescPublication}
|
||||
baseURL='https://opds.example.com/opds'
|
||||
resolveURL={(href, base) => new URL(href, base).toString()}
|
||||
onDownload={vi.fn(async () => null)}
|
||||
onNavigate={vi.fn()}
|
||||
onGenerateCachedImageUrl={vi.fn(async (url: string) => url)}
|
||||
/>
|
||||
</DropdownProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('strong')?.textContent).toBe('great');
|
||||
expect(container.textContent).not.toContain('<p>');
|
||||
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: '<p>Creators: Alice Henkel</p><p>A <strong>practical</strong> bulletin.</p>',
|
||||
},
|
||||
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(
|
||||
<DropdownProvider>
|
||||
<PublicationView
|
||||
publication={resolved!}
|
||||
baseURL='https://opds-test.pglaf.org/opds/'
|
||||
resolveURL={(href, base) => new URL(href, base).toString()}
|
||||
onDownload={vi.fn(async () => null)}
|
||||
onNavigate={vi.fn()}
|
||||
onGenerateCachedImageUrl={vi.fn(async (url: string) => url)}
|
||||
/>
|
||||
</DropdownProvider>,
|
||||
);
|
||||
|
||||
// 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('<p>');
|
||||
// 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(
|
||||
<DropdownProvider>
|
||||
|
||||
@@ -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
|
||||
// <content>), 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 (
|
||||
<div className='flex w-full flex-col px-6 py-6'>
|
||||
|
||||
@@ -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<OPDSPublication | null> => {
|
||||
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,
|
||||
|
||||
@@ -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 = <T extends OPDSBaseLink>(
|
||||
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) ?? [],
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user