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 });
+ });
+});