26 Commits

Author SHA1 Message Date
7889f8eb96 chore: 2.0.2 버전 범프
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 14:30:30 +09:00
8db9e64302 chore(deps): vite 5→6, electron-vite 2→3, vitest 1→4 업그레이드 — 나머지 보안 취약점 해결
Vite path traversal(CVE) 및 esbuild dev-server SSRF 취약점을 Vite 6.4.2,
esbuild 0.25 업그레이드로 해결한다. @vitejs/plugin-react 5.x, vitest 4.x 동반 업그레이드.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 11:41:00 +09:00
2d7d586be7 chore(deps): electron 35 → 39 업그레이드 — Dependabot 보안 취약점 17개 해결
use-after-free, AppleScript injection, IPC spoofing 등 high/medium/low
Electron 취약점을 모두 39.8.x 패치 버전으로 해결한다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 11:22:40 +09:00
17a7a617fd fix(web): custom domain 대응 — base '/' 변경 및 CNAME 추가
Vite base를 '/NFD2NFC/'에서 '/'로 변경하고 web/public/CNAME을 추가해
nfd2nfc.pieroot.xyz에서 자산 404 및 배포 후 custom domain 초기화 문제를 수정한다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 18:49:48 +09:00
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
2e3a9d1402 ci: release.yml 동작 수정 및 package.json publish never 설정
release.yml:
- setup-node cache: 'npm' 추가로 설치 속도 향상
- CSC_IDENTITY_AUTO_DISCOVERY=false: code signing 없이 DMG 빌드 가능
- List dist 단계 추가 (디버깅용)
- softprops/action-gh-release token 명시, files 멀티라인 형식
package.json:
- build.publish: "never" 추가 (electron-builder 자체 auto-publish 방지)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 16:05:13 +09:00
343fe004fe build: build:app을 독립 앱 빌드 타겟으로 분리
build:app: electron-vite build + electron-builder → 단독으로 .app/DMG 생성 가능.
build: build:cli + build:lib + build:app 순서로 재구성 (electron-builder 중복 제거).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:58:38 +09:00
952e74de2f fix: npm tarball 빌드 경로 및 포함 파일 정정
tsconfig.cli.json + tsconfig.lib.json outDir을 out/cli→out, out/lib→out으로 변경.
bin out/cli/index.js, main out/lib/index.js 경로와 일치시킴.
files에서 resources/** 제거 (아이콘 파일 npm 사용자 불필요).
files에 out/core/** 추가, !out/core/__tests__/**로 테스트 파일 제외.
tarball 크기 1.8MB → 8.4KB.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:55:36 +09:00
6fe306e7b1 fix: typecheck/lint 오류 수정
tsconfig.json: module ESNext로 변경, renderer 제외.
.eslintrc.cjs: varsIgnorePattern '^_' 추가.
store.ts: notificationIntervalSecs 기본값(30) 추가.
src/lib/index.ts: FilterOptions/ScanEntry를 올바른 소스 파일에서 import.
scanner.ts: 미사용 fs import 제거.
ipc.ts: 미사용 nanoid import 제거.
cli/index.ts: 미사용 argv 변수 void 처리, showHelp() → 메시지 출력으로 대체.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:50:25 +09:00
05ba9f3034 docs: README v2.0.0 재작성 + Makefile 신규 경로 반영
README: Breaking Change 안내, 트레이 팝오버/설정창/Auto-Manual/Undo 기능 설명,
새 CLI 명령(file/dir/디폴트), v2 라이브러리 API 추가.
Makefile: dist/mac-arm64/NFD2NFC.app 경로, clean 타겟 추가.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:45:07 +09:00
4b89a498f9 ci: release.yml 재작성 + publish.yml 추가
release.yml: Node 매트릭스 제거, Node 20.x 단일, actions v3→v4 업그레이드.
deprecated upload-release-asset@v1 → softprops/action-gh-release@v2.
단일 패키지 npm ci && npm run build 로 통합.
publish.yml: workflow_dispatch 수동 트리거 npm publish 추가.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:44:13 +09:00
2d3167cffe 레거시 디렉토리/스크립트 제거 (MACOS-APP, nfd2nfc, run.sh, run.bat)
단일 패키지 구조로 통합. MACOS-APP/(vanilla Electron 1.0), nfd2nfc/(CLI+lib v1),
OS-detecting launcher 스크립트(run.sh/run.bat) 제거.
아이콘 자산은 커밋 8에서 resources/로 이전 완료.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:43:39 +09:00
46adced6c5 resources: 아이콘 자산을 electron-builder 표준 경로로 이전
MACOS-APP/build/icons/Macicon.icns → resources/icon.icns
MACOS-APP/build/icons/Macicon.iconset/ → resources/icon.iconset/
electron-builder buildResources: resources 설정과 일치.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:43:30 +09:00
1441a5c0b4 package.json: 단일 패키지 통합 (정체성 보존, dual main via extraMetadata)
name=@pieroot/nfd2nfc, appId=com.pieroot.nfd2nfc, productName=NFD2NFC 유지.
package.json main=out/lib/index.js (npm 라이브러리 진입점).
electron-builder extraMetadata.main=out/main/index.js (.app asar만 override).
react/react-dom는 devDependencies로 이동 (Vite renderer 번들에 포함).
files 필드로 npm tarball을 out/cli, out/lib, resources만으로 제한.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:42:51 +09:00
a24367c815 src/lib: 라이브러리 진입점 + 1.0.0 호환 shim
normalizeToNFC/normalizeToNFD — v1 require('@pieroot/nfd2nfc') 호환 유지.
신규 API: normalizeEntry, scan, shouldNormalize + 타입 export.
tsconfig.lib.json: declaration: true, out/lib/에 .d.ts 생성.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:42:10 +09:00
6601c40bb4 src/cli: yargs 기반 CLI (file/dir 서브커맨드 + 디폴트 path)
file/dir 서브커맨드 외에 \$0 <path> 디폴트 커맨드 추가.
stat으로 파일/디렉토리 자동 감지 → 기존 v1 스타일 nfd2nfc <path> 형태 지원.
--recursive(-r), --dry-run(-n) 옵션.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:41:53 +09:00
08f1de7ea0 src/preload + src/renderer: contextBridge + React 19 popover/settings
preload/index.ts: window.api 노출 (dirs/watcher/undo/settings/events/app).
renderer/popover: 트레이 팝오버 (300×400, frameless, alwaysOnTop, blur시 자동 닫힘).
renderer/settings: 설정창 (720×560, 3탭: 디렉토리/Undo기록/일반).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:41:18 +09:00
3dff470044 src/main: 트레이/감시자/저장소/IPC/알림 배치 (Electron 메인)
tray.ts: 트레이 아이콘, 팝오버 BrowserWindow 토글, 컨텍스트 메뉴.
watcher.ts: chokidar 감시, dedup TTL 2s, auto/manual 모드, 글로벌 일시정지.
store.ts: userData/store.json 영속화 (watchedDirs/settings/undoLog).
ipc.ts: 18개 IPC 채널 핸들러.
notifier.ts: interval 기반 알림 배치 처리 (알림 폭주 방지).
settings-window.ts: 설정창 BrowserWindow 라이프사이클.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:41:08 +09:00
4e92bb2690 src/core: 플랫폼 독립 정규화 로직 + Vitest 테스트
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 <noreply@anthropic.com>
2026-05-09 15:40:53 +09:00
8e67d25b3b v2 스캐폴드 추가 (tsconfig 4종, eslint, prettier, vitest)
TypeScript strict + electron-vite + Vitest 환경을 위한 설정 파일 추가.
tsconfig 4종: 베이스/cli/node(main+preload)/web(renderer) 역할 분담.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:39:01 +09:00
87 changed files with 10732 additions and 3979 deletions

17
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,17 @@
module.exports = {
root: true,
env: { node: true, es2022: true },
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
overrides: [
{
files: ['src/renderer/**/*.tsx', 'src/renderer/**/*.ts'],
env: { browser: true },
},
],
};

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

25
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Publish to npm
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
- name: Publish
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -4,59 +4,36 @@ on:
release:
types: [published]
permissions:
contents: write
jobs:
build-and-release:
runs-on: macos-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
- name: Install dependencies and build nfd2nfc
run: |
cd nfd2nfc
npm install
npm run build
- name: Install dependencies and build MACOS-APP
run: |
cd MACOS-APP
npm install
npm run build
- name: Upload NFD2NFC executable
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/setup-node@v4
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./nfd2nfc/dist/NFD2NFC-$(uname -s).zip
asset_name: NFD2NFC-$(uname -s).zip
asset_content_type: application/zip
node-version: '20.x'
cache: 'npm'
- name: Upload MacOS .app
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./MACOS-APP/dist/*.app.zip
asset_name: MacApp.zip
asset_content_type: application/zip
- name: Install dependencies
run: npm ci
- name: Upload MacOS .dmg
uses: actions/upload-release-asset@v1
- name: Build
run: npm run build:cli && npm run build:lib && npm run build:app -- --publish never
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: List dist
run: ls -lh dist/
- name: Upload DMG assets
uses: softprops/action-gh-release@v2
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ./MACOS-APP/dist/*.dmg
asset_name: MacApp.dmg
asset_content_type: application/octet-stream
token: ${{ secrets.GITHUB_TOKEN }}
files: |
dist/*.dmg

1
.gitignore vendored
View File

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

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "es5"
}

View File

@@ -1,34 +0,0 @@
# 파일 이름 변환기
macos 에서 파일 이름을 NFD에서 NFC 인코딩으로 변환하는 패키지입니다.
## 목적
macos에서 한글 파일 이름을 사용할 때 NFD 인코딩으로 저장되는 문제를 해결하기 위해 개발되었습니다.
매번 파일 이름을 수동으로 변경하는 것은 번거롭기 때문에 자동으로 변환하는 프로그램을 개발하였습니다.
디렉토리를 지정하면 하위 디렉토리까지 변환하며, 백그라운드 실행을 지원합니다.
## 특징
- NFD에서 NFC 변환 지원
- 하위 디렉토리 변환 지원
- 백그라운드 실행 지원
## 설치
```bash
# 전역 설치
npm install -g @pieroot/nfd2nfc
# 또는 로컬 설치
npm i @pieroot/nfd2nfc
```
## 사용법
## 라이선스
MIT 라이선스

Binary file not shown.

Before

Width:  |  Height:  |  Size: 882 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -1,166 +0,0 @@
<!-- index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Directory Watcher App</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 20px;
}
#selected-directories {
margin-top: 20px;
max-width: 700px;
margin-left: auto;
margin-right: auto;
text-align: left;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 12px;
border: 1px solid #ddd;
}
th {
background-color: #f2f2f2;
}
.remove-button {
background-color: #ff4d4d;
border: none;
color: white;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
}
#log {
margin-top: 20px;
text-align: left;
white-space: pre-wrap;
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
height: 150px;
overflow-y: scroll;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
margin-bottom: 10px;
}
</style>
</head>
<body>
<h1>디렉토리 감시 애플리케이션</h1>
<button id="select-directory">디렉토리 선택</button>
<div id="selected-directories">
<h2>감시 중인 디렉토리</h2>
<table>
<thead>
<tr>
<th>디렉토리 경로</th>
<th>제거</th>
</tr>
</thead>
<tbody id="directories-list">
<!-- 선택된 디렉토리 목록이 여기에 표시됩니다 -->
</tbody>
</table>
</div>
<div id="log">
로그가 여기에 표시됩니다.
</div>
<script>
const selectDirButton = document.getElementById('select-directory');
const directoriesList = document.getElementById('directories-list');
const logDiv = document.getElementById('log');
selectDirButton.addEventListener('click', async () => {
const result = await window.electronAPI.selectDirectories();
if (!result.canceled) {
refreshDirectories(); // 즉시 갱신
}
});
function addDirectoryToList(dirPath) {
if (document.querySelector(`[data-path="${ dirPath }"]`)) {
return;
}
const directoryRow = document.createElement('tr');
directoryRow.setAttribute('data-path', dirPath);
const pathCell = document.createElement('td');
pathCell.textContent = dirPath;
const removeCell = document.createElement('td');
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-button';
removeBtn.textContent = '제거';
removeBtn.addEventListener('click', async () => {
const response = await window.electronAPI.removeDirectory(dirPath);
if (response.success) {
directoriesList.removeChild(directoryRow);
appendLog(`디렉토리 감시 중지: "${ dirPath }"`);
} else {
appendLog(`디렉토리 제거 실패: "${ response.message }"`);
}
});
removeCell.appendChild(removeBtn);
directoryRow.appendChild(pathCell);
directoryRow.appendChild(removeCell);
directoriesList.appendChild(directoryRow);
}
function refreshDirectories() {
window.electronAPI.getDirectories().then((directories) => {
directoriesList.innerHTML = '';
for (const dirPath of Object.keys(directories)) {
addDirectoryToList(dirPath);
}
});
}
window.addEventListener('DOMContentLoaded', () => {
// Initially load directories
refreshDirectories();
// Then refresh every 10 seconds
setInterval(refreshDirectories, 10000);
});
// Listen for refresh-directories event
window.electronAPI.on('refresh-directories', refreshDirectories);
function appendLog(message) {
logDiv.textContent += `${ message }\n`;
logDiv.scrollTop = logDiv.scrollHeight;
}
window.electronAPI.onLog((message) => {
appendLog(message);
});
</script>
</body>
</html>

View File

@@ -1,436 +0,0 @@
// main.js
const {
app,
BrowserWindow,
dialog,
ipcMain,
Notification,
Tray,
Menu,
} = require("electron");
const path = require("path");
const chokidar = require("chokidar");
const fs = require("fs").promises;
const fsSync = require("fs"); // For synchronous path checks
let tray = null;
let mainWindow = null;
let watchers = {}; // To keep track of file watchers
// 로그 파일 경로 지정
const logFilePath = path.join(app.getPath("userData"), "watcher.log");
// JSON 파일 경로 지정
const watchedDirectoriesPath = path.join(
app.getPath("userData"),
"watchedDirectories.json"
);
// 현재 감시 중인 디렉토리 목록 및 마지막 갱신 시간
let watchedDirectories = {}; // { "path/to/dir": "2024-12-16T23:30:25.615Z", ... }
// Load the directories from JSON
async function loadWatchedDirectories() {
try {
const data = await fs.readFile(watchedDirectoriesPath, "utf-8");
watchedDirectories = JSON.parse(data);
for (const dirPath of Object.keys(watchedDirectories)) {
watchDirectory(dirPath);
}
} catch (error) {
if (error.code !== "ENOENT") {
console.error(`Error loading directories: ${error}`);
new Notification({
title: "Error",
body: `Error loading directories: ${error.message}`,
}).show();
} else {
console.log("No watched directories found. Starting fresh.");
watchedDirectories = {};
}
}
}
// Save directories to JSON
async function saveWatchedDirectories() {
try {
await fs.writeFile(
watchedDirectoriesPath,
JSON.stringify(watchedDirectories, null, 2),
"utf-8"
);
} catch (error) {
console.error(`Error saving directories: ${error}`);
}
}
// 로그 기록 함수
async function log(message) {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${message}\n`;
try {
await fs.appendFile(logFilePath, logMessage);
} catch (error) {
console.error("로그 파일 작성 실패:", error);
}
}
// 무시할 파일/디렉토리를 결정하는 함수
function shouldIgnore(itemName) {
const ignoredItems = [".git", "node_modules", ".env"];
return ignoredItems.includes(itemName);
}
// 파일 이름을 정규화하는 함수
async function normalizeFileName(filePath) {
const dir = path.dirname(filePath);
const oldName = path.basename(filePath);
const newName = oldName.normalize("NFC");
if (oldName !== newName && !shouldIgnore(oldName)) {
const newPath = path.join(dir, newName);
try {
await fs.rename(filePath, newPath);
// watchedDirectories[newPath] = new Date().toISOString();
// delete watchedDirectories[filePath];
await saveWatchedDirectories();
await log(`이름 변경: "${oldName}" -> "${newName}"`);
// 알림 생성
new Notification({
title: "이름 변경 완료",
body: `"${oldName}"이 "${newName}"으로 변경되었습니다.`,
}).show();
// 렌더러 프로세스로 알림 전송
if (mainWindow) {
mainWindow.webContents.send(
"log-message",
`이름 변경: "${oldName}" -> "${newName}"`
);
}
return newPath;
} catch (error) {
await log(`이름 변경 실패 ("${oldName}"): ${error}`);
if (mainWindow) {
mainWindow.webContents.send(
"log-message",
`이름 변경 실패 ("${oldName}"): ${error}`
);
}
return filePath;
}
}
// 변경 시 마지막 갱신 시간 업데이트
// watchedDirectories[filePath] = new Date().toISOString();
// await saveWatchedDirectories();
return filePath;
}
// 디렉토리를 재귀적으로 처리하는 함수
async function processDirectory(dirPath) {
try {
await log(`디렉토리 처리 시작: "${dirPath}"`);
if (mainWindow) {
mainWindow.webContents.send(
"log-message",
`디렉토리 처리 시작: "${dirPath}"`
);
}
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
if (!shouldIgnore(entry.name)) {
await processDirectory(fullPath);
await normalizeFileName(fullPath);
}
} else {
await normalizeFileName(fullPath);
}
}
await normalizeFileName(dirPath);
// console.log(`디렉토리 처리 완료: "${dirPath}"`);
// watchedDirectories[dirPath] = new Date().toISOString();
// await saveWatchedDirectories();
await log(`디렉토리 처리 완료: "${dirPath}"`);
if (mainWindow) {
mainWindow.webContents.send(
"log-message",
`디렉토리 처리 완료: "${dirPath}"`
);
}
} catch (error) {
await log(`디렉토리 처리 중 오류 발생 ("${dirPath}"): ${error}`);
if (mainWindow) {
mainWindow.webContents.send(
"log-message",
`디렉토리 처리 중 오류 발생 ("${dirPath}"): ${error}`
);
}
}
}
// 디렉토리를 감시하는 함수
function watchDirectory(directory) {
const watcher = chokidar.watch(directory, {
ignored: (pathStr) => {
const baseName = path.basename(pathStr);
return shouldIgnore(baseName);
},
persistent: true,
ignoreInitial: false,
awaitWriteFinish: {
stabilityThreshold: 200,
pollInterval: 100,
},
depth: Infinity,
});
watchers[directory] = watcher;
watcher
.on("add", async (filePath) => {
// await log(`파일 추가됨: "${filePath}"`);
// if (mainWindow) {
// mainWindow.webContents.send(
// "log-message",
// `파일 추가됨: "${filePath}"`
// );
// }
await normalizeFileName(filePath);
})
.on("change", async (filePath) => {
// await log(`파일 변경됨: "${filePath}"`);
// if (mainWindow) {
// mainWindow.webContents.send(
// "log-message",
// `파일 변경됨: "${filePath}"`
// );
// }
await normalizeFileName(filePath);
})
.on("unlink", async (filePath) => {
// await log(`파일 삭제됨: "${filePath}"`);
// if (mainWindow) {
// mainWindow.webContents.send(
// "log-message",
// `파일 삭제됨: "${filePath}"`
// );
// }
})
.on("addDir", async (dirPath) => {
// await log(`디렉토리 추가됨: "${dirPath}"`);
// if (mainWindow) {
// mainWindow.webContents.send(
// "log-message",
// `디렉토리 추가됨: "${dirPath}"`
// );
// }
await processDirectory(dirPath);
})
.on("unlinkDir", async (dirPath) => {
// await log(`디렉토리 삭제됨: "${dirPath}"`);
// if (mainWindow) {
// mainWindow.webContents.send(
// "log-message",
// `디렉토리 삭제됨: "${dirPath}"`
// );
// }
})
.on("error", async (error) => {
await log(`Watcher error: ${error}`);
if (mainWindow) {
mainWindow.webContents.send("log-message", `Watcher error: ${error}`);
}
new Notification({
title: "Watcher Error",
body: `Error watching directory: ${error.message}`,
}).show();
})
.on("ready", () => {
log(
`초기 스캔 완료. "${directory}"에서 변경 사항을 감시 중입니다.`
).catch(console.error);
if (mainWindow) {
mainWindow.webContents.send(
"log-message",
`초기 스캔 완료. "${directory}"에서 변경 사항을 감시 중입니다.`
);
}
});
}
function setTray() {
const iconPath = path.join(
__dirname,
"build/icons/Macicon.iconset/icon_32x32.png"
); // Define iconPath here
if (!fsSync.existsSync(iconPath)) {
console.error("Tray icon not found at:", iconPath);
new Notification({
title: "Error",
body: `Tray icon not found at ${iconPath}`,
}).show();
return;
}
tray = new Tray(iconPath);
const contextMenu = Menu.buildFromTemplate([
{
label: "열기",
click: () => {
if (mainWindow === null) {
createWindow();
} else {
mainWindow.show();
}
},
},
{
label: "종료",
click: () => {
app.quit();
},
},
]);
tray.setToolTip("Directory Watcher App");
tray.setContextMenu(contextMenu);
// 더블 클릭 시 창 열기
tray.on("double-click", () => {
if (mainWindow === null) {
createWindow();
} else {
mainWindow.show();
}
});
}
// 애플리케이션 준비 시 창 및 트레이 설정 후 디렉토리 목록 로드
app.whenReady().then(async () => {
createWindow();
setTray();
await loadWatchedDirectories();
if (process.platform === "darwin") {
app.dock.hide();
}
app.on("activate", function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
// 모든 창이 닫히면 애플리케이션 종료 (macOS 특성)
app.on("window-all-closed", function () {
if (process.platform !== "darwin") app.quit();
});
// 디렉토리 감시 로직 처리
ipcMain.handle("select-directories", async () => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", "multiSelections"],
});
if (result.canceled) {
return { canceled: true };
} else {
const selectedPaths = result.filePaths;
await log(`선택된 디렉토리: "${selectedPaths.join('", "')}"`);
if (mainWindow) {
mainWindow.webContents.send(
"log-message",
`선택된 디렉토리: "${selectedPaths.join('", "')}"`
);
}
for (const selectedPath of selectedPaths) {
if (!watchedDirectories.hasOwnProperty(selectedPath)) {
watchedDirectories[selectedPath] = new Date().toISOString();
watchDirectory(selectedPath);
}
}
await saveWatchedDirectories();
// 렌더러 프로세스로 선택된 디렉토리 목록 업데이트 요청
if (mainWindow) {
mainWindow.webContents.send("update-directories", watchedDirectories);
}
return { canceled: false, paths: selectedPaths };
}
});
// 디렉토리 제거 핸들러
ipcMain.handle("remove-directory", async (event, dirPath) => {
if (watchedDirectories.hasOwnProperty(dirPath)) {
watchedDirectories = Object.keys(watchedDirectories)
.filter((key) => key !== dirPath)
.reduce((obj, key) => {
obj[key] = watchedDirectories[key];
return obj;
}, {});
if (watchers[dirPath]) {
await watchers[dirPath].close();
delete watchers[dirPath];
await log(`디렉토리 감시 중지: "${dirPath}"`);
if (mainWindow) {
mainWindow.webContents.send(
"log-message",
`디렉토리 감시 중지: "${dirPath}"`
);
mainWindow.webContents.send("update-directories", watchedDirectories);
}
}
await saveWatchedDirectories();
return { success: true };
} else {
return { success: false, message: "디렉토리가 감시 목록에 없습니다." };
}
});
ipcMain.handle("get-directories", async () => {
return watchedDirectories;
});
process.on("unhandledRejection", (reason, promise) => {
new Notification({
title: "Unhandled Promise Rejection",
body: reason.message || "Unknown error",
}).show();
});
// 창을 생성하는 함수
function createWindow() {
mainWindow = new BrowserWindow({
width: 550, // 너비 조정
height: 550, // 높이 조정
webPreferences: {
preload: path.join(__dirname, "preload.js"), // 보안상 추천
nodeIntegration: false,
contextIsolation: true,
},
});
mainWindow.loadFile("index.html");
mainWindow.on("show", () => {
mainWindow.webContents.send("get-directories");
});
mainWindow.on("closed", function () {
mainWindow = null;
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +0,0 @@
{
"name": "nfd2nfc",
"version": "1.0.0",
"main": "main.js",
"description": "Convert NFD to NFC",
"dependencies": {
"chokidar": "^4.0.1",
"electron": "^33.2.1",
"minimist": "^1.2.8",
"readdirp": "^4.0.2"
},
"scripts": {
"start": "electron .",
"package": "electron-packager . NFD2NFC --platform=darwin --arch=arm64,x64 --icon=build/icons/MacIcon.icns --overwrite --prune=true --out=dist --asar --app-bundle-id=com.pieroot.nfd2nfc",
"package dev": "electron-packager . NFD2NFC --platform=darwin --arch=arm64,x64 --icon=build/icons/MacIcon-dev.icns --overwrite --prune=true --out=dist --asar --app-bundle-id=com.pieroot.nfd2nfc",
"dmg": "electron-installer-dmg ./dist/NFD2NFC-darwin-arm64/NFD2NFC.app NFD2NFC --overwrite --icon=build/icons/Macicon.icns --out=dist",
"build": "npm run package && npm run dmg"
},
"bin": {
"nfd2nfc": "normalize.js"
},
"directories": {
"output": "dist",
"buildResources": "build"
},
"keywords": [
"NFD",
"NFC",
"Unicode",
"Normalization",
"macOS",
"Linux",
"korean"
],
"author": "jung-geun <pieroot.02@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/jung-geun/NFD2NFC.git"
},
"license": "MIT",
"devDependencies": {
"electron": "^33.2.1",
"electron-installer-dmg": "^5.0.1",
"electron-packager": "^17.1.2"
},
"bugs": {
"url": "https://github.com/jung-geun/NFD2NFC/issues"
},
"homepage": "https://github.com/jung-geun/NFD2NFC#readme"
}

View File

@@ -1,14 +0,0 @@
// preload.js
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
selectDirectories: () => ipcRenderer.invoke("select-directories"),
removeDirectory: (dirPath) => ipcRenderer.invoke("remove-directory", dirPath),
getDirectories: () => ipcRenderer.invoke("get-directories"),
onLog: (callback) =>
ipcRenderer.on("log-message", (event, message) => callback(message)),
onUpdateDirectories: (callback) =>
ipcRenderer.on("update-directories", (event, directories) =>
callback(directories)
),
});

11
Makefile Normal file
View File

@@ -0,0 +1,11 @@
.PHONY: install build clean
build:
npm ci
npm run build
install: build
cp -R "dist/mac-arm64/NFD2NFC.app" /Applications/
clean:
rm -rf out dist node_modules

111
README.md
View File

@@ -1,82 +1,115 @@
# NFD to NFC Normalizer
이 애플리케이션은 백그라운드에서 선택한 디렉토리를 감시하고, NFD로 인코딩된 파일 이름을 자동으로 NFC 인코딩으로 변환합니다.
macOS에서 한글 파일명이 NFD(자모 분해)로 저장되는 문제를 실시간으로 해결하는 트레이 앱 + CLI + Node.js 라이브러리 통합 패키지입니다.
Nomalize는 macOS 용 애플리케이션과 Node.js 패키지로 제공됩니다. macOS 앱은 `MACOS-APP`에서 빌드 가능하며, Node.js 패키지는 `nfd2nfc`에서 빌드 가능하고 다음 명령어로 설치할 수 있습니다:
> 🌐 **웹 버전**: Windows/Linux 사용자는 설치 없이 [https://jung-geun.github.io/NFD2NFC/](https://jung-geun.github.io/NFD2NFC/) 에 파일/폴더를 끌어다 놓으면 NFC 이름의 ZIP 으로 받을 수 있습니다.
```bash
npm install @pieroot/nfd2nfc
```
> **Breaking Change (2.0.0)**: 단일 패키지 구조로 통합됐습니다. `MACOS-APP/`, `nfd2nfc/` 서브 패키지 구조는 제거됐습니다. CLI 명령이 `nfd2nfc file <path>` / `nfd2nfc dir <path>` 형태로 변경됐습니다.
## 주요 기능
- **실시간 감시**: 선택한 디렉토리를 chokidar로 감시하여 파일이 생기는 즉시 NFC로 자동 변환
- **트레이 팝오버**: macOS 메뉴바에 상주, 좌클릭으로 300×400 팝오버 표시
- **설정창**: 3탭 설정 화면 (감시 디렉토리 / Undo 기록 / 일반)
- **Auto / Manual 모드**: Auto는 즉시 변환, Manual은 큐에 누적 후 직접 적용
- **Undo 시스템**: 마지막 5초 배치 또는 개별 변환 되돌리기
- **알림 배치**: 30초 간격으로 변환 건수를 묶어 알림 — 알림 폭주 방지
- **APFS 정확 처리**: 같은 inode 비교로 normalization-insensitive 파일 시스템 올바르게 처리
- **한글 자모 필터**: U+1100-11FF, U+A960-A97F, U+D7B0-D7FF 영역만 대상 (라틴 NFD는 무시)
## 설치
### macOS 애플리케이션
`MACOS-APP` 디렉토리에서 빌드하여 설치하거나 [릴리즈 페이지]()에서 다운로드합니다.
[릴리즈 페이지](https://github.com/jung-geun/NFD2NFC/releases)에서 `NFD2NFC-arm64.dmg` 또는 `NFD2NFC-x64.dmg`를 다운로드하거나 직접 빌드합니다.
### Node.js 패키지
npm을 통해 패키지를 설치합니다:
### CLI (글로벌 설치)
```bash
# 지역적으로 설치
npm install @pieroot/nfd2nfc
# 글로벌로 설치
npm install -g @pieroot/nfd2nfc
```
### Node.js 라이브러리
```bash
npm install @pieroot/nfd2nfc
```
## 사용법
### macOS 애플리케이션
애플리케이션을 실행하여 원하는 기능을 사용합니다.
앱 실행 후 메뉴바 아이콘을 좌클릭하면 팝오버가 표시됩니다. 감시 중인 디렉토리 목록과 최근 활동, Undo 버튼을 한눈에 확인할 수 있습니다.
![애플리케이션 화면](./assets/start-app.png)
디렉토리 선택 버튼을 클릭하여 디렉토리를 선택합니다.
![트레이 팝오버](./assets/manubar.png)
![디렉토리 선택](./assets/select-directory.png)
감시할 디렉토리를 선택합니다.
메뉴바 아이콘을 우클릭하면 감시 일시 정지, 설정창 열기, 종료 메뉴가 나옵니다.
![메뉴바 컨텍스트 메뉴](./assets/menubar-setting.png)
설정창의 **디렉토리** 탭에서 감시할 디렉토리를 추가하고, 디렉토리별로 감시 활성화 여부 / Auto·Manual 모드 / 하위 폴더 포함 / 추가 필터 범위를 개별 설정할 수 있습니다. 추가된 디렉토리는 앱 재시작 시에도 유지됩니다.
![디렉토리 설정](./assets/setting-directory.png)
**일반** 탭에서는 기본 변환 모드, macOS 알림 활성화, 알림 인터벌(초)을 설정합니다.
![일반 설정](./assets/setting-general.png)
### CLI
CLI를 사용하여 변환할 문자열을 입력합니다:
```bash
nfd2nfc [options] <path>
# 디렉토리 변환 (재귀)
nfd2nfc dir <path> --recursive
# 옵션
nfd2nfc -h # 도움말
nfd2nfc -v # verbose 모드
# 미리보기 (실제 변환 없음)
nfd2nfc dir <path> --dry-run
# 단일 파일 변환
nfd2nfc file <path>
# 경로 자동 감지 (파일/디렉토리 판별 후 처리)
nfd2nfc <path>
# 도움말
nfd2nfc --help
```
### Node.js 패키지
패키지를 불러와서 사용합니다:
### Node.js 라이브러리
```javascript
const nfd2nfc = require("@pieroot/nfd2nfc");
let str_nfc = nfd2nfc.normalizeToNFC("NFD로 인코딩된 문자열");
let str_nfd = nfd2nfc.normalizeToNFD("NFC로 인코딩된 문자열");
// v1 호환 — 단순 문자열 정규화
const nfc = nfd2nfc.normalizeToNFC("NFD로 인코딩된 문자열");
const nfd = nfd2nfc.normalizeToNFD("NFC로 인코딩된 문자열");
// v2 신규 — 파일 시스템 API
const { normalizeEntry, scan, shouldNormalize } = require("@pieroot/nfd2nfc");
// 단일 파일/디렉토리 rename (APFS inode 처리 포함)
const result = await normalizeEntry("/path/to/file", "file");
// result.status: 'renamed' | 'skipped' | 'noop-same-inode' | 'collision'
// 디렉토리 재귀 스캔 (깊이 역순 정렬)
const entries = await scan("/path/to/dir", true);
```
## 빌드 방법
- macOS 애플리케이션은 `MACOS-APP`에서 빌드할 수 있습니다.
## 빌드
```bash
cd MACOS-APP
npm install
npm run build
```
- Node.js 패키지는 `nfd2nfc`에서 빌드 가능하며, 다음 명령어로 설치할 수 있습니다:
# 개발 모드
npm run dev
```bash
cd nfd2nfc
npm install
# 전체 빌드 (앱 + CLI + 라이브러리 + DMG)
npm run build
# 앱만 빌드
npm run build:app
# CLI만 빌드
npm run build: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

38
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { resolve } from 'path';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts'),
},
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
},
},
},
},
renderer: {
root: 'src/renderer',
plugins: [react()],
build: {
rollupOptions: {
input: {
popover: resolve(__dirname, 'src/renderer/popover/index.html'),
settings: resolve(__dirname, 'src/renderer/settings/index.html'),
},
},
},
},
});

View File

@@ -1,35 +0,0 @@
# 파일 이름 변환기
백그라운드에서 파일을 감지하고 변환하여 파일 이름을 NFD에서 NFC 인코딩으로 자동 변환하는 macOS 패키지입니다.
npm 패키지는 명령어를 통해 사용할 수 있는 CLI 도구를 제공합니다.
Application 패키지는 macOS에서 백그라운드 프로세스로 실행되며, 파일 변환을 자동으로 처리합니다.
## 특징
- 자동 파일 감지
- 백그라운드 변환 프로세스
- NFD에서 NFC 변환 지원
## 설치
```bash
# 설치 지침을 여기에 작성하세요
npm install -g @pieroot/nfd2nfc
# or
npm i @pieroot/nfd2nfc
```
## 사용법
```bash
nfd2nfc [options] <path>
Options:
-V, --version output the version number
-h, --help display help for command
```
## 라이선스
MIT 라이선스

View File

@@ -1,143 +0,0 @@
#!/usr/bin/env node
const fs = require("fs").promises;
const path = require("path");
const minimist = require("minimist");
const cliProgress = require("cli-progress");
// Parse command-line arguments
const args = minimist(process.argv.slice(2), {
alias: { v: "verbose", h: "help" },
boolean: ["verbose", "help"],
});
// Function to display help message
function displayHelp() {
console.log(`
Usage: node index.js [path] [options]
Options:
-v, --verbose Enable verbose logging with progress bar
-h, --help Display this help message
Provide a path directly as an argument
`);
}
// Check for help flag
if (args.help) {
displayHelp();
process.exit(0);
}
// Main processing logic
async function processPath(targetPath) {
try {
const stats = await fs.lstat(targetPath);
const depth = 0;
if (stats.isDirectory()) {
await processDirectory(targetPath, depth);
} else if (stats.isFile()) {
await normalizeFileName(targetPath);
}
} catch (error) {
console.error(`Error processing path "${targetPath}":`, error);
}
}
// Function to determine if a file/directory should be ignored
function shouldIgnore(itemName) {
const ignoredItems = [".git", "node_modules", ".env"];
return ignoredItems.includes(itemName);
}
function normalizeToNFC(str) {
return str.normalize("NFC");
}
function normalizeToNFD(str) {
return str.normalize("NFD");
}
// Function to normalize file names
async function normalizeFileName(filePath) {
const dir = path.dirname(filePath);
const oldName = path.basename(filePath);
const newName = convertToNFC(oldName);
if (oldName !== newName && !shouldIgnore(oldName)) {
const newPath = path.join(dir, newName);
try {
await fs.rename(filePath, newPath);
return newPath;
} catch (error) {
console.error(`Failed to rename "${oldName}":`, error);
return filePath;
}
}
return filePath;
}
let entriesLength = 0;
// Function to process directories recursively
async function processDirectory(dirPath, depth = 0) {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const filesToProcess = entries.filter(
(entry) => !entry.isDirectory()
).length;
let processedFiles = 0;
if (args.verbose && depth == 0) {
entriesLength = entries.length;
console.log("Processing directory:", dirPath);
progressBar.start(entriesLength, 0);
}
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
if (!shouldIgnore(entry.name)) {
await processDirectory(fullPath, depth + 1);
}
} else {
await normalizeFileName(fullPath);
}
if (args.verbose && filesToProcess > 0 && depth == 0) {
processedFiles++;
progressBar.update((processedFiles / entriesLength) * entriesLength);
}
}
await normalizeFileName(dirPath);
if (args.verbose && depth == 0) {
progressBar.stop();
}
} catch (error) {
console.error(`Error processing directory "${dirPath}":`, error);
}
}
// Initialize progress bar
const progressBar = new cliProgress.SingleBar(
{
format: "{bar} {percentage}% | {value}/{total}",
clearOnComplete: false,
},
cliProgress.Presets.shades_classic
);
module.exports = {
processPath,
shouldIgnore,
normalizeToNFC,
normalizeToNFD,
normalizeFileName,
};
// Handle input: if no flags, assume first non-flag argument is a path
const nonFlagArgs = args._;
if (nonFlagArgs.length > 0) {
processPath(nonFlagArgs[0]);
} else {
displayHelp();
}

View File

@@ -1,114 +0,0 @@
#!/usr/bin/env node
const fs = require("fs").promises;
const path = require("path");
const minimist = require("minimist");
function containsKorean(text) {
// 한글 유니코드 범위: 가-힣, ㄱ-ㅎ, ㅏ-ㅣ
return /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(text);
}
// Parse command-line arguments
const args = minimist(process.argv.slice(2), {
alias: { d: "directory", f: "file", v: "verbose", h: "help" },
});
// Function to display help message
function displayHelp() {
console.log(`
Usage: node index.js [options]
Options:
-d, --directory Specify a directory to process
-f, --file Specify a file to process
-v, --verbose Enable verbose logging
-h, --help Display this help message
`);
}
// Check for help flag or no arguments
if (args.help || (!args.directory && !args.file)) {
displayHelp();
process.exit(0);
}
// Main processing logic
async function processPath(targetPath) {
if (!targetPath) {
console.error("Please provide a path using -d or -f");
process.exit(1);
}
try {
const stats = await fs.lstat(targetPath);
if (stats.isDirectory()) {
await processDirectory(targetPath);
} else if (stats.isFile()) {
await normalizeFileName(targetPath);
}
} catch (error) {
console.error(`Error processing path "${targetPath}":`, error);
}
}
// Function to determine if a file/directory should be ignored
function shouldIgnore(itemName) {
const ignoredItems = [".git", "node_modules", ".env"];
return ignoredItems.includes(itemName);
}
// Function to normalize file names
async function normalizeFileName(filePath) {
const dir = path.dirname(filePath);
const oldName = path.basename(filePath);
const newName = oldName.normalize("NFC");
if (
oldName !== newName &&
!shouldIgnore(oldName) &&
containsKorean(oldName)
) {
const newPath = path.join(dir, newName);
try {
await fs.rename(filePath, newPath);
if (args.verbose) {
console.log(`Renamed: "${oldName}" -> "${newName}"`);
}
return newPath;
} catch (error) {
console.error(`Failed to rename "${oldName}":`, error);
return filePath;
}
}
return filePath;
}
// Function to process directories recursively
async function processDirectory(dirPath) {
if (args.verbose) {
console.log(`Processing directory: "${dirPath}"`);
}
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
if (!shouldIgnore(entry.name)) {
await processDirectory(fullPath);
await normalizeFileName(fullPath);
}
} else {
await normalizeFileName(fullPath);
}
}
await normalizeFileName(dirPath);
} catch (error) {
console.error(`Error processing directory "${dirPath}":`, error);
}
}
// Process the given path based on arguments
if (args.directory) {
processPath(args.directory);
} else if (args.file) {
processPath(args.file);
}

View File

@@ -1,91 +0,0 @@
{
"name": "@pieroot/nfd2nfc",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pieroot/nfd2nfc",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"cli-progress": "^3.12.0",
"minimist": "^1.2.8"
},
"bin": {
"nfd2nfc": "normalize.js"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cli-progress": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
"integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
"license": "MIT",
"dependencies": {
"string-width": "^4.2.3"
},
"engines": {
"node": ">=4"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
}
}
}

View File

@@ -1,38 +0,0 @@
{
"name": "@pieroot/nfd2nfc",
"version": "1.0.0",
"main": "index.js",
"description": "Convert NFD to NFC",
"dependencies": {
"cli-progress": "^3.12.0",
"minimist": "^1.2.8"
},
"scripts": {
"build": "pkg normalize.js --target node16-macos-x64,node16-linux-x64,node16-win-x64 --output ./dist/NFD2NFC"
},
"bin": {
"nfd2nfc": "normalize.js"
},
"directories": {
"output": "dist"
},
"keywords": [
"NFD",
"NFC",
"Unicode",
"Normalization",
"macOS",
"Linux",
"korean"
],
"author": "jung-geun <pieroot.02@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/jung-geun/NFD2NFC.git"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/jung-geun/NFD2NFC/issues"
},
"homepage": "https://github.com/jung-geun/NFD2NFC#readme"
}

7517
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

116
package.json Normal file
View File

@@ -0,0 +1,116 @@
{
"name": "@pieroot/nfd2nfc",
"version": "2.0.2",
"description": "macOS 한글 파일명 NFD→NFC 실시간 변환 트레이 앱 + CLI + 라이브러리",
"main": "out/lib/index.js",
"types": "out/lib/index.d.ts",
"exports": {
".": {
"types": "./out/lib/index.d.ts",
"default": "./out/lib/index.js"
}
},
"bin": {
"nfd2nfc": "out/cli/index.js"
},
"files": [
"out/cli/**",
"out/lib/**",
"out/core/**",
"!out/core/__tests__/**",
"README.md",
"LICENSE"
],
"scripts": {
"dev": "electron-vite dev",
"build:app": "electron-vite build && electron-builder",
"build:cli": "tsc --project tsconfig.cli.json",
"build:lib": "tsc --project tsconfig.lib.json",
"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",
"typecheck": "tsc --noEmit && tsc --noEmit --project tsconfig.cli.json && tsc --noEmit --project tsconfig.lib.json"
},
"dependencies": {
"chokidar": "^3.6.0",
"fflate": "^0.8.2",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/yargs": "^17.0.32",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"@vitejs/plugin-react": "^5.1.4",
"electron": "^39.8.5",
"electron-builder": "^26.0.12",
"electron-vite": "^3.1.0",
"eslint": "^8.57.0",
"prettier": "^3.2.5",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"typescript": "^5.4.0",
"vite": "^6.4.2",
"vitest": "^4.1.6"
},
"author": "jung-geun <pieroot.02@gmail.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/jung-geun/NFD2NFC.git"
},
"bugs": {
"url": "https://github.com/jung-geun/NFD2NFC/issues"
},
"homepage": "https://github.com/jung-geun/NFD2NFC#readme",
"keywords": [
"NFD",
"NFC",
"Unicode",
"Normalization",
"macOS",
"korean",
"electron",
"tray"
],
"build": {
"appId": "com.pieroot.nfd2nfc",
"productName": "NFD2NFC",
"extraMetadata": {
"main": "out/main/index.js"
},
"files": [
"out/main/**",
"out/preload/**",
"out/renderer/**",
"resources/**",
"package.json"
],
"mac": {
"category": "public.app-category.utilities",
"icon": "resources/icon.icns",
"target": [
{
"target": "dmg",
"arch": [
"arm64",
"x64"
]
}
]
},
"directories": {
"buildResources": "resources",
"output": "dist"
},
"publish": null
}
}

View File

Before

Width:  |  Height:  |  Size: 917 KiB

After

Width:  |  Height:  |  Size: 917 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 305 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -1,3 +0,0 @@
@echo off
cd /d %~dp0
normalize-win.exe

19
run.sh
View File

@@ -1,19 +0,0 @@
#!/bin/bash
# Detect the operating system
OS="$(uname -s)"
case "$OS" in
Darwin)
echo "Running on macOS"
./normalize-macos $@
;;
Linux)
echo "Running on Linux"
./normalize-linux $@
;;
*)
echo "Unknown OS: $OS"
exit 1
;;
esac

120
src/cli/index.ts Normal file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env node
import fs from 'fs/promises';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { scan } from '../core/scanner';
import { normalizeEntry } from '../core/normalizer';
import type { RenameResult } from '../core/types';
void yargs(hideBin(process.argv))
.usage('Usage: $0 <command|path> [options]')
.command(
'file <path>',
'단일 파일의 이름을 NFD→NFC로 변환',
(y) => y.positional('path', { describe: '변환할 파일 경로', type: 'string', demandOption: true }),
(args) => runFile(args.path as string)
)
.command(
'dir <path>',
'디렉토리 내 파일명을 NFD→NFC로 변환',
(y) =>
y
.positional('path', { describe: '변환할 디렉토리 경로', type: 'string', demandOption: true })
.option('recursive', { alias: 'r', describe: '하위 디렉토리 포함', type: 'boolean', default: false })
.option('dry-run', { alias: 'n', describe: '실제 변환 없이 대상 파일만 출력', type: 'boolean', default: false }),
(args) => runDir(args.path as string, args.recursive, args['dry-run'])
)
.command(
'$0 [path]',
'파일 또는 디렉토리를 자동 감지하여 NFD→NFC로 변환',
(y) =>
y
.positional('path', { describe: '변환할 경로', type: 'string' })
.option('recursive', { alias: 'r', describe: '하위 디렉토리 포함 (디렉토리인 경우)', type: 'boolean', default: false })
.option('dry-run', { alias: 'n', describe: '실제 변환 없이 대상만 출력', type: 'boolean', default: false }),
async (args) => {
if (!args.path) {
console.error('경로를 지정해주세요. nfd2nfc --help 로 도움말을 확인하세요.');
process.exit(1);
}
try {
const stat = await fs.stat(args.path);
if (stat.isDirectory()) {
await runDir(args.path, args.recursive, args['dry-run']);
} else {
await runFile(args.path);
}
} catch (err) {
console.error(`오류: ${(err as Error).message}`);
process.exit(1);
}
}
)
.help()
.alias('help', 'h')
.version()
.alias('version', 'v')
.parseAsync();
async function runFile(filePath: string): Promise<void> {
try {
console.log(`파일 변환: ${filePath}`);
const result = await normalizeEntry(filePath, 'file');
printResult(result);
} catch (err) {
console.error(`오류: ${(err as Error).message}`);
process.exit(1);
}
}
async function runDir(dirPath: string, recursive: boolean, dryRun: boolean): Promise<void> {
try {
const entries = await scan(dirPath, recursive);
if (entries.length === 0) {
console.log('변환 대상 없음.');
return;
}
if (dryRun) {
console.log(`[미리보기] 변환 대상 ${entries.length}개:`);
for (const e of entries) {
const nfc = e.path.split('/').pop()!.normalize('NFC');
console.log(` ${e.type === 'directory' ? '📁' : '📄'} ${e.path} → .../${nfc}`);
}
return;
}
console.log(`디렉토리 변환 시작: ${dirPath}${recursive ? ' (재귀)' : ''}`);
const results: RenameResult[] = [];
for (const e of entries) {
const result = await normalizeEntry(e.path, e.type);
results.push(result);
}
printResults(results);
} catch (err) {
console.error(`오류: ${(err as Error).message}`);
process.exit(1);
}
}
function printResult(r: RenameResult): void {
const label = r.type === 'directory' ? '폴더' : '파일';
if (r.status === 'renamed') {
console.log(`${label}: ${r.oldPath}${r.newPath}`);
} else if (r.status === 'skipped') {
console.log(`- ${label}: 변환 불필요 (${r.oldPath})`);
} else if (r.status === 'noop-same-inode') {
console.log(`= ${label}: APFS 동일 inode — 이미 접근 가능 (${r.newPath})`);
} else {
console.warn(`${label}: 충돌 — 대상 파일이 이미 존재합니다 (${r.newPath})`);
}
}
function printResults(results: RenameResult[]): void {
let renamed = 0;
for (const r of results) {
printResult(r);
if (r.status === 'renamed') renamed++;
}
console.log(`\n완료: ${renamed}개 변환됨 (총 ${results.length}개 처리)`);
}

View File

@@ -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);
});
});

View File

@@ -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));
});
});

View File

@@ -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');
});
});

29
src/core/filter.ts Normal file
View File

@@ -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;
}

51
src/core/normalizer.ts Normal file
View File

@@ -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<RenameResult> {
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<RenameResult> {
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' };
}

50
src/core/scanner.ts Normal file
View File

@@ -0,0 +1,50 @@
import { 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<ScanEntry[]> {
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<void> {
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);
}
}
}

47
src/core/types.ts Normal file
View File

@@ -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[];
}

11
src/lib/index.ts Normal file
View File

@@ -0,0 +1,11 @@
// 1.0.0 호환 shim — 단순 문자열 정규화
export const normalizeToNFC = (s: string): string => s.normalize('NFC');
export const normalizeToNFD = (s: string): string => s.normalize('NFD');
// 파일 시스템 API
export { normalizeEntry } from '../core/normalizer';
export { scan } from '../core/scanner';
export { shouldNormalize } from '../core/filter';
export type { FilterOptions } from '../core/filter';
export type { ScanEntry } from '../core/scanner';
export type { RenameResult } from '../core/types';

37
src/main/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import { app } from 'electron';
import { createTray, destroyTray } from './tray';
import { openSettingsWindow } from './settings-window';
import { registerIpcHandlers } from './ipc';
import * as store from './store';
import * as watcher from './watcher';
// 단일 인스턴스 강제
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
}
app.whenReady().then(async () => {
registerIpcHandlers();
createTray();
// 저장된 디렉토리 감시 복원
const dirs = await store.getDirs();
for (const dir of dirs.filter((d) => d.enabled)) {
watcher.startDir(dir).catch(console.error);
}
// 감시 디렉토리가 없으면 설정창 자동 오픈 (첫 실행 안내)
if (dirs.length === 0) {
openSettingsWindow();
}
});
app.on('window-all-closed', () => {
// 트레이 앱은 윈도우를 모두 닫아도 종료하지 않음
});
app.on('will-quit', async () => {
destroyTray();
await watcher.stopAll();
});

125
src/main/ipc.ts Normal file
View File

@@ -0,0 +1,125 @@
import { ipcMain, dialog, app, BrowserWindow } from 'electron';
import { randomUUID } from 'crypto';
import { scan } from '../core/scanner';
import { normalizeEntry } from '../core/normalizer';
import * as store from './store';
import * as watcher from './watcher';
import type { WatchedDir, UndoEntry } from '../core/types';
export function registerIpcHandlers(): void {
// ── 디렉토리 선택 ──
ipcMain.handle('dialog:selectDirectory', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(win!, { properties: ['openDirectory'] });
return result.canceled ? null : result.filePaths[0];
});
// ── 디렉토리 CRUD ──
ipcMain.handle('dirs:list', async () => store.getDirs());
ipcMain.handle('dirs:add', async (_e, dirPath: string) => {
const settings = await store.getSettings();
const dir: WatchedDir = {
id: randomUUID(),
path: dirPath,
recursive: false,
mode: settings.defaultMode,
enabled: true,
customRanges: [],
};
await store.addDir(dir);
await watcher.startDir(dir);
return dir;
});
ipcMain.handle('dirs:update', async (_e, id: string, patch: Partial<WatchedDir>) => {
await store.updateDir(id, patch);
// 모드/recursive 변경 시 재시작
if ('mode' in patch || 'recursive' in patch || 'enabled' in patch || 'customRanges' in patch) {
await watcher.stopDir(id);
const dirs = await store.getDirs();
const dir = dirs.find((d) => d.id === id);
if (dir?.enabled) await watcher.startDir(dir);
}
});
ipcMain.handle('dirs:remove', async (_e, id: string) => {
await watcher.stopDir(id);
await store.removeDir(id);
});
// ── Dry-run 스캔 ──
ipcMain.handle('dirs:scan', async (_e, id: string) => {
const dirs = await store.getDirs();
const dir = dirs.find((d) => d.id === id);
if (!dir) return [];
return scan(dir.path, dir.recursive, { customRanges: dir.customRanges });
});
// ── 수동 모드 대기 큐 적용 ──
ipcMain.handle('dirs:applyQueue', async (_e, id: string) => {
const dirs = await store.getDirs();
const dir = dirs.find((d) => d.id === id);
if (!dir) return [];
return watcher.applyManualQueue(id, dir);
});
ipcMain.handle('dirs:pendingQueue', async (_e, id: string) => {
return watcher.getPendingQueue(id);
});
// ── 감시 일시정지/재개/상태 ──
ipcMain.handle('watcher:status', () => ({ paused: watcher.isGloballyPaused() }));
ipcMain.handle('watcher:pauseAll', async () => {
await watcher.pauseAll();
});
ipcMain.handle('watcher:resumeAll', async () => {
const dirs = await store.getDirs();
await watcher.resumeAll(dirs);
});
// ── Undo ──
ipcMain.handle('undo:list', async () => store.getUndoLog());
ipcMain.handle('undo:revertEntry', async (_e, entry: UndoEntry) => {
try {
const result = await normalizeEntry(entry.newPath, 'file');
// 실제 되돌리기: newPath → oldPath
const fs = await import('fs/promises');
await fs.rename(entry.newPath, entry.oldPath);
await store.markUndoReverted([entry.id]);
return { success: true, result };
} catch (err) {
return { success: false, error: String(err) };
}
});
ipcMain.handle('undo:revertLastBatch', async () => {
const log = await store.getUndoLog();
if (!log.length) return [];
const lastTs = log[log.length - 1].ts;
// 마지막 5초 이내의 항목을 한 배치로 묶음
const batch = log.filter((e) => !e.reverted && lastTs - e.ts < 5000);
const fs = await import('fs/promises');
const results = [];
for (const entry of [...batch].reverse()) {
try {
await fs.rename(entry.newPath, entry.oldPath);
results.push({ ...entry, success: true });
} catch (err) {
results.push({ ...entry, success: false, error: String(err) });
}
}
await store.markUndoReverted(batch.map((e) => e.id));
return results;
});
// ── 설정 ──
ipcMain.handle('settings:get', async () => store.getSettings());
ipcMain.handle('settings:update', async (_e, patch) => store.updateSettings(patch));
// ── 앱 정보 ──
ipcMain.handle('app:version', () => app.getVersion());
}

5
src/main/nanoid.ts Normal file
View File

@@ -0,0 +1,5 @@
import { randomBytes } from 'crypto';
export function nanoid(): string {
return randomBytes(12).toString('base64url');
}

68
src/main/notifier.ts Normal file
View File

@@ -0,0 +1,68 @@
import { Notification } from 'electron';
import * as store from './store';
interface Batch {
count: number;
timer: ReturnType<typeof setTimeout>;
}
// dirPath → 현재 진행 중인 배치
const batches = new Map<string, Batch>();
/**
* 자동 변환 알림을 배치에 추가한다.
* 첫 rename이 들어오면 타이머를 시작하고, 인터벌이 끝나면
* 그동안 쌓인 총 건수를 담은 알림을 한 번만 발송한다.
*/
export async function queueRenamedNotification(dirPath: string): Promise<void> {
const settings = await store.getSettings();
if (!settings.notificationsEnabled) return;
const intervalMs = (settings.notificationIntervalSecs ?? 30) * 1000;
let batch = batches.get(dirPath);
if (!batch) {
const timer = setTimeout(() => flushBatch(dirPath), intervalMs);
batch = { count: 0, timer };
batches.set(dirPath, batch);
}
batch.count++;
}
function flushBatch(dirPath: string): void {
const batch = batches.get(dirPath);
if (!batch) return;
const count = batch.count;
batches.delete(dirPath);
if (!Notification.isSupported() || count === 0) return;
const dirName = dirPath.split('/').pop() ?? dirPath;
new Notification({
title: 'NFD → NFC 변환 완료',
body: `"${dirName}" 에서 ${count}개 파일명이 변환되었습니다.`,
silent: true,
}).show();
}
/** 수동 모드: 감지 즉시 알림 (사용자 액션 유도) */
export async function notifyManualQueue(count: number, dirPath: string): Promise<void> {
const settings = await store.getSettings();
if (!settings.notificationsEnabled) return;
if (!Notification.isSupported()) return;
const dirName = dirPath.split('/').pop() ?? dirPath;
new Notification({
title: 'NFD 파일 감지됨',
body: `"${dirName}" 에서 ${count}개 대기 중. 트레이에서 처리하세요.`,
silent: true,
}).show();
}
/** 앱 종료 시 남아있는 배치를 즉시 발송 */
export function flushAll(): void {
for (const [dirPath] of batches) {
flushBatch(dirPath);
}
}

View File

@@ -0,0 +1,59 @@
import { BrowserWindow, shell, app } from 'electron';
import { join } from 'path';
let settingsWin: BrowserWindow | null = null;
export function openSettingsWindow(): void {
if (settingsWin && !settingsWin.isDestroyed()) {
settingsWin.focus();
return;
}
settingsWin = new BrowserWindow({
width: 720,
height: 560,
title: 'NFD to NFC — 설정',
resizable: false,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: join(__dirname, '../preload/index.js'),
},
});
settingsWin.on('closed', () => {
settingsWin = null;
app.dock?.hide();
});
settingsWin.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
// 로딩 실패 시 콘솔 출력
settingsWin.webContents.on('did-fail-load', (_e, code, desc, url) => {
console.error('[settings-window] did-fail-load:', code, desc, url);
});
const loadPromise = process.env['ELECTRON_RENDERER_URL']
? settingsWin.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/settings/index.html`)
: settingsWin.loadFile(join(__dirname, '../renderer/settings/index.html'));
loadPromise
.then(() => {
app.dock?.show();
settingsWin?.show();
settingsWin?.focus();
// 개발 모드에서 devtools 자동 오픈
if (process.env['ELECTRON_RENDERER_URL']) {
settingsWin?.webContents.openDevTools({ mode: 'detach' });
}
})
.catch((err: Error) => console.error('[settings-window] load error:', err));
}
export function closeSettingsWindow(): void {
settingsWin?.close();
}

95
src/main/store.ts Normal file
View File

@@ -0,0 +1,95 @@
import { app } from 'electron';
import fs from 'fs/promises';
import path from 'path';
import type { AppSchema, AppSettings, WatchedDir, UndoEntry } from '../core/types';
const STORE_PATH = path.join(app.getPath('userData'), 'store.json');
const MAX_UNDO_LOG = 1000;
const DEFAULT_SETTINGS: AppSettings = {
startAtLogin: false,
defaultMode: 'auto',
notificationsEnabled: true,
notificationIntervalSecs: 30,
};
const DEFAULT_SCHEMA: AppSchema = {
watchedDirs: [],
settings: DEFAULT_SETTINGS,
undoLog: [],
};
let cache: AppSchema | null = null;
async function load(): Promise<AppSchema> {
if (cache) return cache;
try {
const raw = await fs.readFile(STORE_PATH, 'utf-8');
cache = { ...DEFAULT_SCHEMA, ...JSON.parse(raw) };
} catch {
cache = structuredClone(DEFAULT_SCHEMA);
}
return cache!;
}
async function save(): Promise<void> {
if (!cache) return;
await fs.mkdir(path.dirname(STORE_PATH), { recursive: true });
await fs.writeFile(STORE_PATH, JSON.stringify(cache, null, 2), 'utf-8');
}
export async function getDirs(): Promise<WatchedDir[]> {
return (await load()).watchedDirs;
}
export async function addDir(dir: WatchedDir): Promise<void> {
const data = await load();
data.watchedDirs.push(dir);
await save();
}
export async function updateDir(id: string, patch: Partial<WatchedDir>): Promise<void> {
const data = await load();
const idx = data.watchedDirs.findIndex((d) => d.id === id);
if (idx === -1) return;
data.watchedDirs[idx] = { ...data.watchedDirs[idx], ...patch };
await save();
}
export async function removeDir(id: string): Promise<void> {
const data = await load();
data.watchedDirs = data.watchedDirs.filter((d) => d.id !== id);
await save();
}
export async function getSettings(): Promise<AppSettings> {
return (await load()).settings;
}
export async function updateSettings(patch: Partial<AppSettings>): Promise<void> {
const data = await load();
data.settings = { ...data.settings, ...patch };
await save();
}
export async function appendUndoEntries(entries: UndoEntry[]): Promise<void> {
const data = await load();
data.undoLog.push(...entries);
if (data.undoLog.length > MAX_UNDO_LOG) {
data.undoLog = data.undoLog.slice(-MAX_UNDO_LOG);
}
await save();
}
export async function getUndoLog(): Promise<UndoEntry[]> {
return (await load()).undoLog;
}
export async function markUndoReverted(ids: string[]): Promise<void> {
const data = await load();
const set = new Set(ids);
data.undoLog.forEach((e) => {
if (set.has(e.id)) e.reverted = true;
});
await save();
}

111
src/main/tray.ts Normal file
View File

@@ -0,0 +1,111 @@
import { Tray, Menu, BrowserWindow, nativeImage, app } from 'electron';
import { join } from 'path';
import { openSettingsWindow } from './settings-window';
import * as watcher from './watcher';
import { getDirs } from './store';
let tray: Tray | null = null;
let popoverWin: BrowserWindow | null = null;
function buildTrayIcon(): Electron.NativeImage {
// resources/ 파일이 있으면 우선 사용
const iconPath = join(__dirname, '../../resources/tray-icon-Template.png');
const fromFile = nativeImage.createFromPath(iconPath);
if (!fromFile.isEmpty()) {
fromFile.setTemplateImage(true);
return fromFile;
}
// 없으면 32×32 비트맵으로 원형 아이콘 생성 (@2x → 16px 논리 크기)
const SIZE = 32;
const data = Buffer.alloc(SIZE * SIZE * 4, 0);
const cx = SIZE / 2, cy = SIZE / 2, r = SIZE * 0.38;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
if (Math.hypot(x - cx, y - cy) <= r) {
const i = (y * SIZE + x) * 4;
data[i + 3] = 255; // alpha only — template image가 색을 결정
}
}
}
const img = nativeImage.createFromBitmap(data, { width: SIZE, height: SIZE, scaleFactor: 2.0 });
img.setTemplateImage(true);
return img;
}
export function createTray(): void {
const icon = buildTrayIcon();
tray = new Tray(icon);
tray.setToolTip('NFD to NFC 변환기');
tray.on('click', () => togglePopover());
tray.on('right-click', () => showContextMenu());
}
function togglePopover(): void {
if (popoverWin && !popoverWin.isDestroyed()) {
popoverWin.close();
return;
}
const bounds = tray!.getBounds();
popoverWin = new BrowserWindow({
width: 300,
height: 400,
x: Math.round(bounds.x - 150 + bounds.width / 2),
y: Math.round(bounds.y + bounds.height + 4),
frame: false,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: join(__dirname, '../preload/index.js'),
},
});
popoverWin.on('blur', () => {
popoverWin?.close();
});
popoverWin.on('closed', () => {
popoverWin = null;
});
if (process.env['ELECTRON_RENDERER_URL']) {
popoverWin.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popover/index.html`);
} else {
popoverWin.loadFile(join(__dirname, '../renderer/popover/index.html'));
}
popoverWin.once('ready-to-show', () => popoverWin?.show());
}
function showContextMenu(): void {
// watcher 모듈이 단일 진실의 원천 — 여기서 직접 읽음
const paused = watcher.isGloballyPaused();
const menu = Menu.buildFromTemplate([
{
label: paused ? '감시 재개' : '감시 일시 정지',
click: async () => {
if (watcher.isGloballyPaused()) {
const dirs = await getDirs();
await watcher.resumeAll(dirs);
} else {
await watcher.pauseAll();
}
},
},
{ label: '설정…', click: () => openSettingsWindow() },
{ type: 'separator' },
{ label: '종료', click: () => app.quit() },
]);
tray!.popUpContextMenu(menu);
}
export function destroyTray(): void {
tray?.destroy();
tray = null;
}

164
src/main/watcher.ts Normal file
View File

@@ -0,0 +1,164 @@
import fs from 'fs/promises';
import chokidar, { FSWatcher } from 'chokidar';
import { BrowserWindow } from 'electron';
import { nanoid } from './nanoid';
import { shouldNormalize } from '../core/filter';
import { normalizeEntry } from '../core/normalizer';
import type { WatchedDir, ActivityEvent, RenameResult } from '../core/types';
import * as store from './store';
import * as notifier from './notifier';
type ActivityListener = (event: ActivityEvent) => void;
const watchers = new Map<string, FSWatcher>();
const recentlyRenamed = new Map<string, number>();
const RENAME_TTL = 2000;
const listeners = new Set<ActivityListener>();
const manualQueue = new Map<string, string[]>();
// 전역 일시정지 상태 — 단일 진실의 원천
let globallyPaused = false;
export function isGloballyPaused(): boolean {
return globallyPaused;
}
export function onActivity(listener: ActivityListener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function emit(event: ActivityEvent): void {
for (const l of listeners) l(event);
BrowserWindow.getAllWindows().forEach((w) => {
if (!w.isDestroyed()) w.webContents.send('watcher:activity', event);
});
}
// 일시정지 상태 변경을 렌더러에 브로드캐스트
function emitPauseState(): void {
BrowserWindow.getAllWindows().forEach((w) => {
if (!w.isDestroyed()) w.webContents.send('watcher:paused', globallyPaused);
});
}
export async function startDir(dir: WatchedDir): Promise<void> {
if (watchers.has(dir.id)) return;
const filterOpts = { customRanges: dir.customRanges };
const w = chokidar.watch(dir.path, {
ignoreInitial: false,
persistent: true,
depth: dir.recursive ? undefined : 0,
ignored: /(^|[/\\])\../,
});
async function handlePath(filePath: string, type: 'file' | 'directory'): Promise<void> {
const now = Date.now();
const lastRenamed = recentlyRenamed.get(filePath);
if (lastRenamed && now - lastRenamed < RENAME_TTL) return;
const basename = filePath.split('/').pop() ?? '';
if (!shouldNormalize(basename, filterOpts)) return;
if (dir.mode === 'auto') {
const result = await normalizeEntry(filePath, type, filterOpts);
if (result.status === 'renamed' || result.status === 'noop-same-inode') {
recentlyRenamed.set(result.newPath, 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 {
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);
}
}
w.on('add', (p) => handlePath(p, 'file').catch(console.error))
.on('addDir', (p) => {
if (p === dir.path) return;
handlePath(p, 'directory').catch(console.error);
})
.on('error', (err) => {
emit({ type: 'error', ts: Date.now(), dirId: dir.id, message: String(err) });
});
watchers.set(dir.id, w);
}
export async function stopDir(dirId: string): Promise<void> {
const w = watchers.get(dirId);
if (!w) return;
await w.close();
watchers.delete(dirId);
manualQueue.delete(dirId);
}
/** 전역 일시정지: 모든 감시 중단 + 상태 플래그 변경 */
export async function pauseAll(): Promise<void> {
globallyPaused = true;
for (const [id] of [...watchers]) await stopDir(id);
emitPauseState();
}
/** 전역 재개: 지정된 dirs로 감시 재시작 + 상태 플래그 변경 */
export async function resumeAll(dirs: WatchedDir[]): Promise<void> {
globallyPaused = false;
for (const d of dirs.filter((x) => x.enabled)) {
await startDir(d);
}
emitPauseState();
}
/** 앱 종료용 정리 — 일시정지 플래그 변경 없음 */
export async function stopAll(): Promise<void> {
for (const [id] of [...watchers]) await stopDir(id);
}
export function getPendingQueue(dirId: string): string[] {
return manualQueue.get(dirId) ?? [];
}
export async function applyManualQueue(dirId: string, dir: WatchedDir): Promise<RenameResult[]> {
const queue = manualQueue.get(dirId) ?? [];
const filterOpts = { customRanges: dir.customRanges };
const results: RenameResult[] = [];
const undoEntries = [];
const now = Date.now();
for (const filePath of queue) {
try {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat) continue;
const type = stat.isDirectory() ? 'directory' : 'file';
const result = await normalizeEntry(filePath, type, filterOpts);
results.push(result);
if (result.status === 'renamed') {
recentlyRenamed.set(result.newPath, Date.now());
undoEntries.push({ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false });
}
} catch (err) {
console.error('applyManualQueue error:', err);
}
}
manualQueue.set(dirId, []);
if (undoEntries.length) await store.appendUndoEntries(undoEntries);
// 수동 적용은 즉시 배치에 추가 (인터벌 내 합산)
const renamedCount = results.filter((r) => r.status === 'renamed').length;
for (let i = 0; i < renamedCount; i++) await notifier.queueRenamedNotification(dir.path);
return results;
}

55
src/preload/index.ts Normal file
View File

@@ -0,0 +1,55 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { WatchedDir, UndoEntry, AppSettings, ActivityEvent } from '../core/types';
contextBridge.exposeInMainWorld('api', {
// 디렉토리 관리
dirs: {
list: (): Promise<WatchedDir[]> => ipcRenderer.invoke('dirs:list'),
add: (path: string): Promise<WatchedDir> => ipcRenderer.invoke('dirs:add', path),
update: (id: string, patch: Partial<WatchedDir>) => ipcRenderer.invoke('dirs:update', id, patch),
remove: (id: string) => ipcRenderer.invoke('dirs:remove', id),
scan: (id: string) => ipcRenderer.invoke('dirs:scan', id),
applyQueue: (id: string) => ipcRenderer.invoke('dirs:applyQueue', id),
pendingQueue: (id: string) => ipcRenderer.invoke('dirs:pendingQueue', id),
selectDirectory: (): Promise<string | null> => ipcRenderer.invoke('dialog:selectDirectory'),
},
// 감시 제어
watcher: {
status: (): Promise<{ paused: boolean }> => ipcRenderer.invoke('watcher:status'),
pauseAll: () => ipcRenderer.invoke('watcher:pauseAll'),
resumeAll: () => ipcRenderer.invoke('watcher:resumeAll'),
},
// Undo
undo: {
list: (): Promise<UndoEntry[]> => ipcRenderer.invoke('undo:list'),
revertEntry: (entry: UndoEntry) => ipcRenderer.invoke('undo:revertEntry', entry),
revertLastBatch: () => ipcRenderer.invoke('undo:revertLastBatch'),
},
// 설정
settings: {
get: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
update: (patch: Partial<AppSettings>) => ipcRenderer.invoke('settings:update', patch),
},
// 이벤트 (main → renderer 푸시)
events: {
onActivity: (cb: (event: ActivityEvent) => void) => {
const handler = (_: unknown, event: ActivityEvent) => cb(event);
ipcRenderer.on('watcher:activity', handler);
return () => ipcRenderer.removeListener('watcher:activity', handler);
},
// 일시정지 상태 변경 (트레이 컨텍스트 메뉴 포함 모든 경로에서 발생)
onPausedChange: (cb: (paused: boolean) => void) => {
const handler = (_: unknown, paused: boolean) => cb(paused);
ipcRenderer.on('watcher:paused', handler);
return () => ipcRenderer.removeListener('watcher:paused', handler);
},
},
app: {
version: (): Promise<string> => ipcRenderer.invoke('app:version'),
},
});

View File

@@ -0,0 +1,113 @@
import React, { useEffect, useState, useCallback } from 'react';
import type { WatchedDir, ActivityEvent } from '../../core/types';
const api = (window as any).api;
export function Popover() {
const [dirs, setDirs] = useState<WatchedDir[]>([]);
const [activity, setActivity] = useState<ActivityEvent[]>([]);
const [paused, setPaused] = useState(false);
const refresh = useCallback(async () => {
setDirs(await api.dirs.list());
}, []);
useEffect(() => {
// 초기 상태: main process에서 직접 읽음 (단일 진실의 원천)
api.watcher.status().then(({ paused: p }: { paused: boolean }) => setPaused(p));
refresh();
const unsubActivity = api.events.onActivity((ev: ActivityEvent) => {
setActivity((prev) => [ev, ...prev].slice(0, 20));
refresh();
});
// main에서 pause 상태가 바뀔 때마다 업데이트 (트레이 컨텍스트 메뉴 포함)
const unsubPaused = api.events.onPausedChange((p: boolean) => setPaused(p));
return () => {
unsubActivity();
unsubPaused();
};
}, [refresh]);
const togglePause = async () => {
if (paused) {
await api.watcher.resumeAll();
} else {
await api.watcher.pauseAll();
}
// 상태는 onPausedChange 이벤트로 자동 반영되므로 직접 setPaused 불필요
};
return (
<div className="popover">
<div className="popover-header">
<h1>NFD NFC</h1>
<div className="popover-actions">
<button className="secondary" onClick={togglePause} title={paused ? '재개' : '일시정지'}>
{paused ? '▶ 재개' : '⏸ 정지'}
</button>
</div>
</div>
<div className="dir-list">
{dirs.length === 0 && (
<div className="empty-state">
.<br />
.
</div>
)}
{dirs.map((dir) => (
<DirRow key={dir.id} dir={dir} paused={paused} onRefresh={refresh} />
))}
</div>
<div className="activity-log">
<h3> </h3>
{activity.length === 0 && <div className="activity-item"> </div>}
{activity.map((ev, i) => (
<div key={i} className={`activity-item ${ev.type === 'error' || ev.type === 'collision' ? 'error' : ''}`}>
{ev.message.split('/').pop() ?? ev.message}
</div>
))}
</div>
<div className="popover-footer">
<button className="secondary" onClick={() => api.undo.revertLastBatch()}>Undo </button>
</div>
</div>
);
}
function DirRow({ dir, paused, onRefresh }: { dir: WatchedDir; paused: boolean; onRefresh: () => void }) {
const [pending, setPending] = useState(0);
useEffect(() => {
if (dir.mode === 'manual') {
api.dirs.pendingQueue(dir.id).then((q: string[]) => setPending(q.length));
}
}, [dir]);
const applyQueue = async () => {
await api.dirs.applyQueue(dir.id);
setPending(0);
onRefresh();
};
const statusClass = paused ? 'paused' : dir.enabled ? 'active' : 'disabled';
const dirName = dir.path.split('/').pop() ?? dir.path;
return (
<div className="dir-row">
<div className={`status-dot ${statusClass}`} title={statusClass} />
<div className="dir-info">
<div className="dir-name">{dirName}</div>
<div className="dir-path">{dir.path}</div>
</div>
{dir.mode === 'manual' && pending > 0 && (
<button onClick={applyQueue}>{pending} </button>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<title>NFD to NFC</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,307 @@
import React, { useEffect, useState, useCallback } from 'react';
import type { WatchedDir, UndoEntry, AppSettings } from '../../core/types';
const api = (window as any).api;
type Tab = 'dirs' | 'undo' | 'general';
export function Settings() {
const [tab, setTab] = useState<Tab>('dirs');
return (
<div className="settings">
<div className="settings-header">
<h1>NFD NFC </h1>
</div>
<div className="tabs">
{(['dirs', 'undo', 'general'] as Tab[]).map((t) => (
<button key={t} className={`tab-btn${tab === t ? ' active' : ''}`} onClick={() => setTab(t)}>
{{ dirs: '디렉토리', undo: 'Undo 기록', general: '일반' }[t]}
</button>
))}
</div>
<div className="tab-content">
{tab === 'dirs' && <DirsTab />}
{tab === 'undo' && <UndoTab />}
{tab === 'general' && <GeneralTab />}
</div>
</div>
);
}
// ── 디렉토리 탭 ──
function DirsTab() {
const [dirs, setDirs] = useState<WatchedDir[]>([]);
const [scanning, setScanning] = useState<string | null>(null);
const [scanResults, setScanResults] = useState<Record<string, unknown[]>>({});
const refresh = useCallback(async () => setDirs(await api.dirs.list()), []);
useEffect(() => { refresh(); }, [refresh]);
const addDir = async () => {
const p = await api.dirs.selectDirectory();
if (!p) return;
await api.dirs.add(p);
refresh();
};
const removeDir = async (id: string) => {
await api.dirs.remove(id);
refresh();
};
const toggleEnabled = async (dir: WatchedDir) => {
await api.dirs.update(dir.id, { enabled: !dir.enabled });
refresh();
};
const toggleMode = async (dir: WatchedDir) => {
await api.dirs.update(dir.id, { mode: dir.mode === 'auto' ? 'manual' : 'auto' });
refresh();
};
const toggleRecursive = async (dir: WatchedDir) => {
await api.dirs.update(dir.id, { recursive: !dir.recursive });
refresh();
};
const dryRun = async (id: string) => {
setScanning(id);
const results = await api.dirs.scan(id);
setScanResults((prev) => ({ ...prev, [id]: results }));
setScanning(null);
};
return (
<div>
<div className="add-dir-bar">
<button onClick={addDir}>+ </button>
<span style={{ fontSize: 11, color: '#8e8e93' }}> </span>
</div>
{dirs.length === 0 && <div className="empty-state" style={{ marginTop: 40 }}> .</div>}
{dirs.map((dir) => (
<div key={dir.id} className="section">
<div className="card">
<div className="card-row">
<div>
<div style={{ fontWeight: 600 }}>{dir.path.split('/').pop()}</div>
<div className="row-desc">{dir.path}</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="secondary" onClick={() => dryRun(dir.id)} disabled={scanning === dir.id}>
{scanning === dir.id ? '스캔 중…' : '미리보기'}
</button>
<button className="danger" onClick={() => removeDir(dir.id)}></button>
</div>
</div>
<div className="card-row">
<label> </label>
<input type="checkbox" checked={dir.enabled} onChange={() => toggleEnabled(dir)} />
</div>
<div className="card-row">
<div>
<div>: <strong>{dir.mode === 'auto' ? '자동 변환' : '수동 승인'}</strong></div>
<div className="row-desc">
{dir.mode === 'auto' ? 'NFD 감지 즉시 자동 rename' : '감지 후 사용자 확인 필요'}
</div>
</div>
<button className="secondary" onClick={() => toggleMode(dir)}></button>
</div>
<div className="card-row">
<label> ()</label>
<input type="checkbox" checked={dir.recursive} onChange={() => toggleRecursive(dir)} />
</div>
<div className="card-row">
<div>
<div> </div>
<div className="row-desc">hex (: 0300-036F). .</div>
</div>
<RangeEditor dir={dir} onRefresh={refresh} />
</div>
</div>
{scanResults[dir.id] && (
<ScanPreview results={scanResults[dir.id]} onClose={() => setScanResults((p) => { const n = {...p}; delete n[dir.id]; return n; })} />
)}
</div>
))}
</div>
);
}
function RangeEditor({ dir, onRefresh }: { dir: WatchedDir; onRefresh: () => void }) {
const [input, setInput] = useState('');
const addRange = async () => {
const parts = input.trim().split('-');
if (parts.length !== 2) return;
const lo = parseInt(parts[0], 16);
const hi = parseInt(parts[1], 16);
if (isNaN(lo) || isNaN(hi)) return;
await api.dirs.update(dir.id, { customRanges: [...dir.customRanges, [lo, hi]] });
setInput('');
onRefresh();
};
const removeRange = async (idx: number) => {
const newRanges = dir.customRanges.filter((_, i) => i !== idx);
await api.dirs.update(dir.id, { customRanges: newRanges });
onRefresh();
};
return (
<div>
<div style={{ display: 'flex', gap: 6, marginBottom: 4 }}>
<input
type="text"
placeholder="0300-036F"
value={input}
onChange={(e) => setInput(e.target.value)}
style={{ width: 120 }}
onKeyDown={(e) => e.key === 'Enter' && addRange()}
/>
<button className="secondary" onClick={addRange}></button>
</div>
<div className="range-list">
{dir.customRanges.map(([lo, hi], i) => (
<span key={i} className="range-tag" title="클릭해서 제거" onClick={() => removeRange(i)}>
{lo.toString(16).toUpperCase()}-{hi.toString(16).toUpperCase()}
</span>
))}
</div>
</div>
);
}
function ScanPreview({ results, onClose }: { results: unknown[]; onClose: () => void }) {
return (
<div style={{ background: '#fff', borderRadius: 10, border: '1px solid #e5e5ea', padding: 12, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong> ({results.length})</strong>
<button className="secondary" onClick={onClose}></button>
</div>
{results.length === 0 && <div style={{ color: '#8e8e93', fontSize: 12 }}> </div>}
{results.map((r: any, i) => (
<div key={i} style={{ fontSize: 11, color: '#3c3c43', padding: '2px 0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{r.type === 'directory' ? '📁' : '📄'} {r.path.split('/').pop()}
</div>
))}
</div>
);
}
// ── Undo 탭 ──
function UndoTab() {
const [log, setLog] = useState<UndoEntry[]>([]);
const refresh = useCallback(async () => {
const entries = await api.undo.list();
setLog([...entries].reverse().slice(0, 200));
}, []);
useEffect(() => { refresh(); }, [refresh]);
const revertEntry = async (entry: UndoEntry) => {
await api.undo.revertEntry(entry);
refresh();
};
const revertBatch = async () => {
await api.undo.revertLastBatch();
refresh();
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 12, color: '#8e8e93' }}> 1000 </span>
<button className="secondary" onClick={revertBatch}> </button>
</div>
<div className="undo-list">
{log.length === 0 && <div className="empty-state">Undo .</div>}
{log.map((entry) => (
<div key={entry.id} className={`undo-item${entry.reverted ? ' reverted' : ''}`}>
<div className="undo-paths">
<div className="undo-old"> {entry.oldPath.split('/').pop()}</div>
<div className="undo-new"> {entry.newPath.split('/').pop()}</div>
</div>
<div className="undo-ts">{new Date(entry.ts).toLocaleTimeString('ko-KR')}</div>
{!entry.reverted && (
<button className="secondary" style={{ fontSize: 11, padding: '3px 8px' }} onClick={() => revertEntry(entry)}>
</button>
)}
</div>
))}
</div>
</div>
);
}
// ── 일반 탭 ──
function GeneralTab() {
const [settings, setSettings] = useState<AppSettings | null>(null);
useEffect(() => {
api.settings.get().then(setSettings);
}, []);
const update = async (patch: Partial<AppSettings>) => {
await api.settings.update(patch);
setSettings((prev) => prev ? { ...prev, ...patch } : prev);
};
if (!settings) return <div className="empty-state"> </div>;
return (
<div>
<div className="section">
<div className="section-title"></div>
<div className="card">
<div className="card-row">
<div>
<label> </label>
<div className="row-desc"> </div>
</div>
<select
value={settings.defaultMode}
onChange={(e) => update({ defaultMode: e.target.value as 'auto' | 'manual' })}
>
<option value="auto"> </option>
<option value="manual"> </option>
</select>
</div>
<div className="card-row">
<label>macOS </label>
<input
type="checkbox"
checked={settings.notificationsEnabled}
onChange={(e) => update({ notificationsEnabled: e.target.checked })}
/>
</div>
<div className="card-row">
<div>
<label> ()</label>
<div className="row-desc"> </div>
</div>
<input
type="number"
min={5}
max={3600}
step={5}
disabled={!settings.notificationsEnabled}
value={settings.notificationIntervalSecs ?? 30}
onChange={(e) => {
const v = Math.max(5, Math.min(3600, Number(e.target.value)));
update({ notificationIntervalSecs: v });
}}
style={{ width: 70 }}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<title>NFD to NFC — 설정</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

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

166
src/renderer/styles.css Normal file
View File

@@ -0,0 +1,166 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13px;
color: #1d1d1f;
background: #f5f5f7;
-webkit-font-smoothing: antialiased;
}
/* ── 공통 ── */
button {
cursor: pointer;
border: none;
border-radius: 6px;
padding: 5px 12px;
font-size: 12px;
font-family: inherit;
background: #0071e3;
color: #fff;
transition: opacity 0.1s;
}
button:hover { opacity: 0.85; }
button:disabled { opacity: 0.4; cursor: default; }
button.secondary { background: #e5e5ea; color: #1d1d1f; }
button.danger { background: #ff3b30; }
input[type="checkbox"] { accent-color: #0071e3; }
input[type="text"], select {
border: 1px solid #d1d1d6;
border-radius: 6px;
padding: 5px 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
outline: none;
}
input[type="text"]:focus, select:focus { border-color: #0071e3; }
/* ── 팝오버 ── */
.popover {
display: flex;
flex-direction: column;
height: 100vh;
background: rgba(240,240,245,0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.popover-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 8px;
border-bottom: 1px solid rgba(0,0,0,0.08);
}
.popover-header h1 { font-size: 14px; font-weight: 600; }
.popover-actions { display: flex; gap: 6px; }
.dir-list { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 4px; }
.dir-row {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 8px;
padding: 8px 10px;
gap: 8px;
}
.dir-row .dir-info { flex: 1; overflow: hidden; }
.dir-row .dir-path {
font-size: 11px;
color: #8e8e93;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dir-row .dir-name { font-size: 13px; font-weight: 500; }
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.active { background: #34c759; }
.status-dot.paused { background: #ff9500; }
.status-dot.disabled { background: #c7c7cc; }
.activity-log {
padding: 8px 14px;
border-top: 1px solid rgba(0,0,0,0.08);
max-height: 110px;
overflow-y: auto;
}
.activity-log h3 { font-size: 11px; color: #8e8e93; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
.activity-item { font-size: 11px; color: #3c3c43; padding: 2px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-item.error { color: #ff3b30; }
.popover-footer {
display: flex;
justify-content: space-between;
padding: 8px 14px;
border-top: 1px solid rgba(0,0,0,0.08);
}
.empty-state { padding: 20px; text-align: center; color: #8e8e93; font-size: 12px; }
/* ── 설정 ── */
.settings { display: flex; flex-direction: column; height: 100vh; }
.settings-header {
padding: 16px 20px 12px;
border-bottom: 1px solid #e5e5ea;
background: #fff;
}
.settings-header h1 { font-size: 16px; font-weight: 600; }
.tabs { display: flex; gap: 0; border-bottom: 1px solid #e5e5ea; background: #fff; padding: 0 20px; }
.tab-btn {
background: none;
color: #8e8e93;
padding: 8px 14px;
border-radius: 0;
border-bottom: 2px solid transparent;
font-size: 13px;
margin-bottom: -1px;
}
.tab-btn.active { color: #0071e3; border-bottom-color: #0071e3; }
.tab-content { flex: 1; overflow-y: auto; padding: 16px 20px; }
.section { margin-bottom: 20px; }
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #8e8e93; margin-bottom: 8px; }
.card { background: #fff; border-radius: 10px; overflow: hidden; border: 1px solid #e5e5ea; }
.card-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #f2f2f7;
gap: 12px;
}
.card-row:last-child { border-bottom: none; }
.card-row label { font-size: 13px; color: #1d1d1f; }
.card-row .row-desc { font-size: 11px; color: #8e8e93; margin-top: 2px; }
.add-dir-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
.range-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.range-tag {
background: #e5e5ea;
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
}
.range-tag:hover { background: #ff3b30; color: #fff; }
.undo-list { display: flex; flex-direction: column; gap: 4px; }
.undo-item {
background: #fff;
border-radius: 8px;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid #e5e5ea;
}
.undo-item.reverted { opacity: 0.5; }
.undo-paths { flex: 1; overflow: hidden; }
.undo-old { font-size: 11px; color: #ff3b30; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.undo-new { font-size: 11px; color: #34c759; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.undo-ts { font-size: 10px; color: #8e8e93; flex-shrink: 0; }

12
tsconfig.cli.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "out",
"rootDir": "src",
"declaration": false,
"types": ["node"]
},
"include": ["src/core/**/*", "src/cli/**/*"]
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "."
},
"exclude": ["node_modules", "out", "dist", "dist-web", "src/renderer/**", "web/**"]
}

14
tsconfig.lib.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "out",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"types": ["node"]
},
"include": ["src/lib/**/*", "src/core/**/*"],
"exclude": ["node_modules", "out", "dist", "src/core/__tests__"]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2022"],
"module": "CommonJS",
"moduleResolution": "bundler",
"types": ["node"]
},
"include": ["src/main/**/*", "src/preload/**/*", "src/core/**/*", "electron.vite.config.ts"]
}

12
tsconfig.web.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["node"]
},
"include": ["src/renderer/**/*"]
}

9
vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/__tests__/**/*.test.ts'],
globals: true,
},
});

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>

1
web/public/CNAME Normal file
View File

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

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: '/',
plugins: [react()],
build: {
outDir: resolve(__dirname, '../dist-web'),
emptyOutDir: true,
target: 'es2022',
},
resolve: {
alias: {
'@core': resolve(__dirname, '../src'),
},
},
});