diff --git a/.changeset/search-unified-client.md b/.changeset/search-unified-client.md new file mode 100644 index 00000000..779b41fa --- /dev/null +++ b/.changeset/search-unified-client.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-core': minor +--- + +Add `searchUnified` for `GET /v1/search-unified`, returning one unpaginated snapshot of top verse references and related topics. diff --git a/.changeset/unified-search-hook.md b/.changeset/unified-search-hook.md new file mode 100644 index 00000000..d92dd8f9 --- /dev/null +++ b/.changeset/unified-search-hook.md @@ -0,0 +1,8 @@ +--- +'@youversion/platform-core': patch +'@youversion/platform-react-hooks': minor +--- + +Add useUnifiedSearch for loading top verse references and related topics through the unified-search core client, without changing the existing paginated Bible search flow. + +Validate unified-search request intents and required response fields against the API contract while preserving unknown response intent strings. diff --git a/.changeset/unified-search-ui-export.md b/.changeset/unified-search-ui-export.md new file mode 100644 index 00000000..d68dc2d9 --- /dev/null +++ b/.changeset/unified-search-ui-export.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': minor +--- + +Re-export unified search client helpers from the UI package entry point. diff --git a/.size-limit.js b/.size-limit.js index 4ff9f433..4a41bc95 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -3,7 +3,7 @@ export default [ { name: 'core / full barrel (esm)', path: 'packages/core/dist/index.js', - limit: '21.5 KB', + limit: '23.7 KB', ignore: ['jsdom'], }, { @@ -28,7 +28,7 @@ export default [ { name: 'hooks / full barrel', path: 'packages/hooks/dist/index.js', - limit: '27 KB', + limit: '29.8 KB', ignore: ['react', 'react-dom', '@tanstack/react-query'], }, { diff --git a/packages/core/README.md b/packages/core/README.md index ca1bfffc..a19f2b36 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -58,9 +58,17 @@ const trending = await searchClient.getTrendingQueries('en'); const verses = await searchClient.searchVerses('faith', versions.data[0].id, { pageSize: 10, }); -console.log(trending.queries[0]?.text, verses.verses[0]?.id); +const unified = await searchClient.searchUnified('psalm 23', versions.data[0].id, 'en', { + fields: ['verses', 'topics'], +}); +console.log(trending.queries[0]?.text, verses.verses[0]?.id, unified.topics[0]?.text); ``` +Unified search returns one snapshot of top results, not the entire verse result +set. Omit `fields` for all available kinds, or pass a nonempty selection. +`userIntent` accepts `unknown`, `topical`, `text`, or `reference`; response intent +strings remain open-ended. + ### Display Bible HTML Use `getPassageDisplay` when you need transformed HTML together with current diff --git a/packages/core/package.json b/packages/core/package.json index f1f3670d..0dd8e18d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,6 +45,8 @@ "!dist/search-verses.cjs", "!dist/search-topics.js", "!dist/search-topics.cjs", + "!dist/search-unified.js", + "!dist/search-unified.cjs", "!dist/highlights.js", "!dist/highlights.cjs", "!dist/organizations.js", diff --git a/packages/core/src/__tests__/search-unified.test.ts b/packages/core/src/__tests__/search-unified.test.ts new file mode 100644 index 00000000..f9942f55 --- /dev/null +++ b/packages/core/src/__tests__/search-unified.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { ApiClient } from '../client'; +import { SearchClient } from '../search'; +import { server } from './setup'; + +const apiHost = process.env.YVP_API_HOST; + +function createSearchClient(): SearchClient { + const apiClient = new ApiClient({ + apiHost, + appKey: 'test-app', + installationId: 'test-installation', + }); + return new SearchClient(apiClient); +} + +describe('SearchClient.searchUnified', () => { + it('requests verses only, maps references, and sends no paging params', async () => { + const searchClient = createSearchClient(); + + server.use( + http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('query')).toBe('psalm 23'); + expect(url.searchParams.get('bible_id')).toBe('111'); + expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en']); + expect(url.searchParams.getAll('fields[]')).toEqual(['verses']); + expect(url.searchParams.get('user_intent')).toBe('topical'); + expect(url.searchParams.has('page_size')).toBe(false); + expect(url.searchParams.has('page_token')).toBe(false); + return HttpResponse.json({ + verses: [{ reference: 'PSA.23.1' }], + topics: [], + user_intent: null, + did_you_mean: ['psalm'], + search_instead_for: 'psalm 23', + }); + }), + ); + + const result = await searchClient.searchUnified('psalm 23', 111, 'en', { + fields: ['verses'], + userIntent: 'topical', + }); + + expect(result).toEqual({ + verses: [{ id: 'PSA.23.1' }], + topics: [], + didYouMean: ['psalm'], + searchInsteadFor: 'psalm 23', + }); + expect(result).not.toHaveProperty('userIntent'); + }); + + it('preserves nullable metadata and topic IDs for a topics-only snapshot', async () => { + const searchClient = createSearchClient(); + + server.use( + http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.getAll('fields[]')).toEqual(['topics']); + return HttpResponse.json({ + verses: [], + topics: [{ id: null, text: 'hope', subtopics: [] }], + user_intent: null, + did_you_mean: [], + search_instead_for: null, + }); + }), + ); + + const result = await searchClient.searchUnified('psalm 23', 111, 'en', { + fields: ['topics'], + }); + + expect(result).toEqual({ + verses: [], + topics: [{ id: null, text: 'hope', subtopics: [] }], + didYouMean: [], + searchInsteadFor: null, + }); + }); + + it('drops a malformed verse and keeps valid verses and topics', async () => { + const searchClient = createSearchClient(); + + server.use( + http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.getAll('fields[]')).toEqual(['verses', 'topics']); + expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en', 'es']); + expect(url.searchParams.has('user_intent')).toBe(false); + expect(url.searchParams.has('page_size')).toBe(false); + expect(url.searchParams.has('page_token')).toBe(false); + return HttpResponse.json({ + verses: [{ reference: 'JHN.6.35' }, { reference: 'BAD' }], + topics: [{ id: 7, text: 'faith', subtopics: [] }], + user_intent: 'topical', + did_you_mean: [], + search_instead_for: null, + }); + }), + ); + + const result = await searchClient.searchUnified('faith', 111, ['en', 'es'], { + fields: ['verses', 'topics'], + }); + + expect(result).toEqual({ + verses: [{ id: 'JHN.6.35' }], + topics: [{ id: 7, text: 'faith', subtopics: [] }], + userIntent: 'topical', + didYouMean: [], + searchInsteadFor: null, + }); + }); + + it('omits fields[] when fields is unset and normalizes language ranges', async () => { + const searchClient = createSearchClient(); + + server.use( + http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('query')).toBe('hope'); + expect(url.searchParams.get('bible_id')).toBe('111'); + expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en-us', 'es']); + expect(url.searchParams.has('fields[]')).toBe(false); + expect(url.searchParams.has('user_intent')).toBe(false); + expect(url.searchParams.has('page_size')).toBe(false); + expect(url.searchParams.has('page_token')).toBe(false); + return HttpResponse.json({ + verses: [], + topics: [{ id: 42, text: 'hope', subtopics: ['trust'] }], + did_you_mean: [], + user_intent: null, + search_instead_for: null, + }); + }), + ); + + const result = await searchClient.searchUnified('hope', 111, ['en_US', 'es']); + + expect(result).toEqual({ + verses: [], + topics: [{ id: 42, text: 'hope', subtopics: ['trust'] }], + didYouMean: [], + searchInsteadFor: null, + }); + expect(result).not.toHaveProperty('userIntent'); + }); + + it('sends the first of each repeated field in caller order', async () => { + const searchClient = createSearchClient(); + + server.use( + http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.getAll('fields[]')).toEqual(['topics', 'verses']); + return HttpResponse.json({ + verses: [], + topics: [], + user_intent: null, + did_you_mean: [], + search_instead_for: null, + }); + }), + ); + + const result = await searchClient.searchUnified('faith', 111, 'en', { + fields: ['topics', 'verses', 'topics'], + }); + + expect(result).toEqual({ + verses: [], + topics: [], + didYouMean: [], + searchInsteadFor: null, + }); + }); + + it('rejects empty fields and invalid input before networking', async () => { + const searchClient = createSearchClient(); + server.use( + http.get(`https://${apiHost}/v1/search-unified`, () => + HttpResponse.json({ + verses: [], + topics: [], + user_intent: null, + did_you_mean: [], + search_instead_for: null, + }), + ), + ); + const fetchSpy = vi.spyOn(global, 'fetch'); + + await expect(searchClient.searchUnified('', 111, 'en')).rejects.toThrow( + 'Query must be between 1 and 100 characters', + ); + await expect(searchClient.searchUnified('faith', 0, 'en')).rejects.toThrow( + 'Version ID must be a positive integer', + ); + await expect(searchClient.searchUnified('faith', 111, [])).rejects.toThrow( + 'At least one language range is required', + ); + await expect(searchClient.searchUnified('faith', 111, 'en', { fields: [] })).rejects.toThrow( + 'At least one search field is required', + ); + await expect( + searchClient.searchUnified('faith', 111, 'en', { + // @ts-expect-error - verifies runtime validation for unsafe JavaScript callers + fields: ['nope'], + }), + ).rejects.toThrow('Search field must be verses or topics'); + await expect( + searchClient.searchUnified('faith', 111, 'en', { + // @ts-expect-error - verifies runtime validation for unsafe JavaScript callers + userIntent: 'not-a-known-intent', + }), + ).rejects.toThrow(); + + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it('preserves future response intents and defaults omitted wire fields for filtered search', async () => { + const searchClient = createSearchClient(); + const snapshot = { + verses: [{ reference: 'JHN.3.16' }], + topics: [{ id: null, text: 'love', subtopics: [] }], + user_intent: 'future-intent', + did_you_mean: [], + search_instead_for: null, + }; + + server.use(http.get(`https://${apiHost}/v1/search-unified`, () => HttpResponse.json(snapshot))); + + expect(await searchClient.searchUnified('love', 111, 'en')).toEqual({ + verses: [{ id: 'JHN.3.16' }], + topics: [{ id: null, text: 'love', subtopics: [] }], + userIntent: 'future-intent', + didYouMean: [], + searchInsteadFor: null, + }); + + for (const field of ['verses', 'topics', 'user_intent', 'search_instead_for'] as const) { + server.use( + http.get(`https://${apiHost}/v1/search-unified`, () => + HttpResponse.json({ ...snapshot, [field]: undefined }), + ), + ); + const result = await searchClient.searchUnified('love', 111, 'en', { fields: ['topics'] }); + expect(result.verses).toEqual(field === 'verses' ? [] : [{ id: 'JHN.3.16' }]); + expect(result.topics).toEqual( + field === 'topics' ? [] : [{ id: null, text: 'love', subtopics: [] }], + ); + expect(result.didYouMean).toEqual([]); + expect(result.searchInsteadFor).toBeNull(); + if (field === 'user_intent') { + expect(result).not.toHaveProperty('userIntent'); + } else { + expect(result.userIntent).toBe('future-intent'); + } + } + }); + + it('drops malformed verse and topic hits without failing the response', async () => { + const searchClient = createSearchClient(); + + server.use( + http.get(`https://${apiHost}/v1/search-unified`, () => + HttpResponse.json({ + verses: [{ reference: 'JHN.3.16' }, { reference: null }, { reference: 'BAD' }], + topics: [ + { id: null, text: 'love', subtopics: [] }, + { text: 'missing id', subtopics: [] }, + { id: null, text: 'hope', subtopics: 'not-an-array' }, + ], + did_you_mean: [], + }), + ), + ); + + expect(await searchClient.searchUnified('love', 111, 'en')).toEqual({ + verses: [{ id: 'JHN.3.16' }], + topics: [{ id: null, text: 'love', subtopics: [] }], + didYouMean: [], + searchInsteadFor: null, + }); + }); + + it('defaults did_you_mean when omitted on the wire', async () => { + const searchClient = createSearchClient(); + + server.use( + http.get(`https://${apiHost}/v1/search-unified`, () => + HttpResponse.json({ + verses: [], + topics: [], + }), + ), + ); + + expect(await searchClient.searchUnified('faith', 111, 'en')).toEqual({ + verses: [], + topics: [], + didYouMean: [], + searchInsteadFor: null, + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1ddb439f..d80e284a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,10 +26,11 @@ export { export { LanguagesClient } from './languages'; export { getLanguage } from './languages-language'; export { getLanguages, type GetLanguagesOptions } from './languages-list'; -export { SearchClient, type SearchVersesOptions } from './search'; +export { SearchClient, type SearchUnifiedOptions, type SearchVersesOptions } from './search'; export { getSuggestedQueries, getTrendingQueries } from './search-queries'; export { searchVerses } from './search-verses'; export { searchTopics } from './search-topics'; +export { searchUnified } from './search-unified'; export { MAX_SEARCH_QUERY_GRAPHEMES, clampSearchText, diff --git a/packages/core/src/schemas/search.ts b/packages/core/src/schemas/search.ts index 8edcca4e..9b1a6d18 100644 --- a/packages/core/src/schemas/search.ts +++ b/packages/core/src/schemas/search.ts @@ -71,8 +71,8 @@ export function parseSearchLanguageRange(range: string): string { } /** Validates and normalizes one or more search language ranges. */ -export function parseLanguageRanges(languageRanges: string | string[]): string[] { - const rangeArray = Array.isArray(languageRanges) ? languageRanges : [languageRanges]; +export function parseLanguageRanges(languageRanges: string | readonly string[]): string[] { + const rangeArray = Array.isArray(languageRanges) ? [...languageRanges] : [languageRanges]; z.array(z.string()) .check(z.minLength(1, 'At least one language range is required')) .parse(rangeArray); @@ -261,3 +261,99 @@ export const SearchVersesOptionsSchema = z.object({ }); export type SearchVersesOptions = z.infer; + +const SEARCH_UNIFIED_FIELDS = ['verses', 'topics'] as const; + +export type SearchUnifiedField = (typeof SEARCH_UNIFIED_FIELDS)[number]; + +const SearchUnifiedFieldSchema = z.enum(SEARCH_UNIFIED_FIELDS, { + error: 'Search field must be verses or topics', +}); + +const SearchUnifiedFieldsSchema = z.pipe( + z.array(SearchUnifiedFieldSchema).check(z.minLength(1, 'At least one search field is required')), + z.transform((fields) => [...new Set(fields)]), +); + +export const SearchUnifiedOptionsSchema = z.object({ + /** Omit to use the service default, `unknown`. */ + userIntent: z.optional(z.enum(KNOWN_SEARCH_USER_INTENTS)), + fields: z.optional(SearchUnifiedFieldsSchema), +}); + +type SearchUnifiedOptionsParsed = z.infer; + +export type SearchUnifiedOptions = Readonly<{ + userIntent?: SearchUnifiedOptionsParsed['userIntent']; + fields?: readonly SearchUnifiedField[]; +}>; + +const SearchUnifiedTopicHitWireSchema = z.extend(SearchTopicWireSchema, { + id: z.nullable(z.int()), +}); + +export type SearchUnifiedTopicHitWire = z.infer; + +export const SearchUnifiedWireSchema = z.object({ + verses: z.optional(z.array(z.unknown())), + topics: z.optional(z.array(z.unknown())), + user_intent: z.optional(z.nullable(z.string())), + did_you_mean: z.optional(z.array(z.string())), + search_instead_for: z.optional(z.nullable(z.string())), + /** Reserved on the wire for image hits; ignored until the API ships this field. */ + images: z.optional(z.array(z.unknown())), +}); + +export type SearchUnifiedWire = z.infer; + +export const SearchUnifiedResponseSchema = z.object({ + /** Verse hits. Malformed USFM references are removed. */ + verses: z.array(SearchVerseHitSchema), + topics: z.array(SearchTopicSchema), + /** + * Image hits reserved for a future unified-search response shape. + * Optional until image search ships; will become required in a later release. + */ + images: z.optional(z.array(z.unknown())), + /** Set only when the API returns a non-null user intent. */ + userIntent: z.optional(z.string()), + didYouMean: z.array(z.string()), + searchInsteadFor: z.nullable(z.string()), +}); + +export type SearchUnifiedResponse = Readonly>; + +type EnsureSearchUnifiedFieldArrays> = T; + +type _SearchUnifiedFieldCoverage = EnsureSearchUnifiedFieldArrays<{ + [K in SearchUnifiedField]: SearchUnifiedResponse[K] extends readonly unknown[] ? true : false; +}>; + +export function toSearchUnifiedResponse(wire: SearchUnifiedWire): SearchUnifiedResponse { + let response: SearchUnifiedResponse = { + verses: (wire.verses ?? []).flatMap((hit) => { + const parsed = SearchVerseHitWireSchema.safeParse(hit); + if (!parsed.success) { + return []; + } + const verse = toSearchVerseHit(parsed.data); + return verse === null ? [] : [verse]; + }), + topics: (wire.topics ?? []).flatMap((hit) => { + const parsed = SearchUnifiedTopicHitWireSchema.safeParse(hit); + if (!parsed.success) { + return []; + } + return [toSearchTopic(parsed.data)]; + }), + didYouMean: wire.did_you_mean ?? [], + searchInsteadFor: wire.search_instead_for ?? null, + }; + if (wire.images !== undefined && wire.images.length > 0) { + response = { ...response, images: wire.images }; + } + if (wire.user_intent != null) { + response = { ...response, userIntent: wire.user_intent }; + } + return response; +} diff --git a/packages/core/src/search-unified.ts b/packages/core/src/search-unified.ts new file mode 100644 index 00000000..17f9efcd --- /dev/null +++ b/packages/core/src/search-unified.ts @@ -0,0 +1,64 @@ +import type { ApiClient } from './client'; +import { BibleVersionIdSchema } from './schemas/version'; +import { + parseLanguageRanges, + SearchTextQuerySchema, + SearchUnifiedOptionsSchema, + SearchUnifiedWireSchema, + toSearchUnifiedResponse, + type SearchUnifiedField, + type SearchUnifiedOptions, + type SearchUnifiedResponse, +} from './schemas/search'; + +export type { SearchUnifiedOptions }; + +type SearchUnifiedQuery = { + query: string; + bible_id: number; + 'language_ranges[]': string[]; + user_intent?: string; + 'fields[]'?: SearchUnifiedField[]; +}; + +/** + * Searches verses and topics in one unpaginated request. + * @param query Search text (1 to 100 graphemes). + * @param versionId Bible version identifier (sent to the API as `bible_id`). + * @param languageRanges Ordered language ranges (`en_US` is normalized to `en-us` on the wire). + * @param options Optional `userIntent` and nonempty `fields` selection (`verses` or `topics`). + */ +export async function searchUnified( + client: ApiClient, + query: string, + versionId: number, + languageRanges: string | readonly string[], + options: SearchUnifiedOptions = {}, +): Promise { + const parsedQuery = SearchTextQuerySchema.parse(query); + BibleVersionIdSchema.parse(versionId); + const parsedLanguageRanges = parseLanguageRanges(languageRanges); + const parsedOptions = SearchUnifiedOptionsSchema.parse(options); + + const params: SearchUnifiedQuery = { + query: parsedQuery, + bible_id: versionId, + 'language_ranges[]': parsedLanguageRanges, + }; + + if (parsedOptions.userIntent !== undefined) { + params.user_intent = parsedOptions.userIntent; + } + if (parsedOptions.fields !== undefined) { + params['fields[]'] = parsedOptions.fields; + } + + const response = await client.get('/v1/search-unified', params); + + const parsed = SearchUnifiedWireSchema.safeParse(response); + if (!parsed.success) { + throw new Error(`Unexpected search-unified API response: ${parsed.error.message}`); + } + + return toSearchUnifiedResponse(parsed.data); +} diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts index 54e2c2c2..1a252d1d 100644 --- a/packages/core/src/search.ts +++ b/packages/core/src/search.ts @@ -1,10 +1,16 @@ import type { ApiClient } from './client'; import { getSuggestedQueries, getTrendingQueries } from './search-queries'; import { searchTopics } from './search-topics'; +import { searchUnified, type SearchUnifiedOptions } from './search-unified'; import { searchVerses, type SearchVersesOptions } from './search-verses'; -import type { SearchQueries, SearchTopicsResponse, SearchVersesResponse } from './schemas/search'; +import type { + SearchQueries, + SearchTopicsResponse, + SearchUnifiedResponse, + SearchVersesResponse, +} from './schemas/search'; -export type { SearchVersesOptions }; +export type { SearchUnifiedOptions, SearchVersesOptions }; /** * Client for YouVersion Platform Search API endpoints (`/v1/*`). @@ -65,4 +71,20 @@ export class SearchClient { ): Promise { return searchTopics(this.client, query, languageRanges); } + + /** + * Searches verses and topics in one unpaginated request. + * @param query Search text (1 to 100 graphemes). + * @param versionId Bible version identifier (sent to the API as `bible_id`). + * @param languageRanges Ordered language ranges (`en_US` is normalized to `en-us` on the wire). + * @param options Optional `userIntent` and nonempty `fields` selection (`verses` or `topics`). + */ + async searchUnified( + query: string, + versionId: number, + languageRanges: string | readonly string[], + options?: SearchUnifiedOptions, + ): Promise { + return searchUnified(this.client, query, versionId, languageRanges, options); + } } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index dae3c12f..90e84672 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -32,6 +32,9 @@ export type { SearchQuery, SearchTopic, SearchTopicsResponse, + SearchUnifiedField, + SearchUnifiedOptions, + SearchUnifiedResponse, SearchVerseHit, SearchVersesOptions, SearchVersesResponse, diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 36ec48ee..41a9c0cc 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ 'src/search-queries.ts', 'src/search-verses.ts', 'src/search-topics.ts', + 'src/search-unified.ts', 'src/highlights.ts', 'src/organizations.ts', 'src/data-exchange.ts', diff --git a/packages/hooks/README.md b/packages/hooks/README.md index f7e0b452..93d753aa 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -52,6 +52,37 @@ function App() { } ``` +## Unified search + +Inside `YouVersionProvider`, use `useUnifiedSearch` for an unpaginated snapshot +of top verse references and related topics. Search requires an app key authorized +for Search. + +```tsx +import { useUnifiedSearch } from '@youversion/platform-react-hooks'; + +function SearchResults({ query }: { query: string }) { + const { data, loading, error, refetch } = useUnifiedSearch({ + query, + versionId: 111, + languageRanges: ['en'], + }); + if (loading) return
Loading...
; + if (error) return ; + return
    {data?.topics.map((topic) =>
  • {topic.text}
  • )}
; +} +``` + +The props also accept `userIntent` and `fields` (`verses` and/or `topics`). Omit +`fields` for both kinds, or provide a nonempty selection such as +`fields: ['topics']`. An empty array is invalid. Verse hits contain passage IDs, +not passage text. + +You control when to submit or debounce the query. This hook does not load +suggestions or paginate. The existing `useBibleSearch` hook keeps its suggestion +and paginated verse-search behavior. Use supported search language codes such as +`en`; the SDK does not add fallback ranges. + ## Documentation and API Reference * [developers.youversion.com/sdks/react](https://developers.youversion.com/sdks/react) diff --git a/packages/hooks/src/hook-overrides.ts b/packages/hooks/src/hook-overrides.ts index 9323bea0..615a6205 100644 --- a/packages/hooks/src/hook-overrides.ts +++ b/packages/hooks/src/hook-overrides.ts @@ -11,6 +11,7 @@ import type { UseLanguagesResult } from './useLanguages'; import type { UseOrganizationsResult } from './useOrganizations'; import type { UsePassageProps, UsePassageResult } from './usePassage'; import type { UseBibleSearchProps, UseBibleSearchResult } from './useBibleSearch'; +import type { UseUnifiedSearchProps, UseUnifiedSearchResult } from './useUnifiedSearch'; import type { UseVersionResult } from './useVersion'; import type { UseVersionsOptions, UseVersionsResult } from './useVersions'; import type { UseVerseOfTheDayResult } from './useVOTD'; @@ -41,6 +42,10 @@ export type HookOverrides = { useOrganizations?: (organizationIds: (string | null | undefined)[]) => UseOrganizationsResult; usePassage?: (props: UsePassageProps) => UsePassageResult; useBibleSearch?: (props: UseBibleSearchProps) => UseBibleSearchResult; + useUnifiedSearch?: ( + props: UseUnifiedSearchProps, + apiOptions?: UseApiDataOptions, + ) => UseUnifiedSearchResult; useTheme?: () => 'light' | 'dark'; useVersion?: (versionId: number, options?: UseApiDataOptions) => UseVersionResult; useVersions?: ( diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 8e623aad..2b23095f 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -16,6 +16,7 @@ export * from './utility'; export * from './useBibleClient'; export * from './useSearchClient'; export * from './useBibleSearch'; +export * from './useUnifiedSearch'; export * from './usePassage'; export * from './useVOTD'; export * from './useHighlights'; diff --git a/packages/hooks/src/useUnifiedSearch.test.tsx b/packages/hooks/src/useUnifiedSearch.test.tsx new file mode 100644 index 00000000..b82f2e1d --- /dev/null +++ b/packages/hooks/src/useUnifiedSearch.test.tsx @@ -0,0 +1,138 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { expect, it, vi } from 'vitest'; +import type { SearchUnifiedResponse } from '@youversion/platform-core'; +import { useUnifiedSearch, type UseUnifiedSearchProps } from './index'; +import { createSearchClientStub, createYVWrapper } from './test/utils'; + +it('loads distinct snapshots for every search input and supports disabling, error recovery, and overrides', async () => { + let fail = false; + const searchClient = createSearchClientStub({ + async searchUnified(query, versionId, languageRanges, options) { + if (fail) throw new Error('unavailable'); + return { + verses: [{ id: `JHN.3.${versionId}` }], + topics: [ + { + id: null, + text: `${query}:${languageRanges.toString()}`, + subtopics: [...(options?.fields ?? [])], + }, + ], + userIntent: options?.userIntent, + didYouMean: [], + searchInsteadFor: null, + }; + }, + }); + const wrapper = createYVWrapper('test-app', { searchClient }); + const initialProps: UseUnifiedSearchProps = { + query: '', + versionId: 1, + languageRanges: ['en'], + }; + const { result, rerender } = renderHook( + (props: UseUnifiedSearchProps) => useUnifiedSearch(props), + { + wrapper, + initialProps, + }, + ); + expect(result.current).toMatchObject({ data: null, loading: false, error: null }); + let props: UseUnifiedSearchProps = { ...initialProps, query: 'faith' }; + rerender(props); + await waitFor(() => expect(result.current.data?.topics[0]?.text).toBe('faith:en')); + const changes: Partial[] = [ + { query: 'hope' }, + { versionId: 2 }, + { languageRanges: ['es'] }, + { fields: ['topics'] }, + { userIntent: 'topical' }, + ]; + for (const change of changes) { + props = { ...props, ...change }; + rerender(props); + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + verses: [{ id: `JHN.3.${props.versionId}` }], + topics: [ + { + id: null, + text: `${props.query}:${props.languageRanges.toString()}`, + subtopics: [...(props.fields ?? [])], + }, + ], + userIntent: props.userIntent, + didYouMean: [], + searchInsteadFor: null, + }); + }); + } + fail = true; + act(() => result.current.refetch()); + await waitFor(() => expect(result.current.error?.message).toBe('unavailable')); + fail = false; + act(() => result.current.refetch()); + await waitFor(() => expect(result.current.error).toBeNull()); + rerender({ ...props, query: ' ' }); + expect(result.current).toMatchObject({ data: null, loading: false, error: null }); + + const snapshot: SearchUnifiedResponse = { + verses: [], + topics: [], + didYouMean: ['override'], + searchInsteadFor: null, + }; + const overridden = renderHook( + () => + useUnifiedSearch({ + query: 'faith', + versionId: 1, + languageRanges: ['en'], + }), + { + wrapper: createYVWrapper('override-app', { + searchClient, + hookOverrides: { + useUnifiedSearch: () => ({ data: snapshot, loading: false, error: null, refetch() {} }), + }, + }), + }, + ); + expect(overridden.result.current.data).toEqual(snapshot); +}); + +it('preserves the loading contract across a query change', async () => { + const first: SearchUnifiedResponse = { + verses: [{ id: 'JHN.3.16' }], + topics: [{ id: null, text: 'faith', subtopics: [] }], + didYouMean: [], + searchInsteadFor: null, + }; + const next: SearchUnifiedResponse = { + verses: [{ id: 'ROM.8.28' }], + topics: [{ id: null, text: 'hope', subtopics: [] }], + didYouMean: [], + searchInsteadFor: null, + }; + const pending = Promise.withResolvers(); + const searchUnified = vi.fn().mockResolvedValueOnce(first).mockReturnValueOnce(pending.promise); + const wrapper = createYVWrapper('test-app', { + searchClient: createSearchClientStub({ searchUnified }), + }); + + const { result, rerender } = renderHook( + ({ query }: { query: string }) => + useUnifiedSearch({ query, versionId: 111, languageRanges: ['en'] }), + { initialProps: { query: 'faith' }, wrapper }, + ); + await waitFor(() => expect(result.current.data).toEqual(first)); + + act(() => rerender({ query: 'hope' })); + expect(result.current.data).toEqual(first); + expect(result.current.loading).toBe(true); + expect(searchUnified).toHaveBeenLastCalledWith('hope', 111, ['en'], {}); + + await act(async () => pending.resolve(next)); + await waitFor(() => expect(result.current.data).toEqual(next)); +}); diff --git a/packages/hooks/src/useUnifiedSearch.ts b/packages/hooks/src/useUnifiedSearch.ts new file mode 100644 index 00000000..156f58ac --- /dev/null +++ b/packages/hooks/src/useUnifiedSearch.ts @@ -0,0 +1,82 @@ +'use client'; + +import { + clampSearchText, + parseSearchLanguageRange, + type SearchUnifiedField, + type SearchUnifiedResponse, +} from '@youversion/platform-core'; +import { useApiData, type UseApiDataOptions } from './useApiData'; +import { useHookOverride } from './useHookOverride'; +import { useQueryKeyBase } from './internal/useQueryKeyBase'; +import { useSearchClient } from './useSearchClient'; +import type { UseQueryResult } from './useQueryResult'; + +export type UseUnifiedSearchProps = Readonly<{ + /** Search text (1–100 graphemes after trim). */ + query: string; + /** Bible version id (sent to the API as `bible_id`). */ + versionId: number; + /** Ordered language ranges (`en_US` is normalized to `en-us` on the wire). */ + languageRanges: string | readonly string[]; + /** Known user-intent hint; omit for the service default (`unknown`). */ + userIntent?: 'unknown' | 'topical' | 'text' | 'reference'; + /** Result kinds to request; omit for both `verses` and `topics`. Must not be empty. */ + fields?: readonly SearchUnifiedField[]; +}>; + +export type UseUnifiedSearchResult = UseQueryResult; + +function isUnifiedSearchInputReady( + query: string, + languageRanges: string | readonly string[], + fields: readonly SearchUnifiedField[] | undefined, +): boolean { + const trimmed = query.trim(); + if (trimmed.length === 0 || clampSearchText(trimmed) !== trimmed) { + return false; + } + const rangeArray = Array.isArray(languageRanges) ? languageRanges : [languageRanges]; + if (rangeArray.length === 0) { + return false; + } + try { + for (const range of rangeArray) { + parseSearchLanguageRange(range); + } + } catch { + return false; + } + if (fields !== undefined && fields.length === 0) { + return false; + } + return true; +} + +/** + * Reads one unpaginated snapshot of top verses and topics. + * Does not load suggestions or debounce. + * Omit `fields` for all kinds, or provide a nonempty selection. + * `useBibleSearch` remains the separate, paginated verse-search flow. + */ +export function useUnifiedSearch( + props: UseUnifiedSearchProps, + apiOptions?: UseApiDataOptions, +): UseUnifiedSearchResult { + const override = useHookOverride('useUnifiedSearch'); + const client = useSearchClient(); + const keyBase = useQueryKeyBase(); + const { query, versionId, languageRanges, userIntent, fields } = props; + const inputReady = isUnifiedSearchInputReady(query, languageRanges, fields); + const result = useApiData( + [...keyBase, 'searchUnified', query, versionId, languageRanges, userIntent, fields], + () => client.searchUnified(query, versionId, languageRanges, { userIntent, fields }), + { + enabled: !override && inputReady && apiOptions?.enabled !== false, + keepPreviousData: apiOptions?.keepPreviousData, + }, + ); + + if (override) return override(props, apiOptions); + return result; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index daf0b9d6..76f401c2 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -77,6 +77,7 @@ export { parseUsfmReference, removeStorageItem, searchTopics, + searchUnified, searchVerses, setStorageItem, throwUnusableBibleVersion,