diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 2aa7ad0..b27fcaa 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -7,6 +7,7 @@ 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 { GitClient } from '../services/GitClient.js'; import type { TelemetryService } from '../services/TelemetryService.js'; import type { SettingsService } from '../services/SettingsService.js'; import { collectAutostartState } from '../services/AutostartDiagnostic.js'; @@ -281,4 +282,78 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { } return { ok: true } as const; }); + + // v0.3.0 Cut E — sync IPC. + + // settings:configure-sync — URL 저장 + git init + remote add (없으면). + // null URL → 저장만 (init 안 함). 빈 문자열도 null 처리. + ipcMain.handle('settings:configure-sync', async (_e, url: string | null) => { + const trimmed = typeof url === 'string' ? url.trim() : ''; + const finalUrl = trimmed.length === 0 ? null : trimmed; + + try { + await deps.settings.setSyncRepoUrl(finalUrl); + } catch (e) { + return { ok: false as const, reason: `persist failed: ${(e as Error).message}` }; + } + + if (finalUrl === null) return { ok: true as const }; + + // git init + remote add origin + const syncDir = deps.syncSvc.getSyncDir(); + const git = new GitClient(syncDir); + + if (!(await git.isRepo())) { + const init = await git.run(['init']); + if (init.exitCode !== 0) { + return { ok: false as const, reason: `git init failed: ${init.stderr}` }; + } + } + if (!(await git.hasRemote())) { + const add = await git.run(['remote', 'add', 'origin', finalUrl]); + if (add.exitCode !== 0) { + return { ok: false as const, reason: `remote add failed: ${add.stderr}` }; + } + } else { + // remote exists — update URL + const set = await git.run(['remote', 'set-url', 'origin', finalUrl]); + if (set.exitCode !== 0) { + return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` }; + } + } + return { ok: true as const }; + }); + + // settings:test-sync-connection — git ls-remote 결과 + ipcMain.handle('settings:test-sync-connection', async () => { + const syncDir = deps.syncSvc.getSyncDir(); + const git = new GitClient(syncDir); + if (!(await git.isRepo())) return { ok: false as const, reason: 'not_initialized' }; + const r = await git.run(['ls-remote', 'origin']); + if (r.exitCode !== 0) return { ok: false as const, reason: r.stderr || 'connection failed' }; + return { ok: true as const }; + }); + + // sync:list-conflicts — SyncService 캐시 결과 + ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts()); + + // sync:resolve-conflict — local/remote 2 choice + ipcMain.handle('sync:resolve-conflict', async (_e, noteId: string, choice: 'local' | 'remote') => { + if (choice !== 'local' && choice !== 'remote') { + return { ok: false as const, reason: 'invalid choice' }; + } + return deps.syncSvc.resolveConflict(noteId, choice); + }); + + // sync:get-status — lastAt + lastResult + nextAt 계산 + ipcMain.handle('sync:get-status', async () => { + const last = deps.syncSvc.getLastStatus(); + let nextAt: string | null = null; + if (await deps.settings.isAutoSyncEnabled()) { + const intervalMin = await deps.settings.getSyncIntervalMin(); + const baseMs = last.lastAt ? new Date(last.lastAt).getTime() : Date.now(); + nextAt = new Date(baseMs + intervalMin * 60 * 1000).toISOString(); + } + return { lastAt: last.lastAt, lastResult: last.lastResult, nextAt }; + }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index f59ffe2..3db9c04 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -88,6 +88,13 @@ const api: InklingApi = { // v0.2.11 Cut D — search + 회고 aggregate. search: (query, opts) => ipcRenderer.invoke('inbox:search', query, opts ?? {}), reviewAggregate: (period) => ipcRenderer.invoke('inbox:review-aggregate', period), + // v0.3.0 Cut E — 양방향 sync. + configureSync: (url: string | null) => ipcRenderer.invoke('settings:configure-sync', url), + testSyncConnection: () => ipcRenderer.invoke('settings:test-sync-connection'), + listConflicts: () => ipcRenderer.invoke('sync:list-conflicts'), + resolveConflict: (noteId: string, choice: 'local' | 'remote') => + ipcRenderer.invoke('sync:resolve-conflict', noteId, choice), + getSyncStatus: () => ipcRenderer.invoke('sync:get-status'), } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index cc625bf..217cc91 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -40,6 +40,29 @@ export interface ReviewAggregate { dueProgress: { total: number; passed: number; pending: number }; } +// v0.3.0 Cut E — 양방향 sync 결과 + conflict. +export interface SyncConflict { + noteId: string; + localText: string; + remoteText: string; +} + +export interface SyncStatus { + ok: boolean; + reason?: string; + changed?: boolean; + localSha?: string | null; + pushed?: boolean; + importedCount?: number; + conflicts?: SyncConflict[]; +} + +export interface SyncStatusSnapshot { + lastAt: string | null; + lastResult: SyncStatus | null; + nextAt: string | null; +} + export interface Note { id: string; rawText: string; @@ -182,6 +205,12 @@ export interface InboxApi { // v0.2.11 Cut D — FTS5 search + 회고 aggregate. search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise; reviewAggregate(period: ReviewPeriod): Promise; + // v0.3.0 Cut E — 양방향 sync. + configureSync(url: string | null): Promise<{ ok: true } | { ok: false; reason: string }>; + testSyncConnection(): Promise<{ ok: true } | { ok: false; reason: string }>; + listConflicts(): Promise; + resolveConflict(noteId: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>; + getSyncStatus(): Promise; } export interface InklingApi { diff --git a/tests/unit/sync-ipc.test.ts b/tests/unit/sync-ipc.test.ts new file mode 100644 index 0000000..d2f459a --- /dev/null +++ b/tests/unit/sync-ipc.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() }, dialog: {}, shell: {} } })); +vi.mock('../../src/main/services/GitClient.js'); + +import electron from 'electron'; +import { GitClient } from '../../src/main/services/GitClient.js'; +import { registerSettingsApi } from '../../src/main/ipc/settingsApi.js'; +import type { SettingsIpcDeps } from '../../src/main/ipc/settingsApi.js'; + +function getHandler(channel: string): (...args: unknown[]) => unknown { + const handle = (electron.ipcMain as unknown as { handle: ReturnType }).handle; + const call = handle.mock.calls.find((c) => c[0] === channel); + if (!call) throw new Error(`channel ${channel} not registered`); + return call[1] as (...args: unknown[]) => unknown; +} + +function makeDeps() { + const gitInstance = { + isRepo: vi.fn(async () => false), + hasRemote: vi.fn(async () => false), + run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) + }; + (GitClient as unknown as ReturnType).mockImplementation(function () { return gitInstance; }); + + const syncSvc = { + getSyncDir: vi.fn(() => '/tmp/sync'), + listConflicts: vi.fn(() => [] as { noteId: string; localText: string; remoteText: string }[]), + resolveConflict: vi.fn(async () => ({ ok: true as const })), + getLastStatus: vi.fn(() => ({ lastAt: null as string | null, lastResult: null as { ok: boolean } | null })) + }; + const settings = { + getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git'), + setSyncRepoUrl: vi.fn(async () => {}), + isAutoSyncEnabled: vi.fn(async () => false), + getSyncIntervalMin: vi.fn(async () => 30), + getAll: vi.fn(async () => ({})), + setAiEnabled: vi.fn(async () => {}), + setOnboardingCompleted: vi.fn(async () => {}), + isAiEnabled: vi.fn(async () => true) + }; + + const deps: Partial = { + backup: { runDaily: vi.fn(async () => ({ snapshotted: false })) } as never, + exportSvc: {} as never, + importSvc: {} as never, + syncSvc: syncSvc as never, + telemetry: { exportTo: vi.fn(async () => ({ eventCount: 0 })) } as never, + settings: settings as never, + getInboxWindow: () => null + }; + + return { gitInstance, syncSvc, settings, deps }; +} + +describe('sync IPC channels', () => { + beforeEach(() => { + (electron.ipcMain as unknown as { handle: ReturnType }).handle.mockClear(); + vi.clearAllMocks(); + }); + + it('5 sync channels registered', () => { + const { deps } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + + const handle = (electron.ipcMain as unknown as { handle: ReturnType }).handle; + const channels = handle.mock.calls.map((c) => c[0]); + expect(channels).toContain('settings:configure-sync'); + expect(channels).toContain('settings:test-sync-connection'); + expect(channels).toContain('sync:list-conflicts'); + expect(channels).toContain('sync:resolve-conflict'); + expect(channels).toContain('sync:get-status'); + }); + + describe('settings:configure-sync', () => { + it('null URL → setSyncRepoUrl(null), no git init', async () => { + const { deps, settings, gitInstance } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, null); + expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null); + expect(gitInstance.run).not.toHaveBeenCalled(); + expect(r).toEqual({ ok: true }); + }); + + it('empty string URL → treated as null', async () => { + const { deps, settings, gitInstance } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, ' '); + expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null); + expect(gitInstance.run).not.toHaveBeenCalled(); + expect(r).toEqual({ ok: true }); + }); + + it('valid URL → isRepo=false → git init + remote add', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(false); + gitInstance.hasRemote.mockResolvedValue(false); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, 'git@github.com:user/repo.git'); + expect(gitInstance.run).toHaveBeenCalledWith(['init']); + expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'add', 'origin', 'git@github.com:user/repo.git']); + expect(r).toEqual({ ok: true }); + }); + + it('valid URL → isRepo=true, hasRemote=true → remote set-url', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(true); + gitInstance.hasRemote.mockResolvedValue(true); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, 'git@github.com:user/new-repo.git'); + expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'set-url', 'origin', 'git@github.com:user/new-repo.git']); + expect(r).toEqual({ ok: true }); + }); + + it('git init failure → ok: false', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(false); + gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'permission denied', exitCode: 1 }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, 'git@github.com:user/repo.git'); + expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('git init failed') }); + }); + + it('setSyncRepoUrl throws → ok: false', async () => { + const { deps, settings } = makeDeps(); + settings.setSyncRepoUrl.mockRejectedValue(new Error('disk full')); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, 'git@github.com:user/repo.git'); + expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('persist failed') }); + }); + }); + + describe('settings:test-sync-connection', () => { + it('not initialized → ok: false, reason: not_initialized', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(false); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:test-sync-connection'); + const r = await h({}); + expect(r).toEqual({ ok: false, reason: 'not_initialized' }); + }); + + it('ls-remote success → ok: true', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(true); + gitInstance.run.mockResolvedValue({ stdout: 'abc123\trefs/heads/main', stderr: '', exitCode: 0 }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:test-sync-connection'); + const r = await h({}); + expect(gitInstance.run).toHaveBeenCalledWith(['ls-remote', 'origin']); + expect(r).toEqual({ ok: true }); + }); + + it('ls-remote failure → ok: false', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(true); + gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'connection refused', exitCode: 128 }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:test-sync-connection'); + const r = await h({}); + expect(r).toMatchObject({ ok: false, reason: 'connection refused' }); + }); + }); + + describe('sync:list-conflicts', () => { + it('returns syncSvc.listConflicts() result', () => { + const { deps, syncSvc } = makeDeps(); + const conflicts = [{ noteId: 'abc', localText: 'local', remoteText: 'remote' }]; + syncSvc.listConflicts.mockReturnValue(conflicts); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:list-conflicts'); + const r = h({}); + expect(r).toEqual(conflicts); + }); + }); + + describe('sync:resolve-conflict', () => { + it('valid choice "local" → delegates to syncSvc.resolveConflict', async () => { + const { deps, syncSvc } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:resolve-conflict'); + const r = await h({}, 'note-1', 'local'); + expect(syncSvc.resolveConflict).toHaveBeenCalledWith('note-1', 'local'); + expect(r).toEqual({ ok: true }); + }); + + it('valid choice "remote" → delegates to syncSvc.resolveConflict', async () => { + const { deps, syncSvc } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:resolve-conflict'); + await h({}, 'note-2', 'remote'); + expect(syncSvc.resolveConflict).toHaveBeenCalledWith('note-2', 'remote'); + }); + + it('invalid choice → ok: false, reason: invalid choice', async () => { + const { deps, syncSvc } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:resolve-conflict'); + const r = await h({}, 'note-1', 'both'); + expect(syncSvc.resolveConflict).not.toHaveBeenCalled(); + expect(r).toEqual({ ok: false, reason: 'invalid choice' }); + }); + }); + + describe('sync:get-status', () => { + it('auto-sync disabled → nextAt: null', async () => { + const { deps, syncSvc, settings } = makeDeps(); + settings.isAutoSyncEnabled.mockResolvedValue(false); + syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:get-status'); + const r = await h({}); + expect(r).toMatchObject({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true }, nextAt: null }); + }); + + it('auto-sync enabled → nextAt computed from lastAt + interval', async () => { + const { deps, syncSvc, settings } = makeDeps(); + settings.isAutoSyncEnabled.mockResolvedValue(true); + settings.getSyncIntervalMin.mockResolvedValue(30); + syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:get-status'); + const r = (await h({})) as { lastAt: string; nextAt: string }; + const expectedNextAt = new Date(new Date('2026-05-09T10:00:00.000Z').getTime() + 30 * 60 * 1000).toISOString(); + expect(r.nextAt).toBe(expectedNextAt); + }); + + it('no previous sync + auto-sync enabled → nextAt based on Date.now()', async () => { + const { deps, syncSvc, settings } = makeDeps(); + settings.isAutoSyncEnabled.mockResolvedValue(true); + settings.getSyncIntervalMin.mockResolvedValue(15); + syncSvc.getLastStatus.mockReturnValue({ lastAt: null, lastResult: null }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:get-status'); + const before = Date.now(); + const r = (await h({})) as { lastAt: null; nextAt: string }; + const after = Date.now(); + const nextAtMs = new Date(r.nextAt).getTime(); + expect(nextAtMs).toBeGreaterThanOrEqual(before + 15 * 60 * 1000); + expect(nextAtMs).toBeLessThanOrEqual(after + 15 * 60 * 1000); + expect(r.lastAt).toBeNull(); + }); + }); +});