mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(opds): advertise search inline for clients that skip OpenSearch
Moon+ Reader and CrossPoint never fetch the OpenSearch description document, so they
detected no search capability and hid the search affordance entirely. Both look only for a
rel="search" link whose href already carries {searchTerms}, and they match the link type by
equality rather than by substring.
Emit that templated link alongside the existing OpenSearch pointer, keeping the pointer
first so KOReader's has_opensearch guard still wins and its behaviour is unchanged. The
type stays bare application/atom+xml and the href stays relative like every other href in
the feed, so nothing has to reconstruct an external base URL behind a reverse proxy.
Calibre-Web ships the same two-link pairing.
Add the pair to the libraries, collections, smart-scopes, authors and series feeds too.
Those carried only a self link, so search disappeared once a client descended into a
sub-folder even when it did support OpenSearch.
Closes #860
This commit is contained in:
@@ -71,6 +71,53 @@ describe('OpdsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Moon+ Reader and CrossPoint never fetch the OpenSearch description; they detect search
|
||||
// only from a rel="search" href that already carries {searchTerms} (issue #860).
|
||||
describe('inline templated search link', () => {
|
||||
const feeds: [string, () => string][] = [
|
||||
['root', () => makeService().generateRootNavigation()],
|
||||
['libraries', () => makeService().generateLibrariesNavigation([{ id: 1, name: 'Main', bookCount: 2 }])],
|
||||
['collections', () => makeService().generateCollectionsNavigation([{ id: 1, name: 'Favourites', bookCount: 2 }])],
|
||||
['smart scopes', () => makeService().generateSmartScopesNavigation([{ id: 1, name: 'Unread', icon: null }])],
|
||||
['authors', () => makeService().generateAuthorsNavigation([{ name: 'Brandon Sanderson', bookCount: 2 }])],
|
||||
['series', () => makeService().generateSeriesNavigation([{ id: 1, name: 'Mistborn', bookCount: 2 }])],
|
||||
['acquisition', () => makeService().generateAcquisitionFeed('Catalog', 'urn:bookorbit:catalog', [], 0, 1, 50, `${BASE}/catalog`, 'tok')],
|
||||
];
|
||||
|
||||
it.each(feeds)('%s feed advertises both search links', (_name, generate) => {
|
||||
const xml = generate();
|
||||
|
||||
expect(xml).toContain(`<link rel="search" href="${BASE}/search.opds" type="application/opensearchdescription+xml"/>`);
|
||||
expect(xml).toContain(`<link rel="search" href="${BASE}/catalog?q={searchTerms}" type="application/atom+xml" title="Search"/>`);
|
||||
});
|
||||
|
||||
it('leaves the searchTerms placeholder unescaped so clients can substitute it', () => {
|
||||
const xml = makeService().generateRootNavigation();
|
||||
|
||||
expect(xml).toContain('{searchTerms}');
|
||||
expect(xml).not.toContain('%7BsearchTerms%7D');
|
||||
});
|
||||
|
||||
it('keeps the templated link type free of profile and kind parameters', () => {
|
||||
const xml = makeService().generateRootNavigation();
|
||||
const templated = xml.split('\n').find((line) => line.includes('{searchTerms}'))!;
|
||||
|
||||
expect(templated).toContain('type="application/atom+xml"');
|
||||
expect(templated).not.toContain('profile=opds-catalog');
|
||||
});
|
||||
|
||||
it('matches the template advertised by the OpenSearch description', () => {
|
||||
const service = makeService();
|
||||
const templated = service
|
||||
.generateRootNavigation()
|
||||
.split('\n')
|
||||
.find((line) => line.includes('{searchTerms}'))!;
|
||||
|
||||
expect(service.generateOpenSearchDescription()).toContain(`template="${BASE}/catalog?q={searchTerms}"`);
|
||||
expect(templated).toContain(`href="${BASE}/catalog?q={searchTerms}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateLibrariesNavigation', () => {
|
||||
it('generates entries for each library', () => {
|
||||
const service = makeService();
|
||||
|
||||
@@ -55,3 +55,7 @@ export function fileMimeType(format: string): string {
|
||||
export const OPDS_MIME_NAV = 'application/atom+xml;profile=opds-catalog;kind=navigation';
|
||||
export const OPDS_MIME_ACQ = 'application/atom+xml;profile=opds-catalog;kind=acquisition';
|
||||
export const OPDS_MIME_SEARCH = 'application/opensearchdescription+xml';
|
||||
// Bare atom type, used only on the inline templated search link. Clients that skip
|
||||
// OpenSearch discovery match this type by equality, so the profile/kind parameters
|
||||
// carried by OPDS_MIME_ACQ must not be attached here.
|
||||
export const OPDS_MIME_ATOM = 'application/atom+xml';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { esc, fileMimeType, OPDS_MIME_ACQ, OPDS_MIME_NAV, OPDS_MIME_SEARCH, xmlEl, xmlLink } from './opds-xml.helpers';
|
||||
import { esc, fileMimeType, OPDS_MIME_ACQ, OPDS_MIME_ATOM, OPDS_MIME_NAV, OPDS_MIME_SEARCH, xmlEl, xmlLink } from './opds-xml.helpers';
|
||||
import type { OpdsBookEntry } from './opds-book.service';
|
||||
|
||||
const BASE = '/api/v1/opds';
|
||||
const SEARCH_TEMPLATE = `${BASE}/catalog?q={searchTerms}`;
|
||||
|
||||
@Injectable()
|
||||
export class OpdsService {
|
||||
@@ -13,7 +14,7 @@ export class OpdsService {
|
||||
'bookorbit OPDS Catalog',
|
||||
'urn:bookorbit:root',
|
||||
now,
|
||||
[xmlLink('self', BASE, OPDS_MIME_NAV), xmlLink('start', BASE, OPDS_MIME_NAV), xmlLink('search', `${BASE}/search.opds`, OPDS_MIME_SEARCH)],
|
||||
[xmlLink('self', BASE, OPDS_MIME_NAV), xmlLink('start', BASE, OPDS_MIME_NAV), ...this.searchLinks()],
|
||||
[
|
||||
this.navEntry('urn:bookorbit:all', 'All Books', 'Browse the full catalog', `${BASE}/catalog`, now),
|
||||
this.navEntry('urn:bookorbit:recent', 'Recent Books', 'Recently added books', `${BASE}/recent`, now),
|
||||
@@ -32,7 +33,13 @@ export class OpdsService {
|
||||
const entries = libs.map((lib) =>
|
||||
this.navEntry(`urn:bookorbit:library:${lib.id}`, lib.name, `${lib.bookCount} books`, `${BASE}/catalog?libraryId=${lib.id}`, now),
|
||||
);
|
||||
return this.wrapFeed('Libraries', 'urn:bookorbit:libraries', now, [xmlLink('self', `${BASE}/libraries`, OPDS_MIME_NAV)], entries);
|
||||
return this.wrapFeed(
|
||||
'Libraries',
|
||||
'urn:bookorbit:libraries',
|
||||
now,
|
||||
[xmlLink('self', `${BASE}/libraries`, OPDS_MIME_NAV), ...this.searchLinks()],
|
||||
entries,
|
||||
);
|
||||
}
|
||||
|
||||
generateCollectionsNavigation(cols: { id: number; name: string; bookCount: number }[]): string {
|
||||
@@ -40,7 +47,13 @@ export class OpdsService {
|
||||
const entries = cols.map((col) =>
|
||||
this.navEntry(`urn:bookorbit:collection:${col.id}`, col.name, `${col.bookCount} books`, `${BASE}/catalog?collectionId=${col.id}`, now),
|
||||
);
|
||||
return this.wrapFeed('Collections', 'urn:bookorbit:collections', now, [xmlLink('self', `${BASE}/collections`, OPDS_MIME_NAV)], entries);
|
||||
return this.wrapFeed(
|
||||
'Collections',
|
||||
'urn:bookorbit:collections',
|
||||
now,
|
||||
[xmlLink('self', `${BASE}/collections`, OPDS_MIME_NAV), ...this.searchLinks()],
|
||||
entries,
|
||||
);
|
||||
}
|
||||
|
||||
generateSmartScopesNavigation(items: { id: number; name: string; icon: string | null }[]): string {
|
||||
@@ -54,7 +67,13 @@ export class OpdsService {
|
||||
now,
|
||||
),
|
||||
);
|
||||
return this.wrapFeed('SmartScopes', 'urn:bookorbit:smartScopes', now, [xmlLink('self', `${BASE}/smart-scopes`, OPDS_MIME_NAV)], entries);
|
||||
return this.wrapFeed(
|
||||
'SmartScopes',
|
||||
'urn:bookorbit:smartScopes',
|
||||
now,
|
||||
[xmlLink('self', `${BASE}/smart-scopes`, OPDS_MIME_NAV), ...this.searchLinks()],
|
||||
entries,
|
||||
);
|
||||
}
|
||||
|
||||
generateAuthorsNavigation(items: { name: string; bookCount: number }[]): string {
|
||||
@@ -68,7 +87,13 @@ export class OpdsService {
|
||||
now,
|
||||
),
|
||||
);
|
||||
return this.wrapFeed('Authors', 'urn:bookorbit:authors', now, [xmlLink('self', `${BASE}/authors`, OPDS_MIME_NAV)], entries);
|
||||
return this.wrapFeed(
|
||||
'Authors',
|
||||
'urn:bookorbit:authors',
|
||||
now,
|
||||
[xmlLink('self', `${BASE}/authors`, OPDS_MIME_NAV), ...this.searchLinks()],
|
||||
entries,
|
||||
);
|
||||
}
|
||||
|
||||
generateSeriesNavigation(items: { id?: number; name: string; bookCount: number }[]): string {
|
||||
@@ -82,7 +107,7 @@ export class OpdsService {
|
||||
now,
|
||||
),
|
||||
);
|
||||
return this.wrapFeed('Series', 'urn:bookorbit:series', now, [xmlLink('self', `${BASE}/series`, OPDS_MIME_NAV)], entries);
|
||||
return this.wrapFeed('Series', 'urn:bookorbit:series', now, [xmlLink('self', `${BASE}/series`, OPDS_MIME_NAV), ...this.searchLinks()], entries);
|
||||
}
|
||||
|
||||
generateAcquisitionFeed(
|
||||
@@ -97,11 +122,7 @@ export class OpdsService {
|
||||
): string {
|
||||
const now = new Date().toISOString();
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
const links = [
|
||||
xmlLink('self', selfPath, OPDS_MIME_ACQ),
|
||||
xmlLink('start', BASE, OPDS_MIME_NAV),
|
||||
xmlLink('search', `${BASE}/search.opds`, OPDS_MIME_SEARCH),
|
||||
];
|
||||
const links = [xmlLink('self', selfPath, OPDS_MIME_ACQ), xmlLink('start', BASE, OPDS_MIME_NAV), ...this.searchLinks()];
|
||||
|
||||
const url = new URL(selfPath, 'http://localhost');
|
||||
const pageUrl = (p: number) => {
|
||||
@@ -129,7 +150,7 @@ export class OpdsService {
|
||||
'<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">',
|
||||
` ${xmlEl('ShortName', 'bookorbit OPDS')}`,
|
||||
` ${xmlEl('Description', 'Search the bookorbit book catalog')}`,
|
||||
` <Url type="${esc(OPDS_MIME_ACQ)}" template="${esc(BASE)}/catalog?q={searchTerms}"/>`,
|
||||
` <Url type="${esc(OPDS_MIME_ACQ)}" template="${esc(SEARCH_TEMPLATE)}"/>`,
|
||||
' <InputEncoding>UTF-8</InputEncoding>',
|
||||
' <OutputEncoding>UTF-8</OutputEncoding>',
|
||||
'</OpenSearchDescription>',
|
||||
@@ -189,6 +210,13 @@ export class OpdsService {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Both links are needed. Compliant clients follow the OpenSearch description; Moon+ Reader
|
||||
// and CrossPoint never fetch it and only detect search from a rel="search" href that already
|
||||
// contains {searchTerms}, so without the second link they hide search entirely (issue #860).
|
||||
private searchLinks(): string[] {
|
||||
return [xmlLink('search', `${BASE}/search.opds`, OPDS_MIME_SEARCH), xmlLink('search', SEARCH_TEMPLATE, OPDS_MIME_ATOM, 'Search')];
|
||||
}
|
||||
|
||||
private navEntry(id: string, title: string, content: string, href: string, updated: string): string {
|
||||
return [
|
||||
'<entry>',
|
||||
|
||||
@@ -446,6 +446,21 @@ describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
|
||||
expect(searchResponse.body).toContain('/api/v1/opds/catalog?q={searchTerms}');
|
||||
});
|
||||
|
||||
// Clients that skip OpenSearch discovery need the templated link inline, on every
|
||||
// feed they can land on, or they show no search affordance at all (issue #860).
|
||||
it('advertises the inline templated search link on every navigable feed', async () => {
|
||||
const paths = ['/api/v1/opds', '/api/v1/opds/libraries', '/api/v1/opds/collections', '/api/v1/opds/smart-scopes'];
|
||||
const acquisitionPaths = ['/api/v1/opds/authors', '/api/v1/opds/series', '/api/v1/opds/catalog', '/api/v1/opds/recent'];
|
||||
|
||||
for (const path of [...paths, ...acquisitionPaths]) {
|
||||
const response = await opdsGet(path, ownerCredentials);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain(
|
||||
'<link rel="search" href="/api/v1/opds/catalog?q={searchTerms}" type="application/atom+xml" title="Search"/>',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('scopes libraries, collections, smartScopes, authors, and series to the authenticated parent user', async () => {
|
||||
const librariesResponse = await opdsGet('/api/v1/opds/libraries', ownerCredentials);
|
||||
expect(librariesResponse.statusCode).toBe(200);
|
||||
|
||||
Reference in New Issue
Block a user