Compare commits
29 Commits
v1.0.0
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fa1bc04f4 | ||
| aa1b61cfb3 | |||
| 7209486a71 | |||
| 7889f8eb96 | |||
| 8db9e64302 | |||
| 2d7d586be7 | |||
| 17a7a617fd | |||
| 6f44af3e4f | |||
| 51f99856cf | |||
| e967a0fbdb | |||
| aa772f7f97 | |||
| 8f2af73ed4 | |||
| a469c666fe | |||
| 4cbc044d15 | |||
| 2e3a9d1402 | |||
| 343fe004fe | |||
| 952e74de2f | |||
| 6fe306e7b1 | |||
| 05ba9f3034 | |||
| 4b89a498f9 | |||
| 2d3167cffe | |||
| 46adced6c5 | |||
| 1441a5c0b4 | |||
| a24367c815 | |||
| 6601c40bb4 | |||
| 08f1de7ea0 | |||
| 3dff470044 | |||
| 4e92bb2690 | |||
| 8e67d25b3b |
17
.eslintrc.cjs
Normal 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
@@ -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
@@ -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 }}
|
||||||
65
.github/workflows/release.yml
vendored
@@ -4,59 +4,36 @@ on:
|
|||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build-and-release:
|
||||||
runs-on: macos-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
|
|
||||||
- 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 }}
|
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ github.event.release.upload_url }}
|
node-version: '20.x'
|
||||||
asset_path: ./nfd2nfc/dist/NFD2NFC-$(uname -s).zip
|
cache: 'npm'
|
||||||
asset_name: NFD2NFC-$(uname -s).zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
|
|
||||||
- name: Upload MacOS .app
|
- name: Install dependencies
|
||||||
uses: actions/upload-release-asset@v1
|
run: npm ci
|
||||||
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: Upload MacOS .dmg
|
- name: Build
|
||||||
uses: actions/upload-release-asset@v1
|
run: npm run build:cli && npm run build:lib && npm run build:app -- --publish never
|
||||||
env:
|
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:
|
with:
|
||||||
upload_url: ${{ github.event.release.upload_url }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
asset_path: ./MACOS-APP/dist/*.dmg
|
files: |
|
||||||
asset_name: MacApp.dmg
|
dist/*.dmg
|
||||||
asset_content_type: application/octet-stream
|
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -142,6 +142,7 @@ out
|
|||||||
# Nuxt.js build / generate output
|
# Nuxt.js build / generate output
|
||||||
.nuxt
|
.nuxt
|
||||||
dist
|
dist
|
||||||
|
dist-web
|
||||||
|
|
||||||
# Gatsby files
|
# Gatsby files
|
||||||
.cache/
|
.cache/
|
||||||
@@ -208,4 +209,5 @@ dist
|
|||||||
.history
|
.history
|
||||||
.ionide
|
.ionide
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/macos,git,visualstudiocode,node
|
# End of https://www.toptal.com/developers/gitignore/api/macos,git,visualstudiocode,node
|
||||||
|
.gstack/
|
||||||
|
|||||||
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 파일 이름 변환기
|
|
||||||
|
|
||||||
macos 에서 파일 이름을 NFD에서 NFC 인코딩으로 변환하는 패키지입니다.
|
|
||||||
|
|
||||||
## 목적
|
|
||||||
|
|
||||||
macos에서 한글 파일 이름을 사용할 때 NFD 인코딩으로 저장되는 문제를 해결하기 위해 개발되었습니다.
|
|
||||||
|
|
||||||
매번 파일 이름을 수동으로 변경하는 것은 번거롭기 때문에 자동으로 변환하는 프로그램을 개발하였습니다.
|
|
||||||
|
|
||||||
디렉토리를 지정하면 하위 디렉토리까지 변환하며, 백그라운드 실행을 지원합니다.
|
|
||||||
|
|
||||||
## 특징
|
|
||||||
|
|
||||||
- NFD에서 NFC 변환 지원
|
|
||||||
- 하위 디렉토리 변환 지원
|
|
||||||
- 백그라운드 실행 지원
|
|
||||||
|
|
||||||
## 설치
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 전역 설치
|
|
||||||
npm install -g @pieroot/nfd2nfc
|
|
||||||
# 또는 로컬 설치
|
|
||||||
npm i @pieroot/nfd2nfc
|
|
||||||
```
|
|
||||||
|
|
||||||
## 사용법
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 라이선스
|
|
||||||
|
|
||||||
MIT 라이선스
|
|
||||||
|
Before Width: | Height: | Size: 882 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
2750
MACOS-APP/package-lock.json
generated
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
117
README.md
@@ -1,83 +1,116 @@
|
|||||||
# NFD to NFC Normalizer
|
# 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
|
> **Breaking Change (2.0.0)**: 단일 패키지 구조로 통합됐습니다. `MACOS-APP/`, `nfd2nfc/` 서브 패키지 구조는 제거됐습니다. CLI 명령이 `nfd2nfc file <path>` / `nfd2nfc dir <path>` 형태로 변경됐습니다.
|
||||||
npm install @pieroot/nfd2nfc
|
|
||||||
```
|
## 주요 기능
|
||||||
|
|
||||||
|
- **실시간 감시**: 선택한 디렉토리를 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 애플리케이션
|
||||||
|
|
||||||
`MACOS-APP` 디렉토리에서 빌드하여 설치하거나 [릴리즈 페이지]()에서 다운로드합니다.
|
[릴리즈 페이지](https://github.com/jung-geun/NFD2NFC/releases)에서 `NFD2NFC-arm64.dmg` 또는 `NFD2NFC-x64.dmg`를 다운로드하거나 직접 빌드합니다.
|
||||||
|
|
||||||
### Node.js 패키지
|
### CLI (글로벌 설치)
|
||||||
|
|
||||||
npm을 통해 패키지를 설치합니다:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 지역적으로 설치
|
|
||||||
npm install @pieroot/nfd2nfc
|
|
||||||
|
|
||||||
# 글로벌로 설치
|
|
||||||
npm install -g @pieroot/nfd2nfc
|
npm install -g @pieroot/nfd2nfc
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Node.js 라이브러리
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @pieroot/nfd2nfc
|
||||||
|
```
|
||||||
|
|
||||||
## 사용법
|
## 사용법
|
||||||
|
|
||||||
### macOS 애플리케이션
|
### macOS 애플리케이션
|
||||||
|
|
||||||
애플리케이션을 실행하여 원하는 기능을 사용합니다.
|
앱 실행 후 메뉴바 아이콘을 좌클릭하면 팝오버가 표시됩니다. 감시 중인 디렉토리 목록과 최근 활동, Undo 버튼을 한눈에 확인할 수 있습니다.
|
||||||
|
|
||||||

|

|
||||||
디렉토리 선택 버튼을 클릭하여 디렉토리를 선택합니다.
|
|
||||||
|
|
||||||

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

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

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

|
||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
CLI를 사용하여 변환할 문자열을 입력합니다:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nfd2nfc [options] <path>
|
# 디렉토리 변환 (재귀)
|
||||||
|
nfd2nfc dir <path> --recursive
|
||||||
|
|
||||||
# 옵션
|
# 미리보기 (실제 변환 없음)
|
||||||
nfd2nfc -h # 도움말
|
nfd2nfc dir <path> --dry-run
|
||||||
nfd2nfc -v # verbose 모드
|
|
||||||
|
# 단일 파일 변환
|
||||||
|
nfd2nfc file <path>
|
||||||
|
|
||||||
|
# 경로 자동 감지 (파일/디렉토리 판별 후 처리)
|
||||||
|
nfd2nfc <path>
|
||||||
|
|
||||||
|
# 도움말
|
||||||
|
nfd2nfc --help
|
||||||
```
|
```
|
||||||
|
|
||||||
### Node.js 패키지
|
### Node.js 라이브러리
|
||||||
|
|
||||||
패키지를 불러와서 사용합니다:
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const nfd2nfc = require("@pieroot/nfd2nfc");
|
const nfd2nfc = require("@pieroot/nfd2nfc");
|
||||||
|
|
||||||
let str_nfc = nfd2nfc.normalizeToNFC("NFD로 인코딩된 문자열");
|
// v1 호환 — 단순 문자열 정규화
|
||||||
let str_nfd = nfd2nfc.normalizeToNFD("NFC로 인코딩된 문자열");
|
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
|
||||||
|
npm install
|
||||||
|
|
||||||
```bash
|
# 개발 모드
|
||||||
cd MACOS-APP
|
npm run dev
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
- Node.js 패키지는 `nfd2nfc`에서 빌드 가능하며, 다음 명령어로 설치할 수 있습니다:
|
# 전체 빌드 (앱 + CLI + 라이브러리 + DMG)
|
||||||
|
npm run build
|
||||||
|
|
||||||
```bash
|
# 앱만 빌드
|
||||||
cd nfd2nfc
|
npm run build:app
|
||||||
npm install
|
|
||||||
npm run build
|
# CLI만 빌드
|
||||||
```
|
npm run build:cli
|
||||||
|
```
|
||||||
|
|
||||||
## 기여
|
## 기여
|
||||||
|
|
||||||
|
|||||||
BIN
assets/manubar.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/menubar-setting.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 80 KiB |
BIN
assets/setting-directory.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
assets/setting-general.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 107 KiB |
38
electron.vite.config.ts
Normal 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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 라이선스
|
|
||||||
143
nfd2nfc/index.js
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
91
nfd2nfc/package-lock.json
generated
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
7478
package-lock.json
generated
Normal file
116
package.json
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"name": "@pieroot/nfd2nfc",
|
||||||
|
"version": "2.0.3",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 917 KiB After Width: | Height: | Size: 917 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
19
run.sh
@@ -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
@@ -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}개 처리)`);
|
||||||
|
}
|
||||||
53
src/core/__tests__/filter.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/core/__tests__/normalizer.test.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/core/__tests__/scanner.test.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||||
103
src/main/__tests__/watcher.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('chokidar');
|
||||||
|
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }));
|
||||||
|
|
||||||
|
const mockAppendUndoEntries = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mock('../store', () => ({ appendUndoEntries: mockAppendUndoEntries }));
|
||||||
|
|
||||||
|
const mockQueueRenamedNotification = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mock('../notifier', () => ({
|
||||||
|
queueRenamedNotification: mockQueueRenamedNotification,
|
||||||
|
notifyManualQueue: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../nanoid', () => ({ nanoid: () => 'test-id' }));
|
||||||
|
|
||||||
|
const mockNormalizeEntry = vi.fn();
|
||||||
|
vi.mock('../../core/normalizer', () => ({ normalizeEntry: mockNormalizeEntry }));
|
||||||
|
|
||||||
|
import chokidar from 'chokidar';
|
||||||
|
import type { WatchedDir } from '../../core/types';
|
||||||
|
|
||||||
|
// 테스트마다 고유 경로를 사용해 모듈 레벨 recentlyRenamed 상태 충돌 방지
|
||||||
|
function makePaths(slug: string) {
|
||||||
|
const nfc = `/test/watch/${slug}.txt`;
|
||||||
|
return { nfc, nfd: nfc.normalize('NFD') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDir(id: string): WatchedDir {
|
||||||
|
return { id, path: '/test/watch', recursive: false, mode: 'auto', enabled: true, customRanges: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('watcher — in-flight race condition', () => {
|
||||||
|
let mockWatcher: EventEmitter & { close: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockWatcher = Object.assign(new EventEmitter(), { close: vi.fn().mockResolvedValue(undefined) });
|
||||||
|
vi.mocked(chokidar.watch).mockReturnValue(mockWatcher as unknown as ReturnType<typeof chokidar.watch>);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const { stopDir } = await import('../watcher');
|
||||||
|
// 각 테스트에서 사용한 id 정리
|
||||||
|
for (const id of ['dir-burst', 'dir-nfc-reentry', 'dir-nfd-reentry']) {
|
||||||
|
await stopDir(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('동일 NFD 경로에 burst add 이벤트가 와도 normalizeEntry가 1번만 호출된다', async () => {
|
||||||
|
const { nfc, nfd } = makePaths('강의자료-burst');
|
||||||
|
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
|
||||||
|
|
||||||
|
const { startDir } = await import('../watcher');
|
||||||
|
await startDir(makeDir('dir-burst'));
|
||||||
|
|
||||||
|
// 같은 경로를 동기적으로 두 번 emit — race 시나리오 재현
|
||||||
|
mockWatcher.emit('add', nfd);
|
||||||
|
mockWatcher.emit('add', nfd);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockAppendUndoEntries).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rename 완료 후 NFC 경로로 다시 add 이벤트가 와도 recentlyRenamed 가드에 막힌다', async () => {
|
||||||
|
const { nfc, nfd } = makePaths('강의자료-nfc-reentry');
|
||||||
|
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
|
||||||
|
|
||||||
|
const { startDir } = await import('../watcher');
|
||||||
|
await startDir(makeDir('dir-nfc-reentry'));
|
||||||
|
|
||||||
|
mockWatcher.emit('add', nfd);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// RENAME_TTL(2000ms) 이내에 NFC 경로로 재진입 — 가드에 막혀야 함
|
||||||
|
mockWatcher.emit('add', nfc);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(mockNormalizeEntry).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rename 완료 후 NFD 경로로 재진입해도 recentlyRenamed 가드에 막힌다', async () => {
|
||||||
|
const { nfc, nfd } = makePaths('강의자료-nfd-reentry');
|
||||||
|
mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc });
|
||||||
|
|
||||||
|
const { startDir } = await import('../watcher');
|
||||||
|
await startDir(makeDir('dir-nfd-reentry'));
|
||||||
|
|
||||||
|
mockWatcher.emit('add', nfd);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(mockNormalizeEntry).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockWatcher.emit('add', nfd);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(mockNormalizeEntry).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/main/index.ts
Normal 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
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
export function nanoid(): string {
|
||||||
|
return randomBytes(12).toString('base64url');
|
||||||
|
}
|
||||||
68
src/main/notifier.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/main/settings-window.ts
Normal 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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
178
src/main/watcher.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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 inFlight = new Set<string>();
|
||||||
|
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: /(^|[/\\])\../,
|
||||||
|
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handlePath(filePath: string, type: 'file' | 'directory'): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = filePath.normalize('NFC');
|
||||||
|
|
||||||
|
// 동일 경로가 이미 처리 중이면 즉시 return (burst add 이벤트로 인한 race 차단)
|
||||||
|
if (inFlight.has(key)) return;
|
||||||
|
const lastRenamed = recentlyRenamed.get(key);
|
||||||
|
if (lastRenamed && now - lastRenamed < RENAME_TTL) return;
|
||||||
|
|
||||||
|
const basename = filePath.split('/').pop() ?? '';
|
||||||
|
if (!shouldNormalize(basename, filterOpts)) return;
|
||||||
|
|
||||||
|
inFlight.add(key);
|
||||||
|
try {
|
||||||
|
if (dir.mode === 'auto') {
|
||||||
|
const result = await normalizeEntry(filePath, type, filterOpts);
|
||||||
|
if (result.status === 'renamed' || result.status === 'noop-same-inode') {
|
||||||
|
// oldPath와 newPath 모두 등록 — chokidar가 NFD/NFC 변형 중 어느 쪽으로 이벤트를 보내도 막힘
|
||||||
|
recentlyRenamed.set(result.oldPath.normalize('NFC'), Date.now());
|
||||||
|
recentlyRenamed.set(result.newPath.normalize('NFC'), Date.now());
|
||||||
|
if (result.status === 'renamed') {
|
||||||
|
await store.appendUndoEntries([
|
||||||
|
{ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
emit({ type: 'rename', ts: now, dirId: dir.id, message: `${result.oldPath} → ${result.newPath}`, result });
|
||||||
|
// 알림은 배치로 처리 — 인터벌마다 총 건수 발송
|
||||||
|
await notifier.queueRenamedNotification(dir.path);
|
||||||
|
} else if (result.status === 'collision') {
|
||||||
|
emit({ type: 'collision', ts: now, dirId: dir.id, message: `충돌: ${result.oldPath}`, result });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.oldPath.normalize('NFC'), Date.now());
|
||||||
|
recentlyRenamed.set(result.newPath.normalize('NFC'), 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
@@ -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'),
|
||||||
|
},
|
||||||
|
});
|
||||||
113
src/renderer/popover/Popover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/renderer/popover/index.html
Normal 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>
|
||||||
10
src/renderer/popover/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
307
src/renderer/settings/Settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/renderer/settings/index.html
Normal 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>
|
||||||
10
src/renderer/settings/main.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
nfd2nfc.pieroot.xyz
|
||||||
349
web/src/App.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { useCallback, useReducer, type DragEvent } from 'react';
|
||||||
|
import {
|
||||||
|
collectFromDataTransfer,
|
||||||
|
collectFromFileList,
|
||||||
|
supportsDirectoryDrop,
|
||||||
|
type CollectResult,
|
||||||
|
} from './lib/collectFiles';
|
||||||
|
import { buildZip, downloadBlob, type BuildProgress } from './lib/buildZip';
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { kind: 'idle'; dragActive: boolean; error?: string }
|
||||||
|
| { kind: 'reading' }
|
||||||
|
| {
|
||||||
|
kind: 'processing';
|
||||||
|
collected: CollectResult;
|
||||||
|
progress: BuildProgress;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'ready';
|
||||||
|
collected: CollectResult;
|
||||||
|
blob?: Blob;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: 'drag'; active: boolean }
|
||||||
|
| { type: 'reading' }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
| { type: 'processing-start'; collected: CollectResult }
|
||||||
|
| { type: 'progress'; progress: BuildProgress }
|
||||||
|
| { type: 'ready'; collected: CollectResult; blob: Blob; filename: string }
|
||||||
|
| { type: 'ready-empty'; collected: CollectResult; filename: string }
|
||||||
|
| { type: 'reset' };
|
||||||
|
|
||||||
|
const initial: State = { kind: 'idle', dragActive: false };
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'drag':
|
||||||
|
if (state.kind !== 'idle') return state;
|
||||||
|
return { ...state, dragActive: action.active };
|
||||||
|
case 'reading':
|
||||||
|
return { kind: 'reading' };
|
||||||
|
case 'error':
|
||||||
|
return { kind: 'idle', dragActive: false, error: action.message };
|
||||||
|
case 'processing-start':
|
||||||
|
return {
|
||||||
|
kind: 'processing',
|
||||||
|
collected: action.collected,
|
||||||
|
progress: { done: 0, total: action.collected.files.length },
|
||||||
|
};
|
||||||
|
case 'progress':
|
||||||
|
if (state.kind !== 'processing') return state;
|
||||||
|
return { ...state, progress: action.progress };
|
||||||
|
case 'ready':
|
||||||
|
return {
|
||||||
|
kind: 'ready',
|
||||||
|
collected: action.collected,
|
||||||
|
blob: action.blob,
|
||||||
|
filename: action.filename,
|
||||||
|
};
|
||||||
|
case 'ready-empty':
|
||||||
|
return {
|
||||||
|
kind: 'ready',
|
||||||
|
collected: action.collected,
|
||||||
|
filename: action.filename,
|
||||||
|
};
|
||||||
|
case 'reset':
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(
|
||||||
|
d.getMinutes(),
|
||||||
|
)}${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initial);
|
||||||
|
|
||||||
|
const process = useCallback(async (collected: CollectResult) => {
|
||||||
|
const filename = `nfc-${timestamp()}.zip`;
|
||||||
|
if (collected.files.length === 0) {
|
||||||
|
dispatch({ type: 'ready-empty', collected, filename });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch({ type: 'processing-start', collected });
|
||||||
|
try {
|
||||||
|
const blob = await buildZip(collected.files, (progress) =>
|
||||||
|
dispatch({ type: 'progress', progress }),
|
||||||
|
);
|
||||||
|
dispatch({ type: 'ready', collected, blob, filename });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({
|
||||||
|
type: 'error',
|
||||||
|
message: e instanceof Error ? e.message : 'ZIP 생성 중 알 수 없는 오류가 발생했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
async (ev: DragEvent<HTMLLabelElement>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (state.kind !== 'idle') return;
|
||||||
|
|
||||||
|
if (!supportsDirectoryDrop(ev.dataTransfer.items)) {
|
||||||
|
dispatch({
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
'이 브라우저는 폴더 드롭을 완전히 지원하지 않습니다. 최신 Chrome / Edge / Safari 16.4+ 를 사용해주세요.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({ type: 'reading' });
|
||||||
|
try {
|
||||||
|
const collected = await collectFromDataTransfer(ev.dataTransfer);
|
||||||
|
await process(collected);
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({
|
||||||
|
type: 'error',
|
||||||
|
message: e instanceof Error ? e.message : '파일을 읽는 중 오류가 발생했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[process, state.kind],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileInput = useCallback(
|
||||||
|
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const fl = ev.target.files;
|
||||||
|
if (!fl || fl.length === 0) return;
|
||||||
|
dispatch({ type: 'reading' });
|
||||||
|
try {
|
||||||
|
const collected = collectFromFileList(fl);
|
||||||
|
await process(collected);
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({
|
||||||
|
type: 'error',
|
||||||
|
message: e instanceof Error ? e.message : '파일을 읽는 중 오류가 발생했습니다.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// 같은 파일을 다시 선택할 수 있게 input 초기화.
|
||||||
|
ev.target.value = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[process],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragOver = (ev: DragEvent<HTMLLabelElement>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (state.kind === 'idle') dispatch({ type: 'drag', active: true });
|
||||||
|
};
|
||||||
|
const onDragLeave = (ev: DragEvent<HTMLLabelElement>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (state.kind === 'idle') dispatch({ type: 'drag', active: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header>
|
||||||
|
<h1>NFD → NFC 파일명 변환기</h1>
|
||||||
|
<p>
|
||||||
|
macOS 에서 만들어진 ZIP 이나 폴더의 한글 파일명이 Windows / Linux 에서 깨져 보이는 문제를
|
||||||
|
해결합니다. 파일이나 폴더를 아래 영역에 끌어다 놓으면 NFC 로 정규화된 ZIP 을 다운로드할 수
|
||||||
|
있습니다. 업로드되는 파일은 서버로 전송되지 않고 브라우저 안에서만 처리됩니다.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{state.kind === 'idle' && (
|
||||||
|
<IdleView
|
||||||
|
dragActive={state.dragActive}
|
||||||
|
error={state.error}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onFileInput={handleFileInput}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{state.kind === 'reading' && <ReadingView />}
|
||||||
|
{state.kind === 'processing' && (
|
||||||
|
<ProcessingView collected={state.collected} progress={state.progress} />
|
||||||
|
)}
|
||||||
|
{state.kind === 'ready' && (
|
||||||
|
<ReadyView
|
||||||
|
collected={state.collected}
|
||||||
|
blob={state.blob}
|
||||||
|
filename={state.filename}
|
||||||
|
onReset={() => dispatch({ type: 'reset' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>
|
||||||
|
모든 변환은 브라우저에서 로컬로 수행됩니다. 큰 폴더(약 2GB 이상)는 메모리 한계로 실패할
|
||||||
|
수 있습니다.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IdleView(props: {
|
||||||
|
dragActive: boolean;
|
||||||
|
error?: string;
|
||||||
|
onDrop: (ev: DragEvent<HTMLLabelElement>) => void;
|
||||||
|
onDragOver: (ev: DragEvent<HTMLLabelElement>) => void;
|
||||||
|
onDragLeave: (ev: DragEvent<HTMLLabelElement>) => void;
|
||||||
|
onFileInput: (ev: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label
|
||||||
|
className={'dropzone' + (props.dragActive ? ' active' : '')}
|
||||||
|
onDrop={props.onDrop}
|
||||||
|
onDragOver={props.onDragOver}
|
||||||
|
onDragLeave={props.onDragLeave}
|
||||||
|
>
|
||||||
|
<h2>여기에 파일이나 폴더를 끌어다 놓으세요</h2>
|
||||||
|
<p>
|
||||||
|
또는 클릭하여 폴더를 선택할 수 있습니다.
|
||||||
|
<br />
|
||||||
|
드롭한 파일은 NFC 이름으로 정규화되어 하나의 ZIP 으로 묶입니다.
|
||||||
|
</p>
|
||||||
|
<p className="hint">한글이 없는 파일은 이름을 그대로 유지합니다.</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
/* @ts-expect-error — webkitdirectory 는 React 타입 정의가 누락된 비표준 속성. */
|
||||||
|
webkitdirectory=""
|
||||||
|
directory=""
|
||||||
|
multiple
|
||||||
|
onChange={props.onFileInput}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{props.error && <div className="error">{props.error}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadingView() {
|
||||||
|
return (
|
||||||
|
<div className="status">
|
||||||
|
<h2>파일 목록을 읽는 중…</h2>
|
||||||
|
<p className="progress-label">큰 폴더는 시간이 걸릴 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProcessingView(props: {
|
||||||
|
collected: CollectResult;
|
||||||
|
progress: BuildProgress;
|
||||||
|
}) {
|
||||||
|
const pct =
|
||||||
|
props.progress.total === 0 ? 0 : Math.round((props.progress.done / props.progress.total) * 100);
|
||||||
|
return (
|
||||||
|
<div className="status">
|
||||||
|
<h2>ZIP 으로 묶는 중…</h2>
|
||||||
|
<div className="progress-track">
|
||||||
|
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="progress-label">
|
||||||
|
{props.progress.done.toLocaleString()} / {props.progress.total.toLocaleString()} 파일
|
||||||
|
{' · '}
|
||||||
|
{pct}%
|
||||||
|
</div>
|
||||||
|
<div className="stats">
|
||||||
|
<span>
|
||||||
|
<strong>{props.collected.normalizedCount.toLocaleString()}</strong>변환 대상
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{props.collected.passthroughCount.toLocaleString()}</strong>그대로 유지
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadyView(props: {
|
||||||
|
collected: CollectResult;
|
||||||
|
blob?: Blob;
|
||||||
|
filename: string;
|
||||||
|
onReset: () => void;
|
||||||
|
}) {
|
||||||
|
const { collected, blob, filename, onReset } = props;
|
||||||
|
const hasFiles = collected.files.length > 0;
|
||||||
|
const hasNormalizable = collected.normalizedCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="status">
|
||||||
|
<h2>
|
||||||
|
{hasNormalizable ? '완료되었습니다.' : '변환할 파일이 없습니다.'}
|
||||||
|
</h2>
|
||||||
|
<div className="stats">
|
||||||
|
<span>
|
||||||
|
<strong>{collected.normalizedCount.toLocaleString()}</strong>이름 변경
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{collected.passthroughCount.toLocaleString()}</strong>그대로 유지
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasFiles && (
|
||||||
|
<div className="warn">
|
||||||
|
드롭한 항목에서 처리할 파일을 찾지 못했습니다. 폴더가 비어있거나 형식이 지원되지 않을 수
|
||||||
|
있습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFiles && !hasNormalizable && (
|
||||||
|
<div className="success">
|
||||||
|
모든 파일명이 이미 NFC 입니다. 별도의 변환이 필요하지 않습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collected.collisions.length > 0 && (
|
||||||
|
<div className="warn">
|
||||||
|
정규화 후 같은 이름이 된 파일 {collected.collisions.length} 개를 자동으로 <code>-2</code>,
|
||||||
|
<code>-3</code> ... 형태로 이름을 변경했습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
{blob && hasFiles && (
|
||||||
|
<button className="primary" onClick={() => downloadBlob(blob, filename)}>
|
||||||
|
ZIP 다운로드 ({formatSize(blob.size)})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="link" onClick={onReset}>
|
||||||
|
다시 시작
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
94
web/src/lib/buildZip.ts
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||