Files
inkling/src/main/ipc/inboxApi.ts
altair823 4db7a0bce0 refactor(v032): recall IPC handle→on + fix sibling test mocks (#36)
- inbox:emitRecallShown / emitRecallSnoozed: ipcMain.handle → on
  (fire-and-forget honest pattern, return value 의존자 0)
- preload: ipcRenderer.invoke → send (matching on the main side)
- shared/types: Promise<void> → void on both recall emit methods
- store.ts: drop await on emitRecallSnoozed (now void)
- inboxApi-*.test.ts: add ipcMain.on to electron mock (broken by above)
- tests/unit/recall-ipc.test.ts: new TDD test for handle→on migration

Note: #20 CaptureService telemetry .catch debug log skipped —
CaptureService has no logger field; adding one would require non-trivial
constructor signature change. Reported as CONCERN below.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:23:19 +09:00

328 lines
13 KiB
TypeScript

import electron from 'electron';
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 { ContinuityService } from '../services/ContinuityService.js';
import type { CaptureService } from '../services/CaptureService.js';
import type { HealthChecker } from '../services/HealthChecker.js';
import type { IntentService } from '../services/IntentService.js';
import type { Note, NoteStatus } from '@shared/types';
import type { HealthResult } from '../ai/InferenceProvider.js';
import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js';
import { classifyStatus } from '../ai/classifyStatus.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { ProviderHolder } from '../ai/ProviderHolder.js';
export interface InboxIpcDeps {
repo: NoteRepository;
continuity: ContinuityService;
capture: CaptureService;
health: HealthChecker;
intent: IntentService;
getInboxWindow: () => BrowserWindow | null;
settings: SettingsService;
providerHolder: ProviderHolder;
// v0.2.8 Cut A — `inbox:open-media` 의 path traversal 검사 baseline.
paths: { profileDir: string };
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 처리 시 in-memory worker queue 갱신.
// 미주입 시 fire-and-forget skip (다음 launch 의 loadFromDb 가 처리). 본 hook 은
// AiWorker 인스턴스 직접 주입을 피해 IPC 모듈이 worker import 를 갖지 않도록 분리.
enqueue?: (noteId: string) => Promise<void>;
}
export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:list', (_e, opts: { limit: number; cursor?: string }) =>
deps.repo.list(opts)
);
ipcMain.handle(
'inbox:updateAi',
(_e, arg: { noteId: string; fields: { title?: string; summary?: string; tags?: string[] } }) => {
deps.repo.updateUserAiFields(arg.noteId, arg.fields);
}
);
ipcMain.handle('inbox:setDueDate', (_e, arg: { noteId: string; date: string | null }) => {
deps.repo.setDueDate(arg.noteId, arg.date);
});
ipcMain.handle('inbox:trash', async (_e, noteId: string) => {
await deps.capture.deleteNote(noteId);
});
ipcMain.handle(
'inbox:setIntent',
(_e, arg: { noteId: string; text: string }) => {
deps.intent.setIntent(arg.noteId, arg.text);
}
);
ipcMain.handle('inbox:dismissIntent', (_e, noteId: string) => {
deps.intent.dismissIntent(noteId);
});
ipcMain.handle('inbox:continuity', () => deps.continuity.get());
ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount());
ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus());
ipcMain.handle('inbox:todayCount', () => deps.repo.countToday());
ipcMain.handle('inbox:restore', async (_e, noteId: string) => {
await deps.capture.restoreNote(noteId);
});
ipcMain.handle('inbox:permanentDelete', async (_e, noteId: string) => {
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['영구 삭제', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: '이 노트를 영구 삭제합니다',
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { confirmed: false };
await deps.capture.permanentDeleteNote(noteId);
return { confirmed: true };
});
ipcMain.handle('inbox:emptyTrash', async () => {
const fullCount = deps.repo.countTrashed();
if (fullCount === 0) return { confirmed: true, count: 0 };
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['휴지통 비우기', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: `휴지통의 노트 ${fullCount}개를 영구 삭제합니다`,
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { confirmed: false, count: 0 };
const result = await deps.capture.emptyTrash();
return { confirmed: true, count: result.count };
});
ipcMain.handle('inbox:listTrash', (_e, opts: { limit: number }) =>
deps.repo.listTrashed(opts)
);
ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed());
ipcMain.handle('inbox:listExpired', async () => deps.capture.listExpired());
ipcMain.handle(
'inbox:trashExpiredBatch',
async (_e, payload: { ids: string[] }) => {
if (payload.ids.length === 0) return { trashedCount: 0, confirmed: false };
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['옮기기', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: `선택한 노트 ${payload.ids.length}개를 휴지통으로 옮깁니다`,
detail: '복구는 휴지통 탭에서 가능합니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { trashedCount: 0, confirmed: false };
const result = await deps.capture.trashExpiredBatch(payload.ids);
return { trashedCount: result.trashedCount, confirmed: true };
}
);
ipcMain.handle('inbox:ollamaRecheck', async () => {
await deps.health.runOnce({ manual: true });
return deps.health.lastStatus();
});
ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed());
ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed());
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
ipcMain.on('inbox:emitRecallShown', (_e, id: string) => { void deps.capture.emitRecallShown(id); });
ipcMain.on('inbox:emitRecallSnoozed', (_e, id: string) => { void deps.capture.emitRecallSnoozed(id); });
ipcMain.handle('inbox:loadOllamaSettings', async () => {
const s = await deps.settings.load();
return s.ollama ?? null;
});
// v0.2.8 Cut A — 첨부 이미지 클릭 시 OS 기본 뷰어로 열기 (Task 3).
// path traversal 검사는 inkling-media:// protocol handler 와 동일한 패턴 (Task 1).
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
if (typeof relPath !== 'string' || relPath.length === 0) {
return { ok: false as const, reason: 'invalid path' as const };
}
const profileDir = deps.paths.profileDir;
const mediaRoot = join(profileDir, 'media');
const target = normalize(join(profileDir, relPath));
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
return { ok: false as const, reason: 'invalid path' as const };
}
await shell.openPath(target);
return { ok: true as const };
});
// v0.2.9 Cut B Task 4 — status 별 노트 목록.
ipcMain.handle(
'inbox:list-by-status',
(_e, status: NoteStatus, opts: { limit?: number } = {}) => {
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
if (!VALID.includes(status)) return [] as Note[];
return deps.repo.listByStatus(status, opts);
}
);
// v0.2.9 Cut B Task 4 — 4 status counts (헤더 4탭 badge).
ipcMain.handle('inbox:counts-by-status', () => ({
active: deps.repo.countByStatus('active'),
completed: deps.repo.countByStatus('completed'),
archived: deps.repo.countByStatus('archived'),
trashed: deps.repo.countByStatus('trashed')
}));
// v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함).
// Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는
// NoteRepository.setStatus 내부에서 처리 (deleted_at sync).
ipcMain.handle(
'inbox:set-status',
async (_e, id: string, status: NoteStatus, reason: string | null) => {
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
if (!VALID.includes(status)) {
return { ok: false as const, reason: 'invalid status' as const };
}
deps.repo.setStatus(id, status, reason);
return { ok: true as const };
}
);
// v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
// Ollama provider.generateRaw 호출 + JSON 응답 파싱. 에러/실패 시 archived fallback
// (사용자 데이터 보존 우선). 자세한 prompt + parse 로직은 src/main/ai/classifyStatus.ts.
ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => {
const note = deps.repo.findById(id);
if (note === null) {
return {
recommended: 'archived' as const,
rationale: '메모를 찾을 수 없음 — 안전하게 보관 추천'
};
}
const provider = deps.providerHolder.get();
return classifyStatus({
provider,
rawText: note.rawText,
summary: note.aiSummary ?? '',
reason: typeof reason === 'string' ? reason : ''
});
});
// v0.2.9 Cut B Task 16 — disabled 메모 (ai_enabled OFF 시기 캡처) 일괄 재투입.
// OFF→ON 전환 후 사용자가 "지금 모두 처리" 버튼 클릭 path. repo.requeueDisabled 가
// ai_status='pending' + pending_jobs row 보장, worker.enqueue 가 in-memory queue 갱신.
ipcMain.handle('inbox:enqueue-disabled', async () => {
// requeue 전 대상 id 수집 — UPDATE 가 status 바꾸므로 select 후 update 필요 없이
// requeueDisabled 가 처리한 다음 pending_jobs 에서 다시 가져와 enqueue.
const targets = deps.repo.getAllPendingJobs().map((j) => j.noteId);
const before = new Set(targets);
const count = deps.repo.requeueDisabled();
if (count > 0 && deps.enqueue) {
const after = deps.repo.getAllPendingJobs();
// requeue 직후 새로 들어온 pending_jobs row 만 enqueue (기존 row 는 이미 in-memory queue 에).
for (const j of after) {
if (!before.has(j.noteId)) {
await deps.enqueue(j.noteId);
}
}
}
return { count };
});
ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled'));
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
const r = await trial.healthCheck();
if (!r.ok) return { ok: false, reason: r.reason ?? 'unknown' };
try {
await deps.settings.setOllama(value);
} catch (e) {
return { ok: false, reason: `persist failed: ${(e as Error).message}` };
}
deps.providerHolder.get().abort?.();
deps.providerHolder.replace(trial);
// 즉시 health 재확인 → onUpdate callback 통해 OllamaBanner 자동 갱신
await deps.health.runOnce();
return { ok: true };
});
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
// updateRawText: 빈 문자열 reject (trim 후 length===0). 그 외엔 그대로 (newline/space 보존).
// listRevisions: 그대로 반환 (camelCase 이미 hydrate 됨).
// restoreRevision: repo throw → { ok: false } (UI 가 에러 표시).
ipcMain.handle('inbox:update-raw-text', async (_e, id: string, newText: string) => {
if (typeof newText !== 'string' || newText.trim().length === 0) {
return { ok: false as const, reason: 'empty' as const };
}
deps.repo.updateRawText(id, newText);
return { ok: true as const };
});
ipcMain.handle('inbox:list-revisions', (_e, id: string) => deps.repo.listRevisions(id));
ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => {
try {
deps.repo.restoreRevision(id, revId);
return { ok: true as const };
} catch (e) {
return { ok: false as const, reason: (e as Error).message };
}
});
// v0.2.11 Cut D — FTS5 검색 + 회고 aggregate.
ipcMain.handle(
'inbox:search',
(_e, query: string, opts: { limit?: number; status?: NoteStatus } = {}) =>
deps.repo.search(query, opts)
);
ipcMain.handle('inbox:review-aggregate', (_e, period: 'daily' | 'weekly' | 'monthly') => {
const VALID = ['daily', 'weekly', 'monthly'] as const;
if (!(VALID as readonly string[]).includes(period)) {
return {
totalCount: 0,
recentNotes: [],
tagCounts: [],
dueProgress: { total: 0, passed: 0, pending: 0 }
};
}
return deps.repo.reviewAggregate(period);
});
}
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
const w = getWin();
if (!w || w.isDestroyed()) return;
w.webContents.send('note:updated', note);
}
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
const w = getWin();
if (!w || w.isDestroyed()) return;
w.webContents.send('ollama:status', status);
}