diff --git a/server/src/modules/opds/__tests__/opds.service.test.ts b/server/src/modules/opds/__tests__/opds.service.test.ts index 20963903a..57dcbc161 100644 --- a/server/src/modules/opds/__tests__/opds.service.test.ts +++ b/server/src/modules/opds/__tests__/opds.service.test.ts @@ -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(``); + expect(xml).toContain(``); + }); + + 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(); diff --git a/server/src/modules/opds/opds-xml.helpers.ts b/server/src/modules/opds/opds-xml.helpers.ts index 039110878..81cf979e7 100644 --- a/server/src/modules/opds/opds-xml.helpers.ts +++ b/server/src/modules/opds/opds-xml.helpers.ts @@ -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'; diff --git a/server/src/modules/opds/opds.service.ts b/server/src/modules/opds/opds.service.ts index 6d208d3b5..891d7168a 100644 --- a/server/src/modules/opds/opds.service.ts +++ b/server/src/modules/opds/opds.service.ts @@ -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 { '', ` ${xmlEl('ShortName', 'bookorbit OPDS')}`, ` ${xmlEl('Description', 'Search the bookorbit book catalog')}`, - ` `, + ` `, ' UTF-8', ' UTF-8', '', @@ -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 [ '', diff --git a/server/test/opds-auth-catalog.e2e-spec.ts b/server/test/opds-auth-catalog.e2e-spec.ts index 753cb574b..1872ae99b 100644 --- a/server/test/opds-auth-catalog.e2e-spec.ts +++ b/server/test/opds-auth-catalog.e2e-spec.ts @@ -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( + '', + ); + } + }); + 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);