generated from ttipo/project-base
fix(backend): 메일함 안 검색을 찾기 GIN 하나로 거르고 미리 보기 · 본문 강조를 고친다
14단계 실사용 시험(2026-11-06)에서 나온 것을 고친다. - 1만 통 메일함 안 검색이 1.1초였다. 계획기가 메일함만 담은 GIN(메일함 id 가 계정마다 같은 꼴이라 posting 이 회사 전체)으로 그 메일함 줄을 다 읽고 묶음 배열을 줄마다 풀었다. 직원 · 묶음 · 메일함을 한 색인에 담은 GIN 하나로 바꾸고 옛 GIN 둘을 지운다(마이그레이션 SearchIndexUserGramsMailboxes — 큰 메일함 15ms · 작은 메일함 1ms · 전체 15ms) - 한 글자 낱말뿐이면 본문을 찾지 않는데 본문 글자를 강조했다 — 본문 강조는 본문을 찾은 두 글자 이상 낱말만 - 답장의 결과 미리 보기가 원문 인용 조각이었다 — 인용 줄 밖의 맞은 곳을 먼저, 인용 안에서만 맞고 다른 칸에서도 맞았으면 본문 첫머리, 인용 조각은 줄 앞 `>` 를 뗀다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,8 @@ import type { SqlQuery } from './search-sql';
|
||||
/**
|
||||
* 대화의 SQL (순수 함수 — 14단계 설계 C3 · C4). 값은 모두 매개변수다.
|
||||
* 언제나 그 직원 주소(`"user_email" = $1` — btree)로 먼저 좁힌다. 메일함 조건은 `$n = ANY("mailbox_ids")` 로 적는다 —
|
||||
* `mailbox_ids` GIN 은 회사 전체 posting 이라(메일함 id 가 계정마다 같은 꼴) 쓰지 않게(리뷰 — 그 직원의 줄에서만 거른다)
|
||||
* 메일함의 posting 은 회사 전체라(메일함 id 가 계정마다 같은 꼴) GIN 으로 거르지 않고 그 직원의 줄에서만 거른다(리뷰).
|
||||
* 메일함 칸을 담은 GIN 은 찾기 GIN(lower 식 · 묶음 · 메일함) 하나뿐이고 이 쿼리들은 그것을 쓰지 않는다
|
||||
*/
|
||||
|
||||
const TABLE = `"${SEARCH_MESSAGES_TABLE}"`;
|
||||
|
||||
@@ -97,7 +97,7 @@ function normalize(user: string): string {
|
||||
* - 같은 대화: 그 메일의 키와 그 키의 줄을 한 문장으로(3차 리뷰 L2), 오래된 차례 · 커서(conversation-cursor.ts 의 thread)
|
||||
* - 묶은 목록: 그 메일함 안의 줄을 대화 키로 묶어 마지막 받은 시각 최신부터 · 커서(list), 그 쪽의 대화만 자세히(통 수 · 그 메일함 안 id(최신부터
|
||||
* CONVERSATION_MESSAGE_IDS_MAX 통까지) · 읽음 …)와 참여자(첫 보낸 사람 칸 — 주소마다 한 번, CONVERSATION_PARTICIPANTS_MAX 명까지)
|
||||
* 모든 쿼리는 그 직원 주소로 먼저 좁힌다 — 메일함 GIN(회사 전체 posting)을 쓰지 않는다(conversation-sql.ts)
|
||||
* 모든 쿼리는 그 직원 주소(btree)로 먼저 좁히고 메일함은 그 줄에서 거른다 — 메일함의 GIN posting(회사 전체)을 쓰지 않는다(conversation-sql.ts)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailConversationQueryService {
|
||||
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
* 접기는 코드 포인트 하나씩 바꾸므로 두 글의 위치가 같다. grams 는 다섯 접기 칸의 글자 두 개 묶음(겹치지 않게 — 칸 경계 · 공백을 넘지 않는다)이고
|
||||
* GIN 으로 후보를 좁힌다.
|
||||
*
|
||||
* GIN 색인 셋((lower(user_email), grams) · mailbox_ids · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다
|
||||
* (migration:generate 가 지우지 않게). 묶음 GIN 은 직원 주소와 함께다(btree_gin — 14단계 2차 `SearchIndexUserGrams`). 다만 GIN 의 posting 은 칸마다 따로라
|
||||
* 흔한 묶음은 **회사 전체** 줄의 posting 을 읽어 그 직원 주소의 posting 과 맞춘다(그 직원의 줄만 읽는 것이 아니다 — 10명 흉내에서 '계약' 7만여 줄이 34쪽 · 수 ms).
|
||||
* 주소 칸은 lower 식이라 후보 쿼리(search-sql.ts 의 candidatesSql)만 이 색인을 쓴다. mailbox_ids GIN 도 회사 전체다 — 메일함 id 는 계정마다 같은 꼴(a · b …)이다.
|
||||
* 계획기는 그 메일함 id 가 회사 전체에서도 적을 때만 묶음 GIN 과 함께(BitmapAnd) 쓴다(2026-11-06 10명 흉내 — 큰 메일함은 쓰지 않고 13~20ms, 작은 메일함은 함께 써 4~9ms)
|
||||
* GIN 색인 둘((lower(user_email), grams, mailbox_ids) · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다
|
||||
* (migration:generate 가 지우지 않게). 찾기 GIN 은 직원 주소 · 묶음 · 메일함을 한 색인에 담는다(btree_gin — 14단계 2차 `SearchIndexUserGrams` →
|
||||
* 실사용 시험 `SearchIndexUserGramsMailboxes`). GIN 의 posting 은 칸마다 따로라 흔한 묶음 · 메일함 id 는 **회사 전체** 줄의 posting 이다
|
||||
* (메일함 id 는 계정마다 같은 꼴(a · b …)이다) — 색인 하나가 그 직원 주소의 posting 과 맞추고 맞은 줄만 읽는다(10명 흉내에서 '계약' 7만여 줄 · 수 ms).
|
||||
* 주소 칸은 lower 식이라 후보 쿼리(search-sql.ts 의 candidatesSql)만 이 색인을 쓴다. **메일함만 담은 GIN 을 다시 만들지 않는다** — 계획기가 그것만으로
|
||||
* 메일함의 줄을 모두 읽고 묶음 배열(TOAST)을 줄마다 풀어 보는 길을 고를 수 있게 된다(2026-11-06 — 1만 통 메일함 안 1.1초 · 남의 큰 메일함과 id 가 같은 빈 메일함 1.6초)
|
||||
*/
|
||||
@Entity({ name: SEARCH_MESSAGES_TABLE })
|
||||
@Index('IDX_mail_search_messages_received', [
|
||||
@@ -29,8 +30,7 @@ import {
|
||||
])
|
||||
@Index('IDX_mail_search_messages_message_id', ['userEmail', 'messageId'])
|
||||
@Index('IDX_mail_search_messages_thread_key', ['userEmail', 'threadKey'])
|
||||
@Index('IDX_mail_search_messages_user_grams', { synchronize: false })
|
||||
@Index('IDX_mail_search_messages_mailbox_ids', { synchronize: false })
|
||||
@Index('IDX_mail_search_messages_user_grams_mailboxes', { synchronize: false })
|
||||
@Index('IDX_mail_search_messages_refs', { synchronize: false })
|
||||
export class MailSearchMessageEntity {
|
||||
@PrimaryColumn({
|
||||
|
||||
@@ -463,10 +463,19 @@ describe('MailSearchQueryService (색인으로 찾기 — 14단계 설계 G)', (
|
||||
expect(recorded).toEqual([]);
|
||||
});
|
||||
|
||||
it('강조에 쓰는 글 — 찾은 쪽의 줄만 한 번에, id 가 없으면 묻지 않는다', async () => {
|
||||
it('강조에 쓰는 글 — 찾은 쪽의 줄만 한 번에(본문 밖의 접은 칸 넷도 — 미리 보기 고르기), id 가 없으면 묻지 않는다', async () => {
|
||||
const { service, recorded } = fakeDataSource({
|
||||
highlight: () => [
|
||||
{ email_id: 'm1', subject: '계약', body_text: '본문', preview: '미리' },
|
||||
{
|
||||
email_id: 'm1',
|
||||
subject: '계약',
|
||||
body_text: '본문',
|
||||
preview: '미리',
|
||||
fold_from: '이도윤 <doyun.lee@arasystem.test>',
|
||||
fold_to: '김아라 <ara.kim@arasystem.test>',
|
||||
fold_subject: '계약',
|
||||
fold_attachment: '계약서.pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -476,7 +485,20 @@ describe('MailSearchQueryService (색인으로 찾기 — 14단계 설계 G)', (
|
||||
const texts = await service.highlightTexts(USER, ['m1', 'm2']);
|
||||
expect(recorded[0].params).toEqual([USER, ['m1', 'm2']]);
|
||||
expect([...texts.entries()]).toEqual([
|
||||
['m1', { subject: '계약', bodyText: '본문', preview: '미리' }],
|
||||
[
|
||||
'm1',
|
||||
{
|
||||
subject: '계약',
|
||||
bodyText: '본문',
|
||||
preview: '미리',
|
||||
otherFields: [
|
||||
'이도윤 <doyun.lee@arasystem.test>',
|
||||
'김아라 <ara.kim@arasystem.test>',
|
||||
'계약',
|
||||
'계약서.pdf',
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface IndexHighlightText {
|
||||
readonly subject: string;
|
||||
readonly bodyText: string;
|
||||
readonly preview: string;
|
||||
/** 본문 밖의 접은 칸 — 보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 (미리 보기 고르기 — search-highlight.ts) */
|
||||
readonly otherFields: readonly string[];
|
||||
}
|
||||
|
||||
/** 쿼리의 줄 */
|
||||
@@ -68,7 +70,9 @@ interface Found {
|
||||
|
||||
/** 계획기를 거드는 설정 — 그 쿼리만 (SET LOCAL — 트랜잭션이 끝나면 돌아온다) */
|
||||
type PlannerHint =
|
||||
/** 후보 — GIN 의 bitmap 으로만 (줄마다 묶음 배열을 풀어 보는 순차 읽기를 고르지 않게) */
|
||||
/**
|
||||
* 후보 — 찾기 GIN(주소 · 묶음 · 메일함 — 이것 말고 받을 색인이 없다)의 bitmap 으로만 (줄마다 묶음 배열을 풀어 보는 순차 읽기를 고르지 않게)
|
||||
*/
|
||||
| 'bitmap'
|
||||
/** 받은 차례 색인으로 훑는다 — 계획기가 그 직원의 줄을 모두 모은 뒤 놓지 않게 */
|
||||
| 'keyset';
|
||||
@@ -76,7 +80,10 @@ type PlannerHint =
|
||||
/** 한 스냅숏 안의 쿼리 하나 */
|
||||
type Ask = <T>(query: SqlQuery) => Promise<T>;
|
||||
|
||||
const PLANNER_SETTINGS: Readonly<Record<PlannerHint, readonly string[]>> = {
|
||||
/** 계획기 설정 — e2e 의 계획 시험(실제 PostgreSQL)도 같은 설정으로 본다 */
|
||||
export const PLANNER_SETTINGS: Readonly<
|
||||
Record<PlannerHint, readonly string[]>
|
||||
> = {
|
||||
bitmap: [
|
||||
'SET LOCAL enable_seqscan = off',
|
||||
'SET LOCAL enable_indexscan = off',
|
||||
@@ -131,7 +138,7 @@ function normalize(user: string): string {
|
||||
* 색인으로 찾기 (14단계 설계 C1 · G — 2026-11-05 다시 잼) — 계획(search-plan.ts)을 쿼리(search-sql.ts)로 돌린다. 엔진을 모른다.
|
||||
*
|
||||
* - 낱말 확인이 없으면(기간 · 첨부 · 메일함만): 받은 차례로 바로
|
||||
* - 묶음이 있으면: 후보를 주소 + 묶음(GIN)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다(줄의 글을 풀지 않는다). 그다음
|
||||
* - 묶음이 있으면: 후보를 주소 + 묶음 + 메일함(GIN 하나)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다(줄의 글을 풀지 않는다). 그다음
|
||||
* - 두 글자 낱말뿐(exactByGrams)이면 후보가 곧 결과
|
||||
* - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인 — 첫 쪽의 total 을 안다
|
||||
* - 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다.
|
||||
@@ -188,6 +195,10 @@ export class MailSearchQueryService {
|
||||
subject: string;
|
||||
body_text: string;
|
||||
preview: string;
|
||||
fold_from: string;
|
||||
fold_to: string;
|
||||
fold_subject: string;
|
||||
fold_attachment: string;
|
||||
}[]
|
||||
>(query.sql, query.params);
|
||||
return new Map(
|
||||
@@ -197,6 +208,12 @@ export class MailSearchQueryService {
|
||||
subject: row.subject,
|
||||
bodyText: row.body_text,
|
||||
preview: row.preview,
|
||||
otherFields: [
|
||||
row.fold_from,
|
||||
row.fold_to,
|
||||
row.fold_subject,
|
||||
row.fold_attachment,
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -121,12 +121,14 @@ export const SEARCH_CRON_TIME_ZONE = 'Asia/Seoul';
|
||||
export const INDEX_FORMAT = 4;
|
||||
|
||||
/**
|
||||
* 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이 비싸 그 수를 묶는다:
|
||||
* - 후보: 주소 + 묶음(GIN — 마이그레이션 SearchIndexUserGrams 의 lower 식)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다
|
||||
* (줄의 글을 풀지 않는다 — 1만 통에 10ms 안팎)
|
||||
* 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼 · 2026-11-06 메일함 범위 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이
|
||||
* 비싸 그 수를 묶는다:
|
||||
* - 후보: 주소 + 묶음 + 메일함(GIN 하나 — 마이그레이션 SearchIndexUserGramsMailboxes 의 lower 식)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지
|
||||
* 읽는다(줄의 글을 풀지 않는다 — 1만 통 직원의 흔한 낱말이 전체 · 큰 메일함 10~30ms, 작은 메일함 1~7ms. 맞은 줄이 표에 흩어져 있으면 그 줄 수만큼 더 든다)
|
||||
* - 두 글자 낱말뿐(검색어 — 다섯 칸)이면 묶음 하나가 곧 그 낱말이라 후보가 곧 결과다 — 확인하지 않는다
|
||||
* - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인한다(첫 쪽의 total 을 안다), 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터
|
||||
* SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다 — 한 요청이 확인하는 줄은 SEARCH_CHECK_ROWS_LIMIT 까지(≈ 0.5~0.8초).
|
||||
* SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다 — 한 요청이 확인하는 줄은 SEARCH_CHECK_ROWS_LIMIT 까지(≈ 0.5~0.8초 — 캐시가 찬 때.
|
||||
* 2026-11-06 1만 통 직원의 드문 구절로 3,000줄 0.51~0.58초).
|
||||
* 넘으면 찾은 것까지와 이어 찾기 커서를 준다(쪽이 limit 보다 적어도 hasNext)
|
||||
* - total 은 첫 쪽에서 SEARCH_TOTAL_LIMIT 까지만 센다 — 넘거나 이 요청 안에서 다 세지 못하면 null
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { matchRanges } from '@/shared/utils/search-text';
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { foldText, matchRanges } from '@/shared/utils/search-text';
|
||||
import {
|
||||
SNIPPET_BOUNDARY_SLACK,
|
||||
SNIPPET_LEAD_CHARS,
|
||||
@@ -15,8 +16,20 @@ const terms = (...words: string[]): HighlightTerms => ({
|
||||
subject: words,
|
||||
body: words,
|
||||
attachment: words,
|
||||
query: words,
|
||||
});
|
||||
|
||||
/** 색인의 접은 칸 — 보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 (listHighlight 의 otherFields) */
|
||||
const otherFields = (fields: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
attachment?: string;
|
||||
}): string[] =>
|
||||
[fields.from, fields.to, fields.subject, fields.attachment].map((field) =>
|
||||
foldText(field ?? ''),
|
||||
);
|
||||
|
||||
/** 글에서 낱말이 처음 맞은 곳 */
|
||||
const firstMatch = (body: string, word: string) =>
|
||||
matchRanges(body, [word], 1)[0];
|
||||
@@ -98,6 +111,7 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', ()
|
||||
preview: '엔진의 미리 보기',
|
||||
bodyText:
|
||||
'안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.',
|
||||
otherFields: null,
|
||||
},
|
||||
terms('계약', '갱신'),
|
||||
);
|
||||
@@ -124,6 +138,7 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', ()
|
||||
subject: '계약 안내',
|
||||
preview: '첨부한 계약서를\n확인해 주세요',
|
||||
bodyText: null,
|
||||
otherFields: null,
|
||||
},
|
||||
terms('계약'),
|
||||
);
|
||||
@@ -133,7 +148,12 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', ()
|
||||
});
|
||||
|
||||
const notInBody = listHighlight(
|
||||
{ subject: '계약 안내', preview: '첨부 확인', bodyText: '첨부 확인' },
|
||||
{
|
||||
subject: '계약 안내',
|
||||
preview: '첨부 확인',
|
||||
bodyText: '첨부 확인',
|
||||
otherFields: otherFields({ subject: '계약 안내' }),
|
||||
},
|
||||
terms('계약'),
|
||||
);
|
||||
expect(notInBody.preview).toEqual({ text: '첨부 확인', ranges: [] });
|
||||
@@ -146,8 +166,9 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', ()
|
||||
subject: '견적 요청',
|
||||
preview: '견적 부탁드립니다',
|
||||
bodyText: '견적 부탁드립니다',
|
||||
otherFields: null,
|
||||
},
|
||||
{ subject: ['견적'], body: [], attachment: [] },
|
||||
{ subject: ['견적'], body: [], attachment: [], query: [] },
|
||||
);
|
||||
expect(highlight).toEqual({
|
||||
subject: [[0, 2]],
|
||||
@@ -158,7 +179,12 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', ()
|
||||
it('접어서 맞춘다 — 대문자 · 전각 · NFD 로 적은 미리 보기도 NFC 글 기준 위치', () => {
|
||||
const nfd = '계약 PROJECT'.normalize('NFD');
|
||||
const highlight = listHighlight(
|
||||
{ subject: 'PROJECT 안내', preview: nfd, bodyText: null },
|
||||
{
|
||||
subject: 'PROJECT 안내',
|
||||
preview: nfd,
|
||||
bodyText: null,
|
||||
otherFields: null,
|
||||
},
|
||||
terms('project', '계약'),
|
||||
);
|
||||
expect(highlight.subject).toEqual([[0, 7]]);
|
||||
@@ -175,4 +201,182 @@ describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', ()
|
||||
expect(highlightedSubject(nfd).length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('답장의 원문 인용(> 줄)과 미리 보기 (2026-11-06 사용자 관점 점검)', () => {
|
||||
/** 미리 보기 — 본문 · 강조할 말 · 색인의 접은 칸(없으면 null) */
|
||||
const previewOf = (
|
||||
bodyText: string,
|
||||
words: HighlightTerms,
|
||||
fields: string[] | null = null,
|
||||
) =>
|
||||
listHighlight(
|
||||
{
|
||||
subject: '제목',
|
||||
preview: '엔진의 미리 보기',
|
||||
bodyText,
|
||||
otherFields: fields,
|
||||
},
|
||||
words,
|
||||
).preview;
|
||||
|
||||
const REPLY_SUBJECT = 'RE: 해오름 프로젝트 견적 검토 요청';
|
||||
const REPLY_FIELDS = otherFields({
|
||||
from: '이도윤 <doyun.lee@arasystem.test>',
|
||||
to: '김아라 <ara.kim@arasystem.test>',
|
||||
subject: REPLY_SUBJECT,
|
||||
});
|
||||
|
||||
it('인용 밖과 안에서 모두 맞으면 인용 밖의 맞은 곳 — 본문에서 먼저 나온 인용 안의 맞은 곳보다 먼저', () => {
|
||||
const preview = previewOf(
|
||||
'> 견적 부탁드립니다.\n\n견적서 확인했습니다. 금요일까지 회신 드리겠습니다.',
|
||||
terms('견적'),
|
||||
);
|
||||
expect(preview).toEqual({
|
||||
text: '…견적서 확인했습니다. 금요일까지 회신 드리겠습니다.',
|
||||
ranges: [[1, 3]],
|
||||
});
|
||||
});
|
||||
|
||||
it('인용 밖에서 맞은 곳의 둘레는 인용 줄에 닿기 전까지 — 뒤따르는 인용은 싣지 않고 "…"', () => {
|
||||
const preview = previewOf(
|
||||
'금요일까지 견적서 회신 드리겠습니다.\n\n> 원본 메일\n> 해오름 프로젝트 견적 자료를 보냅니다.',
|
||||
terms('견적'),
|
||||
);
|
||||
expect(preview).toEqual({
|
||||
text: '금요일까지 견적서 회신 드리겠습니다.…',
|
||||
ranges: [[6, 8]],
|
||||
});
|
||||
});
|
||||
|
||||
it('인용 안에서만 맞고 그 말이 다른 칸(제목 · 사람 · 첨부 이름)에서도 맞으면 인용 앞의 글 — 그 글에 맞은 곳이 없으면 범위도 없다', () => {
|
||||
const body =
|
||||
'네, 확인했습니다. 다음 주에 뵙겠습니다.\n\n> 제목: 해오름 프로젝트 견적 검토 요청\n> > 도윤 님, 견적 부탁드립니다.';
|
||||
const expected = {
|
||||
text: '네, 확인했습니다. 다음 주에 뵙겠습니다.…',
|
||||
ranges: [],
|
||||
};
|
||||
// 제목에서 맞음
|
||||
expect(previewOf(body, terms('견적'), REPLY_FIELDS)).toEqual(expected);
|
||||
// 보낸 사람에서 맞음
|
||||
expect(previewOf(body, terms('도윤'), REPLY_FIELDS)).toEqual(expected);
|
||||
// 첨부 이름에서 맞음
|
||||
expect(
|
||||
previewOf(
|
||||
body,
|
||||
terms('검토'),
|
||||
otherFields({ subject: '회신', attachment: '검토 의견.pdf' }),
|
||||
),
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it('인용 안에서만 맞고 다른 칸에서는 맞지 않으면 인용 조각 — 줄 앞의 > 를 뺀 글을 그 인용 안에서만, 위치는 보이는 글 기준', () => {
|
||||
const preview = previewOf(
|
||||
'네, 받았습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
terms('R9A51'),
|
||||
otherFields({ subject: 'RE: 해오름 프로젝트 안내' }),
|
||||
);
|
||||
expect(preview).toEqual({
|
||||
text: '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
ranges: [[16, 21]],
|
||||
});
|
||||
expect(slices(preview.text, preview.ranges)).toEqual(['R9A51']);
|
||||
});
|
||||
|
||||
it('본문에서 맞은 말 가운데 하나라도 다른 칸에서 맞지 않으면 인용 조각 — 내용 조건의 말은 본문에서만 찾으므로 다른 칸에 있어도 인용 조각', () => {
|
||||
const body =
|
||||
'네, 받았습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.';
|
||||
const fields = otherFields({ subject: 'RE: 해오름 프로젝트 안내' });
|
||||
|
||||
const both = previewOf(body, terms('해오름', 'R9A51'), fields);
|
||||
expect(both.text).toBe(
|
||||
'…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
);
|
||||
expect(slices(both.text, both.ranges)).toEqual(['해오름', 'R9A51']);
|
||||
|
||||
const bodyCondition = previewOf(
|
||||
body,
|
||||
{ subject: [], body: ['해오름'], attachment: [], query: [] },
|
||||
fields,
|
||||
);
|
||||
expect(bodyCondition.text).toBe(
|
||||
'…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
);
|
||||
expect(slices(bodyCondition.text, bodyCondition.ranges)).toEqual([
|
||||
'해오름',
|
||||
]);
|
||||
});
|
||||
|
||||
it('인용으로 시작하는 본문(인용 아래에 쓴 답장)은 인용 밖의 첫 글 — 본문이 모두 인용이면 다른 칸에서 맞아도 인용 조각', () => {
|
||||
expect(
|
||||
previewOf(
|
||||
'> 해오름 프로젝트 견적 부탁드립니다.\n\n네, 금요일까지 보내 드리겠습니다.',
|
||||
terms('견적'),
|
||||
REPLY_FIELDS,
|
||||
),
|
||||
).toEqual({ text: '…네, 금요일까지 보내 드리겠습니다.', ranges: [] });
|
||||
|
||||
const quoteOnly = previewOf(
|
||||
'> 해오름 프로젝트 견적 부탁드립니다.\n> 감사합니다.',
|
||||
terms('견적'),
|
||||
REPLY_FIELDS,
|
||||
);
|
||||
expect(quoteOnly.text).toBe(
|
||||
'해오름 프로젝트 견적 부탁드립니다. 감사합니다.',
|
||||
);
|
||||
expect(slices(quoteOnly.text, quoteOnly.ranges)).toEqual(['견적']);
|
||||
});
|
||||
|
||||
it('인용 줄 알아보기 — 앞 공백 · 탭 뒤의 > · 겹친 표시(>> · > >) · CRLF, 줄 가운데의 > 는 인용이 아니다', () => {
|
||||
expect(
|
||||
previewOf('a > b 견적 정리\r\n >> > 견적 원문', terms('견적')),
|
||||
).toEqual({ text: 'a > b 견적 정리…', ranges: [[6, 8]] });
|
||||
|
||||
expect(
|
||||
previewOf(' >> > 견적 원문\r\n\t> 두 번째 견적', terms('견적')),
|
||||
).toEqual({
|
||||
text: '견적 원문 두 번째 견적',
|
||||
ranges: [
|
||||
[0, 2],
|
||||
[11, 13],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('인용 조각도 서로게이트 쌍을 가르지 않고 위치는 NFC 글 기준 — NFD 로 적은 말도 맞는다', () => {
|
||||
const body = `확인했습니다.\n\n> ${'😀'.repeat(15)} 계약서 보내 드립니다 ${'😀'.repeat(40)}`;
|
||||
const preview = previewOf(body, terms('계약'.normalize('NFD')));
|
||||
|
||||
expect(preview.text).not.toContain('>');
|
||||
expect(() => encodeURIComponent(preview.text)).not.toThrow();
|
||||
expect(slices(preview.text, preview.ranges)).toEqual(['계약']);
|
||||
expect(preview.text.startsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('본문 길이에 비례한다 — 인용 줄이 많은 긴 본문(색인 본문 상한 남짓)에서 끝의 인용 조각을 골라도', () => {
|
||||
const bodyOf = (lines: number) =>
|
||||
`확인했습니다.\n\n${Array.from(
|
||||
{ length: lines },
|
||||
(_, index) => `> > 인용 줄 ${index} 가나다라마바사`,
|
||||
).join('\n')}\n> 마지막 견적 줄`;
|
||||
const run = (body: string) => {
|
||||
for (let round = 0; round < 20; round += 1) {
|
||||
previewOf(body, terms('견적'));
|
||||
}
|
||||
};
|
||||
const small = bodyOf(50);
|
||||
const large = bodyOf(1_000);
|
||||
expect(previewOf(large, terms('견적')).text).toContain('마지막 견적 줄');
|
||||
run(small);
|
||||
|
||||
const smallCpu = startCpuTimer();
|
||||
run(small);
|
||||
const smallMs = smallCpu();
|
||||
const largeCpu = startCpuTimer();
|
||||
run(large);
|
||||
const largeMs = largeCpu();
|
||||
// 20배 입력 — 제곱이면 400배다. 지금 값의 10배쯤을 한도로 둔다 (test/cpu-time.ts)
|
||||
expect(largeMs).toBeLessThan(Math.max(smallMs, 20) * 20 * 10);
|
||||
expect(largeMs).toBeLessThan(5_000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
foldTerms,
|
||||
foldText,
|
||||
isSpaceCode,
|
||||
matchFoldedRanges,
|
||||
matchRanges,
|
||||
type MatchRange,
|
||||
} from '@/shared/utils/search-text';
|
||||
@@ -14,12 +17,21 @@ import type { HighlightTerms } from './search-plan';
|
||||
/**
|
||||
* 목록의 검색 강조 (순수 함수 — 14단계 설계 C1) — 제목의 맞은 곳과 미리 보기 한 줄.
|
||||
* - 제목: 검색어 · 제목 조건의 말이 맞은 곳 (위치는 NFC 제목 기준 — 부르는 쪽이 응답의 제목도 NFC 로 둔다)
|
||||
* - 미리 보기: 본문(색인의 글 — NFC)에서 맞았으면 처음 맞은 말 둘레(앞 SNIPPET_LEAD_CHARS 자 남짓 · 모두 SNIPPET_TOTAL_CHARS 자 남짓 —
|
||||
* SNIPPET_BOUNDARY_SLACK 안의 공백에서 자르고, 앞뒤가 잘렸으면 "…"), 아니면 엔진의 미리 보기. 공백은 하나로 줄인 한 줄이고 맞은 곳은 그 글 기준으로 다시 찾는다
|
||||
* - 미리 보기: 본문(색인의 글 — NFC)에서 맞은 곳을 아래 차례로 고르고(2026-11-06 사용자 관점 점검 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이라
|
||||
* 셋째 줄로 답장끼리 가를 수 없었다), 본문에서 맞지 않았으면 엔진의 미리 보기
|
||||
* 1. 인용 줄(앞 공백 뒤 `>` 로 시작하는 줄) 밖에서 처음 맞은 곳의 둘레 — 인용 줄에 닿기 전까지만
|
||||
* 2. 인용 안에서만 맞았고 본문에서 맞은 말이 모두 다른 칸(제목 · 사람 · 첨부 이름 — 검색어의 낱말)에서도 맞았으면 인용 밖의 첫 글(보통 인용 앞의 글)
|
||||
* 3. 그 밖에는 인용 안에서 처음 맞은 곳의 둘레 — 줄 앞의 `>` 표시를 뺀 글로, 그 인용(이어진 인용 줄) 안에서만
|
||||
* 다른 메일 프로그램의 인용 꼴(`>` 없이 "-----Original Message-----" 아래에 두는 것 등)은 알아보지 않는다
|
||||
* - 둘레는 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 남짓 · 모두 SNIPPET_TOTAL_CHARS 자 남짓 — SNIPPET_BOUNDARY_SLACK 안의 공백에서 자르고, 앞뒤에 잘라 낸 글이 있으면 "…".
|
||||
* 공백은 하나로 줄인 한 줄이고 맞은 곳은 보이는 그 글에서 다시 찾는다(그 글에 맞은 곳이 없으면 범위도 없다)
|
||||
* 위치는 UTF-16 [시작, 끝)
|
||||
*/
|
||||
|
||||
const ELLIPSIS = '…';
|
||||
/** 인용 줄의 표시 `>` */
|
||||
const QUOTE_MARK_CODE = 0x3e;
|
||||
const LINE_BREAK = '\n';
|
||||
|
||||
function isHighSurrogate(code: number): boolean {
|
||||
return code >= 0xd800 && code <= 0xdbff;
|
||||
@@ -78,29 +90,35 @@ function oneLine(text: string): string {
|
||||
|
||||
/**
|
||||
* 맞은 말 둘레 — 처음 맞은 곳(`first`)을 담아 앞 SNIPPET_LEAD_CHARS · 모두 SNIPPET_TOTAL_CHARS 남짓을 말 경계에서 자른다.
|
||||
* 앞 · 뒤에 잘라 낸 글(공백만이 아닌)이 있으면 "…" 를 붙이고 한 줄로 만든다
|
||||
* `bounds`([시작, 끝) — 처음 값은 글 전체) 밖으로는 나가지 않는다(인용 줄에 닿기 전까지 등).
|
||||
* 앞 · 뒤에 잘라 낸 글(공백만이 아닌 — `bounds` 밖도 센다)이 있으면 "…" 를 붙이고 한 줄로 만든다
|
||||
*/
|
||||
export function snippetAround(body: string, first: MatchRange): string {
|
||||
export function snippetAround(
|
||||
body: string,
|
||||
first: MatchRange,
|
||||
bounds: MatchRange = [0, body.length],
|
||||
): string {
|
||||
const [low, high] = bounds;
|
||||
const [matchStart, matchEnd] = first;
|
||||
// 시작 — 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 둘레의 공백 뒤(앞으로 여유만큼, 없으면 맞은 곳 쪽으로), 그래도 없으면 그 자리
|
||||
const lead = Math.max(0, matchStart - SNIPPET_LEAD_CHARS);
|
||||
const lead = Math.max(low, matchStart - SNIPPET_LEAD_CHARS);
|
||||
let start = lead;
|
||||
if (lead > 0) {
|
||||
if (lead > low) {
|
||||
const before = findSpace(
|
||||
body,
|
||||
lead,
|
||||
Math.max(0, lead - SNIPPET_BOUNDARY_SLACK),
|
||||
Math.max(low, lead - SNIPPET_BOUNDARY_SLACK),
|
||||
);
|
||||
const after =
|
||||
before < 0 ? findSpace(body, lead, Math.max(lead, matchStart - 1)) : -1;
|
||||
const space = before >= 0 ? before : after;
|
||||
if (space >= 0) start = space + 1;
|
||||
}
|
||||
start = alignStart(body, start);
|
||||
start = Math.max(low, alignStart(body, start));
|
||||
// 끝 — 시작에서 SNIPPET_TOTAL_CHARS 자 둘레의 공백 앞(뒤로 여유만큼, 없으면 앞으로), 맞은 곳은 늘 담는다
|
||||
const target = Math.max(matchEnd, start + SNIPPET_TOTAL_CHARS);
|
||||
let end = Math.min(body.length, target);
|
||||
if (end < body.length) {
|
||||
let end = Math.min(high, target);
|
||||
if (end < high) {
|
||||
const back = findSpace(
|
||||
body,
|
||||
end,
|
||||
@@ -108,49 +126,213 @@ export function snippetAround(body: string, first: MatchRange): string {
|
||||
);
|
||||
const forward =
|
||||
back < 0
|
||||
? findSpace(
|
||||
body,
|
||||
end,
|
||||
Math.min(body.length - 1, end + SNIPPET_BOUNDARY_SLACK),
|
||||
)
|
||||
? findSpace(body, end, Math.min(high - 1, end + SNIPPET_BOUNDARY_SLACK))
|
||||
: -1;
|
||||
const space = back >= 0 ? back : forward;
|
||||
if (space >= 0) end = space;
|
||||
}
|
||||
end = alignEnd(body, end);
|
||||
end = Math.min(high, alignEnd(body, end));
|
||||
const text = oneLine(body.slice(start, end));
|
||||
const head = hasVisible(body, 0, start) ? ELLIPSIS : '';
|
||||
const tail = hasVisible(body, end, body.length) ? ELLIPSIS : '';
|
||||
return `${head}${text}${tail}`;
|
||||
}
|
||||
|
||||
/** 본문의 한 줄 — [start, end) (줄바꿈 글자는 빼고). 인용 줄이면 `quoteText` 가 표시 다음 글의 시작, 아니면 null */
|
||||
interface BodyLine {
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
readonly quoteText: number | null;
|
||||
}
|
||||
|
||||
/** 인용 줄이면 표시 다음 글의 시작 — 앞 공백 뒤 `>` 로 시작하는 줄 (`> > 글` 처럼 겹친 표시와 그 사이 · 뒤 공백을 모두 넘는다) */
|
||||
function quoteTextStart(
|
||||
body: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): number | null {
|
||||
let index = start;
|
||||
while (index < end && isSpaceCode(body.charCodeAt(index))) index += 1;
|
||||
if (index >= end || body.charCodeAt(index) !== QUOTE_MARK_CODE) return null;
|
||||
while (index < end) {
|
||||
const code = body.charCodeAt(index);
|
||||
if (code !== QUOTE_MARK_CODE && !isSpaceCode(code)) break;
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** 본문의 줄들 — 한 번 훑는다 */
|
||||
function bodyLines(body: string): BodyLine[] {
|
||||
const lines: BodyLine[] = [];
|
||||
for (let start = 0; ; ) {
|
||||
const found = body.indexOf(LINE_BREAK, start);
|
||||
const end = found < 0 ? body.length : found;
|
||||
lines.push({ start, end, quoteText: quoteTextStart(body, start, end) });
|
||||
if (found < 0) return lines;
|
||||
start = found + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 그 자리를 담은 줄의 차례 (줄은 시작 차례로 놓여 있다) */
|
||||
function lineIndexAt(lines: readonly BodyLine[], position: number): number {
|
||||
let low = 0;
|
||||
let high = lines.length - 1;
|
||||
while (low < high) {
|
||||
const middle = (low + high + 1) >> 1;
|
||||
if (lines[middle].start <= position) low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/** 인용 줄을 공백으로 덮은 글 — 위치 · 길이는 그대로 (인용 밖에서만 맞추려고) */
|
||||
function maskQuotes(text: string, lines: readonly BodyLine[]): string {
|
||||
let masked = '';
|
||||
let from = 0;
|
||||
for (const line of lines) {
|
||||
if (line.quoteText === null) continue;
|
||||
masked += text.slice(from, line.start) + ' '.repeat(line.end - line.start);
|
||||
from = line.end;
|
||||
}
|
||||
return masked + text.slice(from);
|
||||
}
|
||||
|
||||
/** 그 줄과 같은 쪽(인용 · 인용 밖)인 줄들의 이어진 범위 [시작, 끝) — 미리 보기가 인용과 인용 밖을 섞지 않게 */
|
||||
function runAround(lines: readonly BodyLine[], index: number): MatchRange {
|
||||
const quoted = lines[index].quoteText !== null;
|
||||
const same = (line: BodyLine) => (line.quoteText !== null) === quoted;
|
||||
let first = index;
|
||||
while (first > 0 && same(lines[first - 1])) first -= 1;
|
||||
let last = index;
|
||||
while (last + 1 < lines.length && same(lines[last + 1])) last += 1;
|
||||
return [lines[first].start, lines[last].end];
|
||||
}
|
||||
|
||||
/** 인용 밖의 첫 글 — 보이는 글이 있는 첫 인용 밖 줄부터 다음 인용 줄 전까지 (없으면 null) */
|
||||
function firstPlainRun(
|
||||
body: string,
|
||||
lines: readonly BodyLine[],
|
||||
): MatchRange | null {
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (line.quoteText === null && hasVisible(body, line.start, line.end)) {
|
||||
return [line.start, runAround(lines, index)[1]];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 인용 표시를 뺀 글 — 줄은 그대로 잇고, 원래 글의 자리를 이 글의 자리로 바꾸는 함수를 함께 준다 */
|
||||
function stripQuoteMarks(
|
||||
body: string,
|
||||
lines: readonly BodyLine[],
|
||||
): { readonly text: string; readonly at: (position: number) => number } {
|
||||
const parts: string[] = [];
|
||||
const starts: number[] = [];
|
||||
let length = 0;
|
||||
lines.forEach((line, index) => {
|
||||
if (index > 0) {
|
||||
parts.push(LINE_BREAK);
|
||||
length += LINE_BREAK.length;
|
||||
}
|
||||
starts.push(length);
|
||||
const from = line.quoteText ?? line.start;
|
||||
parts.push(body.slice(from, line.end));
|
||||
length += line.end - from;
|
||||
});
|
||||
return {
|
||||
text: parts.join(''),
|
||||
at: (position) => {
|
||||
const index = lineIndexAt(lines, position);
|
||||
const line = lines[index];
|
||||
const from = line.quoteText ?? line.start;
|
||||
return (
|
||||
starts[index] + Math.min(Math.max(0, position - from), line.end - from)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문에서 맞은 말(`words` — 접은 말)이 모두 다른 칸에서도 맞았나 — 검색어의 낱말이고(내용 조건의 낱말은 본문에서만 찾는다)
|
||||
* 색인의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름) 가운데 하나에 든다. 칸을 모르면(null) 아니다
|
||||
*/
|
||||
function matchedElsewhere(
|
||||
folded: string,
|
||||
words: readonly string[],
|
||||
query: readonly string[],
|
||||
otherFields: readonly string[] | null,
|
||||
): boolean {
|
||||
if (otherFields === null) return false;
|
||||
const queryWords = new Set(foldTerms(query));
|
||||
return words
|
||||
.filter((word) => folded.includes(word))
|
||||
.every(
|
||||
(word) =>
|
||||
queryWords.has(word) &&
|
||||
otherFields.some((field) => field.includes(word)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 본문에서 고른 미리 보기 한 줄 (위의 1 · 2 · 3) — 본문에서 맞지 않았으면 null */
|
||||
function bodySnippet(
|
||||
text: string,
|
||||
terms: HighlightTerms,
|
||||
otherFields: readonly string[] | null,
|
||||
): string | null {
|
||||
// 접은 글은 NFC 본문과 위치가 같다 — 줄 · 인용은 본문에서, 맞추기는 접은 글에서 (색인의 본문은 이미 NFC 다 — 거의 그대로 돌아온다)
|
||||
const body = text.normalize('NFC');
|
||||
const folded = foldText(body);
|
||||
const words = foldTerms(terms.body);
|
||||
const lines = bodyLines(body);
|
||||
const [outside] = matchFoldedRanges(maskQuotes(folded, lines), words, 1);
|
||||
if (outside) {
|
||||
return snippetAround(
|
||||
body,
|
||||
outside,
|
||||
runAround(lines, lineIndexAt(lines, outside[0])),
|
||||
);
|
||||
}
|
||||
const [inside] = matchFoldedRanges(folded, words, 1);
|
||||
if (!inside) return null;
|
||||
if (matchedElsewhere(folded, words, terms.query, otherFields)) {
|
||||
const run = firstPlainRun(body, lines);
|
||||
if (run) return snippetAround(body, [run[0], run[0]], run);
|
||||
}
|
||||
// 인용 조각 — 그 인용(이어진 인용 줄)에 닿기 전까지, `>` 표시를 뺀 글에서
|
||||
const stripped = stripQuoteMarks(body, lines);
|
||||
const [runStart, runEnd] = runAround(lines, lineIndexAt(lines, inside[0]));
|
||||
return snippetAround(
|
||||
stripped.text,
|
||||
[stripped.at(inside[0]), stripped.at(inside[1])],
|
||||
[stripped.at(runStart), stripped.at(runEnd)],
|
||||
);
|
||||
}
|
||||
|
||||
function toRanges(ranges: readonly MatchRange[]): SearchRange[] {
|
||||
return ranges.map(([start, end]) => [start, end] as const);
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 항목 하나의 강조 — `subject` 는 응답에 실을 제목(NFC), `preview` 는 엔진의 미리 보기, `bodyText` 는 색인의 본문 글(NFC — 없으면 null)
|
||||
* 목록 항목 하나의 강조 — `subject` 는 응답에 실을 제목(NFC), `preview` 는 엔진의 미리 보기, `bodyText` 는 색인의 본문 글(NFC — 없으면 null),
|
||||
* `otherFields` 는 색인의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 — 없으면 null)
|
||||
*/
|
||||
export function listHighlight(
|
||||
input: {
|
||||
readonly subject: string;
|
||||
readonly preview: string;
|
||||
readonly bodyText: string | null;
|
||||
readonly otherFields: readonly string[] | null;
|
||||
},
|
||||
terms: HighlightTerms,
|
||||
): MessageListHighlight {
|
||||
const subject = toRanges(matchRanges(input.subject, terms.subject));
|
||||
if (input.bodyText && terms.body.length > 0) {
|
||||
const inBody = matchRanges(input.bodyText, terms.body, 1);
|
||||
if (inBody.length > 0) {
|
||||
const text = snippetAround(input.bodyText, inBody[0]);
|
||||
return {
|
||||
subject,
|
||||
preview: { text, ranges: toRanges(matchRanges(text, terms.body)) },
|
||||
};
|
||||
}
|
||||
}
|
||||
const text = oneLine(input.preview.normalize('NFC'));
|
||||
const fromBody =
|
||||
input.bodyText && terms.body.length > 0
|
||||
? bodySnippet(input.bodyText, terms, input.otherFields)
|
||||
: null;
|
||||
const text = fromBody ?? oneLine(input.preview.normalize('NFC'));
|
||||
return {
|
||||
subject,
|
||||
preview: { text, ranges: toRanges(matchRanges(text, terms.body)) },
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => {
|
||||
subject: ['계약', 'PROJECT'],
|
||||
body: ['계약', 'PROJECT'],
|
||||
attachment: ['계약', 'PROJECT'],
|
||||
query: ['계약', 'PROJECT'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +79,7 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => {
|
||||
subject: ['견적'],
|
||||
body: ['단가 조정'],
|
||||
attachment: [],
|
||||
query: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +101,29 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => {
|
||||
{ term: '묵', fields: ALL_FOLD_FIELDS },
|
||||
{ term: '계약', fields: ALL_FOLD_FIELDS },
|
||||
]);
|
||||
// 본문 강조는 두 글자 이상 낱말만 — 한 글자 낱말은 제목 · 첨부 이름에만 (2026-11-06 실사용 시험)
|
||||
expect(plan.highlight).toEqual({
|
||||
subject: ['묵', '계약'],
|
||||
body: ['계약'],
|
||||
attachment: ['묵', '계약'],
|
||||
query: ['묵', '계약'],
|
||||
});
|
||||
});
|
||||
|
||||
it('본문 강조는 본문을 찾은 두 글자 이상 낱말만 — 내용 조건과 섞여도 · 묶음이 없는 구절(한 글자씩 띄운 것)도 한 글자 낱말과 같다', () => {
|
||||
// 내용 조건의 두 글자 이상 낱말과 섞임 — 검색어의 한 글자 낱말은 본문에 강조하지 않는다
|
||||
expect(
|
||||
planSearch(criteria({ text: '견', body: '계약서' })).highlight.body,
|
||||
).toEqual(['계약서']);
|
||||
// 두 글자 이상 낱말이 강조하지 않는 칸(보낸 사람)에만 있어도 섞인 검색이다 — 본문에 강조할 말이 없다
|
||||
const withSender = planSearch(criteria({ text: '견', from: '김아라' }));
|
||||
expect(withSender.bodySkipped).toBe(false);
|
||||
expect(withSender.highlight.body).toEqual([]);
|
||||
expect(withSender.highlight.subject).toEqual(['견']);
|
||||
// 한 글자씩 띄운 구절은 묶음이 없다
|
||||
expect(planSearch(criteria({ text: '"a b" 계약' })).highlight.body).toEqual(
|
||||
['계약'],
|
||||
);
|
||||
});
|
||||
|
||||
it('한 글자 낱말뿐이면 본문은 훑지 않는다 — 제목 · 사람 · 첨부 이름에서만 (SINGLE_CHAR_TERMS_SKIP_BODY)', () => {
|
||||
@@ -119,8 +144,13 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => {
|
||||
},
|
||||
{ term: '김', fields: ['fold_from'] },
|
||||
]);
|
||||
// 강조는 본문에도 — 계획이 본문을 훑지 않았을 뿐 맞은 곳은 보인다
|
||||
expect(plan.highlight.body).toEqual(['묵', '뷁']);
|
||||
// 본문을 찾지 않았으니 본문(미리 보기 · 읽기 화면)에는 강조하지 않는다 — 제목 · 첨부 이름에는 그대로 (2026-11-06 실사용 시험)
|
||||
expect(plan.highlight).toEqual({
|
||||
subject: ['묵', '뷁'],
|
||||
body: [],
|
||||
attachment: ['묵', '뷁'],
|
||||
query: ['묵', '뷁'],
|
||||
});
|
||||
});
|
||||
|
||||
it('한 글자 낱말뿐인데 내용 조건이 있으면 찾을 곳이 없다 — 아무것도 맞지 않는다', () => {
|
||||
@@ -217,6 +247,7 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => {
|
||||
subject: ['계약', '견적'],
|
||||
body: ['계약', '단가 조정'],
|
||||
attachment: ['계약'],
|
||||
query: ['계약'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,6 +255,29 @@ describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => {
|
||||
expect(highlightTermsOf({})).toBeNull();
|
||||
expect(highlightTermsOf({ text: '…', subject: '""' })).toBeNull();
|
||||
});
|
||||
|
||||
it('본문에는 목록과 같이 본문을 찾은 낱말만 — 한 글자 낱말뿐이면 제목 · 첨부 이름에만, 섞이면 두 글자 이상 낱말만 (보낸 사람 · 받는 사람 조건도 섞임을 정한다)', () => {
|
||||
expect(highlightTermsOf({ text: '견' })).toEqual({
|
||||
subject: ['견'],
|
||||
body: [],
|
||||
attachment: ['견'],
|
||||
query: ['견'],
|
||||
});
|
||||
expect(highlightTermsOf({ text: '견 계약서' })?.body).toEqual(['계약서']);
|
||||
expect(highlightTermsOf({ text: '견', to: '김아라' })?.body).toEqual([]);
|
||||
// 한 글자 내용 조건뿐 — 찾을 곳이 없어 강조할 말도 없다
|
||||
expect(highlightTermsOf({ body: '견' })).toBeNull();
|
||||
// 목록의 계획과 같은 강조
|
||||
for (const given of [
|
||||
{ text: '견' },
|
||||
{ text: '견 계약서', subject: '회' },
|
||||
{ text: '견', from: '김아라', body: '단가' },
|
||||
]) {
|
||||
expect(highlightTermsOf(given)).toEqual(
|
||||
planSearch(criteria(given)).highlight,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('검색 글의 NUL 은 U+FFFD 로 — 확인 · 묶음 · 강조가 같은 글자를 쓴다 (리뷰 L2)', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { SearchCriteria } from './mail-search.types';
|
||||
* - 글을 준 칸인데 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표) 아무것도 맞지 않는다 — 범위 전체를 돌려주지 않는다(엔진 검색과 같다)
|
||||
* - 검색어의 낱말이 모두 두 글자(코드 포인트 둘 · 공백 없음)면 묶음 하나가 곧 그 낱말이다 — 묶음이 맞은 줄은 다섯 칸 가운데 어딘가에 그 낱말이 있다(exactByGrams).
|
||||
* 색인의 묶음은 저장한 접기 칸 그대로에서 만든다(index-document.ts) — 그래서 확인하지 않아도 된다
|
||||
* - 본문(목록 미리 보기 · 읽기 화면)에는 본문을 찾은 낱말만 강조한다(2026-11-06 실사용 시험 — bodyHighlightOf). 제목 · 첨부 이름 강조는 낱말 그대로다
|
||||
*/
|
||||
|
||||
/** 확인하는 접기 칸 — 표의 열 이름 */
|
||||
@@ -54,8 +55,14 @@ export interface TermCheck {
|
||||
/** 강조할 말 — 원래 낱말 · 구절(접기 전) — 제목 · 본문 · 첨부 이름 */
|
||||
export interface HighlightTerms {
|
||||
readonly subject: readonly string[];
|
||||
/** 본문을 찾은 낱말만 (bodyHighlightOf) — 한 글자 낱말은 들지 않는다 */
|
||||
readonly body: readonly string[];
|
||||
readonly attachment: readonly string[];
|
||||
/**
|
||||
* 검색어(q)의 낱말 — 다섯 칸 어디서든 찾는다. 목록 미리 보기가 본문의 인용 안에서만 맞은 말을 다른 칸(제목 · 사람 · 첨부 이름)에서도
|
||||
* 찾았는지 볼 때 쓴다(search-highlight.ts). 내용 조건(body)의 낱말은 본문에서만 찾으므로 들지 않는다
|
||||
*/
|
||||
readonly query: readonly string[];
|
||||
}
|
||||
|
||||
export interface SearchPlan {
|
||||
@@ -110,6 +117,27 @@ function dateOf(value: string | undefined): Date | null {
|
||||
return Number.isNaN(time) ? null : new Date(time);
|
||||
}
|
||||
|
||||
/** 묶음이 있는 낱말인가 — 접어서 글자 두 개 묶음이 하나라도 나온다 (한 글자 낱말 · 한 글자씩 띄운 구절은 없다) */
|
||||
function hasGrams(raw: string): boolean {
|
||||
return gramsOf(foldText(raw)).size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문에 강조할 말 (2026-11-06 실사용 시험) — 본문을 찾은 낱말만이다. 검색어 · 내용 조건의 낱말(`raws`) 가운데
|
||||
* - 한 글자 낱말뿐이라 본문을 훑지 않았으면(bodySkipped) 없다
|
||||
* - 묶음이 있는 낱말(두 글자 이상)이 하나라도 있으면(`anyGrams` — 다섯 칸의 낱말 어디든) 그 낱말만 — 한 글자 낱말은 함께 찾아 본문도 확인했지만
|
||||
* 본문 곳곳에 맞아 미리 보기 · 읽기 화면의 강조가 흩어진다
|
||||
* - 묶음이 하나도 없는데 본문을 훑었으면(SINGLE_CHAR_TERMS_SKIP_BODY 를 끈 때) 모두
|
||||
*/
|
||||
function bodyHighlightOf(
|
||||
raws: readonly string[],
|
||||
bodySkipped: boolean,
|
||||
anyGrams: boolean,
|
||||
): string[] {
|
||||
if (bodySkipped) return [];
|
||||
return anyGrams ? raws.filter(hasGrams) : [...raws];
|
||||
}
|
||||
|
||||
export function planSearch(criteria: SearchCriteria): SearchPlan {
|
||||
const query = termsOf(criteria.text);
|
||||
const conditions = CONDITION_NAMES.map((name) => ({
|
||||
@@ -175,22 +203,29 @@ export function planSearch(criteria: SearchCriteria): SearchPlan {
|
||||
exactByGrams,
|
||||
highlight: {
|
||||
subject: [...query.raw, ...subjectTerms],
|
||||
body: [...query.raw, ...bodyTerms],
|
||||
body: bodyHighlightOf(
|
||||
[...query.raw, ...bodyTerms],
|
||||
bodySkipped,
|
||||
grams.size > 0,
|
||||
),
|
||||
attachment: [...query.raw],
|
||||
query: [...query.raw],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 강조할 말만 (읽기 화면 — 설계 C2): 검색어의 낱말은 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에.
|
||||
* 보낸 사람 · 받는 사람 조건은 강조하지 않는다. 강조할 말이 하나도 없으면 null
|
||||
* 강조할 말만 (읽기 화면 — 설계 C2): 검색어의 낱말은 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에 — 목록과 같은 계획(planSearch)에서 꺼낸다.
|
||||
* 본문에는 본문을 찾은 낱말만(한 글자 낱말 — bodyHighlightOf). 보낸 사람 · 받는 사람 조건은 강조하지 않지만 받는다 — 두 글자 이상 낱말이
|
||||
* 그 칸에만 있어도 한 글자 낱말과 "섞인" 검색이다(목록의 찾기와 같은 판정). 강조할 말이 하나도 없으면 null
|
||||
*/
|
||||
export function highlightTermsOf(
|
||||
criteria: Pick<SearchCriteria, 'text' | 'subject' | 'body'>,
|
||||
criteria: Pick<SearchCriteria, 'text' | 'from' | 'to' | 'subject' | 'body'>,
|
||||
): HighlightTerms | null {
|
||||
const query = termsOf(criteria.text).raw;
|
||||
const subject = [...query, ...termsOf(criteria.subject).raw];
|
||||
const body = [...query, ...termsOf(criteria.body).raw];
|
||||
if (subject.length === 0 && body.length === 0) return null;
|
||||
return { subject, body, attachment: query };
|
||||
const { highlight } = planSearch({ ...criteria, hasAttachment: false });
|
||||
// 첨부 이름의 말(검색어)은 늘 제목의 말 안에 든다
|
||||
if (highlight.subject.length === 0 && highlight.body.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return highlight;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const plan = (overrides: Partial<SearchCriteria>) =>
|
||||
const flat = (sql: string) => sql.replace(/\s+/g, ' ');
|
||||
|
||||
describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => {
|
||||
it('후보 — 주소(묶음 GIN 의 lower 식) · 빼는 메일함 · 빼는 키워드 · 묶음만으로 받은 시각 최신부터 limit 줄 (확인하지 않는다), 값은 모두 매개변수', () => {
|
||||
it('후보 — 주소(찾기 GIN 의 lower 식) · 빼는 메일함 · 빼는 키워드 · 묶음만으로 받은 시각 최신부터 limit 줄 (확인하지 않는다), 값은 모두 매개변수', () => {
|
||||
const query = candidatesSql(
|
||||
USER,
|
||||
ALL,
|
||||
@@ -56,7 +56,7 @@ describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => {
|
||||
expect(query.sql).not.toContain('strpos');
|
||||
});
|
||||
|
||||
it('메일함 범위 · 기간 · 첨부 · 이어 찾을 자리(받은 시각 · id 가 더 작은 쪽)', () => {
|
||||
it('메일함 범위는 찾기 GIN 이 함께 맞추는 꼴(@>) — = ANY 로 적으면 그 직원의 묶음 줄을 모두 읽고 메일함을 줄마다 거른다 (2026-11-06 실사용 시험), 기간 · 첨부 · 이어 찾을 자리(받은 시각 · id 가 더 작은 쪽)', () => {
|
||||
const after = {
|
||||
receivedAt: new Date('2026-11-01T00:00:00Z'),
|
||||
emailId: 'm9',
|
||||
@@ -78,6 +78,7 @@ describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => {
|
||||
'lower("user_email") = $1 AND "mailbox_ids" @> $2::text[] AND "received_at" >= $3::timestamptz AND "received_at" < $4::timestamptz ' +
|
||||
'AND "has_attachment" AND ("received_at", "email_id") < ($5::timestamptz, $6) AND "grams" @> $7::text[]',
|
||||
);
|
||||
expect(query.sql).not.toContain('ANY("mailbox_ids")');
|
||||
expect(query.params).toEqual([
|
||||
USER,
|
||||
['a'],
|
||||
@@ -184,10 +185,11 @@ describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('강조에 쓰는 글 — 그 직원의 찾은 줄만', () => {
|
||||
it('강조에 쓰는 글 — 그 직원의 찾은 줄만 (본문 밖의 접은 칸 넷도 — 미리 보기가 인용 안에서만 맞은 말을 다른 칸에서 찾았는지 본다)', () => {
|
||||
const query = highlightTextsSql(USER, ['m1', 'm2']);
|
||||
expect(flat(query.sql)).toBe(
|
||||
'SELECT "email_id", "subject", "body_text", "preview" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])',
|
||||
'SELECT "email_id", "subject", "body_text", "preview", "fold_from", "fold_to", "fold_subject", "fold_attachment" ' +
|
||||
'FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])',
|
||||
);
|
||||
expect(query.params).toEqual([USER, ['m1', 'm2']]);
|
||||
});
|
||||
|
||||
@@ -6,8 +6,11 @@ import type { SearchPlan } from './search-plan';
|
||||
* 색인 찾기의 SQL (순수 함수 — 14단계 설계 G). 값은 모두 매개변수다 — 글 · 주소를 SQL 에 이어 붙이지 않는다.
|
||||
*
|
||||
* 줄의 글(묶음 배열 · 접은 본문)은 TOAST 에 있어 줄마다 풀면 비싸다(0.1~0.3ms). 그래서 쿼리를 둘로 나눈다:
|
||||
* - 후보(candidatesSql): 주소 + 묶음을 GIN 에서만 거른다 — 주소를 `lower("user_email")` 식으로 적어(색인과 같은 식) 다른 색인을 쓸 수 없게 한다.
|
||||
* - 후보(candidatesSql): 주소 + 묶음 + 메일함을 GIN 하나(`IDX_mail_search_messages_user_grams_mailboxes` — 마이그레이션 SearchIndexUserGramsMailboxes)에서만
|
||||
* 거른다 — 주소를 `lower("user_email")` 식으로 적어(색인과 같은 식) 다른 색인을 쓸 수 없게 한다. 메일함 범위는 `"mailbox_ids" @> $n` 그대로 둔다 —
|
||||
* 같은 GIN 이 메일함까지 맞춰 맞은 줄만 읽는다(`= ANY` 로 적으면 그 직원의 그 묶음 줄을 모두 읽고 메일함을 줄마다 거른다).
|
||||
* 주소 그대로면 계획기가 흔한 묶음에서 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-05 측정 — 260~700ms → 10ms).
|
||||
* 메일함만 담은 GIN 이 있던 때는 계획기가 그것만으로 메일함의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-06 실사용 시험 — 1만 통 메일함 안 1.1초).
|
||||
* 줄에서는 작은 칸(받은 시각 · 메일함 · 키워드 · 첨부)만 읽는다
|
||||
* - 확인(checkHitsSql): 받은 후보 id 의 줄만 기본 키로 읽어 낱말을 strpos 로 본다
|
||||
* 묶음이 없는 찾기(한 글자 낱말뿐 — 본문을 훑지 않는다)는 받은 차례(keyset — btree)로 값싼 칸만 확인하고, 낱말 확인이 없는 찾기(기간 · 첨부만)는
|
||||
@@ -44,7 +47,8 @@ class Params {
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원을 거르는 길 — `gin`: 묶음 GIN 의 식(lower)으로 묶음과 함께(후보), `btree`: 주소 그대로(받은 차례 · 기본 키)
|
||||
* 직원을 거르는 길 — `gin`: 찾기 GIN 의 식(lower)으로 묶음 · 메일함과 함께(후보), `btree`: 주소 그대로(받은 차례 · 기본 키 —
|
||||
* 메일함 조건은 줄을 읽은 뒤 거른다. 이 길의 쿼리는 bitmap 을 끈다)
|
||||
* 표의 주소는 늘 소문자라 두 식은 같은 줄을 고른다
|
||||
*/
|
||||
type UserMatch = 'gin' | 'btree';
|
||||
@@ -106,7 +110,7 @@ function checkConditions(params: Params, plan: SearchPlan): string {
|
||||
.join(' AND ');
|
||||
}
|
||||
|
||||
/** 후보 — 주소 + 묶음(GIN)과 값싼 조건만으로 받은 시각 최신부터 `limit` 줄 (확인하지 않는다 — 묶음이 있는 계획만) */
|
||||
/** 후보 — 주소 + 묶음 + 메일함(GIN 하나)과 값싼 조건만으로 받은 시각 최신부터 `limit` 줄 (확인하지 않는다 — 묶음이 있는 계획만) */
|
||||
export function candidatesSql(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
@@ -260,13 +264,16 @@ export function plainCountSql(
|
||||
};
|
||||
}
|
||||
|
||||
/** 강조에 쓰는 글 — 찾은 쪽의 줄만 */
|
||||
/**
|
||||
* 강조에 쓰는 글 — 찾은 쪽의 줄만. 본문 밖의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름)은 미리 보기를 고를 때
|
||||
* 본문의 인용 안에서만 맞은 말을 그 칸에서도 찾았는지 본다(search-highlight.ts)
|
||||
*/
|
||||
export function highlightTextsSql(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): SqlQuery {
|
||||
return {
|
||||
sql: `SELECT "email_id", "subject", "body_text", "preview" FROM ${TABLE} WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`,
|
||||
sql: `SELECT "email_id", "subject", "body_text", "preview", "fold_from", "fold_to", "fold_subject", "fold_attachment" FROM ${TABLE} WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`,
|
||||
params: [user, [...ids]],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -678,11 +678,14 @@ export class MailService {
|
||||
id: string,
|
||||
query: MessageDetailQueryDto = {},
|
||||
): Promise<MessageDetail> {
|
||||
// 검색 강조(14단계) — 같은 검색 쿼리로 열었으면 정화한 뒤의 본문 · 제목 · 첨부 이름에 맞은 곳을 단다 (검색 글은 로그에 남기지 않는다)
|
||||
// 검색 강조(14단계) — 같은 검색 쿼리로 열었으면 정화한 뒤의 본문 · 제목 · 첨부 이름에 맞은 곳을 단다 (검색 글은 로그에 남기지 않는다).
|
||||
// 보낸 사람 · 받는 사람 조건은 강조하지 않지만 넘긴다 — 본문에 강조할 말(한 글자 낱말)을 목록과 같게 고른다
|
||||
return highlightDetail(
|
||||
await this.readMessage(user, id, query),
|
||||
highlightTermsOf({
|
||||
text: query.q,
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
subject: query.subject,
|
||||
body: query.body,
|
||||
}),
|
||||
|
||||
@@ -106,6 +106,34 @@ describe('읽기 화면의 검색 강조 (message-highlight — 14단계 설계
|
||||
expect(bodyOnly.htmlBody).toContain('<mark>일정</mark>');
|
||||
});
|
||||
|
||||
it('한 글자 낱말뿐이면 본문은 강조하지 않는다 — 본문을 찾지 않았다, 제목 · 첨부 이름은 그대로 (2026-11-06 실사용 시험)', () => {
|
||||
const source = detail({
|
||||
subject: '견적 안내',
|
||||
htmlBody: '<p>이번 달 견적 안내</p>',
|
||||
textBody: '이번 달 견적 안내',
|
||||
});
|
||||
// '견' 은 제목 · 첨부 이름(견적.xlsx) · 본문에, '달' 은 본문에만 있다
|
||||
const single = highlightDetail(source, highlightTermsOf({ text: '달 견' }));
|
||||
expect(single.searchHighlight).toEqual({
|
||||
subject: [[0, 1]],
|
||||
attachments: { p2: [[0, 1]] },
|
||||
textBody: [],
|
||||
});
|
||||
expect(single.htmlBody).toBe('<p>이번 달 견적 안내</p>');
|
||||
|
||||
// 두 글자 이상 낱말과 섞이면 그 낱말만 본문에 — 한 글자 낱말은 제목 · 첨부 이름에만
|
||||
const mixed = highlightDetail(
|
||||
source,
|
||||
highlightTermsOf({ text: '달 견적' }),
|
||||
);
|
||||
expect(slices(mixed.textBody!, mixed.searchHighlight!.textBody)).toEqual([
|
||||
'견적',
|
||||
]);
|
||||
expect(mixed.htmlBody).toBe('<p>이번 달 <mark>견적</mark> 안내</p>');
|
||||
expect(mixed.searchHighlight!.subject).toEqual([[0, 2]]);
|
||||
expect(mixed.searchHighlight!.attachments).toEqual({ p2: [[0, 2]] });
|
||||
});
|
||||
|
||||
it('강조가 있으면 제목 · 첨부 이름 · textBody 를 NFC 로 싣는다 — 위치의 기준', () => {
|
||||
const nfd = (text: string) => text.normalize('NFD');
|
||||
const result = highlightDetail(
|
||||
|
||||
@@ -252,6 +252,7 @@ describe('MessageListService (GET /api/messages — 목록 · 검색, 14단계)'
|
||||
subject: '계약 갱신 안내',
|
||||
bodyText: '안녕하세요. 이번 달 계약 갱신 일정입니다.',
|
||||
preview: '안녕하세요.',
|
||||
otherFields: ['', '', '계약 갱신 안내', ''],
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -272,6 +273,42 @@ describe('MessageListService (GET /api/messages — 목록 · 검색, 14단계)'
|
||||
});
|
||||
});
|
||||
|
||||
it('색인 방식의 미리 보기는 답장의 원문 인용(> 줄)을 피한다 — 인용 안에서만 맞고 색인의 다른 칸(제목)에서도 맞으면 인용 앞의 글 (2026-11-06 사용자 관점 점검)', async () => {
|
||||
const { service, provider, searchQuery } = setup();
|
||||
searchQuery.search.mockResolvedValue({
|
||||
hits: [{ id: 'm1', receivedAt: new Date() }],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 1,
|
||||
});
|
||||
provider.getSummaries.mockResolvedValue([
|
||||
summary('m1', {
|
||||
subject: 'RE: 해오름 견적',
|
||||
preview: '네, 확인했습니다. > 견적서를 보내 드립니다.',
|
||||
}),
|
||||
]);
|
||||
searchQuery.highlightTexts.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'm1',
|
||||
{
|
||||
subject: 'RE: 해오름 견적',
|
||||
bodyText: '네, 확인했습니다.\n\n> 견적서를 보내 드립니다.',
|
||||
preview: '네, 확인했습니다.',
|
||||
otherFields: ['', '', 're: 해오름 견적', ''],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const page = await service.list(user, { limit: 20, q: '견적' });
|
||||
|
||||
expect(page.items[0].searchHighlight).toEqual({
|
||||
subject: [[8, 10]],
|
||||
preview: { text: '네, 확인했습니다.…', ranges: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('엔진이 범위 밖에 둔 메일(그사이 휴지통으로 옮김) · 엔진에 없는 메일은 뺀다 — 메일함 범위면 그 메일함에 든 것만', async () => {
|
||||
const { service, provider, searchQuery } = setup();
|
||||
searchQuery.search.mockResolvedValue({
|
||||
|
||||
@@ -188,6 +188,7 @@ export class MessageListService {
|
||||
const summary = byId.get(id);
|
||||
if (!summary || !inScope(summary, scope)) return [];
|
||||
const subject = highlightedSubject(summary.subject);
|
||||
const text = texts.get(id);
|
||||
return [
|
||||
{
|
||||
...summary,
|
||||
@@ -196,7 +197,8 @@ export class MessageListService {
|
||||
{
|
||||
subject,
|
||||
preview: summary.preview,
|
||||
bodyText: texts.get(id)?.bodyText ?? null,
|
||||
bodyText: text?.bodyText ?? null,
|
||||
otherFields: text?.otherFields ?? null,
|
||||
},
|
||||
plan.highlight,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user