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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-10 12:53:49 +08:00
committed by GitHub
parent 8425d0b91f
commit d12e1ad087
4 changed files with 88 additions and 1 deletions
@@ -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');
+8
View File
@@ -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: {
@@ -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<string>);
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 => {
+2
View File
@@ -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 {