From 31ebf4b58648ffc0dcf17faa8bc9e87beb048965 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 10 Jun 2026 13:15:03 +0800 Subject: [PATCH] feat(opds): make subject and author links clickable in the book detail view (#4515) * feat(opds): add getOPDSNavLink helper + subject/author link types (#4504) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(opds): make subject/author links clickable in detail view (#4504) Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/__tests__/app/opds/opds-utils.test.ts | 38 ++++++++- .../app/opds/publication-view.test.tsx | 82 +++++++++++++++++++ .../app/opds/components/PublicationView.tsx | 60 ++++++++++++-- apps/readest-app/src/app/opds/page.tsx | 1 + .../src/app/opds/utils/opdsUtils.ts | 11 +++ apps/readest-app/src/types/opds.ts | 3 +- 6 files changed, 184 insertions(+), 11 deletions(-) diff --git a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts index 79dc22ab..7ca94eda 100644 --- a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts +++ b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts @@ -4,8 +4,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('foliate-js/opds.js', () => ({ isOPDSCatalog: vi.fn((type: string) => { return ( - type.includes('application/atom+xml') && - (type.includes('opds-catalog') || type.includes('opds')) + type.includes('application/opds+json') || + (type.includes('application/atom+xml') && + (type.includes('opds-catalog') || type.includes('opds'))) ); }), })); @@ -54,6 +55,7 @@ import { parseOPDSXML, MIME, validateOPDSURL, + getOPDSNavLink, } from '@/app/opds/utils/opdsUtils'; import type { OPDSBaseLink } from '@/types/opds'; import { fetchWithAuth } from '@/app/opds/utils/opdsReq'; @@ -747,4 +749,36 @@ describe('opdsUtils', () => { ); }); }); + + describe('getOPDSNavLink', () => { + it('returns the href of the first OPDS-typed link', () => { + expect( + getOPDSNavLink([{ href: '/opds/subjects?id=164', type: 'application/opds+json' }]), + ).toBe('/opds/subjects?id=164'); + }); + + it('skips links whose type is not an OPDS catalog type', () => { + expect( + getOPDSNavLink([{ href: 'https://example.com/author', type: 'text/html' }]), + ).toBeUndefined(); + }); + + it('returns the first OPDS link when mixed with non-OPDS links', () => { + expect( + getOPDSNavLink([ + { href: 'https://example.com/author', type: 'text/html' }, + { href: '/opds/search?author_id=52836', type: 'application/opds+json' }, + ]), + ).toBe('/opds/search?author_id=52836'); + }); + + it('returns undefined for an empty or missing array', () => { + expect(getOPDSNavLink([])).toBeUndefined(); + expect(getOPDSNavLink(undefined)).toBeUndefined(); + }); + + it('ignores entries without an href', () => { + expect(getOPDSNavLink([{ type: 'application/opds+json' }])).toBeUndefined(); + }); + }); }); 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 f7caae56..2c115cd9 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 @@ -39,6 +39,27 @@ const publication: OPDSPublication = { images: [], }; +const linkedPublication: OPDSPublication = { + metadata: { + title: 'Linked Entry', + author: [ + { + name: 'Tuttle', + links: [{ href: '/opds/search?author_id=52836', type: 'application/opds+json' }], + }, + ], + subject: [ + { + name: 'Horses -- Fiction', + links: [{ href: '/opds/subjects?id=164', type: 'application/opds+json' }], + }, + { name: 'Plain Tag' }, + ], + }, + links: [], + images: [], +}; + const existingBook: Book = { hash: 'existing-book', format: 'EPUB', @@ -65,6 +86,7 @@ describe('PublicationView', () => { existingBook={existingBook} resolveURL={(href, base) => new URL(href, base).toString()} onDownload={onDownload} + onNavigate={vi.fn()} onGenerateCachedImageUrl={vi.fn(async (url: string) => url)} /> , @@ -81,4 +103,64 @@ describe('PublicationView', () => { }); expect(navigateToReader).not.toHaveBeenCalled(); }); + + it('navigates when a tag with an OPDS link is clicked', () => { + const onNavigate = vi.fn(); + + render( + + new URL(href, base).toString()} + onDownload={vi.fn(async () => null)} + onNavigate={onNavigate} + onGenerateCachedImageUrl={vi.fn(async (url: string) => url)} + /> + , + ); + + fireEvent.click(screen.getByRole('button', { name: /Horses -- Fiction/ })); + expect(onNavigate).toHaveBeenCalledWith('https://gutenberg.example.com/opds/subjects?id=164'); + }); + + it('navigates when an author with an OPDS link is clicked', () => { + const onNavigate = vi.fn(); + + render( + + new URL(href, base).toString()} + onDownload={vi.fn(async () => null)} + onNavigate={onNavigate} + onGenerateCachedImageUrl={vi.fn(async (url: string) => url)} + /> + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Tuttle' })); + expect(onNavigate).toHaveBeenCalledWith( + 'https://gutenberg.example.com/opds/search?author_id=52836', + ); + }); + + it('renders a tag without an OPDS link as plain, non-clickable text', () => { + render( + + new URL(href, base).toString()} + onDownload={vi.fn(async () => null)} + onNavigate={vi.fn()} + onGenerateCachedImageUrl={vi.fn(async (url: string) => url)} + /> + , + ); + + expect(screen.queryByRole('button', { name: 'Plain Tag' })).toBeNull(); + expect(screen.getByText('Plain Tag')).toBeTruthy(); + }); }); diff --git a/apps/readest-app/src/app/opds/components/PublicationView.tsx b/apps/readest-app/src/app/opds/components/PublicationView.tsx index fd4a1265..6afa112e 100644 --- a/apps/readest-app/src/app/opds/components/PublicationView.tsx +++ b/apps/readest-app/src/app/opds/components/PublicationView.tsx @@ -13,7 +13,7 @@ import { getImportErrorMessage, ImportError } from '@/services/errors'; import { eventDispatcher } from '@/utils/event'; import { navigateToReader } from '@/utils/nav'; import { CachedImage } from '@/components/CachedImage'; -import { groupByArray } from '../utils/opdsUtils'; +import { groupByArray, getOPDSNavLink } from '../utils/opdsUtils'; import { getOPDSDescriptionHtml } from '../utils/opdsContent'; import Dropdown from '@/components/Dropdown'; import MenuItem from '@/components/MenuItem'; @@ -31,6 +31,7 @@ interface PublicationViewProps { */ existingBook?: Book | null; resolveURL: (url: string, base: string) => string; + onNavigate: (url: string) => void; onDownload: ( href: string, type?: string, @@ -45,6 +46,7 @@ export function PublicationView({ baseURL, existingBook, resolveURL, + onNavigate, onDownload, onStream, onGenerateCachedImageUrl, @@ -92,13 +94,21 @@ export function PublicationView({ const authors = useMemo(() => { const author = publication.metadata?.author; - if (!author) return undefined; + if (!author) return [] as Array<{ name: string; href: string | undefined }>; const authorList = Array.isArray(author) ? author : [author]; - return authorList.map((a) => (typeof a === 'string' ? a : a?.name)).filter(Boolean); + return authorList + .map((a) => + typeof a === 'string' + ? { name: a, href: undefined as string | undefined } + : { name: a?.name, href: getOPDSNavLink(a?.links) }, + ) + .filter((a): a is { name: string; href: string | undefined } => Boolean(a.name)); }, [publication.metadata?.author]); + const authorNames = useMemo(() => authors.map((a) => a.name), [authors]); + const acquisitionLinks = useMemo(() => { const links: Array<{ rel: string; links: OPDSAcquisitionLink[] }> = []; for (const [rel, linkList] of Array.from(linksByRel.entries())) { @@ -191,8 +201,25 @@ export function PublicationView({

{publication.metadata?.title || 'Untitled'}

- {authors && authors.length > 0 && ( -

{authors.join(', ')}

+ {authors.length > 0 && ( +

+ {authors.map((author, index) => ( + + {index > 0 && ', '} + {author.href ? ( + + ) : ( + author.name + )} + + ))} +

)} @@ -288,7 +315,7 @@ export function PublicationView({ link.href!, count, publication.metadata?.title || '', - authors?.join(', ') || '', + authorNames.join(', '), ) } disabled={downloading || !!downloadedBook} @@ -399,12 +426,29 @@ export function PublicationView({ {publication.metadata.subject.map((subject, index: number) => { const tag = typeof subject === 'string' ? subject : subject.name || subject.code || _('Tag'); - return ( -
+ const href = + typeof subject === 'string' ? undefined : getOPDSNavLink(subject.links); + const badgeClass = 'badge badge-outline max-w-full gap-1'; + const inner = ( + <>
{tag}
+ + ); + return href ? ( + + ) : ( +
+ {inner}
); })} diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx index ed1b971b..1822da35 100644 --- a/apps/readest-app/src/app/opds/page.tsx +++ b/apps/readest-app/src/app/opds/page.tsx @@ -957,6 +957,7 @@ export default function BrowserPage() { onDownload={handleDownload} onStream={handleStream} resolveURL={resolveURL} + onNavigate={handleNavigate} onGenerateCachedImageUrl={handleGenerateCachedImageUrl} /> )} diff --git a/apps/readest-app/src/app/opds/utils/opdsUtils.ts b/apps/readest-app/src/app/opds/utils/opdsUtils.ts index 7d79e68f..4a3e0ae1 100644 --- a/apps/readest-app/src/app/opds/utils/opdsUtils.ts +++ b/apps/readest-app/src/app/opds/utils/opdsUtils.ts @@ -128,6 +128,17 @@ export const parseOPDSXML = (text: string): Document => { return doc; }; +/** + * Return the first OPDS-navigable href from a links array (e.g. on an OPDS 2.0 + * JSON author or subject). Only links whose `type` is an OPDS catalog type + * (per foliate-js `isOPDSCatalog`, e.g. `application/opds+json`) qualify, so a + * non-OPDS link such as an author's external homepage is ignored. Returns + * undefined when no such link exists. + */ +export const getOPDSNavLink = ( + links?: Array<{ href?: string; type?: string }>, +): string | undefined => links?.find((link) => link.href && isOPDSCatalog(link.type ?? ''))?.href; + export const isSearchLink = (link: OPDSBaseLink): boolean => { const rels = Array.isArray(link.rel) ? link.rel : [link.rel || '']; if (!rels.includes('search')) return false; diff --git a/apps/readest-app/src/types/opds.ts b/apps/readest-app/src/types/opds.ts index 5714763c..1efc142a 100644 --- a/apps/readest-app/src/types/opds.ts +++ b/apps/readest-app/src/types/opds.ts @@ -117,13 +117,14 @@ export interface OPDSBaseLink { interface OPDSPerson { name?: string; - links: Array<{ href: string }>; + links: Array<{ href: string; type?: string }>; } interface OPDSSubject { name?: string; code?: string; scheme?: string; + links?: Array<{ href: string; type?: string }>; } export interface OPDSContent {