mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): treat typographic quotes as exact phrase search (#3180)
This commit is contained in:
@@ -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<string>} {
|
||||||
|
const phrases: Array<string> = [];
|
||||||
|
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<T extends {content?: string; exact_phrases?: Array<string>}>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type {GuildService} from '@app/api/guild/services/GuildService';
|
|||||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||||
import {GlobalSearchService} from '@app/api/search/GlobalSearchService';
|
import {GlobalSearchService} from '@app/api/search/GlobalSearchService';
|
||||||
|
import {normalizeQuotedPhrases} from '@app/api/search/SearchQuotedPhrases';
|
||||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||||
@@ -58,7 +59,8 @@ export class SearchService {
|
|||||||
data: GlobalSearchMessagesRequest;
|
data: GlobalSearchMessagesRequest;
|
||||||
}): Promise<MessageSearchResponse> {
|
}): Promise<MessageSearchResponse> {
|
||||||
const {userId, requestCache, data} = params;
|
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 contextChannelId = context_channel_id ? createChannelID(context_channel_id) : null;
|
||||||
const contextGuildId = context_guild_id ? createGuildID(context_guild_id) : null;
|
const contextGuildId = context_guild_id ? createGuildID(context_guild_id) : null;
|
||||||
const channelIds = (channel_ids ?? channel_id)?.map((id) => createChannelID(id)) ?? [];
|
const channelIds = (channel_ids ?? channel_id)?.map((id) => createChannelID(id)) ?? [];
|
||||||
|
|||||||
@@ -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<MessageSearchResponse>(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 () => {
|
test('exact_phrases combined with author_id filter', async () => {
|
||||||
const owner = await createTestAccount(harness);
|
const owner = await createTestAccount(harness);
|
||||||
const guild = await createGuild(harness, owner.token, 'Author Filter Phrase Guild');
|
const guild = await createGuild(harness, owner.token, 'Author Filter Phrase Guild');
|
||||||
|
|||||||
Reference in New Issue
Block a user