1 Commits

Author SHA1 Message Date
dependabot[bot]
1e2a61c518 build(deps-dev): Bump vite, electron-vite and vitest
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite), [electron-vite](https://github.com/alex8088/electron-vite) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies needed to be updated together.

Updates `vite` from 5.4.21 to 8.0.11
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.11/packages/vite)

Updates `electron-vite` from 2.3.0 to 5.0.0
- [Release notes](https://github.com/alex8088/electron-vite/releases)
- [Changelog](https://github.com/alex8088/electron-vite/blob/master/CHANGELOG.md)
- [Commits](https://github.com/alex8088/electron-vite/compare/v2.3.0...v5.0.0)

Updates `vitest` from 1.6.1 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

---
updated-dependencies:
- dependency-name: electron-vite
  dependency-version: 5.0.0
  dependency-type: direct:development
- dependency-name: vite
  dependency-version: 8.0.11
  dependency-type: direct:development
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 06:13:38 +00:00
9 changed files with 1113 additions and 995 deletions

View File

@@ -32,8 +32,6 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run web:build - run: npm run web:build
- uses: actions/configure-pages@v5 - uses: actions/configure-pages@v5
with:
enablement: true
- uses: actions/upload-pages-artifact@v3 - uses: actions/upload-pages-artifact@v3
with: with:
path: dist-web path: dist-web

1
.gitignore vendored
View File

@@ -210,4 +210,3 @@ dist-web
.ionide .ionide
# End of https://www.toptal.com/developers/gitignore/api/macos,git,visualstudiocode,node # End of https://www.toptal.com/developers/gitignore/api/macos,git,visualstudiocode,node
.gstack/

1923
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@pieroot/nfd2nfc", "name": "@pieroot/nfd2nfc",
"version": "2.0.3", "version": "2.0.1",
"description": "macOS 한글 파일명 NFD→NFC 실시간 변환 트레이 앱 + CLI + 라이브러리", "description": "macOS 한글 파일명 NFD→NFC 실시간 변환 트레이 앱 + CLI + 라이브러리",
"main": "out/lib/index.js", "main": "out/lib/index.js",
"types": "out/lib/index.d.ts", "types": "out/lib/index.d.ts",
@@ -49,17 +49,17 @@
"@types/yargs": "^17.0.32", "@types/yargs": "^17.0.32",
"@typescript-eslint/eslint-plugin": "^7.7.0", "@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0", "@typescript-eslint/parser": "^7.7.0",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^4.3.4",
"electron": "^39.8.5", "electron": "^35.1.5",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-vite": "^3.1.0", "electron-vite": "^5.0.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"typescript": "^5.4.0", "typescript": "^5.4.0",
"vite": "^6.4.2", "vite": "^8.0.11",
"vitest": "^4.1.6" "vitest": "^4.1.5"
}, },
"author": "jung-geun <pieroot.02@gmail.com>", "author": "jung-geun <pieroot.02@gmail.com>",
"license": "MIT", "license": "MIT",

View File

@@ -1,103 +0,0 @@
import { EventEmitter } from 'events';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
vi.mock('chokidar');
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }));
const mockAppendUndoEntries = vi.fn().mockResolvedValue(undefined);
vi.mock('../store', () => ({ appendUndoEntries: mockAppendUndoEntries }));
const mockQueueRenamedNotification = vi.fn().mockResolvedValue(undefined);
vi.mock('../notifier', () => ({
queueRenamedNotification: mockQueueRenamedNotification,
notifyManualQueue: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../nanoid', () => ({ nanoid: () => 'test-id' }));
const mockNormalizeEntry = vi.fn();
vi.mock('../../core/normalizer', () => ({ normalizeEntry: mockNormalizeEntry }));
import chokidar from 'chokidar';
import type { WatchedDir } from '../../core/types';
// 테스트마다 고유 경로를 사용해 모듈 레벨 recentlyRenamed 상태 충돌 방지
function makePaths(slug: string) {
const nfc = `/test/watch/${slug}.txt`;
return { nfc, nfd: nfc.normalize('NFD') };
}
function makeDir(id: string): WatchedDir {
return { id, path: '/test/watch', recursive: false, mode: 'auto', enabled: true, customRanges: [] };
}
describe('watcher — in-flight race condition', () => {
let mockWatcher: EventEmitter & { close: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockWatcher = Object.assign(new EventEmitter(), { close: vi.fn().mockResolvedValue(undefined) });
vi.mocked(chokidar.watch).mockReturnValue(mockWatcher as unknown as ReturnType<typeof chokidar.watch>);
});
afterEach(async () => {
const { stopDir } = await import('../watcher');
// 각 테스트에서 사용한 id 정리
for (const id of ['dir-burst', 'dir-nfc-reentry', 'dir-nfd-reentry']) {
await stopDir(id);
}
});
it('동일 NFD 경로에 burst add 이벤트가 와도 normalizeEntry가 1번만 호출된다', async () => {
const { nfc, nfd } = makePaths('강의자료-burst');
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
const { startDir } = await import('../watcher');
await startDir(makeDir('dir-burst'));
// 같은 경로를 동기적으로 두 번 emit — race 시나리오 재현
mockWatcher.emit('add', nfd);
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
expect(mockAppendUndoEntries).toHaveBeenCalledTimes(1);
});
it('rename 완료 후 NFC 경로로 다시 add 이벤트가 와도 recentlyRenamed 가드에 막힌다', async () => {
const { nfc, nfd } = makePaths('강의자료-nfc-reentry');
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
const { startDir } = await import('../watcher');
await startDir(makeDir('dir-nfc-reentry'));
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
// RENAME_TTL(2000ms) 이내에 NFC 경로로 재진입 — 가드에 막혀야 함
mockWatcher.emit('add', nfc);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).not.toHaveBeenCalled();
});
it('rename 완료 후 NFD 경로로 재진입해도 recentlyRenamed 가드에 막힌다', async () => {
const { nfc, nfd } = makePaths('강의자료-nfd-reentry');
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
const { startDir } = await import('../watcher');
await startDir(makeDir('dir-nfd-reentry'));
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
mockWatcher.emit('add', nfd);
await new Promise((r) => setTimeout(r, 0));
expect(mockNormalizeEntry).not.toHaveBeenCalled();
});
});

View File

@@ -12,7 +12,6 @@ type ActivityListener = (event: ActivityEvent) => void;
const watchers = new Map<string, FSWatcher>(); const watchers = new Map<string, FSWatcher>();
const recentlyRenamed = new Map<string, number>(); const recentlyRenamed = new Map<string, number>();
const inFlight = new Set<string>();
const RENAME_TTL = 2000; const RENAME_TTL = 2000;
const listeners = new Set<ActivityListener>(); const listeners = new Set<ActivityListener>();
const manualQueue = new Map<string, string[]>(); const manualQueue = new Map<string, string[]>();
@@ -52,29 +51,20 @@ export async function startDir(dir: WatchedDir): Promise<void> {
persistent: true, persistent: true,
depth: dir.recursive ? undefined : 0, depth: dir.recursive ? undefined : 0,
ignored: /(^|[/\\])\../, ignored: /(^|[/\\])\../,
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
}); });
async function handlePath(filePath: string, type: 'file' | 'directory'): Promise<void> { async function handlePath(filePath: string, type: 'file' | 'directory'): Promise<void> {
const now = Date.now(); const now = Date.now();
const key = filePath.normalize('NFC'); const lastRenamed = recentlyRenamed.get(filePath);
// 동일 경로가 이미 처리 중이면 즉시 return (burst add 이벤트로 인한 race 차단)
if (inFlight.has(key)) return;
const lastRenamed = recentlyRenamed.get(key);
if (lastRenamed && now - lastRenamed < RENAME_TTL) return; if (lastRenamed && now - lastRenamed < RENAME_TTL) return;
const basename = filePath.split('/').pop() ?? ''; const basename = filePath.split('/').pop() ?? '';
if (!shouldNormalize(basename, filterOpts)) return; if (!shouldNormalize(basename, filterOpts)) return;
inFlight.add(key);
try {
if (dir.mode === 'auto') { if (dir.mode === 'auto') {
const result = await normalizeEntry(filePath, type, filterOpts); const result = await normalizeEntry(filePath, type, filterOpts);
if (result.status === 'renamed' || result.status === 'noop-same-inode') { if (result.status === 'renamed' || result.status === 'noop-same-inode') {
// oldPath와 newPath 모두 등록 — chokidar가 NFD/NFC 변형 중 어느 쪽으로 이벤트를 보내도 막힘 recentlyRenamed.set(result.newPath, Date.now());
recentlyRenamed.set(result.oldPath.normalize('NFC'), Date.now());
recentlyRenamed.set(result.newPath.normalize('NFC'), Date.now());
if (result.status === 'renamed') { if (result.status === 'renamed') {
await store.appendUndoEntries([ await store.appendUndoEntries([
{ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false }, { id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false },
@@ -95,9 +85,6 @@ export async function startDir(dir: WatchedDir): Promise<void> {
} }
await notifier.notifyManualQueue(queue.length, dir.path); await notifier.notifyManualQueue(queue.length, dir.path);
} }
} finally {
inFlight.delete(key);
}
} }
w.on('add', (p) => handlePath(p, 'file').catch(console.error)) w.on('add', (p) => handlePath(p, 'file').catch(console.error))
@@ -160,8 +147,7 @@ export async function applyManualQueue(dirId: string, dir: WatchedDir): Promise<
const result = await normalizeEntry(filePath, type, filterOpts); const result = await normalizeEntry(filePath, type, filterOpts);
results.push(result); results.push(result);
if (result.status === 'renamed') { if (result.status === 'renamed') {
recentlyRenamed.set(result.oldPath.normalize('NFC'), Date.now()); recentlyRenamed.set(result.newPath, Date.now());
recentlyRenamed.set(result.newPath.normalize('NFC'), Date.now());
undoEntries.push({ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false }); undoEntries.push({ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false });
} }
} catch (err) { } catch (err) {

View File

@@ -1 +0,0 @@
nfd2nfc.pieroot.xyz

View File

@@ -82,8 +82,6 @@ main {
} }
.dropzone { .dropzone {
display: block;
width: 100%;
border: 2px dashed var(--border); border: 2px dashed var(--border);
border-radius: 16px; border-radius: 16px;
padding: 64px 24px; padding: 64px 24px;

View File

@@ -4,7 +4,7 @@ import { resolve } from 'path';
export default defineConfig({ export default defineConfig({
root: __dirname, root: __dirname,
base: '/', base: '/NFD2NFC/',
plugins: [react()], plugins: [react()],
build: { build: {
outDir: resolve(__dirname, '../dist-web'), outDir: resolve(__dirname, '../dist-web'),