mirror of
https://github.com/jung-geun/NFD2NFC.git
synced 2026-06-21 12:25:14 +09:00
Compare commits
1 Commits
v2.0.3
...
1e2a61c518
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e2a61c518 |
2
.github/workflows/pages.yml
vendored
2
.github/workflows/pages.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -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/
|
|
||||||
|
|||||||
1982
package-lock.json
generated
1982
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,51 +51,39 @@ 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);
|
if (dir.mode === 'auto') {
|
||||||
try {
|
const result = await normalizeEntry(filePath, type, filterOpts);
|
||||||
if (dir.mode === 'auto') {
|
if (result.status === 'renamed' || result.status === 'noop-same-inode') {
|
||||||
const result = await normalizeEntry(filePath, type, filterOpts);
|
recentlyRenamed.set(result.newPath, Date.now());
|
||||||
if (result.status === 'renamed' || result.status === 'noop-same-inode') {
|
if (result.status === 'renamed') {
|
||||||
// oldPath와 newPath 모두 등록 — chokidar가 NFD/NFC 변형 중 어느 쪽으로 이벤트를 보내도 막힘
|
await store.appendUndoEntries([
|
||||||
recentlyRenamed.set(result.oldPath.normalize('NFC'), Date.now());
|
{ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false },
|
||||||
recentlyRenamed.set(result.newPath.normalize('NFC'), Date.now());
|
]);
|
||||||
if (result.status === 'renamed') {
|
|
||||||
await store.appendUndoEntries([
|
|
||||||
{ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
emit({ type: 'rename', ts: now, dirId: dir.id, message: `${result.oldPath} → ${result.newPath}`, result });
|
|
||||||
// 알림은 배치로 처리 — 인터벌마다 총 건수 발송
|
|
||||||
await notifier.queueRenamedNotification(dir.path);
|
|
||||||
} else if (result.status === 'collision') {
|
|
||||||
emit({ type: 'collision', ts: now, dirId: dir.id, message: `충돌: ${result.oldPath}`, result });
|
|
||||||
}
|
}
|
||||||
} else {
|
emit({ type: 'rename', ts: now, dirId: dir.id, message: `${result.oldPath} → ${result.newPath}`, result });
|
||||||
const queue = manualQueue.get(dir.id) ?? [];
|
// 알림은 배치로 처리 — 인터벌마다 총 건수 발송
|
||||||
if (!queue.includes(filePath)) {
|
await notifier.queueRenamedNotification(dir.path);
|
||||||
queue.push(filePath);
|
} else if (result.status === 'collision') {
|
||||||
manualQueue.set(dir.id, queue);
|
emit({ type: 'collision', ts: now, dirId: dir.id, message: `충돌: ${result.oldPath}`, result });
|
||||||
emit({ type: 'info', ts: now, dirId: dir.id, message: `수동 대기: ${filePath}` });
|
|
||||||
}
|
|
||||||
await notifier.notifyManualQueue(queue.length, dir.path);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} else {
|
||||||
inFlight.delete(key);
|
const queue = manualQueue.get(dir.id) ?? [];
|
||||||
|
if (!queue.includes(filePath)) {
|
||||||
|
queue.push(filePath);
|
||||||
|
manualQueue.set(dir.id, queue);
|
||||||
|
emit({ type: 'info', ts: now, dirId: dir.id, message: `수동 대기: ${filePath}` });
|
||||||
|
}
|
||||||
|
await notifier.notifyManualQueue(queue.length, dir.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
nfd2nfc.pieroot.xyz
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user