7 Commits

Author SHA1 Message Date
6f44af3e4f fix(web): 드롭존 label에 display:block 추가 — 테두리 잘림 수정
label 요소는 기본 display:inline이라 content 너비만큼만 테두리가 그려짐.
display:block + width:100% 로 full-width 드롭존으로 수정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:38:24 +09:00
51f99856cf fix(ci): configure-pages enablement: true 추가 — Pages 미활성화 시 자동 활성화
첫 배포 시 'Get Pages site failed. Not Found' 오류 수정.
actions/configure-pages@v5 의 enablement: true 옵션으로 repo Pages 설정이
없을 때 자동으로 GitHub Actions source 로 활성화.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:17:03 +09:00
e967a0fbdb feat(web): GitHub Pages 기반 NFD→NFC 파일명 변환 웹앱 추가
설치 없이 브라우저에서 한글 파일명을 NFC 로 정규화할 수 있는 웹 도구.
파일/폴더 드롭 → 모든 path segment 를 독립적으로 NFC 변환 → fflate 스트리밍
ZIP 다운로드. UTF-8 flag(bit 11) 자동 설정으로 Windows Explorer 에서 정상 표시.

기존 src/core/filter.ts 의 shouldNormalize 를 그대로 재사용. 빌드는 web/
디렉토리의 별도 Vite 설정으로 격리되어 Electron 빌드에 영향 없음.

main 브랜치 push 시 .github/workflows/pages.yml 이
https://jung-geun.github.io/NFD2NFC/ 로 자동 배포.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:11:41 +09:00
aa772f7f97 Refactor code structure for improved readability and maintainability 2026-05-11 14:18:14 +09:00
8f2af73ed4 fix(ci): release 워크플로에 contents: write 권한 추가
DMG asset 업로드 시 softprops/action-gh-release가
"Resource not accessible by integration" 오류로 실패. 기본 GITHUB_TOKEN에
release 수정 권한이 없어 발생.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:04:47 +09:00
a469c666fe fix(build): electron-builder publish 비활성화 및 explicit 플래그 적용
- package.json: "publish": "never" → null (이전 값은 "never" 이름의
  publisher provider를 찾으려 해 v2.0.0/v2.0.1 release 빌드가 실패함)
- release.yml: electron-builder에 --publish never 플래그를 명시적으로
  전달해 git tag 기반 implicit publishing(v27에서 제거 예정) 경고 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:02:02 +09:00
4cbc044d15 perf(watcher): chokidar awaitWriteFinish 제거로 idle CPU 절감
100ms 주기 stat 폴링이 누적 CPU를 점유했음. 파일 이름만 다루는
정규화 도구이므로 안정화 대기가 불필요하며, recentlyRenamed TTL
맵이 이미 중복 이벤트를 막아준다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:48:28 +09:00
23 changed files with 1027 additions and 13 deletions

49
.github/workflows/pages.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Deploy web to Pages
on:
push:
branches: [main]
paths:
- 'web/**'
- 'src/core/filter.ts'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/pages.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- run: npm ci
- run: npm run web:build
- uses: actions/configure-pages@v5
with:
enablement: true
- uses: actions/upload-pages-artifact@v3
with:
path: dist-web
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4

View File

@@ -4,6 +4,9 @@ on:
release:
types: [published]
permissions:
contents: write
jobs:
build-and-release:
runs-on: macos-latest
@@ -21,7 +24,7 @@ jobs:
run: npm ci
- name: Build
run: npm run build
run: npm run build:cli && npm run build:lib && npm run build:app -- --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"

1
.gitignore vendored
View File

@@ -142,6 +142,7 @@ out
# Nuxt.js build / generate output
.nuxt
dist
dist-web
# Gatsby files
.cache/

View File

@@ -2,6 +2,8 @@
macOS에서 한글 파일명이 NFD(자모 분해)로 저장되는 문제를 실시간으로 해결하는 트레이 앱 + CLI + Node.js 라이브러리 통합 패키지입니다.
> 🌐 **웹 버전**: Windows/Linux 사용자는 설치 없이 [https://jung-geun.github.io/NFD2NFC/](https://jung-geun.github.io/NFD2NFC/) 에 파일/폴더를 끌어다 놓으면 NFC 이름의 ZIP 으로 받을 수 있습니다.
> **Breaking Change (2.0.0)**: 단일 패키지 구조로 통합됐습니다. `MACOS-APP/`, `nfd2nfc/` 서브 패키지 구조는 제거됐습니다. CLI 명령이 `nfd2nfc file <path>` / `nfd2nfc dir <path>` 형태로 변경됐습니다.
## 주요 기능
@@ -37,14 +39,21 @@ npm install @pieroot/nfd2nfc
### macOS 애플리케이션
<!-- TODO: 새 트레이 UI 스크린샷 교체 -->
![애플리케이션 화면](./assets/start-app.png)
앱 실행 후 메뉴바 아이콘을 좌클릭하면 팝오버가 표시됩니다. 감시 중인 디렉토리 목록과 최근 활동, Undo 버튼을 한눈에 확인할 수 있습니다.
앱 실행 후 메뉴바 아이콘을 클릭하면 팝오버가 표시됩니다. 팝오버에서 디렉토리를 추가하거나 설정창을 열 수 있습니다.
![트레이 팝오버](./assets/manubar.png)
![디렉토리 선택](./assets/select-directory.png)
메뉴바 아이콘을 우클릭하면 감시 일시 정지, 설정창 열기, 종료 메뉴가 나옵니다.
감시할 디렉토리를 선택합니다. 추가된 디렉토리는 앱 재시작 시에도 유지됩니다.
![메뉴바 컨텍스트 메뉴](./assets/menubar-setting.png)
설정창의 **디렉토리** 탭에서 감시할 디렉토리를 추가하고, 디렉토리별로 감시 활성화 여부 / Auto·Manual 모드 / 하위 폴더 포함 / 추가 필터 범위를 개별 설정할 수 있습니다. 추가된 디렉토리는 앱 재시작 시에도 유지됩니다.
![디렉토리 설정](./assets/setting-directory.png)
**일반** 탭에서는 기본 변환 모드, macOS 알림 활성화, 알림 인터벌(초)을 설정합니다.
![일반 설정](./assets/setting-general.png)
### CLI

BIN
assets/manubar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/menubar-setting.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
assets/setting-general.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

11
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{
"name": "@pieroot/nfd2nfc",
"version": "2.0.0",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pieroot/nfd2nfc",
"version": "2.0.0",
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"chokidar": "^3.6.0",
"fflate": "^0.8.2",
"yargs": "^17.7.2"
},
"bin": {
@@ -4580,6 +4581,12 @@
"pend": "~1.2.0"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@pieroot/nfd2nfc",
"version": "2.0.0",
"version": "2.0.1",
"description": "macOS 한글 파일명 NFD→NFC 실시간 변환 트레이 앱 + CLI + 라이브러리",
"main": "out/lib/index.js",
"types": "out/lib/index.d.ts",
@@ -29,6 +29,9 @@
"build": "npm run build:cli && npm run build:lib && npm run build:app",
"prepublishOnly": "npm run build:cli && npm run build:lib && chmod +x out/cli/index.js",
"preview": "electron-vite preview",
"web:dev": "vite --config web/vite.config.ts",
"web:build": "vite build --config web/vite.config.ts",
"web:preview": "vite preview --config web/vite.config.ts",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --ext .ts,.tsx",
@@ -36,6 +39,7 @@
},
"dependencies": {
"chokidar": "^3.6.0",
"fflate": "^0.8.2",
"yargs": "^17.7.2"
},
"devDependencies": {
@@ -96,7 +100,10 @@
"target": [
{
"target": "dmg",
"arch": ["arm64", "x64"]
"arch": [
"arm64",
"x64"
]
}
]
},
@@ -104,6 +111,6 @@
"buildResources": "resources",
"output": "dist"
},
"publish": "never"
"publish": null
}
}

View File

@@ -50,7 +50,6 @@ export async function startDir(dir: WatchedDir): Promise<void> {
ignoreInitial: false,
persistent: true,
depth: dir.recursive ? undefined : 0,
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
ignored: /(^|[/\\])\../,
});

View File

@@ -11,5 +11,5 @@
"forceConsistentCasingInFileNames": true,
"baseUrl": "."
},
"exclude": ["node_modules", "out", "dist", "src/renderer/**"]
"exclude": ["node_modules", "out", "dist", "dist-web", "src/renderer/**", "web/**"]
}

16
web/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="macOS에서 NFD로 깨진 한글 파일명을 NFC로 변환하는 웹 도구. 설치 없이 브라우저에서 바로 사용."
/>
<title>NFD → NFC 파일명 변환기</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

349
web/src/App.tsx Normal file
View File

@@ -0,0 +1,349 @@
import { useCallback, useReducer, type DragEvent } from 'react';
import {
collectFromDataTransfer,
collectFromFileList,
supportsDirectoryDrop,
type CollectResult,
} from './lib/collectFiles';
import { buildZip, downloadBlob, type BuildProgress } from './lib/buildZip';
type State =
| { kind: 'idle'; dragActive: boolean; error?: string }
| { kind: 'reading' }
| {
kind: 'processing';
collected: CollectResult;
progress: BuildProgress;
}
| {
kind: 'ready';
collected: CollectResult;
blob?: Blob;
filename: string;
};
type Action =
| { type: 'drag'; active: boolean }
| { type: 'reading' }
| { type: 'error'; message: string }
| { type: 'processing-start'; collected: CollectResult }
| { type: 'progress'; progress: BuildProgress }
| { type: 'ready'; collected: CollectResult; blob: Blob; filename: string }
| { type: 'ready-empty'; collected: CollectResult; filename: string }
| { type: 'reset' };
const initial: State = { kind: 'idle', dragActive: false };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'drag':
if (state.kind !== 'idle') return state;
return { ...state, dragActive: action.active };
case 'reading':
return { kind: 'reading' };
case 'error':
return { kind: 'idle', dragActive: false, error: action.message };
case 'processing-start':
return {
kind: 'processing',
collected: action.collected,
progress: { done: 0, total: action.collected.files.length },
};
case 'progress':
if (state.kind !== 'processing') return state;
return { ...state, progress: action.progress };
case 'ready':
return {
kind: 'ready',
collected: action.collected,
blob: action.blob,
filename: action.filename,
};
case 'ready-empty':
return {
kind: 'ready',
collected: action.collected,
filename: action.filename,
};
case 'reset':
return initial;
}
}
function timestamp(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(
d.getMinutes(),
)}${pad(d.getSeconds())}`;
}
export function App() {
const [state, dispatch] = useReducer(reducer, initial);
const process = useCallback(async (collected: CollectResult) => {
const filename = `nfc-${timestamp()}.zip`;
if (collected.files.length === 0) {
dispatch({ type: 'ready-empty', collected, filename });
return;
}
dispatch({ type: 'processing-start', collected });
try {
const blob = await buildZip(collected.files, (progress) =>
dispatch({ type: 'progress', progress }),
);
dispatch({ type: 'ready', collected, blob, filename });
} catch (e) {
dispatch({
type: 'error',
message: e instanceof Error ? e.message : 'ZIP 생성 중 알 수 없는 오류가 발생했습니다.',
});
}
}, []);
const handleDrop = useCallback(
async (ev: DragEvent<HTMLLabelElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (state.kind !== 'idle') return;
if (!supportsDirectoryDrop(ev.dataTransfer.items)) {
dispatch({
type: 'error',
message:
'이 브라우저는 폴더 드롭을 완전히 지원하지 않습니다. 최신 Chrome / Edge / Safari 16.4+ 를 사용해주세요.',
});
return;
}
dispatch({ type: 'reading' });
try {
const collected = await collectFromDataTransfer(ev.dataTransfer);
await process(collected);
} catch (e) {
dispatch({
type: 'error',
message: e instanceof Error ? e.message : '파일을 읽는 중 오류가 발생했습니다.',
});
}
},
[process, state.kind],
);
const handleFileInput = useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const fl = ev.target.files;
if (!fl || fl.length === 0) return;
dispatch({ type: 'reading' });
try {
const collected = collectFromFileList(fl);
await process(collected);
} catch (e) {
dispatch({
type: 'error',
message: e instanceof Error ? e.message : '파일을 읽는 중 오류가 발생했습니다.',
});
} finally {
// 같은 파일을 다시 선택할 수 있게 input 초기화.
ev.target.value = '';
}
},
[process],
);
const onDragOver = (ev: DragEvent<HTMLLabelElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (state.kind === 'idle') dispatch({ type: 'drag', active: true });
};
const onDragLeave = (ev: DragEvent<HTMLLabelElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (state.kind === 'idle') dispatch({ type: 'drag', active: false });
};
return (
<div className="app">
<header>
<h1>NFD NFC </h1>
<p>
macOS ZIP Windows / Linux
. NFC ZIP
. .
</p>
</header>
<main>
{state.kind === 'idle' && (
<IdleView
dragActive={state.dragActive}
error={state.error}
onDrop={handleDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onFileInput={handleFileInput}
/>
)}
{state.kind === 'reading' && <ReadingView />}
{state.kind === 'processing' && (
<ProcessingView collected={state.collected} progress={state.progress} />
)}
{state.kind === 'ready' && (
<ReadyView
collected={state.collected}
blob={state.blob}
filename={state.filename}
onReset={() => dispatch({ type: 'reset' })}
/>
)}
</main>
<footer>
<p>
. ( 2GB )
.
</p>
</footer>
</div>
);
}
function IdleView(props: {
dragActive: boolean;
error?: string;
onDrop: (ev: DragEvent<HTMLLabelElement>) => void;
onDragOver: (ev: DragEvent<HTMLLabelElement>) => void;
onDragLeave: (ev: DragEvent<HTMLLabelElement>) => void;
onFileInput: (ev: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<>
<label
className={'dropzone' + (props.dragActive ? ' active' : '')}
onDrop={props.onDrop}
onDragOver={props.onDragOver}
onDragLeave={props.onDragLeave}
>
<h2> </h2>
<p>
.
<br />
NFC ZIP .
</p>
<p className="hint"> .</p>
<input
type="file"
/* @ts-expect-error — webkitdirectory 는 React 타입 정의가 누락된 비표준 속성. */
webkitdirectory=""
directory=""
multiple
onChange={props.onFileInput}
/>
</label>
{props.error && <div className="error">{props.error}</div>}
</>
);
}
function ReadingView() {
return (
<div className="status">
<h2> </h2>
<p className="progress-label"> .</p>
</div>
);
}
function ProcessingView(props: {
collected: CollectResult;
progress: BuildProgress;
}) {
const pct =
props.progress.total === 0 ? 0 : Math.round((props.progress.done / props.progress.total) * 100);
return (
<div className="status">
<h2>ZIP </h2>
<div className="progress-track">
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<div className="progress-label">
{props.progress.done.toLocaleString()} / {props.progress.total.toLocaleString()}
{' · '}
{pct}%
</div>
<div className="stats">
<span>
<strong>{props.collected.normalizedCount.toLocaleString()}</strong>
</span>
<span>
<strong>{props.collected.passthroughCount.toLocaleString()}</strong>
</span>
</div>
</div>
);
}
function ReadyView(props: {
collected: CollectResult;
blob?: Blob;
filename: string;
onReset: () => void;
}) {
const { collected, blob, filename, onReset } = props;
const hasFiles = collected.files.length > 0;
const hasNormalizable = collected.normalizedCount > 0;
return (
<div className="status">
<h2>
{hasNormalizable ? '완료되었습니다.' : '변환할 파일이 없습니다.'}
</h2>
<div className="stats">
<span>
<strong>{collected.normalizedCount.toLocaleString()}</strong>
</span>
<span>
<strong>{collected.passthroughCount.toLocaleString()}</strong>
</span>
</div>
{!hasFiles && (
<div className="warn">
.
.
</div>
)}
{hasFiles && !hasNormalizable && (
<div className="success">
NFC . .
</div>
)}
{collected.collisions.length > 0 && (
<div className="warn">
{collected.collisions.length} <code>-2</code>,
<code>-3</code> ... .
</div>
)}
<div className="actions">
{blob && hasFiles && (
<button className="primary" onClick={() => downloadBlob(blob, filename)}>
ZIP ({formatSize(blob.size)})
</button>
)}
<button className="link" onClick={onReset}>
</button>
</div>
</div>
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}

94
web/src/lib/buildZip.ts Normal file
View File

@@ -0,0 +1,94 @@
import { Zip, ZipPassThrough } from 'fflate';
import type { CollectedFile } from './collectFiles';
export interface BuildProgress {
/** 완료된 파일 수. */
done: number;
/** 전체 파일 수. */
total: number;
}
/**
* NFC 경로로 정규화된 파일 목록을 스트리밍 방식으로 ZIP 으로 묶는다.
*
* - fflate 의 Zip + ZipPassThrough 사용. ZipPassThrough 는 deflate 없이 STORE 모드로 묶기 때문에
* 사진/영상처럼 이미 압축된 파일이 많은 일반 케이스에서 CPU 를 낭비하지 않는다.
* - 파일명은 JS 문자열로 그대로 넘긴다. fflate 는 non-ASCII 가 포함되면 general purpose bit 11
* (UTF-8 flag)을 자동으로 켜기 때문에 Windows Explorer 가 CP949 로 오디코딩하지 않는다.
* - 한 번에 하나씩 파이프해서 다중 entry 가 동시에 push 되지 않도록 한다 (Zip 은 순차 처리 필요).
*/
export async function buildZip(
files: CollectedFile[],
onProgress?: (p: BuildProgress) => void,
): Promise<Blob> {
const chunks: Uint8Array[] = [];
const zipDone = new Promise<void>((resolve, reject) => {
const zip = new Zip((err, data, final) => {
if (err) {
reject(err);
return;
}
if (data && data.length) chunks.push(data);
if (final) resolve();
});
void (async () => {
try {
let done = 0;
for (const f of files) {
const entry = new ZipPassThrough(f.zipPath);
zip.add(entry);
await pipeFileToEntry(f.file, entry);
done++;
onProgress?.({ done, total: files.length });
}
zip.end();
} catch (e) {
reject(e);
}
})();
});
await zipDone;
// Uint8Array<ArrayBufferLike> 의 TypeScript 5.7+ 변경 때문에 BlobPart 직접 매칭이 실패할 수 있어 캐스팅.
return new Blob(chunks as BlobPart[], { type: 'application/zip' });
}
async function pipeFileToEntry(file: File, entry: ZipPassThrough): Promise<void> {
// 일부 브라우저(특히 구버전) 에서 Blob.stream() 이 없을 수 있어 fallback 으로 arrayBuffer 사용.
if (typeof file.stream !== 'function') {
const buf = new Uint8Array(await file.arrayBuffer());
entry.push(buf, true);
return;
}
const reader = file.stream().getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
entry.push(new Uint8Array(0), true);
return;
}
if (value && value.length) entry.push(value, false);
}
} finally {
reader.releaseLock();
}
}
/**
* 다운로드 트리거. URL.revokeObjectURL 로 메모리 해제까지 처리.
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// 같은 microtask 에서 revoke 하면 일부 브라우저에서 다운로드가 취소될 수 있어 약간 지연.
setTimeout(() => URL.revokeObjectURL(url), 1_000);
}

164
web/src/lib/collectFiles.ts Normal file
View File

@@ -0,0 +1,164 @@
import { normalizeToNFC, shouldNormalize } from './normalize';
export interface CollectedFile {
/** ZIP 내부에서 사용할 NFC 정규화된 상대 경로 (POSIX, 디렉토리 segment 포함). */
zipPath: string;
/** 원본 상대 경로 (디버그/통계용). */
originalPath: string;
/** 실제 파일 핸들. */
file: File;
/** 경로 segment 중 하나 이상이 정규화되었는지. */
normalized: boolean;
}
export interface CollectResult {
files: CollectedFile[];
normalizedCount: number;
passthroughCount: number;
collisions: string[];
}
/**
* webkitGetAsEntry 가 동작하는지 확인. Safari < 16.4 등에서 folder drop 시 null 반환.
*/
export function supportsDirectoryDrop(items: DataTransferItemList): boolean {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind !== 'file') continue;
if (typeof item.webkitGetAsEntry !== 'function') return false;
}
return true;
}
/**
* DataTransfer 에서 파일과 폴더를 모두 수집한다.
* - 폴더는 webkitGetAsEntry 로 재귀 traversal.
* - DirectoryReader.readEntries 는 Chromium 에서 호출당 최대 100 개만 반환 → 빈 배열 나올 때까지 루프.
* - 각 path segment 를 독립적으로 NFC 정규화.
* - 정규화 후 경로 충돌 시 -2, -3 ... suffix 로 회피.
*/
export async function collectFromDataTransfer(dt: DataTransfer): Promise<CollectResult> {
const items = dt.items;
const entries: FileSystemEntry[] = [];
// file objects collected when item.webkitGetAsEntry() returns null (드물게 발생).
const looseFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind !== 'file') continue;
const entry =
typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
if (entry) {
entries.push(entry);
} else {
const f = item.getAsFile();
if (f) looseFiles.push(f);
}
}
const flat: { path: string; file: File }[] = [];
for (const e of entries) {
await walkEntry(e, '', flat);
}
for (const f of looseFiles) {
flat.push({ path: f.name, file: f });
}
return finalize(flat);
}
/**
* `<input type="file" webkitdirectory>` 또는 일반 input 에서 수집.
*/
export function collectFromFileList(files: FileList): CollectResult {
const flat: { path: string; file: File }[] = [];
for (let i = 0; i < files.length; i++) {
const f = files[i];
// webkitRelativePath 가 비어있으면 단일 파일 선택.
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name;
flat.push({ path: rel, file: f });
}
return finalize(flat);
}
async function walkEntry(
entry: FileSystemEntry,
prefix: string,
out: { path: string; file: File }[],
): Promise<void> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) =>
(entry as FileSystemFileEntry).file(resolve, reject),
);
out.push({ path: prefix + entry.name, file });
return;
}
if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries 페이지네이션: 빈 배열 반환할 때까지 반복.
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
reader.readEntries(resolve, reject),
);
if (batch.length === 0) break;
for (const child of batch) {
await walkEntry(child, prefix + entry.name + '/', out);
}
}
}
}
function finalize(flat: { path: string; file: File }[]): CollectResult {
const seen = new Set<string>();
const collisions: string[] = [];
const collected: CollectedFile[] = [];
let normalizedCount = 0;
let passthroughCount = 0;
for (const { path, file } of flat) {
const segments = path.split('/');
let normalized = false;
const nfcSegments = segments.map((seg) => {
if (shouldNormalize(seg)) {
normalized = true;
return normalizeToNFC(seg);
}
return seg;
});
if (normalized) normalizedCount++;
else passthroughCount++;
let nfcPath = nfcSegments.join('/');
if (seen.has(nfcPath)) {
collisions.push(nfcPath);
nfcPath = disambiguate(nfcPath, seen);
}
seen.add(nfcPath);
collected.push({
zipPath: nfcPath,
originalPath: path,
file,
normalized,
});
}
return {
files: collected,
normalizedCount,
passthroughCount,
collisions,
};
}
function disambiguate(path: string, seen: Set<string>): string {
const dot = path.lastIndexOf('.');
const slash = path.lastIndexOf('/');
const hasExt = dot > slash; // 디렉토리 안의 dot 만 확장자로 인식
const stem = hasExt ? path.slice(0, dot) : path;
const ext = hasExt ? path.slice(dot) : '';
let n = 2;
while (seen.has(`${stem}-${n}${ext}`)) n++;
return `${stem}-${n}${ext}`;
}

6
web/src/lib/normalize.ts Normal file
View File

@@ -0,0 +1,6 @@
// 브라우저 환경 전용 정규화 헬퍼.
// src/core/filter.ts 는 fs 의존성이 없는 순수 모듈이므로 그대로 import.
// src/lib/index.ts 는 normalizer/scanner(fs 사용)를 re-export 하므로 import 하지 않는다.
export { shouldNormalize } from '@core/core/filter';
export const normalizeToNFC = (s: string): string => s.normalize('NFC');

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

259
web/src/styles.css Normal file
View File

@@ -0,0 +1,259 @@
:root {
color-scheme: light dark;
--bg: #fafafa;
--fg: #1a1a1a;
--muted: #6b7280;
--border: #e5e7eb;
--accent: #2563eb;
--accent-fg: #ffffff;
--warn-bg: #fef3c7;
--warn-fg: #92400e;
--success-bg: #d1fae5;
--success-fg: #065f46;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--fg: #f3f4f6;
--muted: #9ca3af;
--border: #2a2f3a;
--accent: #3b82f6;
--accent-fg: #ffffff;
--warn-bg: #422006;
--warn-fg: #fcd34d;
--success-bg: #064e3b;
--success-fg: #a7f3d0;
}
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Segoe UI', Roboto, 'Helvetica Neue',
'Malgun Gothic', sans-serif;
background: var(--bg);
color: var(--fg);
-webkit-font-smoothing: antialiased;
}
.app {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 24px 32px;
}
header {
width: 100%;
max-width: 720px;
margin-bottom: 32px;
}
header h1 {
margin: 0 0 8px;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
header p {
margin: 0;
color: var(--muted);
font-size: 15px;
line-height: 1.6;
}
main {
width: 100%;
max-width: 720px;
flex: 1;
}
.dropzone {
display: block;
width: 100%;
border: 2px dashed var(--border);
border-radius: 16px;
padding: 64px 24px;
text-align: center;
background: transparent;
transition:
border-color 120ms,
background 120ms;
cursor: pointer;
}
.dropzone:hover,
.dropzone.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.dropzone.busy {
cursor: progress;
opacity: 0.7;
pointer-events: none;
}
.dropzone h2 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 600;
}
.dropzone p {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.dropzone .hint {
margin-top: 16px;
font-size: 12px;
color: var(--muted);
}
.dropzone input[type='file'] {
display: none;
}
.status {
margin-top: 24px;
padding: 24px;
border: 1px solid var(--border);
border-radius: 12px;
background: color-mix(in srgb, var(--fg) 3%, transparent);
}
.status h2 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
}
.status .progress-track {
height: 8px;
border-radius: 999px;
background: var(--border);
overflow: hidden;
margin: 12px 0 8px;
}
.status .progress-fill {
height: 100%;
background: var(--accent);
transition: width 120ms ease-out;
}
.status .progress-label {
font-size: 13px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.actions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-top: 16px;
}
button.primary {
background: var(--accent);
color: var(--accent-fg);
border: none;
border-radius: 10px;
padding: 12px 20px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
button.primary:hover {
filter: brightness(1.05);
}
button.link {
background: none;
border: none;
color: var(--accent);
font-size: 14px;
cursor: pointer;
padding: 8px 4px;
}
button.link:hover {
text-decoration: underline;
}
.warn,
.success,
.error {
margin-top: 12px;
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
line-height: 1.6;
}
.warn {
background: var(--warn-bg);
color: var(--warn-fg);
}
.success {
background: var(--success-bg);
color: var(--success-fg);
}
.error {
background: #fee2e2;
color: #991b1b;
}
@media (prefers-color-scheme: dark) {
.error {
background: #450a0a;
color: #fca5a5;
}
}
footer {
margin-top: 48px;
font-size: 12px;
color: var(--muted);
text-align: center;
line-height: 1.6;
}
footer a {
color: var(--muted);
}
.stats {
display: flex;
gap: 24px;
margin-top: 8px;
font-size: 14px;
color: var(--muted);
}
.stats strong {
color: var(--fg);
font-weight: 600;
margin-right: 4px;
}

22
web/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@core/*": ["../src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*", "../src/core/filter.ts"]
}

19
web/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
root: __dirname,
base: '/NFD2NFC/',
plugins: [react()],
build: {
outDir: resolve(__dirname, '../dist-web'),
emptyOutDir: true,
target: 'es2022',
},
resolve: {
alias: {
'@core': resolve(__dirname, '../src'),
},
},
});