diff --git a/src/renderer/inbox/api.ts b/src/renderer/inbox/api.ts index 3a78192..aa42f7e 100644 --- a/src/renderer/inbox/api.ts +++ b/src/renderer/inbox/api.ts @@ -1,2 +1,3 @@ -import type { InboxApi } from '@shared/types'; +import type { InboxApi, NotebookApi } from '@shared/types'; export const inboxApi: InboxApi = window.inkling.inbox; +export const notebookApi: NotebookApi = window.inkling.notebook; diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index d2fda2e..8ab3eff 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import type { Note, ReviewAggregate, WeeklyContinuity } from '@shared/types'; -import { inboxApi } from './api.js'; +import type { Note, Notebook, ReviewAggregate, WeeklyContinuity } from '@shared/types'; +import { inboxApi, notebookApi } from './api.js'; import { nextKstMidnightMs } from '@shared/util/kstDate.js'; export { selectFilteredNotes } from './selectFilteredNotes.js'; @@ -45,6 +45,11 @@ interface InboxState { searchQuery: string; searchResults: Note[] | null; // null = 검색 안 한 상태 reviewData: ReviewAggregate | null; + // v0.4 — Notebook sidebar state. + notebooks: Notebook[]; + selectedNotebookId: string | null; + sidebarVisible: boolean; + sidebarWidth: number; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; @@ -71,6 +76,15 @@ interface InboxState { searchNotes: (q: string) => Promise; clearSearch: () => void; loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise; + // v0.4 — Notebook actions. + loadNotebooks: () => Promise; + selectNotebook: (id: string) => void; + createNotebook: (name: string, color?: string) => Promise<{ ok: boolean; reason?: string }>; + renameNotebook: (id: string, name: string) => Promise<{ ok: boolean; reason?: string }>; + setNotebookColor: (id: string, color: string | null) => Promise; + deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>; + moveNoteToNotebook: (noteId: string, notebookId: string) => Promise; + toggleSidebar: () => void; } const emptyContinuity: WeeklyContinuity = { @@ -101,6 +115,10 @@ export const useInbox = create((set, get) => ({ searchQuery: '', searchResults: null, reviewData: null, + notebooks: [], + selectedNotebookId: null, + sidebarVisible: false, + sidebarWidth: 240, async loadInitial() { // v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset. set({ loading: true }); @@ -388,5 +406,58 @@ export const useInbox = create((set, get) => ({ console.error('[inbox] loadReview failed', period, e); set({ reviewData: { totalCount: 0, tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 }, recentNotes: [] } }); } + }, + // v0.4 — Notebook actions. + async loadNotebooks() { + const notebooks = await notebookApi.list(); + const current = get().selectedNotebookId; + // selectedNotebookId 가 null 이면 첫 notebook (가장 오래된 = 기본) 으로 설정. + const selectedNotebookId = current === null && notebooks.length > 0 + ? notebooks[0]!.id + : current; + set({ notebooks, selectedNotebookId }); + }, + selectNotebook(id) { + set({ selectedNotebookId: id }); + }, + async createNotebook(name, color) { + const r = await notebookApi.create({ name, color }); + if (r.ok) { + set({ notebooks: [...get().notebooks, r.notebook] }); + return { ok: true }; + } + return { ok: false, reason: r.reason }; + }, + async renameNotebook(id, name) { + const r = await notebookApi.rename(id, name); + if (r.ok) { + set({ notebooks: get().notebooks.map((n) => n.id === id ? { ...n, name } : n) }); + return { ok: true }; + } + return { ok: false, reason: r.reason }; + }, + async setNotebookColor(id, color) { + await notebookApi.setColor(id, color); + set({ notebooks: get().notebooks.map((n) => n.id === id ? { ...n, color } : n) }); + }, + async deleteNotebook(id) { + const r = await notebookApi.delete(id); + if (r.ok) { + const remaining = get().notebooks.filter((n) => n.id !== id); + const wasSelected = get().selectedNotebookId === id; + set({ + notebooks: remaining, + selectedNotebookId: wasSelected ? (remaining[0]?.id ?? null) : get().selectedNotebookId + }); + return { ok: true }; + } + return { ok: false, reason: r.reason }; + }, + async moveNoteToNotebook(noteId, notebookId) { + await notebookApi.moveNote(noteId, notebookId); + await get().refreshMeta(); + }, + toggleSidebar() { + set({ sidebarVisible: !get().sidebarVisible }); } })); diff --git a/tests/unit/store.notebook.test.ts b/tests/unit/store.notebook.test.ts new file mode 100644 index 0000000..0903b1b --- /dev/null +++ b/tests/unit/store.notebook.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + listNotes: vi.fn(async () => []), + listTrash: vi.fn(async () => []), + getTrashCount: vi.fn(async () => 0), + getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), + getPendingCount: vi.fn(async () => 0), + getOllamaStatus: vi.fn(async () => ({ ok: true })), + getTodayCount: vi.fn(async () => 0), + listExpired: vi.fn(async () => []), + getFailedCount: vi.fn(async () => 0), + listRecallCandidate: vi.fn(async () => null), + countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })), + getSettings: vi.fn(async () => ({ ai_enabled: true })), + listByStatus: vi.fn(async () => []) + }, + notebookApi: { + list: vi.fn(async () => [ + { id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 3 }, + { id: 'nb-2', name: '회사', color: '#0a4b80', createdAt: 't', updatedAt: 't', noteCount: 1 } + ]), + create: vi.fn(async (i: { name: string; color?: string }) => ({ ok: true as const, notebook: { id: 'nb-new', name: i.name, color: i.color ?? null, createdAt: 't', updatedAt: 't', noteCount: 0 } })), + delete: vi.fn(async () => ({ ok: true as const })), + rename: vi.fn(async () => ({ ok: true as const })), + setColor: vi.fn(async () => ({ ok: true as const })), + moveNote: vi.fn(async () => ({ ok: true as const })) + } +})); + +import { useInbox } from '../../src/renderer/inbox/store.js'; + +describe('store notebooks', () => { + beforeEach(() => { + useInbox.setState({ notebooks: [], selectedNotebookId: null, sidebarVisible: false, sidebarWidth: 240 } as never); + }); + + it('loadNotebooks 가 notebookApi.list 결과 반영', async () => { + await useInbox.getState().loadNotebooks(); + expect(useInbox.getState().notebooks).toHaveLength(2); + }); + + it('loadNotebooks: selectedNotebookId null 이면 첫 notebook 으로 자동 설정', async () => { + await useInbox.getState().loadNotebooks(); + expect(useInbox.getState().selectedNotebookId).toBe('nb-1'); + }); + + it('selectNotebook 가 selectedNotebookId 설정', () => { + useInbox.getState().selectNotebook('nb-X'); + expect(useInbox.getState().selectedNotebookId).toBe('nb-X'); + }); + + it('createNotebook 성공 시 notebooks 에 추가', async () => { + await useInbox.getState().createNotebook('학습', '#ccc'); + expect(useInbox.getState().notebooks.some((n) => n.name === '학습')).toBe(true); + }); + + it('toggleSidebar 가 sidebarVisible 반전', () => { + expect(useInbox.getState().sidebarVisible).toBe(false); + useInbox.getState().toggleSidebar(); + expect(useInbox.getState().sidebarVisible).toBe(true); + }); + + it('deleteNotebook 성공 시 notebooks 에서 제거 + selected 였으면 첫 notebook 으로', async () => { + useInbox.setState({ + notebooks: [ + { id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }, + { id: 'nb-2', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 } + ], + selectedNotebookId: 'nb-2' + } as never); + await useInbox.getState().deleteNotebook('nb-2'); + expect(useInbox.getState().notebooks.map((n) => n.id)).toEqual(['nb-1']); + expect(useInbox.getState().selectedNotebookId).toBe('nb-1'); + }); +});