From a0e6bc53b2a96559eee8ccae9a93d29a72c78e77 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Fri, 15 May 2026 10:23:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(ipc):=20notebookApi=20=E2=80=94=20list/cre?= =?UTF-8?q?ate/rename/setColor/delete/moveNote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/index.ts | 4 ++ src/main/ipc/notebookApi.ts | 51 ++++++++++++++++++ src/preload/index.ts | 9 ++++ src/shared/types.ts | 10 ++++ tests/unit/notebookApi.test.ts | 99 ++++++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 src/main/ipc/notebookApi.ts create mode 100644 tests/unit/notebookApi.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index a9f5133..364aa2a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -21,6 +21,8 @@ import { refreshVisionCache } from './services/VisionDetect.js'; import { registerCaptureApi } from './ipc/captureApi.js'; import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js'; import { registerSettingsApi, navigateInbox } from './ipc/settingsApi.js'; +import { NotebookRepository } from './repository/NotebookRepository.js'; +import { registerNotebookApi } from './ipc/notebookApi.js'; import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js'; import { createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow @@ -101,6 +103,8 @@ app.whenReady().then(async () => { } const db = openDb(paths.dbFile); const repo = new NoteRepository(db); + const notebookRepo = new NotebookRepository(db); + registerNotebookApi({ repo: notebookRepo }); const store = new MediaStore(paths.profileDir); const continuity = new ContinuityService(db); const intent = new IntentService(repo); diff --git a/src/main/ipc/notebookApi.ts b/src/main/ipc/notebookApi.ts new file mode 100644 index 0000000..ff9c86c --- /dev/null +++ b/src/main/ipc/notebookApi.ts @@ -0,0 +1,51 @@ +import electron from 'electron'; +const { ipcMain } = electron; +import type { NotebookRepository } from '../repository/NotebookRepository.js'; + +export interface NotebookIpcDeps { + repo: NotebookRepository; +} + +export function registerNotebookApi(deps: NotebookIpcDeps): void { + ipcMain.handle('notebook:list', () => deps.repo.list()); + + ipcMain.handle('notebook:create', (_e, input: { name: string; color?: string }) => { + if (!input.name || input.name.trim().length === 0) { + return { ok: false as const, reason: 'empty_name' }; + } + try { + const nb = deps.repo.create({ name: input.name.trim(), color: input.color ?? null }); + return { ok: true as const, notebook: nb }; + } catch (e) { + const msg = (e as Error).message; + if (msg.includes('UNIQUE')) return { ok: false as const, reason: 'duplicate_name' }; + return { ok: false as const, reason: msg }; + } + }); + + ipcMain.handle('notebook:rename', (_e, id: string, name: string) => { + if (!name || name.trim().length === 0) { + return { ok: false as const, reason: 'empty_name' }; + } + try { + deps.repo.rename(id, name.trim()); + return { ok: true as const }; + } catch (e) { + const msg = (e as Error).message; + if (msg.includes('UNIQUE')) return { ok: false as const, reason: 'duplicate_name' }; + return { ok: false as const, reason: msg }; + } + }); + + ipcMain.handle('notebook:set-color', (_e, id: string, color: string | null) => { + deps.repo.setColor(id, color); + return { ok: true as const }; + }); + + ipcMain.handle('notebook:delete', (_e, id: string) => deps.repo.delete(id)); + + ipcMain.handle('notebook:move-note', (_e, noteId: string, notebookId: string) => { + deps.repo.moveNote(noteId, notebookId); + return { ok: true as const }; + }); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 81a46a1..8a8cbb8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -103,6 +103,15 @@ const api: InklingApi = { getVisionModels: () => ipcRenderer.invoke('settings:get-vision-models'), setVisionModel: (value: string | null) => ipcRenderer.invoke('settings:set-vision-model', value), refreshVisionCache: () => ipcRenderer.invoke('settings:refresh-vision-cache'), + }, + // v0.4 — notebook CRUD IPC + notebook: { + list: () => ipcRenderer.invoke('notebook:list'), + create: (input: { name: string; color?: string }) => ipcRenderer.invoke('notebook:create', input), + rename: (id: string, name: string) => ipcRenderer.invoke('notebook:rename', id, name), + setColor: (id: string, color: string | null) => ipcRenderer.invoke('notebook:set-color', id, color), + delete: (id: string) => ipcRenderer.invoke('notebook:delete', id), + moveNote: (noteId: string, notebookId: string) => ipcRenderer.invoke('notebook:move-note', noteId, notebookId) } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 7a09150..dbe4687 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -245,9 +245,19 @@ export interface InboxApi { refreshVisionCache(): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }>; } +export interface NotebookApi { + list(): Promise; + create(input: { name: string; color?: string }): Promise<{ ok: true; notebook: Notebook } | { ok: false; reason: string }>; + rename(id: string, name: string): Promise<{ ok: true } | { ok: false; reason: string }>; + setColor(id: string, color: string | null): Promise<{ ok: true }>; + delete(id: string): Promise<{ ok: true } | { ok: false; reason: 'has_notes' | 'not_found' }>; + moveNote(noteId: string, notebookId: string): Promise<{ ok: true }>; +} + export interface InklingApi { capture: CaptureApi; inbox: InboxApi; + notebook: NotebookApi; } export {}; diff --git a/tests/unit/notebookApi.test.ts b/tests/unit/notebookApi.test.ts new file mode 100644 index 0000000..a3685b8 --- /dev/null +++ b/tests/unit/notebookApi.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('electron', () => ({ + default: { ipcMain: { handle: vi.fn(), on: vi.fn() } } +})); + +import electron from 'electron'; +import { registerNotebookApi } from '../../src/main/ipc/notebookApi.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 makeRepo() { + return { + list: vi.fn(() => [] as never[]), + create: vi.fn(), + rename: vi.fn(), + setColor: vi.fn(), + delete: vi.fn(() => ({ ok: true })), + moveNote: vi.fn(), + findById: vi.fn(() => null) + }; +} + +describe('notebookApi IPC', () => { + beforeEach(() => { + (electron.ipcMain as unknown as { handle: ReturnType }).handle.mockClear(); + }); + + it('notebook:list — repo.list 결과 반환', async () => { + const repo = makeRepo(); + repo.list.mockReturnValue([{ id: '1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }] as never); + registerNotebookApi({ repo: repo as never }); + const h = getHandler('notebook:list'); + const r = await h({}) as unknown[]; + expect(r).toHaveLength(1); + }); + + it('notebook:create — UNIQUE 위반 시 ok:false reason="duplicate_name"', async () => { + const repo = makeRepo(); + repo.create.mockImplementation(() => { throw new Error('UNIQUE constraint failed: notebooks.name'); }); + registerNotebookApi({ repo: repo as never }); + const h = getHandler('notebook:create'); + const r = await h({}, { name: '회사' }) as { ok: boolean; reason?: string }; + expect(r.ok).toBe(false); + expect(r.reason).toBe('duplicate_name'); + }); + + it('notebook:create — 빈 이름 시 ok:false reason="empty_name"', async () => { + const repo = makeRepo(); + registerNotebookApi({ repo: repo as never }); + const h = getHandler('notebook:create'); + const r = await h({}, { name: ' ' }) as { ok: boolean; reason?: string }; + expect(r.ok).toBe(false); + expect(r.reason).toBe('empty_name'); + expect(repo.create).not.toHaveBeenCalled(); + }); + + it('notebook:rename — UNIQUE 위반 ok:false reason="duplicate_name"', async () => { + const repo = makeRepo(); + repo.rename.mockImplementation(() => { throw new Error('UNIQUE constraint failed: notebooks.name'); }); + registerNotebookApi({ repo: repo as never }); + const h = getHandler('notebook:rename'); + const r = await h({}, 'id1', '회사') as { ok: boolean; reason?: string }; + expect(r.ok).toBe(false); + expect(r.reason).toBe('duplicate_name'); + }); + + it('notebook:delete — has_notes 시 ok:false reason 전달', async () => { + const repo = makeRepo(); + repo.delete.mockReturnValue({ ok: false, reason: 'has_notes' } as never); + registerNotebookApi({ repo: repo as never }); + const h = getHandler('notebook:delete'); + const r = await h({}, 'id1'); + expect(r).toEqual({ ok: false, reason: 'has_notes' }); + }); + + it('notebook:move-note — repo.moveNote 호출 + ok:true', async () => { + const repo = makeRepo(); + registerNotebookApi({ repo: repo as never }); + const h = getHandler('notebook:move-note'); + const r = await h({}, 'n1', 'nb-2'); + expect(repo.moveNote).toHaveBeenCalledWith('n1', 'nb-2'); + expect(r).toEqual({ ok: true }); + }); + + it('notebook:set-color — repo.setColor 호출 + ok:true', async () => { + const repo = makeRepo(); + registerNotebookApi({ repo: repo as never }); + const h = getHandler('notebook:set-color'); + const r = await h({}, 'id1', '#fff'); + expect(repo.setColor).toHaveBeenCalledWith('id1', '#fff'); + expect(r).toEqual({ ok: true }); + }); +});