diff --git a/.changeset/unified-search.md b/.changeset/unified-search.md new file mode 100644 index 00000000..f1377153 --- /dev/null +++ b/.changeset/unified-search.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-core': minor +'@youversion/platform-react-hooks': minor +'@youversion/platform-react-ui': minor +--- + +Add unified search for top verse references and related topics through SearchClient.searchUnified, the standalone searchUnified function, and the useUnifiedSearch React hook. Existing paginated verse search behavior is unchanged. diff --git a/packages/core/README.md b/packages/core/README.md index ca1bfffc..61e56f4f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -61,6 +61,31 @@ const verses = await searchClient.searchVerses('faith', versions.data[0].id, { console.log(trending.queries[0]?.text, verses.verses[0]?.id); ``` +### Unified search + +Use `searchUnified` to fetch top verse references and related topics in one +`GET /v1/search-unified` request: + +```ts +const results = await searchClient.searchUnified('faith', 111, ['en'], { + userIntent: 'topical', + fields: ['verses', 'topics'], +}); +console.log(results.verses[0]?.id, results.topics[0]?.text); +``` + +Omit `fields` or pass an empty array to include all kinds. The response includes +`didYouMean`, `searchInsteadFor`, and an optional `userIntent`. This unpaginated +snapshot contains top results, not the entire verse result set. Use `searchVerses` +for verse pagination. Verse hits contain passage IDs, not passage text; resolve +text through `BibleClient.getPassage`. + +You can also import `searchUnified` and call it with an `ApiClient` as the first +argument, followed by the same arguments as the client method. + +Use supported search language codes such as `en`. Valid language-range syntax +does not guarantee backend support; the SDK does not add fallback ranges. + ### 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..1a73012a --- /dev/null +++ b/packages/core/src/__tests__/search-unified.test.ts @@ -0,0 +1,126 @@ +import { expect, it } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { ApiClient, SearchClient, searchUnified } from '../index'; +import { server } from './setup'; + +const apiHost = process.env.YVP_API_HOST; + +function createApiClient(): ApiClient { + return new ApiClient({ apiHost, appKey: 'test-app', installationId: 'test-installation' }); +} + +it('returns a unified snapshot from one request, mapping hits and metadata without pagination', async () => { + const requests: URL[] = []; + server.use( + http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => { + requests.push(new URL(request.url)); + return HttpResponse.json({ + verses: [{ reference: 'JHN.6.35' }, { reference: 'BAD' }], + topics: [ + { id: 42, text: 'faith', subtopics: ['trust'] }, + { id: null, text: 'belief', subtopics: [] }, + ], + user_intent: 'future-intent', + did_you_mean: ['faith'], + search_instead_for: 'faith', + }); + }), + ); + + const result = await new SearchClient(createApiClient()).searchUnified( + ' faith ', + 111, + ['en_US', 'es'], + { userIntent: 'topical', fields: ['topics', 'verses'] }, + ); + expect(result).toEqual({ + verses: [{ id: 'JHN.6.35' }], + topics: [ + { id: 42, text: 'faith', subtopics: ['trust'] }, + { id: null, text: 'belief', subtopics: [] }, + ], + userIntent: 'future-intent', + didYouMean: ['faith'], + searchInsteadFor: 'faith', + }); + expect(requests).toHaveLength(1); + const params = requests[0]!.searchParams; + expect(params.get('query')).toBe('faith'); + expect(params.get('bible_id')).toBe('111'); + expect(params.getAll('language_ranges[]')).toEqual(['en-us', 'es']); + expect(params.get('user_intent')).toBe('topical'); + expect(params.getAll('fields[]')).toEqual(['topics', 'verses']); + expect(params.has('page_size')).toBe(false); + expect(params.has('page_token')).toBe(false); +}); + +it('omits default intent and empty fields, accepting empty results and nullable metadata', async () => { + const requests: URL[] = []; + server.use( + http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => { + requests.push(new URL(request.url)); + return HttpResponse.json({ + verses: [], + topics: [], + user_intent: null, + did_you_mean: [], + search_instead_for: null, + }); + }), + ); + const client = createApiClient(); + for (const options of [undefined, { fields: [] }]) { + expect(await searchUnified(client, 'faith', 111, '*', options)).toEqual({ + verses: [], + topics: [], + didYouMean: [], + searchInsteadFor: null, + }); + } + for (const url of requests) { + expect(url.searchParams.getAll('language_ranges[]')).toEqual(['*']); + expect(url.searchParams.has('fields[]')).toBe(false); + expect(url.searchParams.has('user_intent')).toBe(false); + } +}); + +it('rejects invalid inputs before networking and rejects malformed unified responses', async () => { + let requests = 0; + let response = HttpResponse.json({}); + server.use( + http.get(`https://${apiHost}/v1/search-unified`, () => { + requests += 1; + return response; + }), + ); + const client = new SearchClient(createApiClient()); + await expect(client.searchUnified(' ', 111, 'en')).rejects.toThrow(/Query must/); + await expect(client.searchUnified('a'.repeat(101), 111, 'en')).rejects.toThrow(/Query must/); + await expect(client.searchUnified('faith', 0, 'en')).rejects.toThrow(/Version ID/); + await expect(client.searchUnified('faith', 111, [])).rejects.toThrow(/language range/); + await expect(client.searchUnified('faith', 111, 'en-abcdefghi')).rejects.toThrow( + /Language range/, + ); + expect(requests).toBe(0); + + const valid = { + verses: [], + topics: [], + user_intent: null, + did_you_mean: [], + search_instead_for: null, + }; + for (const malformed of [ + {}, + { ...valid, topics: null }, + { ...valid, verses: [{ reference: 7 }] }, + { ...valid, topics: [{ text: 'faith', subtopics: [] }] }, + { ...valid, user_intent: undefined }, + { ...valid, search_instead_for: undefined }, + ]) { + response = HttpResponse.json(malformed); + await expect(client.searchUnified('faith', 111, 'en')).rejects.toThrow( + /Unexpected search-unified API response/, + ); + } +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1ddb439f..24cc0280 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -30,6 +30,7 @@ export { SearchClient, 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..07b38599 100644 --- a/packages/core/src/schemas/search.ts +++ b/packages/core/src/schemas/search.ts @@ -261,3 +261,46 @@ export const SearchVersesOptionsSchema = z.object({ }); export type SearchVersesOptions = z.infer; + +export const SearchUnifiedOptionsSchema = z.object({ + /** Omit to use the service default, `unknown`. */ + userIntent: z.optional(z.enum(KNOWN_SEARCH_USER_INTENTS)), + /** Result kinds to include. Omit or pass an empty array for all kinds. */ + fields: z.optional(z.array(z.enum(['verses', 'topics']))), +}); + +export type SearchUnifiedOptions = z.infer; + +/** Top results per kind, not the entire verse result set. No pagination is available. */ +export const SearchUnifiedResponseSchema = z.object({ + verses: z.array(SearchVerseHitSchema), + topics: z.array(SearchTopicSchema), + userIntent: z.optional(z.string()), + didYouMean: z.array(z.string()), + searchInsteadFor: z.nullable(z.string()), +}); + +export type SearchUnifiedResponse = Readonly>; + +export const SearchUnifiedWireSchema = z.object({ + verses: z.array(SearchVerseHitWireSchema), + topics: z.array(z.extend(SearchTopicWireSchema, { id: z.nullable(z.int()) })), + user_intent: z.nullable(z.string()), + did_you_mean: z.array(z.string()), + search_instead_for: z.nullable(z.string()), +}); + +export type SearchUnifiedWire = z.infer; + +export function toSearchUnifiedResponse(wire: SearchUnifiedWire): SearchUnifiedResponse { + const response: SearchUnifiedResponse = { + verses: wire.verses.map(toSearchVerseHit).filter((hit): hit is SearchVerseHit => hit !== null), + topics: wire.topics.map(toSearchTopic), + didYouMean: wire.did_you_mean, + searchInsteadFor: wire.search_instead_for, + }; + if (wire.user_intent != null) { + return { ...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..ffeee18b --- /dev/null +++ b/packages/core/src/search-unified.ts @@ -0,0 +1,53 @@ +import type { ApiClient } from './client'; +import { BibleVersionIdSchema } from './schemas/version'; +import { + parseLanguageRanges, + SearchTextQuerySchema, + SearchUnifiedOptionsSchema, + SearchUnifiedWireSchema, + toSearchUnifiedResponse, + type SearchUnifiedOptions, + type SearchUnifiedResponse, +} from './schemas/search'; + +type SearchUnifiedQuery = { + query: string; + bible_id: number; + 'language_ranges[]': string[]; + user_intent?: string; + 'fields[]'?: string[]; +}; + +/** Fetches one unpaginated snapshot of top verse references and related topics. */ +export async function searchUnified( + client: ApiClient, + query: string, + versionId: number, + languageRanges: string | 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?.length) { + 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..6c62b6de 100644 --- a/packages/core/src/search.ts +++ b/packages/core/src/search.ts @@ -1,10 +1,17 @@ import type { ApiClient } from './client'; import { getSuggestedQueries, getTrendingQueries } from './search-queries'; import { searchTopics } from './search-topics'; +import { searchUnified } from './search-unified'; import { searchVerses, type SearchVersesOptions } from './search-verses'; -import type { SearchQueries, SearchTopicsResponse, SearchVersesResponse } from './schemas/search'; +import type { + SearchQueries, + SearchTopicsResponse, + SearchUnifiedOptions, + SearchUnifiedResponse, + SearchVersesResponse, +} from './schemas/search'; -export type { SearchVersesOptions }; +export type { SearchUnifiedOptions, SearchVersesOptions }; /** * Client for YouVersion Platform Search API endpoints (`/v1/*`). @@ -65,4 +72,22 @@ export class SearchClient { ): Promise { return searchTopics(this.client, query, languageRanges); } + + /** + * Returns one unpaginated snapshot of top verse references and related topics. + * Resolve passage text through the licensed-content endpoints. Use `searchVerses` + * to paginate verse results; this snapshot is not the entire verse result set. + * @param query Search text (1–100 characters). + * @param versionId Bible version identifier (sent as `bible_id`). + * @param languageRanges Ordered Basic Language Ranges, normalized as in `searchTopics`. + * @param options Optional intent and result kinds. Omit fields for all kinds. + */ + async searchUnified( + query: string, + versionId: number, + languageRanges: string | 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..6c7957d0 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -32,6 +32,8 @@ export type { SearchQuery, SearchTopic, SearchTopicsResponse, + 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..41895b8f 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -52,6 +52,32 @@ 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'] }, + { enabled: query.trim().length > 0 }, + ); + 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` or pass an empty array for all kinds. 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. +Verse hits contain passage IDs, not passage text. + ## 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..c17d55d7 --- /dev/null +++ b/packages/hooks/src/useUnifiedSearch.test.tsx @@ -0,0 +1,93 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { expect, it } 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 & { enabled: boolean } = { + query: 'faith', + versionId: 1, + languageRanges: ['en'], + enabled: false, + }; + const { result, rerender } = renderHook( + ({ enabled, ...props }) => useUnifiedSearch(props, { enabled }), + { wrapper, initialProps }, + ); + expect(result.current).toMatchObject({ data: null, loading: false, error: null }); + let props = { ...initialProps, enabled: true }; + 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, enabled: false }); + expect(result.current).toMatchObject({ data: null, loading: false, error: null }); + + const snapshot: SearchUnifiedResponse = { + verses: [], + topics: [], + didYouMean: ['override'], + searchInsteadFor: null, + }; + const overridden = renderHook(() => useUnifiedSearch(initialProps), { + wrapper: createYVWrapper('override-app', { + searchClient, + hookOverrides: { + useUnifiedSearch: () => ({ data: snapshot, loading: false, error: null, refetch() {} }), + }, + }), + }); + expect(overridden.result.current.data).toEqual(snapshot); +}); diff --git a/packages/hooks/src/useUnifiedSearch.ts b/packages/hooks/src/useUnifiedSearch.ts new file mode 100644 index 00000000..cf7209e4 --- /dev/null +++ b/packages/hooks/src/useUnifiedSearch.ts @@ -0,0 +1,44 @@ +'use client'; + +import type { SearchUnifiedOptions, 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< + SearchUnifiedOptions & { + query: string; + versionId: number; + languageRanges: string | string[]; + } +>; + +export type UseUnifiedSearchResult = UseQueryResult; + +/** + * Reads one unpaginated snapshot of top verses and topics, without suggestions or + * automatic submission/debouncing. Pass `enabled: false` until the query is ready. + * `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 result = useApiData( + [...keyBase, 'searchUnified', query, versionId, languageRanges, userIntent, fields], + () => client.searchUnified(query, versionId, languageRanges, { userIntent, fields }), + { + enabled: !override && 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,