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) <noreply@anthropic.com>

* feat(opds): make subject/author links clickable in detail view (#4504)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-10 13:15:03 +08:00
committed by GitHub
parent 553c2b6398
commit 31ebf4b586
6 changed files with 184 additions and 11 deletions
@@ -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();
});
});
});
@@ -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)}
/>
</DropdownProvider>,
@@ -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(
<DropdownProvider>
<PublicationView
publication={linkedPublication}
baseURL='https://gutenberg.example.com/opds'
resolveURL={(href, base) => new URL(href, base).toString()}
onDownload={vi.fn(async () => null)}
onNavigate={onNavigate}
onGenerateCachedImageUrl={vi.fn(async (url: string) => url)}
/>
</DropdownProvider>,
);
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(
<DropdownProvider>
<PublicationView
publication={linkedPublication}
baseURL='https://gutenberg.example.com/opds'
resolveURL={(href, base) => new URL(href, base).toString()}
onDownload={vi.fn(async () => null)}
onNavigate={onNavigate}
onGenerateCachedImageUrl={vi.fn(async (url: string) => url)}
/>
</DropdownProvider>,
);
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(
<DropdownProvider>
<PublicationView
publication={linkedPublication}
baseURL='https://gutenberg.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(screen.queryByRole('button', { name: 'Plain Tag' })).toBeNull();
expect(screen.getByText('Plain Tag')).toBeTruthy();
});
});
@@ -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({
<h1 className='mb-2 text-base font-bold'>
{publication.metadata?.title || 'Untitled'}
</h1>
{authors && authors.length > 0 && (
<p className='text-base-content/70 text-sm'>{authors.join(', ')}</p>
{authors.length > 0 && (
<p className='text-base-content/70 text-sm'>
{authors.map((author, index) => (
<span key={index}>
{index > 0 && ', '}
{author.href ? (
<button
type='button'
onClick={() => onNavigate(resolveURL(author.href!, baseURL))}
className='hover:underline'
>
{author.name}
</button>
) : (
author.name
)}
</span>
))}
</p>
)}
</div>
@@ -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 (
<div key={index} className='badge badge-outline max-w-full gap-1'>
const href =
typeof subject === 'string' ? undefined : getOPDSNavLink(subject.links);
const badgeClass = 'badge badge-outline max-w-full gap-1';
const inner = (
<>
<IoPricetag className='h-3 min-h-3 w-3 min-w-3' />
<div className='truncate' title={tag}>
{tag}
</div>
</>
);
return href ? (
<button
key={index}
type='button'
onClick={() => onNavigate(resolveURL(href, baseURL))}
className={clsx(badgeClass, 'hover:bg-base-200 cursor-pointer')}
>
{inner}
</button>
) : (
<div key={index} className={badgeClass}>
{inner}
</div>
);
})}
+1
View File
@@ -957,6 +957,7 @@ export default function BrowserPage() {
onDownload={handleDownload}
onStream={handleStream}
resolveURL={resolveURL}
onNavigate={handleNavigate}
onGenerateCachedImageUrl={handleGenerateCachedImageUrl}
/>
)}
@@ -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;
+2 -1
View File
@@ -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 {