mirror of
https://github.com/jung-geun/NFD2NFC.git
synced 2026-06-21 04:15:14 +09:00
src/main: 트레이/감시자/저장소/IPC/알림 배치 (Electron 메인)
tray.ts: 트레이 아이콘, 팝오버 BrowserWindow 토글, 컨텍스트 메뉴. watcher.ts: chokidar 감시, dedup TTL 2s, auto/manual 모드, 글로벌 일시정지. store.ts: userData/store.json 영속화 (watchedDirs/settings/undoLog). ipc.ts: 18개 IPC 채널 핸들러. notifier.ts: interval 기반 알림 배치 처리 (알림 폭주 방지). settings-window.ts: 설정창 BrowserWindow 라이프사이클. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
37
src/main/index.ts
Normal file
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();
|
||||
});
|
||||
126
src/main/ipc.ts
Normal file
126
src/main/ipc.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ipcMain, dialog, app, BrowserWindow } from 'electron';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { scan } from '../core/scanner';
|
||||
import { normalizeEntry } from '../core/normalizer';
|
||||
import { nanoid } from './nanoid';
|
||||
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
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
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
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();
|
||||
}
|
||||
94
src/main/store.ts
Normal file
94
src/main/store.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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,
|
||||
};
|
||||
|
||||
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
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;
|
||||
}
|
||||
165
src/main/watcher.ts
Normal file
165
src/main/watcher.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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,
|
||||
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user