feat(v027): BackupSection — 5 버튼 + IPC 핸들러
This commit is contained in:
@@ -163,7 +163,8 @@ app.whenReady().then(async () => {
|
||||
repo, continuity, capture, health, intent,
|
||||
getInboxWindow, settings: settingsSvc, providerHolder
|
||||
});
|
||||
registerSettingsApi();
|
||||
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
|
||||
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.
|
||||
|
||||
const hotkeys = new HotkeyService();
|
||||
const reg = hotkeys.register({
|
||||
@@ -192,6 +193,12 @@ app.whenReady().then(async () => {
|
||||
.then((r) => logger.info('backup.daily', { ...r } as Record<string, unknown>))
|
||||
.catch((e) => logger.warn('backup.daily.failed', { reason: String(e) }));
|
||||
|
||||
// v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry).
|
||||
// backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록.
|
||||
registerSettingsApi({
|
||||
backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow
|
||||
});
|
||||
|
||||
let backupOnQuitDone = false;
|
||||
let trayInterval: NodeJS.Timeout | null = null;
|
||||
app.on('before-quit', (e) => {
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import electron from 'electron';
|
||||
const { ipcMain, app } = electron;
|
||||
import type { BrowserWindow } from 'electron';
|
||||
const { ipcMain, app, dialog, Notification } = electron;
|
||||
import { logger } from '../logger.js';
|
||||
import type { BackupService } from '../services/BackupService.js';
|
||||
import type { ExportService } from '../services/ExportService.js';
|
||||
import type { ImportService } from '../services/ImportService.js';
|
||||
import type { SyncService } from '../services/SyncService.js';
|
||||
import type { TelemetryService } from '../services/TelemetryService.js';
|
||||
|
||||
export interface SettingsIpcDeps {
|
||||
backup: BackupService;
|
||||
exportSvc: ExportService;
|
||||
importSvc: ImportService;
|
||||
syncSvc: SyncService;
|
||||
telemetry: TelemetryService;
|
||||
getInboxWindow: () => BrowserWindow | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.7 자동 실행 설정 IPC.
|
||||
* v0.2.7 설정 페이지 IPC 핸들러.
|
||||
*
|
||||
* 임시 채널명 (`settings:get-autostart` / `settings:set-autostart`).
|
||||
* Task 22 에서 정식 이름 (`settings:autostart-state` / `settings:autostart-set`) 으로 rename 예정.
|
||||
* - 자동 실행: 임시 채널명 (`settings:get-autostart` / `settings:set-autostart`).
|
||||
* Task 22 에서 정식 이름 (`settings:autostart-state` / `settings:autostart-set`) 으로 rename 예정.
|
||||
* args=['--hidden'] 명시 — 자동 실행 시 백그라운드 모드로 시작 (Quick Capture only).
|
||||
*
|
||||
* args=['--hidden'] 명시 — 자동 실행 시 백그라운드 모드로 시작 (Quick Capture only).
|
||||
* - 백업/내보내기/복원/동기화/사용 로그 (Task 10): 기존 `src/main/index.ts` 트레이 callback
|
||||
* (runBackup, runExport, runImport, runSync, runExportTelemetry) 본문을 그대로 IPC 핸들러로
|
||||
* 복사. 트레이 callback 자체 제거는 Task 16 (Phase 3) — 본 task 에선 잔류 (의도적 중복).
|
||||
*/
|
||||
export function registerSettingsApi(): void {
|
||||
export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
ipcMain.handle('settings:get-autostart', () => {
|
||||
const r = app.getLoginItemSettings({ args: ['--hidden'] });
|
||||
return { openAtLogin: r.openAtLogin };
|
||||
@@ -20,4 +39,183 @@ export function registerSettingsApi(): void {
|
||||
const r = app.getLoginItemSettings({ args: ['--hidden'] });
|
||||
return { openAtLogin: r.openAtLogin };
|
||||
});
|
||||
|
||||
if (!deps) return;
|
||||
const { backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow } = deps;
|
||||
|
||||
ipcMain.handle('settings:run-backup', async () => {
|
||||
try {
|
||||
const r = await backup.runDaily();
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: r.snapshotted
|
||||
? `백업 완료 — ${r.removed?.length ?? 0}개 정리`
|
||||
: `오늘 백업이 이미 있습니다`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('backup.manual.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업을 만들지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-export', async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.',
|
||||
buttonLabel: '여기에 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return { ok: true } as const;
|
||||
try {
|
||||
const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
|
||||
logger.info('export.done', {
|
||||
outDir: r.outDir,
|
||||
noteCount: r.noteCount,
|
||||
mediaCount: r.mediaCount,
|
||||
bytes: r.bytes
|
||||
});
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}개`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-import', async () => {
|
||||
const win = getInboxWindow();
|
||||
const dirOpts: Electron.OpenDialogOptions = {
|
||||
title: '복원할 백업 폴더 선택',
|
||||
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.',
|
||||
buttonLabel: '여기서 복원',
|
||||
properties: ['openDirectory']
|
||||
};
|
||||
const dirResult = win
|
||||
? await dialog.showOpenDialog(win, dirOpts)
|
||||
: await dialog.showOpenDialog(dirOpts);
|
||||
if (dirResult.canceled || dirResult.filePaths.length === 0) return { ok: true } as const;
|
||||
const sourceDir = dirResult.filePaths[0]!;
|
||||
let plan;
|
||||
try {
|
||||
plan = await importSvc.preview(sourceDir);
|
||||
} catch (e) {
|
||||
logger.warn('import.preview.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업 폴더를 읽지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
return { ok: true } as const;
|
||||
}
|
||||
const detail = `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`;
|
||||
const confirmOpts: Electron.MessageBoxOptions = {
|
||||
type: 'question',
|
||||
buttons: ['복원', '취소'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: 'Inkling 복원',
|
||||
message: '복원 미리보기',
|
||||
detail
|
||||
};
|
||||
const confirm = win
|
||||
? await dialog.showMessageBox(win, confirmOpts)
|
||||
: await dialog.showMessageBox(confirmOpts);
|
||||
if (confirm.response !== 0) return { ok: true } as const;
|
||||
try {
|
||||
const r = await importSvc.run(sourceDir);
|
||||
logger.info('import.done', {
|
||||
total: r.total,
|
||||
new: r.newCount,
|
||||
unchanged: r.unchangedCount,
|
||||
forked: r.forkedCount,
|
||||
media: r.mediaCount
|
||||
});
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}개`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('import.run.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '복원을 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-sync', async () => {
|
||||
try {
|
||||
const r = await syncSvc.sync();
|
||||
if (!r.ok) {
|
||||
logger.warn('sync.failed', { reason: r.reason });
|
||||
const body = r.reason === 'not_configured'
|
||||
? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.`
|
||||
: '동기화를 완료하지 못했습니다.';
|
||||
new Notification({ title: 'Inkling', body, silent: true }).show();
|
||||
return { ok: true } as const;
|
||||
}
|
||||
if (r.changed) {
|
||||
logger.info('sync.done', { sha: r.sha, pushed: r.pushed });
|
||||
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
|
||||
} else {
|
||||
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('sync.exception', { reason: String(e) });
|
||||
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-export-telemetry', async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '사용 로그를 내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.',
|
||||
buttonLabel: '여기로 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return { ok: true } as const;
|
||||
try {
|
||||
const r = await telemetry.exportTo(result.filePaths[0]!);
|
||||
logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('telemetry.export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '사용 로그 내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ const api: InklingApi = {
|
||||
// v0.2.7 자동 실행 (임시 채널 — Task 22 에서 settings:autostart-state / settings:autostart-set 으로 rename)
|
||||
getAutostart: () => ipcRenderer.invoke('settings:get-autostart'),
|
||||
setAutostart: (open: boolean) => ipcRenderer.invoke('settings:set-autostart', open),
|
||||
// v0.2.7 백업/복원/동기화/텔레메트리 (Task 10) — 트레이 callback 의 IPC 대응
|
||||
runBackup: () => ipcRenderer.invoke('settings:run-backup'),
|
||||
runExport: () => ipcRenderer.invoke('settings:run-export'),
|
||||
runImport: () => ipcRenderer.invoke('settings:run-import'),
|
||||
runSync: () => ipcRenderer.invoke('settings:run-sync'),
|
||||
runExportTelemetry: () => ipcRenderer.invoke('settings:run-export-telemetry'),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { AiProviderSection } from './settings/AiProviderSection.js';
|
||||
import { AutostartSection } from './settings/AutostartSection.js';
|
||||
import { BackupSection } from './settings/BackupSection.js';
|
||||
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
const setShowSettings = useInbox((s) => s.setShowSettings);
|
||||
@@ -32,7 +33,7 @@ export function SettingsPage(): React.ReactElement {
|
||||
</section>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>백업 / 복원</h2>
|
||||
{/* BackupSection — Task 10 */}
|
||||
<BackupSection />
|
||||
</section>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>정보</h2>
|
||||
|
||||
27
src/renderer/inbox/components/settings/BackupSection.tsx
Normal file
27
src/renderer/inbox/components/settings/BackupSection.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
|
||||
export function BackupSection(): React.ReactElement {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
async function run(label: string, fn: () => Promise<unknown>): Promise<void> {
|
||||
setStatus(`${label}: 진행 중...`);
|
||||
try {
|
||||
await fn();
|
||||
setStatus(`${label}: 완료`);
|
||||
} catch (e) {
|
||||
setStatus(`${label}: 실패 — ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button onClick={() => run('지금 백업', () => inboxApi.runBackup())}>지금 백업</button>
|
||||
<button onClick={() => run('내보내기', () => inboxApi.runExport())}>내보내기...</button>
|
||||
<button onClick={() => run('백업에서 복원', () => inboxApi.runImport())}>백업에서 복원...</button>
|
||||
<button onClick={() => run('지금 동기화', () => inboxApi.runSync())}>지금 동기화</button>
|
||||
<button onClick={() => run('사용 로그 내보내기', () => inboxApi.runExportTelemetry())}>사용 로그 내보내기...</button>
|
||||
{status && <div style={{ fontSize: 12 }}>{status}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,12 @@ export interface InboxApi {
|
||||
// v0.2.7 자동 실행 (임시 채널 — Task 22 에서 정식 이름으로 rename)
|
||||
getAutostart(): Promise<{ openAtLogin: boolean }>;
|
||||
setAutostart(open: boolean): Promise<{ openAtLogin: boolean }>;
|
||||
// v0.2.7 백업 / 복원 / 동기화 / 텔레메트리 — 트레이 callback 의 IPC 대응 (Task 10)
|
||||
runBackup(): Promise<{ ok: true }>;
|
||||
runExport(): Promise<{ ok: true }>;
|
||||
runImport(): Promise<{ ok: true }>;
|
||||
runSync(): Promise<{ ok: true }>;
|
||||
runExportTelemetry(): Promise<{ ok: true }>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
39
tests/unit/BackupSection.test.tsx
Normal file
39
tests/unit/BackupSection.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
runBackup: vi.fn(async () => ({ ok: true })),
|
||||
runExport: vi.fn(async () => ({ ok: true })),
|
||||
runImport: vi.fn(async () => ({ ok: true })),
|
||||
runSync: vi.fn(async () => ({ ok: true })),
|
||||
runExportTelemetry: vi.fn(async () => ({ ok: true }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { BackupSection } from '../../src/renderer/inbox/components/settings/BackupSection';
|
||||
|
||||
describe('BackupSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders 5 buttons', () => {
|
||||
render(<BackupSection />);
|
||||
expect(screen.getByRole('button', { name: /지금 백업/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^내보내기/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /백업에서 복원/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /지금 동기화/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /사용 로그/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking 지금 백업 calls runBackup', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
render(<BackupSection />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /지금 백업/ }));
|
||||
await waitFor(() => expect(inboxApi.runBackup).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,12 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true })),
|
||||
getAutostart: vi.fn(async () => ({ openAtLogin: false })),
|
||||
setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open }))
|
||||
setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open })),
|
||||
runBackup: vi.fn(async () => ({ ok: true })),
|
||||
runExport: vi.fn(async () => ({ ok: true })),
|
||||
runImport: vi.fn(async () => ({ ok: true })),
|
||||
runSync: vi.fn(async () => ({ ok: true })),
|
||||
runExportTelemetry: vi.fn(async () => ({ ok: true }))
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user