diff --git a/fluxer_api/src/api/search/SearchQuotedPhrases.ts b/fluxer_api/src/api/search/SearchQuotedPhrases.ts new file mode 100644 index 000000000..8515c4c15 --- /dev/null +++ b/fluxer_api/src/api/search/SearchQuotedPhrases.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const QUOTE_CHARS = new Set(['"', '“', '”', '„', '‟', '″', '«', '»', '"']); + +function extractQuotedPhrases(input: string): {rest: string; phrases: Array} { + const phrases: Array = []; + let rest = ''; + let i = 0; + while (i < input.length) { + if (!QUOTE_CHARS.has(input[i])) { + rest += input[i]; + i++; + continue; + } + i++; + let phrase = ''; + while (i < input.length && !QUOTE_CHARS.has(input[i])) { + phrase += input[i]; + i++; + } + i++; + const trimmed = phrase.trim(); + if (trimmed) phrases.push(trimmed); + rest += ' '; + } + return {rest: rest.replace(/\s+/g, ' ').trim(), phrases}; +} + +function stripWrappingQuotes(phrase: string): string { + let start = 0; + let end = phrase.length; + while (start < end && QUOTE_CHARS.has(phrase[start])) start++; + while (end > start && QUOTE_CHARS.has(phrase[end - 1])) end--; + return phrase.slice(start, end).trim(); +} + +export function normalizeQuotedPhrases}>(params: T): T { + const exactPhrases = (params.exact_phrases ?? []).map(stripWrappingQuotes).filter(Boolean); + let content = params.content; + if (content) { + const extracted = extractQuotedPhrases(content); + exactPhrases.push(...extracted.phrases); + content = extracted.rest || undefined; + } + return { + ...params, + content, + exact_phrases: exactPhrases.length > 0 ? [...new Set(exactPhrases)] : undefined, + }; +} diff --git a/fluxer_api/src/api/search/SearchService.ts b/fluxer_api/src/api/search/SearchService.ts index d30662d87..b9f483b54 100644 --- a/fluxer_api/src/api/search/SearchService.ts +++ b/fluxer_api/src/api/search/SearchService.ts @@ -7,6 +7,7 @@ import type {GuildService} from '@app/api/guild/services/GuildService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import {GlobalSearchService} from '@app/api/search/GlobalSearchService'; +import {normalizeQuotedPhrases} from '@app/api/search/SearchQuotedPhrases'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; @@ -58,7 +59,8 @@ export class SearchService { data: GlobalSearchMessagesRequest; }): Promise { const {userId, requestCache, data} = params; - const {channel_id, channel_ids, context_channel_id, context_guild_id, ...searchParams} = data; + const {channel_id, channel_ids, context_channel_id, context_guild_id, ...rawSearchParams} = data; + const searchParams = normalizeQuotedPhrases(rawSearchParams); const contextChannelId = context_channel_id ? createChannelID(context_channel_id) : null; const contextGuildId = context_guild_id ? createGuildID(context_guild_id) : null; const channelIds = (channel_ids ?? channel_id)?.map((id) => createChannelID(id)) ?? []; diff --git a/fluxer_api/src/api/search/tests/MessageSearchExactPhrase.test.ts b/fluxer_api/src/api/search/tests/MessageSearchExactPhrase.test.ts index f0b86022b..6cd3872da 100644 --- a/fluxer_api/src/api/search/tests/MessageSearchExactPhrase.test.ts +++ b/fluxer_api/src/api/search/tests/MessageSearchExactPhrase.test.ts @@ -145,6 +145,28 @@ describe('Message Search Exact Phrases', () => { } } }); + test('typographic quotes in content are treated as exact phrases', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Smart Quote Guild'); + const channelId = guild.system_channel_id!; + const timestamp = Date.now(); + const tag = `sq-${timestamp}`; + await sendMessage(harness, owner.token, channelId, `${tag} hello world`); + await sendMessage(harness, owner.token, channelId, `${tag} world hello`); + await markChannelAsIndexed(harness, channelId); + const result = await createBuilder(harness, owner.token) + .post('/search/messages') + .body({ + content: `${tag} \u201chello world\u201d`, + context_channel_id: channelId, + }) + .expect(HTTP_STATUS.OK) + .execute(); + expect(isSearchResult(result)).toBe(true); + if (isSearchResult(result)) { + expect(result.messages.map((m) => m.content)).toEqual([`${tag} hello world`]); + } + }); test('exact_phrases combined with author_id filter', async () => { const owner = await createTestAccount(harness); const guild = await createGuild(harness, owner.token, 'Author Filter Phrase Guild');