- 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>
328 lines
13 KiB
TypeScript
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);
|
|
}
|