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:
@@ -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');
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user