From d12e1ad08743b58e0336f0d231d93c500c48fe86 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 10 Jun 2026 12:53:49 +0800 Subject: [PATCH] fix(opds): enable search for OPDS 2.0 JSON catalogs, closes #4502 (#4509) OPDS 2.0 JSON feeds advertise search as a templated link with type `application/opds+json`, `templated: true`, and an RFC 6570 URI template href (e.g. `/search{?query}`). `isSearchLink` only recognized OpenSearch/Atom types, so `hasSearch` was false and the navbar search input stayed disabled (greyed out). Even when enabled, `handleSearch` only handled OpenSearch/Atom, so a query would not reach the server. - Recognize templated `application/opds+json` search links. - Add `expandOPDSSearchTemplate` to expand the URI template (reusing foliate-js/uri-template.js) with the typed term placed in the primary text variable (query/searchTerms/q), then resolve and navigate. Expansion happens before resolveURL, which would otherwise mangle the `{?query}` braces. Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/__tests__/app/opds/opds-utils.test.ts | 52 +++++++++++++++++++ apps/readest-app/src/app/opds/page.tsx | 8 +++ .../src/app/opds/utils/opdsUtils.ts | 27 +++++++++- apps/readest-app/src/types/opds.ts | 2 + 4 files changed, 88 insertions(+), 1 deletion(-) 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 d274eb5f..79dc22ab 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 @@ -47,6 +47,7 @@ import { groupByArray, parseMediaType, isSearchLink, + expandOPDSSearchTemplate, resolveURL, getFileExtFromPath, looksLikeXMLContent, @@ -241,6 +242,25 @@ describe('opdsUtils', () => { expect(isSearchLink(link)).toBe(false); }); + it('should return true for a templated OPDS 2.0 JSON search link', () => { + const link: OPDSBaseLink = { + rel: 'search', + href: '/opds/search{?query}', + type: MIME.OPDS2, + templated: true, + }; + expect(isSearchLink(link)).toBe(true); + }); + + it('should return false for an OPDS 2.0 JSON search link that is not templated', () => { + const link: OPDSBaseLink = { + rel: 'search', + href: '/opds/search', + type: MIME.OPDS2, + }; + expect(isSearchLink(link)).toBe(false); + }); + it('should return false when rel is undefined', () => { const link: OPDSBaseLink = { href: '/search', @@ -259,6 +279,38 @@ describe('opdsUtils', () => { }); }); + describe('expandOPDSSearchTemplate', () => { + it('expands a {?query} template with the search term', () => { + expect(expandOPDSSearchTemplate('/opds/search{?query}', 'dune')).toBe( + '/opds/search?query=dune', + ); + }); + + it('percent-encodes the search term', () => { + expect(expandOPDSSearchTemplate('/opds/search{?query}', 'harry potter')).toBe( + '/opds/search?query=harry%20potter', + ); + }); + + it('fills a {?searchTerms} template variable', () => { + expect(expandOPDSSearchTemplate('/search{?searchTerms}', 'foo')).toBe( + '/search?searchTerms=foo', + ); + }); + + it('fills only the primary text variable, omitting the rest', () => { + expect(expandOPDSSearchTemplate('/search{?query,lang}', 'foo')).toBe('/search?query=foo'); + }); + + it('falls back to the only variable when none is a known query name', () => { + expect(expandOPDSSearchTemplate('/search{?keyword}', 'foo')).toBe('/search?keyword=foo'); + }); + + it('returns the href unchanged when there are no template variables', () => { + expect(expandOPDSSearchTemplate('/search', 'foo')).toBe('/search'); + }); + }); + describe('resolveURL', () => { it('should resolve an absolute URL relative to a base URL', () => { const result = resolveURL('/feed/new', 'https://example.com/opds'); diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx index d1786dfa..ed1b971b 100644 --- a/apps/readest-app/src/app/opds/page.tsx +++ b/apps/readest-app/src/app/opds/page.tsx @@ -26,6 +26,7 @@ import { navigateToReader } from '@/utils/nav'; import { getFileExtFromMimeType } from '@/libs/document'; import { OPDSFeed, OPDSPublication, OPDSSearch, REL } from '@/types/opds'; import { + expandOPDSSearchTemplate, getFileExtFromPath, isSearchLink, looksLikeXMLContent, @@ -419,6 +420,13 @@ export default function BrowserPage() { const searchURL = resolveURL(searchLink.href, state.baseURL); if (searchLink.type === MIME.OPENSEARCH) { handleNavigate(searchURL, true); + } else if (searchLink.type === MIME.OPDS2) { + // OPDS 2.0 JSON: href is an RFC 6570 URI template (e.g. + // `/search{?query}`). Expand it with the typed term BEFORE resolving + // against the base URL — resolveURL would otherwise mangle the + // `{?query}` template braces and drop the query. + const expandedHref = expandOPDSSearchTemplate(searchLink.href, queryTerm); + handleNavigate(resolveURL(expandedHref, state.baseURL), true); } else if (searchLink.type === MIME.ATOM) { const search: OPDSSearch = { metadata: { diff --git a/apps/readest-app/src/app/opds/utils/opdsUtils.ts b/apps/readest-app/src/app/opds/utils/opdsUtils.ts index e6e18a6e..7d79e68f 100644 --- a/apps/readest-app/src/app/opds/utils/opdsUtils.ts +++ b/apps/readest-app/src/app/opds/utils/opdsUtils.ts @@ -1,4 +1,5 @@ import { isOPDSCatalog } from 'foliate-js/opds.js'; +import { replace as expandURITemplate, getVariables } from 'foliate-js/uri-template.js'; import { OPDSBaseLink } from '@/types/opds'; import { EXTS } from '@/libs/document'; import { fetchWithAuth } from './opdsReq'; @@ -26,6 +27,7 @@ export const MIME = { EPUB: 'application/epub+zip', PDF: 'application/pdf', OPENSEARCH: 'application/opensearchdescription+xml', + OPDS2: 'application/opds+json', }; export const enum VALIDATION_ERROR { @@ -128,7 +130,30 @@ export const parseOPDSXML = (text: string): Document => { export const isSearchLink = (link: OPDSBaseLink): boolean => { const rels = Array.isArray(link.rel) ? link.rel : [link.rel || '']; - return rels.includes('search') && (link.type === MIME.OPENSEARCH || link.type === MIME.ATOM); + if (!rels.includes('search')) return false; + return ( + link.type === MIME.OPENSEARCH || + link.type === MIME.ATOM || + // OPDS 2.0 JSON feeds expose search as a templated link whose href is an + // RFC 6570 URI template (e.g. `/search{?query}`). + (link.type === MIME.OPDS2 && !!link.templated) + ); +}; + +// Template variable names that conventionally carry a free-text search query. +const SEARCH_TERM_VARS = ['query', 'searchTerms', 'q']; + +/** + * Expand an OPDS 2.0 search link's RFC 6570 URI template with a single free-text + * query term. The term is placed into the template's primary text variable + * (`query`, `searchTerms`, or `q`; otherwise the first variable). Returns the + * href unchanged when it has no template variables. + */ +export const expandOPDSSearchTemplate = (templateHref: string, queryTerm: string): string => { + const variables = Array.from(getVariables(templateHref) as Set); + const textVar = variables.find((name) => SEARCH_TERM_VARS.includes(name)) ?? variables[0]; + if (!textVar) return templateHref; + return expandURITemplate(templateHref, new Map([[textVar, queryTerm]])); }; export const resolveURL = (url: string, relativeTo: string): string => { diff --git a/apps/readest-app/src/types/opds.ts b/apps/readest-app/src/types/opds.ts index c44a37f5..5714763c 100644 --- a/apps/readest-app/src/types/opds.ts +++ b/apps/readest-app/src/types/opds.ts @@ -111,6 +111,8 @@ export interface OPDSBaseLink { href?: string; type?: string; title?: string; + /** OPDS 2.0 / RFC 6570: href is a URI template (e.g. `/search{?query}`). */ + templated?: boolean; } interface OPDSPerson {