forked from akai/readest
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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user