Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
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:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: macos-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
- name: Install dependencies and build nfd2nfc
|
||||
run: |
|
||||
cd nfd2nfc
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Install dependencies and build MACOS-APP
|
||||
run: |
|
||||
cd MACOS-APP
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Upload NFD2NFC executable
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./nfd2nfc/dist/NFD2NFC-$(uname -s).zip
|
||||
asset_name: NFD2NFC-$(uname -s).zip
|
||||
asset_content_type: application/zip
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Upload MacOS .app
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./MACOS-APP/dist/*.app.zip
|
||||
asset_name: MacApp.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Upload MacOS .dmg
|
||||
uses: actions/upload-release-asset@v1
|
||||
- name: Build
|
||||
run: npm run build:cli && npm run build:lib && npm run build:app -- --publish never
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
|
||||
- name: List dist
|
||||
run: ls -lh dist/
|
||||
|
||||
- name: Upload DMG assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./MACOS-APP/dist/*.dmg
|
||||
asset_name: MacApp.dmg
|
||||
asset_content_type: application/octet-stream
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: |
|
||||
dist/*.dmg
|
||||
|
||||
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
|
||||
100
README.md
@@ -1,82 +1,106 @@
|
||||
# NFD to NFC Normalizer
|
||||
|
||||
이 애플리케이션은 백그라운드에서 선택한 디렉토리를 감시하고, NFD로 인코딩된 파일 이름을 자동으로 NFC 인코딩으로 변환합니다.
|
||||
macOS에서 한글 파일명이 NFD(자모 분해)로 저장되는 문제를 실시간으로 해결하는 트레이 앱 + CLI + Node.js 라이브러리 통합 패키지입니다.
|
||||
|
||||
Nomalize는 macOS 용 애플리케이션과 Node.js 패키지로 제공됩니다. macOS 앱은 `MACOS-APP`에서 빌드 가능하며, Node.js 패키지는 `nfd2nfc`에서 빌드 가능하고 다음 명령어로 설치할 수 있습니다:
|
||||
> **Breaking Change (2.0.0)**: 단일 패키지 구조로 통합됐습니다. `MACOS-APP/`, `nfd2nfc/` 서브 패키지 구조는 제거됐습니다. CLI 명령이 `nfd2nfc file <path>` / `nfd2nfc dir <path>` 형태로 변경됐습니다.
|
||||
|
||||
```bash
|
||||
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-APP` 디렉토리에서 빌드하여 설치하거나 [릴리즈 페이지]()에서 다운로드합니다.
|
||||
[릴리즈 페이지](https://github.com/jung-geun/NFD2NFC/releases)에서 `NFD2NFC-arm64.dmg` 또는 `NFD2NFC-x64.dmg`를 다운로드하거나 직접 빌드합니다.
|
||||
|
||||
### Node.js 패키지
|
||||
|
||||
npm을 통해 패키지를 설치합니다:
|
||||
### CLI (글로벌 설치)
|
||||
|
||||
```bash
|
||||
# 지역적으로 설치
|
||||
npm install @pieroot/nfd2nfc
|
||||
|
||||
# 글로벌로 설치
|
||||
npm install -g @pieroot/nfd2nfc
|
||||
```
|
||||
|
||||
### Node.js 라이브러리
|
||||
|
||||
```bash
|
||||
npm install @pieroot/nfd2nfc
|
||||
```
|
||||
|
||||
## 사용법
|
||||
|
||||
### macOS 애플리케이션
|
||||
|
||||
애플리케이션을 실행하여 원하는 기능을 사용합니다.
|
||||
|
||||
<!-- TODO: 새 트레이 UI 스크린샷 교체 -->
|
||||

|
||||
디렉토리 선택 버튼을 클릭하여 디렉토리를 선택합니다.
|
||||
|
||||
앱 실행 후 메뉴바 아이콘을 클릭하면 팝오버가 표시됩니다. 팝오버에서 디렉토리를 추가하거나 설정창을 열 수 있습니다.
|
||||
|
||||

|
||||
감시할 디렉토리를 선택합니다.
|
||||
|
||||
감시할 디렉토리를 선택합니다. 추가된 디렉토리는 앱 재시작 시에도 유지됩니다.
|
||||
|
||||
### CLI
|
||||
|
||||
CLI를 사용하여 변환할 문자열을 입력합니다:
|
||||
|
||||
```bash
|
||||
nfd2nfc [options] <path>
|
||||
# 디렉토리 변환 (재귀)
|
||||
nfd2nfc dir <path> --recursive
|
||||
|
||||
# 옵션
|
||||
nfd2nfc -h # 도움말
|
||||
nfd2nfc -v # verbose 모드
|
||||
# 미리보기 (실제 변환 없음)
|
||||
nfd2nfc dir <path> --dry-run
|
||||
|
||||
# 단일 파일 변환
|
||||
nfd2nfc file <path>
|
||||
|
||||
# 경로 자동 감지 (파일/디렉토리 판별 후 처리)
|
||||
nfd2nfc <path>
|
||||
|
||||
# 도움말
|
||||
nfd2nfc --help
|
||||
```
|
||||
|
||||
### Node.js 패키지
|
||||
|
||||
패키지를 불러와서 사용합니다:
|
||||
### Node.js 라이브러리
|
||||
|
||||
```javascript
|
||||
const nfd2nfc = require("@pieroot/nfd2nfc");
|
||||
|
||||
let str_nfc = nfd2nfc.normalizeToNFC("NFD로 인코딩된 문자열");
|
||||
let str_nfd = nfd2nfc.normalizeToNFD("NFC로 인코딩된 문자열");
|
||||
// v1 호환 — 단순 문자열 정규화
|
||||
const nfc = nfd2nfc.normalizeToNFC("NFD로 인코딩된 문자열");
|
||||
const nfd = nfd2nfc.normalizeToNFD("NFC로 인코딩된 문자열");
|
||||
|
||||
// v2 신규 — 파일 시스템 API
|
||||
const { normalizeEntry, scan, shouldNormalize } = require("@pieroot/nfd2nfc");
|
||||
|
||||
// 단일 파일/디렉토리 rename (APFS inode 처리 포함)
|
||||
const result = await normalizeEntry("/path/to/file", "file");
|
||||
// result.status: 'renamed' | 'skipped' | 'noop-same-inode' | 'collision'
|
||||
|
||||
// 디렉토리 재귀 스캔 (깊이 역순 정렬)
|
||||
const entries = await scan("/path/to/dir", true);
|
||||
```
|
||||
|
||||
## 빌드 방법
|
||||
|
||||
- macOS 애플리케이션은 `MACOS-APP`에서 빌드할 수 있습니다.
|
||||
## 빌드
|
||||
|
||||
```bash
|
||||
cd MACOS-APP
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
- Node.js 패키지는 `nfd2nfc`에서 빌드 가능하며, 다음 명령어로 설치할 수 있습니다:
|
||||
# 개발 모드
|
||||
npm run dev
|
||||
|
||||
```bash
|
||||
cd nfd2nfc
|
||||
npm install
|
||||
# 전체 빌드 (앱 + CLI + 라이브러리 + DMG)
|
||||
npm run build
|
||||
|
||||
# 앱만 빌드
|
||||
npm run build:app
|
||||
|
||||
# CLI만 빌드
|
||||
npm run build:cli
|
||||
```
|
||||
|
||||
## 기여
|
||||
|
||||
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"
|
||||
}
|
||||
7727
package-lock.json
generated
Normal file
109
package.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"name": "@pieroot/nfd2nfc",
|
||||
"version": "2.0.1",
|
||||
"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",
|
||||
"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",
|
||||
"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": "^4.3.4",
|
||||
"electron": "^35.1.5",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"prettier": "^3.2.5",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0",
|
||||
"vitest": "^1.6.1"
|
||||
},
|
||||
"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';
|
||||
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;
|
||||
}
|
||||
164
src/main/watcher.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import fs from 'fs/promises';
|
||||
import chokidar, { FSWatcher } from 'chokidar';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { nanoid } from './nanoid';
|
||||
import { shouldNormalize } from '../core/filter';
|
||||
import { normalizeEntry } from '../core/normalizer';
|
||||
import type { WatchedDir, ActivityEvent, RenameResult } from '../core/types';
|
||||
import * as store from './store';
|
||||
import * as notifier from './notifier';
|
||||
|
||||
type ActivityListener = (event: ActivityEvent) => void;
|
||||
|
||||
const watchers = new Map<string, FSWatcher>();
|
||||
const recentlyRenamed = new Map<string, number>();
|
||||
const RENAME_TTL = 2000;
|
||||
const listeners = new Set<ActivityListener>();
|
||||
const manualQueue = new Map<string, string[]>();
|
||||
|
||||
// 전역 일시정지 상태 — 단일 진실의 원천
|
||||
let globallyPaused = false;
|
||||
|
||||
export function isGloballyPaused(): boolean {
|
||||
return globallyPaused;
|
||||
}
|
||||
|
||||
export function onActivity(listener: ActivityListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function emit(event: ActivityEvent): void {
|
||||
for (const l of listeners) l(event);
|
||||
BrowserWindow.getAllWindows().forEach((w) => {
|
||||
if (!w.isDestroyed()) w.webContents.send('watcher:activity', event);
|
||||
});
|
||||
}
|
||||
|
||||
// 일시정지 상태 변경을 렌더러에 브로드캐스트
|
||||
function emitPauseState(): void {
|
||||
BrowserWindow.getAllWindows().forEach((w) => {
|
||||
if (!w.isDestroyed()) w.webContents.send('watcher:paused', globallyPaused);
|
||||
});
|
||||
}
|
||||
|
||||
export async function startDir(dir: WatchedDir): Promise<void> {
|
||||
if (watchers.has(dir.id)) return;
|
||||
|
||||
const filterOpts = { customRanges: dir.customRanges };
|
||||
const w = chokidar.watch(dir.path, {
|
||||
ignoreInitial: false,
|
||||
persistent: true,
|
||||
depth: dir.recursive ? undefined : 0,
|
||||
ignored: /(^|[/\\])\../,
|
||||
});
|
||||
|
||||
async function handlePath(filePath: string, type: 'file' | 'directory'): Promise<void> {
|
||||
const now = Date.now();
|
||||
const lastRenamed = recentlyRenamed.get(filePath);
|
||||
if (lastRenamed && now - lastRenamed < RENAME_TTL) return;
|
||||
|
||||
const basename = filePath.split('/').pop() ?? '';
|
||||
if (!shouldNormalize(basename, filterOpts)) return;
|
||||
|
||||
if (dir.mode === 'auto') {
|
||||
const result = await normalizeEntry(filePath, type, filterOpts);
|
||||
if (result.status === 'renamed' || result.status === 'noop-same-inode') {
|
||||
recentlyRenamed.set(result.newPath, Date.now());
|
||||
if (result.status === 'renamed') {
|
||||
await store.appendUndoEntries([
|
||||
{ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false },
|
||||
]);
|
||||
}
|
||||
emit({ type: 'rename', ts: now, dirId: dir.id, message: `${result.oldPath} → ${result.newPath}`, result });
|
||||
// 알림은 배치로 처리 — 인터벌마다 총 건수 발송
|
||||
await notifier.queueRenamedNotification(dir.path);
|
||||
} else if (result.status === 'collision') {
|
||||
emit({ type: 'collision', ts: now, dirId: dir.id, message: `충돌: ${result.oldPath}`, result });
|
||||
}
|
||||
} else {
|
||||
const queue = manualQueue.get(dir.id) ?? [];
|
||||
if (!queue.includes(filePath)) {
|
||||
queue.push(filePath);
|
||||
manualQueue.set(dir.id, queue);
|
||||
emit({ type: 'info', ts: now, dirId: dir.id, message: `수동 대기: ${filePath}` });
|
||||
}
|
||||
await notifier.notifyManualQueue(queue.length, dir.path);
|
||||
}
|
||||
}
|
||||
|
||||
w.on('add', (p) => handlePath(p, 'file').catch(console.error))
|
||||
.on('addDir', (p) => {
|
||||
if (p === dir.path) return;
|
||||
handlePath(p, 'directory').catch(console.error);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
emit({ type: 'error', ts: Date.now(), dirId: dir.id, message: String(err) });
|
||||
});
|
||||
|
||||
watchers.set(dir.id, w);
|
||||
}
|
||||
|
||||
export async function stopDir(dirId: string): Promise<void> {
|
||||
const w = watchers.get(dirId);
|
||||
if (!w) return;
|
||||
await w.close();
|
||||
watchers.delete(dirId);
|
||||
manualQueue.delete(dirId);
|
||||
}
|
||||
|
||||
/** 전역 일시정지: 모든 감시 중단 + 상태 플래그 변경 */
|
||||
export async function pauseAll(): Promise<void> {
|
||||
globallyPaused = true;
|
||||
for (const [id] of [...watchers]) await stopDir(id);
|
||||
emitPauseState();
|
||||
}
|
||||
|
||||
/** 전역 재개: 지정된 dirs로 감시 재시작 + 상태 플래그 변경 */
|
||||
export async function resumeAll(dirs: WatchedDir[]): Promise<void> {
|
||||
globallyPaused = false;
|
||||
for (const d of dirs.filter((x) => x.enabled)) {
|
||||
await startDir(d);
|
||||
}
|
||||
emitPauseState();
|
||||
}
|
||||
|
||||
/** 앱 종료용 정리 — 일시정지 플래그 변경 없음 */
|
||||
export async function stopAll(): Promise<void> {
|
||||
for (const [id] of [...watchers]) await stopDir(id);
|
||||
}
|
||||
|
||||
export function getPendingQueue(dirId: string): string[] {
|
||||
return manualQueue.get(dirId) ?? [];
|
||||
}
|
||||
|
||||
export async function applyManualQueue(dirId: string, dir: WatchedDir): Promise<RenameResult[]> {
|
||||
const queue = manualQueue.get(dirId) ?? [];
|
||||
const filterOpts = { customRanges: dir.customRanges };
|
||||
const results: RenameResult[] = [];
|
||||
const undoEntries = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const filePath of queue) {
|
||||
try {
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (!stat) continue;
|
||||
const type = stat.isDirectory() ? 'directory' : 'file';
|
||||
const result = await normalizeEntry(filePath, type, filterOpts);
|
||||
results.push(result);
|
||||
if (result.status === 'renamed') {
|
||||
recentlyRenamed.set(result.newPath, Date.now());
|
||||
undoEntries.push({ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('applyManualQueue error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
manualQueue.set(dirId, []);
|
||||
if (undoEntries.length) await store.appendUndoEntries(undoEntries);
|
||||
// 수동 적용은 즉시 배치에 추가 (인터벌 내 합산)
|
||||
const renamedCount = results.filter((r) => r.status === 'renamed').length;
|
||||
for (let i = 0; i < renamedCount; i++) await notifier.queueRenamedNotification(dir.path);
|
||||
return results;
|
||||
}
|
||||
55
src/preload/index.ts
Normal file
@@ -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", "src/renderer/**"]
|
||||
}
|
||||
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,
|
||||
},
|
||||
});
|
||||