diff --git a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts index 5f8427117a..1bf655dd95 100644 --- a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts +++ b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts @@ -6,6 +6,7 @@ import { isEmpty, omit } from 'lodash-es'; import { map, type Observable, of, switchMap } from 'rxjs'; import { z } from 'zod'; +import { normalizeSearchText } from '../../../utils/normalize-search-text'; import type { DocsService } from '../../doc/services/docs'; import type { WorkspaceService } from '../../workspace'; @@ -26,24 +27,6 @@ export class DocsSearchService extends Service { errorMessage: null, } as IndexerSyncState); - private normalizeHighlight(value?: string | null) { - if (!value) { - return value ?? ''; - } - try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed)) { - return parsed.join(' '); - } - if (typeof parsed === 'string') { - return parsed; - } - } catch { - // ignore parse errors, return raw value - } - return value; - } - searchTitle$(query: string) { return this.indexer .search$( @@ -144,12 +127,12 @@ export class DocsSearchService extends Service { const firstMatchFlavour = bucket.hits.nodes[0]?.fields.flavour; if (firstMatchFlavour === 'affine:page') { // is title match - const blockContent = this.normalizeHighlight( + const blockContent = normalizeSearchText( bucket.hits.nodes[1]?.highlights.content[0] ); // try to get block content result.push({ docId: bucket.key, - title: this.normalizeHighlight( + title: normalizeSearchText( bucket.hits.nodes[0].highlights.content[0] ), score: bucket.score, @@ -169,7 +152,7 @@ export class DocsSearchService extends Service { ? matchedBlockId : matchedBlockId[0], score: bucket.score, - blockContent: this.normalizeHighlight( + blockContent: normalizeSearchText( bucket.hits.nodes[0]?.highlights.content[0] ), }); diff --git a/packages/frontend/core/src/modules/search-menu/services/index.ts b/packages/frontend/core/src/modules/search-menu/services/index.ts index 21adc30ea8..5ff249b56a 100644 --- a/packages/frontend/core/src/modules/search-menu/services/index.ts +++ b/packages/frontend/core/src/modules/search-menu/services/index.ts @@ -16,6 +16,7 @@ import { html } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { catchError, map, of } from 'rxjs'; +import { normalizeSearchText } from '../../../utils/normalize-search-text'; import type { CollectionMeta, CollectionService } from '../../collection'; import type { DocDisplayMetaService } from '../../doc-display-meta'; import type { DocsSearchService } from '../../docs-search'; @@ -185,14 +186,16 @@ export class SearchMenuService extends Service { typeof node.fields.docId === 'string' ? node.fields.docId : node.fields.docId[0]; - const title = + const title = normalizeSearchText( typeof node.fields.title === 'string' ? node.fields.title - : node.fields.title[0]; + : node.fields.title[0] + ); + const highlights = normalizeSearchText(node.highlights?.title?.[0]); return { id, title, - highlights: node.highlights?.title?.[0], + highlights: highlights || undefined, }; }) ) diff --git a/packages/frontend/core/src/utils/__tests__/normalize-search-text.spec.ts b/packages/frontend/core/src/utils/__tests__/normalize-search-text.spec.ts new file mode 100644 index 0000000000..2af177c1c7 --- /dev/null +++ b/packages/frontend/core/src/utils/__tests__/normalize-search-text.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'vitest'; + +import { normalizeSearchText } from '../normalize-search-text'; + +test('normalizeSearchText should keep plain text unchanged', () => { + expect(normalizeSearchText('hello world')).toBe('hello world'); +}); + +test('normalizeSearchText should decode serialized single-item array', () => { + expect(normalizeSearchText('["hello world"]')).toBe('hello world'); +}); + +test('normalizeSearchText should decode serialized highlighted array', () => { + expect(normalizeSearchText('["hello world"]')).toBe( + 'hello world' + ); +}); + +test('normalizeSearchText should join serialized multi-item array', () => { + expect(normalizeSearchText('["hello","world"]')).toBe('hello world'); +}); + +test('normalizeSearchText should decode serialized string', () => { + expect(normalizeSearchText('"hello world"')).toBe('hello world'); +}); + +test('normalizeSearchText should keep invalid serialized text unchanged', () => { + expect(normalizeSearchText('["hello"')).toBe('["hello"'); +}); + +test('normalizeSearchText should support array input', () => { + expect(normalizeSearchText(['hello', 'world'])).toBe('hello world'); +}); diff --git a/packages/frontend/core/src/utils/index.ts b/packages/frontend/core/src/utils/index.ts index 999b16109b..0db98ed01a 100644 --- a/packages/frontend/core/src/utils/index.ts +++ b/packages/frontend/core/src/utils/index.ts @@ -2,6 +2,7 @@ export * from './channel'; export * from './create-emotion-cache'; export * from './event'; export * from './extract-emoji-icon'; +export * from './normalize-search-text'; export * from './string2color'; export * from './toast'; export * from './unflatten-object'; diff --git a/packages/frontend/core/src/utils/normalize-search-text.ts b/packages/frontend/core/src/utils/normalize-search-text.ts new file mode 100644 index 0000000000..4a99cd6078 --- /dev/null +++ b/packages/frontend/core/src/utils/normalize-search-text.ts @@ -0,0 +1,62 @@ +interface NormalizeSearchTextOptions { + fallback?: string; + arrayJoiner?: string; +} + +function tryParseSerializedText(value: string): unknown | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + // Indexer/storage may serialize text arrays as JSON strings like ["foo"]. + if (!trimmed.startsWith('[') && !trimmed.startsWith('"')) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +export function normalizeSearchText( + value: unknown, + { fallback = '', arrayJoiner = ' ' }: NormalizeSearchTextOptions = {} +): string { + if (value === null || value === undefined) { + return fallback; + } + + if (Array.isArray(value)) { + const normalized = value + .map(item => + normalizeSearchText(item, { + fallback: '', + arrayJoiner, + }) + ) + .filter(Boolean); + + return normalized.length > 0 ? normalized.join(arrayJoiner) : fallback; + } + + if (typeof value !== 'string') { + return String(value); + } + + const parsed = tryParseSerializedText(value); + if (parsed === null) { + return value; + } + + if (typeof parsed === 'string' || Array.isArray(parsed)) { + return normalizeSearchText(parsed, { + fallback, + arrayJoiner, + }); + } + + return value; +}