mirror of
https://github.com/jung-geun/NFD2NFC.git
synced 2026-06-21 04:15:14 +09:00
Compare commits
7 Commits
2e3a9d1402
...
6f44af3e4f
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f44af3e4f | |||
| 51f99856cf | |||
| e967a0fbdb | |||
| aa772f7f97 | |||
| 8f2af73ed4 | |||
| a469c666fe | |||
| 4cbc044d15 |
49
.github/workflows/pages.yml
vendored
Normal file
49
.github/workflows/pages.yml
vendored
Normal 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
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -142,6 +142,7 @@ out
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
dist-web
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
|
||||
19
README.md
19
README.md
@@ -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 스크린샷 교체 -->
|
||||

|
||||
앱 실행 후 메뉴바 아이콘을 좌클릭하면 팝오버가 표시됩니다. 감시 중인 디렉토리 목록과 최근 활동, Undo 버튼을 한눈에 확인할 수 있습니다.
|
||||
|
||||
앱 실행 후 메뉴바 아이콘을 클릭하면 팝오버가 표시됩니다. 팝오버에서 디렉토리를 추가하거나 설정창을 열 수 있습니다.
|
||||

|
||||
|
||||

|
||||
메뉴바 아이콘을 우클릭하면 감시 일시 정지, 설정창 열기, 종료 메뉴가 나옵니다.
|
||||
|
||||
감시할 디렉토리를 선택합니다. 추가된 디렉토리는 앱 재시작 시에도 유지됩니다.
|
||||

|
||||
|
||||
설정창의 **디렉토리** 탭에서 감시할 디렉토리를 추가하고, 디렉토리별로 감시 활성화 여부 / Auto·Manual 모드 / 하위 폴더 포함 / 추가 필터 범위를 개별 설정할 수 있습니다. 추가된 디렉토리는 앱 재시작 시에도 유지됩니다.
|
||||
|
||||

|
||||
|
||||
**일반** 탭에서는 기본 변환 모드, macOS 알림 활성화, 알림 인터벌(초)을 설정합니다.
|
||||
|
||||

|
||||
|
||||
### CLI
|
||||
|
||||
|
||||
BIN
assets/manubar.png
Normal file
BIN
assets/manubar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/menubar-setting.png
Normal file
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 |
BIN
assets/setting-directory.png
Normal file
BIN
assets/setting-directory.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
assets/setting-general.png
Normal file
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
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
13
package.json
13
package.json
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: /(^|[/\\])\../,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
16
web/index.html
Normal 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
349
web/src/App.tsx
Normal 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
94
web/src/lib/buildZip.ts
Normal 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
164
web/src/lib/collectFiles.ts
Normal 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
6
web/src/lib/normalize.ts
Normal 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
10
web/src/main.tsx
Normal 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
259
web/src/styles.css
Normal 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
22
web/tsconfig.json
Normal 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
19
web/vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user