From 4e92bb269004f56f2f0816489ffc2a32f2fe5a8f Mon Sep 17 00:00:00 2001 From: jung-geun Date: Sat, 9 May 2026 15:40:53 +0900 Subject: [PATCH] =?UTF-8?q?src/core:=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=EB=8F=85=EB=A6=BD=20=EC=A0=95=EA=B7=9C=ED=99=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20+=20Vitest=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filter.ts: 한글 자모 코드포인트 정확 필터 (U+1100-11FF, U+A960-A97F, U+D7B0-D7FF). normalizer.ts: APFS normalization-insensitive 정확 처리 (inode 비교). scanner.ts: 재귀 스캔 결과 깊이 역순 정렬 (자식 먼저 rename). types.ts: WatchedDir, RenameResult, ActivityEvent, AppSchema 등 공유 타입. Co-Authored-By: Claude Opus 4.7 --- src/core/__tests__/filter.test.ts | 53 ++++++++++++++++++++ src/core/__tests__/normalizer.test.ts | 71 +++++++++++++++++++++++++++ src/core/__tests__/scanner.test.ts | 60 ++++++++++++++++++++++ src/core/filter.ts | 29 +++++++++++ src/core/normalizer.ts | 51 +++++++++++++++++++ src/core/scanner.ts | 50 +++++++++++++++++++ src/core/types.ts | 47 ++++++++++++++++++ 7 files changed, 361 insertions(+) create mode 100644 src/core/__tests__/filter.test.ts create mode 100644 src/core/__tests__/normalizer.test.ts create mode 100644 src/core/__tests__/scanner.test.ts create mode 100644 src/core/filter.ts create mode 100644 src/core/normalizer.ts create mode 100644 src/core/scanner.ts create mode 100644 src/core/types.ts diff --git a/src/core/__tests__/filter.test.ts b/src/core/__tests__/filter.test.ts new file mode 100644 index 0000000..3182289 --- /dev/null +++ b/src/core/__tests__/filter.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { shouldNormalize } from '../filter'; + +// 한글 NFD: macOS Finder가 생성하는 한글 파일명 형태 (자모 분리) +// U+1100 (ᄀ) + U+1161 (ᅡ) + U+11AB (ᆫ) = "간" 의 NFD +const HANGUL_GA_NFD = '가'; // 가 (NFD: 초성+중성) +const HANGUL_GAN_NFD = '간'; // 간 (NFD: 초성+중성+종성) +const HANGUL_WORD_NFD = `${HANGUL_GA_NFD}${HANGUL_GAN_NFD}`; // 파일명용 + +// NFC 한글 +const HANGUL_GA_NFC = '가'; // 가 (NFC: 완성형) +const HANGUL_GAN_NFC = '간'; // 간 (NFC: 완성형) + +describe('shouldNormalize', () => { + it('한글 NFD 자모가 포함된 파일명 → true', () => { + expect(shouldNormalize(`${HANGUL_WORD_NFD}.txt`)).toBe(true); + }); + + it('이미 NFC인 한글 파일명 → false', () => { + expect(shouldNormalize(`${HANGUL_GA_NFC}${HANGUL_GAN_NFC}.txt`)).toBe(false); + }); + + it('ASCII 파일명 → false', () => { + expect(shouldNormalize('hello-world.txt')).toBe(false); + }); + + it('라틴 악센트 NFD (café) → false (한글 범위 아님)', () => { + // é = e + combining acute accent (U+0301) → NFD + const cafeNfd = 'café.txt'; // é in NFD + expect(shouldNormalize(cafeNfd)).toBe(false); + }); + + it('한글 NFC + 영문 혼합 파일명이 이미 NFC → false', () => { + expect(shouldNormalize('hello-가나다.txt')).toBe(false); + }); + + it('사용자 커스텀 범위 추가 시 해당 범위 NFD 코드포인트를 포함한 파일명 → true', () => { + // 일본어 히라가나 탁점 결합 (예시): U+3099 combining voiced iteration mark + // 여기서는 임의 코드포인트 U+0300 범위를 화이트리스트에 추가 + const name = 'café.txt'; // é NFD (combining U+0301) + expect(shouldNormalize(name, { customRanges: [[0x0300, 0x036f]] })).toBe(true); + }); + + it('파일명에 한글 자모 확장-A 범위 코드포인트가 있으면 → true', () => { + // U+A960 Hangul Jamo Extended-A + const name = 'ꥠfile.txt'; // 확장-A 자모 + // 이 코드포인트는 단독으로 NFC와 다른 NFD를 만들지는 않지만 filter 범위 테스트 + // shouldNormalize는 먼저 NFC 동일성 체크를 하므로, NFC!=원본인 경우에만 범위 체크함 + // 따라서 실제로 NFD인 상황을 만들기 위해 함께 한글 자모를 섞어 줌 + const nfdWithExtA = `ꥠ${HANGUL_GA_NFD}.txt`; + expect(shouldNormalize(nfdWithExtA)).toBe(true); + }); +}); diff --git a/src/core/__tests__/normalizer.test.ts b/src/core/__tests__/normalizer.test.ts new file mode 100644 index 0000000..111ac63 --- /dev/null +++ b/src/core/__tests__/normalizer.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { normalizeEntry } from '../normalizer'; + +// 테스트용 임시 디렉토리 +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nfc-test-')); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +// 한글 NFD 자모 +const NFD_PREFIX = '간'; // 간 (NFD) +const NFC_PREFIX = '간'; // 간 (NFC) + +describe('normalizeEntry — file', () => { + it('NFD 한글 파일명을 NFC로 rename', async () => { + const nfdName = `${NFD_PREFIX}.txt`; + const nfcName = `${NFC_PREFIX}.txt`; + const filePath = path.join(tmpDir, nfdName); + await fs.writeFile(filePath, 'test'); + + const result = await normalizeEntry(filePath, 'file'); + expect(result.status).toBe('renamed'); + expect(result.newPath).toBe(path.join(tmpDir, nfcName)); + + // 새 경로에 파일 존재 + const exists = await fs.stat(result.newPath).then(() => true).catch(() => false); + expect(exists).toBe(true); + }); + + it('이미 NFC인 파일명 → skipped', async () => { + const nfcName = `${NFC_PREFIX}.txt`; + const filePath = path.join(tmpDir, nfcName); + await fs.writeFile(filePath, 'test'); + + const result = await normalizeEntry(filePath, 'file'); + expect(result.status).toBe('skipped'); + }); + + it('라틴 악센트 NFD → skipped (한글 필터)', async () => { + // e + combining acute accent (é in NFD) + const nfdName = 'caféNFD.txt'; // 이미 NFC라 아래에서 직접 NFD로 만들기 + // 실제 NFD 문자열: NFC e('e') + combining acute U+0301 + const nfdReal = 'café.txt'; + const filePath = path.join(tmpDir, nfdReal); + await fs.writeFile(filePath, 'test'); + + const result = await normalizeEntry(filePath, 'file'); + expect(result.status).toBe('skipped'); + }); +}); + +describe('normalizeEntry — directory', () => { + it('NFD 한글 폴더명을 NFC로 rename', async () => { + const nfdName = `${NFD_PREFIX}_dir`; + const nfcName = `${NFC_PREFIX}_dir`; + const dirPath = path.join(tmpDir, nfdName); + await fs.mkdir(dirPath); + + const result = await normalizeEntry(dirPath, 'directory'); + expect(result.status).toBe('renamed'); + expect(result.newPath).toBe(path.join(tmpDir, nfcName)); + }); +}); diff --git a/src/core/__tests__/scanner.test.ts b/src/core/__tests__/scanner.test.ts new file mode 100644 index 0000000..89a2f6a --- /dev/null +++ b/src/core/__tests__/scanner.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { scan } from '../scanner'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nfc-scan-')); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +const NFD = '간'; // 간 NFD +const NFC = '간'; // 간 NFC + +describe('scan', () => { + it('NFD 파일을 감지해 반환', async () => { + await fs.writeFile(path.join(tmpDir, `${NFD}.txt`), ''); + await fs.writeFile(path.join(tmpDir, 'ascii.txt'), ''); + await fs.writeFile(path.join(tmpDir, `${NFC}.txt`), ''); + + const entries = await scan(tmpDir, false); + expect(entries).toHaveLength(1); + expect(entries[0].path).toContain(NFD); + expect(entries[0].type).toBe('file'); + }); + + it('재귀=false: 하위 폴더 안 파일은 포함 안 됨', async () => { + const sub = path.join(tmpDir, 'subdir'); + await fs.mkdir(sub); + await fs.writeFile(path.join(sub, `${NFD}.txt`), ''); + + const entries = await scan(tmpDir, false); + expect(entries).toHaveLength(0); + }); + + it('재귀=true: 하위 폴더 안 NFD 파일도 포함', async () => { + const sub = path.join(tmpDir, 'subdir'); + await fs.mkdir(sub); + await fs.writeFile(path.join(sub, `${NFD}.txt`), ''); + + const entries = await scan(tmpDir, true); + expect(entries).toHaveLength(1); + }); + + it('결과가 깊이 역순으로 정렬됨 (자식 먼저)', async () => { + const sub = path.join(tmpDir, `${NFD}_dir`); + await fs.mkdir(sub); + await fs.writeFile(path.join(sub, `${NFD}.txt`), ''); + + const entries = await scan(tmpDir, true); + // 파일(더 깊음)이 폴더(얕음)보다 먼저 나와야 함 + expect(entries[0].type).toBe('file'); + expect(entries[entries.length - 1].type).toBe('directory'); + }); +}); diff --git a/src/core/filter.ts b/src/core/filter.ts new file mode 100644 index 0000000..77e3f33 --- /dev/null +++ b/src/core/filter.ts @@ -0,0 +1,29 @@ +// 한글 NFD 자모 코드포인트 범위 (macOS가 한글 파일명을 NFD로 저장할 때 분해되는 범위) +const HANGUL_RANGES: ReadonlyArray<[number, number]> = [ + [0x1100, 0x11ff], // Hangul Jamo (초성·중성·종성) + [0xa960, 0xa97f], // Hangul Jamo Extended-A + [0xd7b0, 0xd7ff], // Hangul Jamo Extended-B +]; + +export interface FilterOptions { + customRanges?: Array<[number, number]>; +} + +/** + * 파일명을 NFC로 변환할 필요가 있는지 확인한다. + * 조건: 이미 NFC가 아니면서, 한글 자모 코드포인트(또는 사용자 화이트리스트 범위)가 포함된 경우. + * 라틴 악센트(U+0300 등)만 있는 파일명은 한글 범위에 해당하지 않으므로 false를 반환한다. + */ +export function shouldNormalize(name: string, opts?: FilterOptions): boolean { + if (name === name.normalize('NFC')) return false; + + const ranges = [...HANGUL_RANGES, ...(opts?.customRanges ?? [])]; + + for (const ch of name) { + const cp = ch.codePointAt(0); + if (cp === undefined) continue; + if (ranges.some(([lo, hi]) => cp >= lo && cp <= hi)) return true; + } + + return false; +} diff --git a/src/core/normalizer.ts b/src/core/normalizer.ts new file mode 100644 index 0000000..065c108 --- /dev/null +++ b/src/core/normalizer.ts @@ -0,0 +1,51 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { shouldNormalize } from './filter'; +import type { FilterOptions } from './filter'; +import type { RenameResult } from './types'; + +export async function normalizeEntry( + entryPath: string, + type: 'file' | 'directory', + opts?: FilterOptions +): Promise { + const dir = path.dirname(entryPath); + const basename = path.basename(entryPath); + + if (!shouldNormalize(basename, opts)) { + return { type, oldPath: entryPath, newPath: entryPath, status: 'skipped' }; + } + + const nfcBasename = basename.normalize('NFC'); + const newPath = path.join(dir, nfcBasename); + + return doRename(entryPath, newPath, type); +} + +async function doRename( + oldPath: string, + newPath: string, + type: 'file' | 'directory' +): Promise { + let targetExists = false; + try { + await fs.access(newPath); + targetExists = true; + } catch { + // 대상 없음 → 안전하게 rename 가능 + } + + if (targetExists) { + // APFS는 normalization-insensitive: NFD와 NFC 경로가 같은 inode일 수 있음 + const [srcStat, dstStat] = await Promise.all([fs.stat(oldPath), fs.stat(newPath)]); + if (srcStat.ino !== dstStat.ino) { + // 다른 inode → 진짜 충돌, 스킵 + return { type, oldPath, newPath, status: 'collision' }; + } + // 같은 inode: APFS가 두 경로를 동일 파일로 처리하는 것. + // 그러나 디렉토리 엔트리는 여전히 NFD로 저장되어 있으므로 rename으로 NFC 엔트리로 업데이트. + } + + await fs.rename(oldPath, newPath); + return { type, oldPath, newPath, status: 'renamed' }; +} diff --git a/src/core/scanner.ts b/src/core/scanner.ts new file mode 100644 index 0000000..6f8d65e --- /dev/null +++ b/src/core/scanner.ts @@ -0,0 +1,50 @@ +import fs, { Dirent } from 'fs'; +import fsPromises from 'fs/promises'; +import path from 'path'; +import { shouldNormalize } from './filter'; +import type { FilterOptions } from './filter'; + +export interface ScanEntry { + path: string; + type: 'file' | 'directory'; +} + +/** + * 디렉토리를 재귀적으로 스캔해 NFD→NFC 변환이 필요한 항목을 반환한다. + * 깊이 역순으로 정렬해 자식부터 rename할 수 있게 한다 (부모 경로 무효화 방지). + */ +export async function scan( + dirPath: string, + recursive: boolean, + opts?: FilterOptions +): Promise { + const entries: ScanEntry[] = []; + await walk(dirPath, recursive, entries, opts); + // 경로 깊이 역순: 하위 항목이 먼저 오도록 + entries.sort((a, b) => b.path.split(path.sep).length - a.path.split(path.sep).length); + return entries; +} + +async function walk( + dir: string, + recursive: boolean, + entries: ScanEntry[], + opts?: FilterOptions +): Promise { + let dirents: Dirent[]; + try { + dirents = await fsPromises.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const dirent of dirents) { + const fullPath = path.join(dir, dirent.name); + if (shouldNormalize(dirent.name, opts)) { + entries.push({ path: fullPath, type: dirent.isDirectory() ? 'directory' : 'file' }); + } + if (recursive && dirent.isDirectory()) { + await walk(fullPath, recursive, entries, opts); + } + } +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..5d51dc4 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,47 @@ +export type DirId = string; +export type WatchMode = 'auto' | 'manual'; + +export interface WatchedDir { + id: DirId; + path: string; + recursive: boolean; + mode: WatchMode; + enabled: boolean; + customRanges: Array<[number, number]>; +} + +export interface RenameResult { + type: 'file' | 'directory'; + oldPath: string; + newPath: string; + status: 'renamed' | 'noop-same-inode' | 'collision' | 'skipped'; +} + +export interface UndoEntry { + id: string; + ts: number; + oldPath: string; + newPath: string; + reverted: boolean; +} + +export interface ActivityEvent { + type: 'rename' | 'error' | 'collision' | 'info'; + ts: number; + dirId: DirId; + message: string; + result?: RenameResult; +} + +export interface AppSettings { + startAtLogin: boolean; + defaultMode: WatchMode; + notificationsEnabled: boolean; + notificationIntervalSecs: number; // 배치 알림 인터벌 (초), 기본 30 +} + +export interface AppSchema { + watchedDirs: WatchedDir[]; + settings: AppSettings; + undoLog: UndoEntry[]; +}