From 53a15792660370c3a0ed02527a0ca0715442a958 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Fri, 15 May 2026 10:44:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(promotion):=20store=20promotionCandidates?= =?UTF-8?q?=20+=20accept/snooze/dismiss=20+=20settings=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsService: promotion_dismissed_tags / promotion_snoozed_until_ms / sidebar_visible / sidebar_width 스키마 + getter/setter 추가 - NotebookRepository: getDefault() (created_at ASC LIMIT 1) 헬퍼 추가 - inboxApi: notebookRepo 옵션 dep + 5개 IPC 핸들러 (list-promotion-candidates / get-dismissed-tags / add-dismissed-tag / get-snoozed-until / set-snoozed-until) - shared/types: PromotionCandidate 인터페이스 + InboxApi 5개 메서드 추가 - preload: 5개 ipcRenderer.invoke 패스스루 - store: promotionCandidates 상태 + loadPromotionCandidates / acceptPromotion / snoozePromotion / dismissPromotion 액션 + toTitleCase helper - tests: store.promotion.test.ts 신설 (6개 케이스) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/index.ts | 4 +- src/main/ipc/inboxApi.ts | 28 +++++ src/main/repository/NotebookRepository.ts | 14 +++ src/main/services/SettingsService.ts | 51 +++++++- src/preload/index.ts | 6 + src/renderer/inbox/store.ts | 62 +++++++++- src/shared/types.ts | 14 +++ tests/unit/store.promotion.test.ts | 144 ++++++++++++++++++++++ 8 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 tests/unit/store.promotion.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index ac8f2a2..72cdfba 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -187,7 +187,9 @@ app.whenReady().then(async () => { getInboxWindow, settings: settingsSvc, providerHolder, paths: { profileDir: paths.profileDir }, // v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신. - enqueue: (id) => worker.enqueue(id) + enqueue: (id) => worker.enqueue(id), + // v0.4 Task 11 — promotion candidates IPC 가 default notebook 식별에 사용. + notebookRepo }); // registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가 // 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동. diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 98c99b4..1eb1d07 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron'; const { ipcMain, dialog, shell } = electron; import { join, normalize, sep } from 'node:path'; import type { NoteRepository } from '../repository/NoteRepository.js'; +import type { NotebookRepository } from '../repository/NotebookRepository.js'; import type { ContinuityService } from '../services/ContinuityService.js'; import type { CaptureService } from '../services/CaptureService.js'; import type { HealthChecker } from '../services/HealthChecker.js'; @@ -29,6 +30,9 @@ export interface InboxIpcDeps { // 미주입 시 fire-and-forget skip (다음 launch 의 loadFromDb 가 처리). 본 hook 은 // AiWorker 인스턴스 직접 주입을 피해 IPC 모듈이 worker import 를 갖지 않도록 분리. enqueue?: (noteId: string) => Promise; + // v0.4 Task 11 — promotion candidates IPC 가 default notebook 식별에 사용. + // 미주입 시 list-promotion-candidates 는 빈 배열 반환 (graceful fallback). + notebookRepo?: NotebookRepository; } export function registerInboxApi(deps: InboxIpcDeps): void { @@ -280,6 +284,30 @@ export function registerInboxApi(deps: InboxIpcDeps): void { ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled')); + // v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화. + ipcMain.handle('inbox:list-promotion-candidates', () => { + if (!deps.notebookRepo) return []; + const defaultNb = deps.notebookRepo.getDefault(); + if (!defaultNb) return []; + return deps.repo.findPromotionCandidates(defaultNb.id); + }); + + ipcMain.handle('inbox:get-promotion-dismissed-tags', () => + deps.settings.getPromotionDismissedTags() + ); + + ipcMain.handle('inbox:add-promotion-dismissed-tag', (_e, tag: string) => + deps.settings.addPromotionDismissedTag(tag) + ); + + ipcMain.handle('inbox:get-promotion-snoozed-until', () => + deps.settings.getPromotionSnoozeUntil() + ); + + ipcMain.handle('inbox:set-promotion-snoozed-until', (_e, ms: number) => + deps.settings.setPromotionSnoozeUntil(ms) + ); + ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { // 검증: 새 인스턴스로 healthCheck const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); diff --git a/src/main/repository/NotebookRepository.ts b/src/main/repository/NotebookRepository.ts index 036b1bc..eba56cd 100644 --- a/src/main/repository/NotebookRepository.ts +++ b/src/main/repository/NotebookRepository.ts @@ -71,6 +71,20 @@ export class NotebookRepository { return r ? this.hydrate(r) : null; } + /** + * v0.4 Task 11 — 가장 오래된 (created_at ASC LIMIT 1) notebook = default. + * m008 마이그레이션이 기존 노트를 자동으로 이 notebook 에 할당. + */ + getDefault(): Notebook | null { + const r = this.db.prepare( + `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at, + (SELECT COUNT(*) FROM notes n + WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count + FROM notebooks nb ORDER BY nb.created_at ASC LIMIT 1` + ).get() as Record | undefined; + return r ? this.hydrate(r) : null; + } + /** notes.notebook_id 갱신만 (status 등은 보존). */ moveNote(noteId: string, notebookId: string): void { this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`) diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts index 3b61286..3e164de 100644 --- a/src/main/services/SettingsService.ts +++ b/src/main/services/SettingsService.ts @@ -21,7 +21,12 @@ const SettingsSchema = z.object({ // v0.3.1 Cut F vision_model: z.string().nullable().optional(), vision_capable_cache: z.array(z.string()).optional(), - vision_cache_at: z.string().optional() + vision_cache_at: z.string().optional(), + // v0.4 Task 11 — promotion candidate 영속화 + sidebar 레이아웃. + promotion_dismissed_tags: z.array(z.string()).optional(), + promotion_snoozed_until_ms: z.number().int().optional(), + sidebar_visible: z.boolean().optional(), + sidebar_width: z.number().int().min(180).max(400).optional() }).strict(); export type Settings = z.infer; @@ -155,6 +160,50 @@ export class SettingsService { await this.persist(next); } + // v0.4 Task 11 — promotion candidate 영속화. + async getPromotionDismissedTags(): Promise { + const s = await this.load(); + return s.promotion_dismissed_tags ?? []; + } + + async addPromotionDismissedTag(tag: string): Promise { + const s = await this.load(); + const list = new Set(s.promotion_dismissed_tags ?? []); + list.add(tag); + await this.persist({ ...s, promotion_dismissed_tags: [...list] }); + } + + async getPromotionSnoozeUntil(): Promise { + const s = await this.load(); + return s.promotion_snoozed_until_ms ?? 0; + } + + async setPromotionSnoozeUntil(ms: number): Promise { + const s = await this.load(); + await this.persist({ ...s, promotion_snoozed_until_ms: ms }); + } + + // v0.4 Task 15 — sidebar 레이아웃 영속화. + async getSidebarVisible(): Promise { + const s = await this.load(); + return s.sidebar_visible ?? false; + } + + async setSidebarVisible(v: boolean): Promise { + const s = await this.load(); + await this.persist({ ...s, sidebar_visible: v }); + } + + async getSidebarWidth(): Promise { + const s = await this.load(); + return s.sidebar_width ?? 240; + } + + async setSidebarWidth(v: number): Promise { + const s = await this.load(); + await this.persist({ ...s, sidebar_width: v }); + } + private async persist(next: Settings): Promise { await mkdir(dirname(this.filePath), { recursive: true }); const tmpPath = this.filePath + '.tmp'; diff --git a/src/preload/index.ts b/src/preload/index.ts index a09fcde..503e92c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -105,6 +105,12 @@ 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 Task 11 — promotion candidates + dismissed/snoozed 영속화. + listPromotionCandidates: () => ipcRenderer.invoke('inbox:list-promotion-candidates'), + getPromotionDismissedTags: () => ipcRenderer.invoke('inbox:get-promotion-dismissed-tags'), + addPromotionDismissedTag: (tag: string) => ipcRenderer.invoke('inbox:add-promotion-dismissed-tag', tag), + getPromotionSnoozeUntil: () => ipcRenderer.invoke('inbox:get-promotion-snoozed-until'), + setPromotionSnoozeUntil: (ms: number) => ipcRenderer.invoke('inbox:set-promotion-snoozed-until', ms), }, // v0.4 — notebook CRUD IPC notebook: { diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 8ab3eff..b0c941e 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -1,8 +1,12 @@ import { create } from 'zustand'; -import type { Note, Notebook, ReviewAggregate, WeeklyContinuity } from '@shared/types'; +import type { Note, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity } from '@shared/types'; import { inboxApi, notebookApi } from './api.js'; import { nextKstMidnightMs } from '@shared/util/kstDate.js'; +function toTitleCase(s: string): string { + return s.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); +} + export { selectFilteredNotes } from './selectFilteredNotes.js'; // v0.2.9 Cut B Task 4 — 4탭 view enum + settings. @@ -50,6 +54,8 @@ interface InboxState { selectedNotebookId: string | null; sidebarVisible: boolean; sidebarWidth: number; + // v0.4 Task 11 — promotion candidates (dismissed/snoozed 필터 적용 후 목록). + promotionCandidates: PromotionCandidate[]; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; @@ -85,6 +91,11 @@ interface InboxState { deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>; moveNoteToNotebook: (noteId: string, notebookId: string) => Promise; toggleSidebar: () => void; + // v0.4 Task 11 — promotion candidate actions. + loadPromotionCandidates: () => Promise; + acceptPromotion: (tag: string, customName: string, color: string | undefined) => Promise; + snoozePromotion: () => Promise; + dismissPromotion: (tag: string) => Promise; } const emptyContinuity: WeeklyContinuity = { @@ -119,6 +130,7 @@ export const useInbox = create((set, get) => ({ selectedNotebookId: null, sidebarVisible: false, sidebarWidth: 240, + promotionCandidates: [], async loadInitial() { // v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset. set({ loading: true }); @@ -459,5 +471,53 @@ export const useInbox = create((set, get) => ({ }, toggleSidebar() { set({ sidebarVisible: !get().sidebarVisible }); + }, + // v0.4 Task 11 — promotion candidate actions. + async loadPromotionCandidates() { + try { + const [dismissed, snoozeUntil, raw] = await Promise.all([ + inboxApi.getPromotionDismissedTags(), + inboxApi.getPromotionSnoozeUntil(), + inboxApi.listPromotionCandidates() + ]); + // snoozed_until > now → 빈 배열 (전체 스누즈). + if (snoozeUntil > Date.now()) { + set({ promotionCandidates: [] }); + return; + } + const dismissedSet = new Set(dismissed); + const candidates: PromotionCandidate[] = raw + .filter((c) => !dismissedSet.has(c.tag)) + .map((c) => ({ ...c, suggestedName: toTitleCase(c.tag) })); + set({ promotionCandidates: candidates }); + } catch (e) { + console.error('[inbox] loadPromotionCandidates failed', e); + set({ promotionCandidates: [] }); + } + }, + async acceptPromotion(tag, customName, color) { + const candidate = get().promotionCandidates.find((c) => c.tag === tag); + if (!candidate) return; + const r = await notebookApi.create({ name: customName, color }); + if (!r.ok) return; + const notebookId = r.notebook.id; + await Promise.all(candidate.noteIds.map((noteId) => notebookApi.moveNote(noteId, notebookId))); + // state: candidate 제거 + notebooks 갱신 + sidebar 열기 + 새 notebook 선택. + set({ + promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag), + notebooks: [...get().notebooks, r.notebook], + sidebarVisible: true, + selectedNotebookId: notebookId + }); + await get().refreshMeta(); + }, + async snoozePromotion() { + const snoozeUntil = Date.now() + 24 * 60 * 60 * 1000; + await inboxApi.setPromotionSnoozeUntil(snoozeUntil); + set({ promotionCandidates: [] }); + }, + async dismissPromotion(tag) { + await inboxApi.addPromotionDismissedTag(tag); + set({ promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag) }); } })); diff --git a/src/shared/types.ts b/src/shared/types.ts index 878a312..a5f2aa7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -96,6 +96,14 @@ export interface Note { notebookId: string; } +// v0.4 Task 11 — tag 기반 notebook 승격 제안 후보. +// suggestedName 은 renderer 가 toTitleCase(tag) 로 채움 — IPC 응답에는 없음. +export interface PromotionCandidate { + tag: string; + noteIds: string[]; + suggestedName: string; +} + // v0.4 — Notebook: 노트 묶음 단위. noteCount = status='active' 노트 수. export interface Notebook { id: string; @@ -245,6 +253,12 @@ export interface InboxApi { getVisionModels(): Promise<{ models: string[]; at: string | null; selected: string | null }>; setVisionModel(value: string | null): Promise<{ ok: true }>; refreshVisionCache(): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }>; + // v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화. + listPromotionCandidates(): Promise; + getPromotionDismissedTags(): Promise; + addPromotionDismissedTag(tag: string): Promise; + getPromotionSnoozeUntil(): Promise; + setPromotionSnoozeUntil(ms: number): Promise; } export interface NotebookApi { diff --git a/tests/unit/store.promotion.test.ts b/tests/unit/store.promotion.test.ts new file mode 100644 index 0000000..2670918 --- /dev/null +++ b/tests/unit/store.promotion.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + // promotion IPC + listPromotionCandidates: vi.fn(async () => []), + getPromotionDismissedTags: vi.fn(async () => []), + addPromotionDismissedTag: vi.fn(async () => undefined), + getPromotionSnoozeUntil: vi.fn(async () => 0), + setPromotionSnoozeUntil: vi.fn(async () => undefined), + // refreshMeta deps + 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), + getTrashCount: 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 })) + }, + notebookApi: { + list: vi.fn(async () => []), + create: vi.fn(async (i: { name: string; color?: string }) => ({ + ok: true as const, + notebook: { id: 'nb-promo', name: i.name, color: i.color ?? null, createdAt: 't', updatedAt: 't', noteCount: 0 } + })), + moveNote: vi.fn(async () => ({ ok: true as const })), + rename: vi.fn(async () => ({ ok: true as const })), + setColor: vi.fn(async () => ({ ok: true as const })), + delete: vi.fn(async () => ({ ok: true as const })) + } +})); + +import { useInbox } from '../../src/renderer/inbox/store.js'; +import { inboxApi } from '../../src/renderer/inbox/api.js'; +import { notebookApi } from '../../src/renderer/inbox/api.js'; + +type MockInboxApi = { + listPromotionCandidates: ReturnType; + getPromotionDismissedTags: ReturnType; + addPromotionDismissedTag: ReturnType; + getPromotionSnoozeUntil: ReturnType; + setPromotionSnoozeUntil: ReturnType; +}; +type MockNotebookApi = { + create: ReturnType; + moveNote: ReturnType; +}; + +const mockInbox = inboxApi as unknown as MockInboxApi; +const mockNotebook = notebookApi as unknown as MockNotebookApi; + +describe('store promotion actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + useInbox.setState({ promotionCandidates: [], notebooks: [], sidebarVisible: false, selectedNotebookId: null } as never); + }); + + it('loadPromotionCandidates: 후보 목록 반환 + suggestedName toTitleCase 변환', async () => { + mockInbox.getPromotionDismissedTags.mockResolvedValueOnce([]); + mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(0); + mockInbox.listPromotionCandidates.mockResolvedValueOnce([ + { tag: 'machine-learning', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' } + ]); + await useInbox.getState().loadPromotionCandidates(); + const candidates = useInbox.getState().promotionCandidates; + expect(candidates).toHaveLength(1); + expect(candidates[0]!.suggestedName).toBe('Machine Learning'); + expect(candidates[0]!.noteIds).toEqual(['n1', 'n2', 'n3']); + }); + + it('loadPromotionCandidates: snooze 유효 시 빈 배열', async () => { + mockInbox.getPromotionDismissedTags.mockResolvedValueOnce([]); + // 24h 후 만료되는 snooze + mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(Date.now() + 24 * 60 * 60 * 1000); + mockInbox.listPromotionCandidates.mockResolvedValueOnce([ + { tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' } + ]); + await useInbox.getState().loadPromotionCandidates(); + expect(useInbox.getState().promotionCandidates).toHaveLength(0); + }); + + it('loadPromotionCandidates: dismissed tag 는 제외', async () => { + mockInbox.getPromotionDismissedTags.mockResolvedValueOnce(['work']); + mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(0); + mockInbox.listPromotionCandidates.mockResolvedValueOnce([ + { tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' }, + { tag: 'study', noteIds: ['n4', 'n5', 'n6'], suggestedName: '' } + ]); + await useInbox.getState().loadPromotionCandidates(); + const candidates = useInbox.getState().promotionCandidates; + expect(candidates).toHaveLength(1); + expect(candidates[0]!.tag).toBe('study'); + }); + + it('dismissPromotion: addPromotionDismissedTag 호출 + state 에서 그 tag 제거', async () => { + useInbox.setState({ + promotionCandidates: [ + { tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Work' }, + { tag: 'study', noteIds: ['n4', 'n5'], suggestedName: 'Study' } + ] + } as never); + await useInbox.getState().dismissPromotion('work'); + expect(mockInbox.addPromotionDismissedTag).toHaveBeenCalledWith('work'); + const candidates = useInbox.getState().promotionCandidates; + expect(candidates).toHaveLength(1); + expect(candidates[0]!.tag).toBe('study'); + }); + + it('snoozePromotion: setPromotionSnoozeUntil 24h 후로 호출 + state 비우기', async () => { + useInbox.setState({ + promotionCandidates: [ + { tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Work' } + ] + } as never); + const before = Date.now(); + await useInbox.getState().snoozePromotion(); + const [[ms]] = mockInbox.setPromotionSnoozeUntil.mock.calls as [[number]]; + expect(ms).toBeGreaterThan(before + 23 * 60 * 60 * 1000); + expect(ms).toBeLessThan(before + 25 * 60 * 60 * 1000); + expect(useInbox.getState().promotionCandidates).toHaveLength(0); + }); + + it('acceptPromotion: notebook 생성 + moveNote 호출 + sidebar 열림 + selectedNotebookId 설정', async () => { + useInbox.setState({ + promotionCandidates: [ + { tag: 'work', noteIds: ['n1', 'n2'], suggestedName: 'Work' } + ], + notebooks: [] + } as never); + await useInbox.getState().acceptPromotion('work', 'Work', '#0a4b80'); + expect(mockNotebook.create).toHaveBeenCalledWith({ name: 'Work', color: '#0a4b80' }); + expect(mockNotebook.moveNote).toHaveBeenCalledTimes(2); + expect(mockNotebook.moveNote).toHaveBeenCalledWith('n1', 'nb-promo'); + expect(mockNotebook.moveNote).toHaveBeenCalledWith('n2', 'nb-promo'); + const state = useInbox.getState(); + expect(state.sidebarVisible).toBe(true); + expect(state.selectedNotebookId).toBe('nb-promo'); + expect(state.promotionCandidates.find((c) => c.tag === 'work')).toBeUndefined(); + expect(state.notebooks.some((n) => n.id === 'nb-promo')).toBe(true); + }); +});