From 5cd38f253714864f5ce628f876cad53e68f690b8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:03:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(v027):=20BackupSection=20=E2=80=94=205=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20+=20IPC=20=ED=95=B8=EB=93=A4=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 9 +- src/main/ipc/settingsApi.ts | 210 +++++++++++++++++- src/preload/index.ts | 6 + .../inbox/components/SettingsPage.tsx | 3 +- .../components/settings/BackupSection.tsx | 27 +++ src/shared/types.ts | 6 + tests/unit/BackupSection.test.tsx | 39 ++++ tests/unit/SettingsPage.test.tsx | 7 +- 8 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 src/renderer/inbox/components/settings/BackupSection.tsx create mode 100644 tests/unit/BackupSection.test.tsx diff --git a/src/main/index.ts b/src/main/index.ts index 9768e0f..7942642 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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)) .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) => { diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 712da9f..a5b3823 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -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; + }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index c6ec4ed..a6a926d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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'), } }; diff --git a/src/renderer/inbox/components/SettingsPage.tsx b/src/renderer/inbox/components/SettingsPage.tsx index d5e4ed6..62f98a5 100644 --- a/src/renderer/inbox/components/SettingsPage.tsx +++ b/src/renderer/inbox/components/SettingsPage.tsx @@ -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 {

백업 / 복원

- {/* BackupSection — Task 10 */} +

정보

diff --git a/src/renderer/inbox/components/settings/BackupSection.tsx b/src/renderer/inbox/components/settings/BackupSection.tsx new file mode 100644 index 0000000..37722ef --- /dev/null +++ b/src/renderer/inbox/components/settings/BackupSection.tsx @@ -0,0 +1,27 @@ +import React, { useState } from 'react'; +import { inboxApi } from '../../api.js'; + +export function BackupSection(): React.ReactElement { + const [status, setStatus] = useState(null); + + async function run(label: string, fn: () => Promise): Promise { + setStatus(`${label}: 진행 중...`); + try { + await fn(); + setStatus(`${label}: 완료`); + } catch (e) { + setStatus(`${label}: 실패 — ${(e as Error).message}`); + } + } + + return ( +
+ + + + + + {status &&
{status}
} +
+ ); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 82c076d..38b3bec 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 { diff --git a/tests/unit/BackupSection.test.tsx b/tests/unit/BackupSection.test.tsx new file mode 100644 index 0000000..b6197c7 --- /dev/null +++ b/tests/unit/BackupSection.test.tsx @@ -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(); + 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(); + fireEvent.click(screen.getByRole('button', { name: /지금 백업/ })); + await waitFor(() => expect(inboxApi.runBackup).toHaveBeenCalled()); + }); +}); diff --git a/tests/unit/SettingsPage.test.tsx b/tests/unit/SettingsPage.test.tsx index 491d0be..371904b 100644 --- a/tests/unit/SettingsPage.test.tsx +++ b/tests/unit/SettingsPage.test.tsx @@ -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 })) } }));