Skip to content

Commit cf0b44c

Browse files
authored
Merge PR #36 (yulepapa): fix 한글 파일명 검색 NFC/NFD 정규화
macOS 파일명은 NFD(분해형)로 저장될 수 있는데 IME 검색어는 NFC라, 화면상 같은 한글이 서로 매칭되지 않던 문제. 파일명 캐시 키·검색어를 NFC 로 맞추고 SQLite 폴백은 두 정규형을 모두 조회. 회귀 테스트 4건 포함, 로컬 278 passed + clippy 클린 확인.
2 parents ad6b9ea + c3d7eb0 commit cf0b44c

3 files changed

Lines changed: 98 additions & 8 deletions

File tree

src-tauri/src/indexer/manager.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,8 @@ impl WatchManager {
669669
file_id: row.get(0)?,
670670
path_lower: path_lower.into_boxed_str(),
671671
path: path.into_boxed_str(),
672-
name_lower: name.to_lowercase().into_boxed_str(),
672+
name_lower: crate::search::filename_cache::normalize_filename_search_key(&name)
673+
.into_boxed_str(),
673674
file_type: file_type.into_boxed_str(),
674675
size: row.get(4)?,
675676
modified_at: row.get(5)?,

src-tauri/src/search/filename.rs

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
use crate::db::escape_like_pattern;
22
use rusqlite::Connection;
3+
use unicode_normalization::UnicodeNormalization;
4+
5+
/// DB에는 기존 버전이 저장한 NFC/NFD 파일명이 섞여 있을 수 있다.
6+
/// 캐시가 잘려 SQLite 폴백을 쓰는 경우에도 두 정규형을 모두 조회한다.
7+
fn canonical_variants(value: &str) -> Vec<String> {
8+
let nfc = crate::search::filename_cache::normalize_filename_search_key(value);
9+
let nfd: String = nfc.nfd().collect();
10+
if nfc == nfd {
11+
vec![nfc]
12+
} else {
13+
vec![nfc, nfd]
14+
}
15+
}
316

417
/// 파일명 검색 (LIKE 기반 - 부분문자열 매칭 지원)
518
/// FTS5 unicode61은 한글 부분문자열 매칭이 안 되므로 LIKE 사용
@@ -15,7 +28,7 @@ pub fn search(
1528
}
1629

1730
// 여러 검색어를 AND로 연결
18-
let terms: Vec<&str> = trimmed.split_whitespace().collect();
31+
let terms: Vec<Vec<String>> = trimmed.split_whitespace().map(canonical_variants).collect();
1932

2033
if terms.is_empty() {
2134
return Ok(vec![]);
@@ -25,15 +38,17 @@ pub fn search(
2538
// ESCAPE 절 추가로 특수문자 처리
2639
let mut where_clauses: Vec<String> = terms
2740
.iter()
28-
.enumerate()
29-
.map(|(i, _)| format!("name LIKE ?{} ESCAPE '\\'", i + 1))
41+
.map(|variants| {
42+
let clauses = vec!["name LIKE ? ESCAPE '\\'"; variants.len()];
43+
format!("({})", clauses.join(" OR "))
44+
})
3045
.collect();
3146

3247
// folder_scope 필터 추가
3348
let scope_pattern = match folder_scope {
3449
Some(scope) if !scope.is_empty() => {
3550
let escaped = escape_like_pattern(scope);
36-
where_clauses.push(format!("path LIKE ?{} ESCAPE '\\'", terms.len() + 1));
51+
where_clauses.push("path LIKE ? ESCAPE '\\'".to_string());
3752
Some(format!("{}%", escaped))
3853
}
3954
_ => None,
@@ -59,6 +74,7 @@ pub fn search(
5974
// 파라미터 바인딩 (LIKE 패턴 이스케이프 + scope + limit)
6075
let like_patterns: Vec<String> = terms
6176
.iter()
77+
.flatten()
6278
.map(|term| format!("%{}%", escape_like_pattern(term)))
6379
.collect();
6480

@@ -111,3 +127,48 @@ pub struct FilenameResult {
111127
/// 하이라이트된 파일명 (LIKE에서는 원본 파일명)
112128
pub highlight: String,
113129
}
130+
131+
#[cfg(test)]
132+
mod tests {
133+
use super::*;
134+
135+
fn test_db(name: &str) -> Connection {
136+
let conn = Connection::open_in_memory().unwrap();
137+
conn.execute_batch(
138+
"CREATE TABLE files (
139+
id INTEGER PRIMARY KEY,
140+
path TEXT NOT NULL,
141+
name TEXT NOT NULL,
142+
file_type TEXT NOT NULL,
143+
size INTEGER,
144+
modified_at INTEGER
145+
)",
146+
)
147+
.unwrap();
148+
conn.execute(
149+
"INSERT INTO files (path, name, file_type, size, modified_at)
150+
VALUES (?1, ?2, 'jpg', 1, 0)",
151+
rusqlite::params![format!("/tmp/{name}"), name],
152+
)
153+
.unwrap();
154+
conn
155+
}
156+
157+
#[test]
158+
fn finds_nfd_filename_with_nfc_query() {
159+
let decomposed = "\u{1100}\u{1169}\u{110b}\u{1163}\u{11bc}\u{110b}\u{1175}.jpg";
160+
let conn = test_db(decomposed);
161+
162+
let results = search(&conn, "고양이", 10, None).unwrap();
163+
assert_eq!(results.len(), 1);
164+
}
165+
166+
#[test]
167+
fn finds_nfc_filename_with_nfd_query() {
168+
let conn = test_db("고양이.jpg");
169+
let decomposed_query = "\u{1100}\u{1169}\u{110b}\u{1163}\u{11bc}\u{110b}\u{1175}";
170+
171+
let results = search(&conn, decomposed_query, 10, None).unwrap();
172+
assert_eq!(results.len(), 1);
173+
}
174+
}

src-tauri/src/search/filename_cache.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ use rusqlite::Connection;
77
use std::collections::HashMap;
88
use std::sync::RwLock;
99

10+
/// 파일명 검색용 키를 생성한다.
11+
///
12+
/// macOS 파일명은 화면상 같은 한글도 NFD(분해형)로 저장될 수 있으므로,
13+
/// 캐시 값과 쿼리를 모두 NFC로 맞춘 뒤 소문자화해야 부분 문자열 검색이 일관된다.
14+
pub(crate) fn normalize_filename_search_key(value: &str) -> String {
15+
crate::utils::normalize_text(value).to_lowercase()
16+
}
17+
1018
/// 캐시 최대 엔트리 수 (~750MB 상한, 24GB RAM 환경 기준)
1119
///
1220
/// 대용량 연구/아카이브 인덱싱 사용자 (100만 파일 이상) 지원을 위해 v2.6.9 에서
@@ -102,7 +110,7 @@ impl FilenameCache {
102110
file_id: row.get(0)?,
103111
path_lower: path_lower.into_boxed_str(),
104112
path: path.into_boxed_str(),
105-
name_lower: name.to_lowercase().into_boxed_str(),
113+
name_lower: normalize_filename_search_key(&name).into_boxed_str(),
106114
file_type: file_type.into_boxed_str(),
107115
size: row.get(4)?,
108116
modified_at: row.get(5)?,
@@ -167,7 +175,7 @@ impl FilenameCache {
167175
// 검색어들 (AND 조건)
168176
let terms: Vec<String> = trimmed
169177
.split_whitespace()
170-
.map(|s| s.to_lowercase())
178+
.map(normalize_filename_search_key)
171179
.collect();
172180

173181
if terms.is_empty() {
@@ -287,7 +295,7 @@ mod tests {
287295
file_id: id,
288296
path_lower: path_lower.into_boxed_str(),
289297
path: path.into_boxed_str(),
290-
name_lower: name.to_lowercase().into_boxed_str(),
298+
name_lower: normalize_filename_search_key(name).into_boxed_str(),
291299
file_type: "txt".to_string().into_boxed_str(),
292300
size: 1000,
293301
modified_at: 0,
@@ -326,6 +334,26 @@ mod tests {
326334
assert_eq!(results.len(), 1);
327335
}
328336

337+
#[test]
338+
fn test_search_normalizes_decomposed_hangul_filename() {
339+
let cache = FilenameCache::new();
340+
let decomposed = "\u{1100}\u{1169}\u{110b}\u{1163}\u{11bc}\u{110b}\u{1175}.jpg";
341+
cache.upsert(create_test_entry(1, decomposed));
342+
343+
let results = cache.search("고양이", 10);
344+
assert_eq!(results.len(), 1);
345+
}
346+
347+
#[test]
348+
fn test_search_normalizes_decomposed_hangul_query() {
349+
let cache = FilenameCache::new();
350+
cache.upsert(create_test_entry(1, "고양이.jpg"));
351+
let decomposed_query = "\u{1100}\u{1169}\u{110b}\u{1163}\u{11bc}\u{110b}\u{1175}";
352+
353+
let results = cache.search(decomposed_query, 10);
354+
assert_eq!(results.len(), 1);
355+
}
356+
329357
#[cfg(windows)]
330358
#[test]
331359
fn test_name_from_path() {

0 commit comments

Comments
 (0)