diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index c83e73e..475d38e 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -102,6 +102,31 @@ export function registerInboxApi(deps: InboxIpcDeps): void { ); 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 }; + } + ); } export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 63e0b76..8563f3f 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -1,5 +1,6 @@ import type { NoteRepository } from '../repository/NoteRepository.js'; import type { MediaStore } from './MediaStore.js'; +import type { Note } from '@shared/types'; export interface TelemetryEmitter { emit(input: @@ -8,6 +9,8 @@ export interface TelemetryEmitter { | { kind: 'restore'; payload: { noteId: string } } | { kind: 'permanent_delete'; payload: { noteId: string } } | { kind: 'empty_trash'; payload: { count: number } } + | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } + | { kind: 'expired_batch_trash'; payload: { count: number } } ): Promise; } @@ -23,6 +26,8 @@ export interface SubmitInput { } export class CaptureService { + private lastExpiredShownSig: string | null = null; + constructor( private repo: NoteRepository, private store: MediaStore, @@ -111,4 +116,45 @@ export class CaptureService { } return { count: noteIds.length }; } + + /** + * 만료 후보 (due_date < today KST, active, ai_status=done) 조회. + * candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit. + * v0.2.3 #5 spec §6.2 — dedup 위치 main 통합. + */ + async listExpired(now: Date = new Date()): Promise { + const candidates = this.repo.findExpiredCandidates(now); + if (candidates.length === 0) { + this.lastExpiredShownSig = null; + return candidates; + } + const sig = `${candidates.length}:${candidates.slice(0, 3).map((n) => n.id).join('-')}`; + if (sig !== this.lastExpiredShownSig) { + this.lastExpiredShownSig = sig; + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ + kind: 'expired_banner_shown', + payload: { candidateCount: candidates.length } + }).catch(() => {}); + } + } + return candidates; + } + + /** + * 만료 후보 일괄 trash. 빈 배열은 즉시 no-op. + * 성공 시 expired_batch_trash 1회 emit (per-id trash emit 은 별도 발화 안 함 — + * stats.md 에서 `trash` (단건) vs `expired_batch_trash` (배치) 분리 통계). + */ + async trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number }> { + if (ids.length === 0) return { trashedCount: 0 }; + const r = this.repo.trashBatch(ids, new Date().toISOString()); + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ + kind: 'expired_batch_trash', + payload: { count: r.trashedCount } + }).catch(() => {}); + } + return r; + } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7111851..7a35600 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,6 +25,8 @@ const api: InklingApi = { emptyTrash: () => ipcRenderer.invoke('inbox:emptyTrash'), listTrash: (opts) => ipcRenderer.invoke('inbox:listTrash', opts), getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'), + listExpired: () => ipcRenderer.invoke('inbox:listExpired'), + trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }), onNoteUpdated: (cb) => { const listener = (_e: unknown, note: Note) => cb(note); ipcRenderer.on('note:updated', listener); diff --git a/src/shared/types.ts b/src/shared/types.ts index e0b0179..33cca70 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -77,6 +77,8 @@ export interface InboxApi { emptyTrash(): Promise<{ confirmed: boolean; count: number }>; listTrash(opts: { limit: number }): Promise; getTrashCount(): Promise; + listExpired(): Promise; + trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number; confirmed: boolean }>; onNoteUpdated(cb: (note: Note) => void): () => void; } diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts index fefcd0a..3760d53 100644 --- a/tests/unit/CaptureService.test.ts +++ b/tests/unit/CaptureService.test.ts @@ -208,3 +208,118 @@ describe('CaptureService trash flow (v0.2.3 #4)', () => { expect(r.count).toBe(0); }); }); + +describe('CaptureService.listExpired (dedup signature)', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let calls: Array<{ kind: string; payload: any }>; + let svc: CaptureService; + + function addExpired(id: string, dueDate: string, createdAt: string = '2026-04-30T10:00:00Z'): void { + db.prepare( + `INSERT INTO notes + (id, raw_text, ai_status, due_date, created_at, updated_at) + VALUES (?, ?, 'done', ?, ?, ?)` + ).run(id, id, dueDate, createdAt, createdAt); + } + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); + store = new MediaStore(tmp); + calls = []; + svc = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {}, + telemetry: { emit: async (input) => { calls.push(input as any); } } + }); + }); + + it('emits expired_banner_shown on first call when candidates > 0', async () => { + addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z'); + addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z'); + const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + expect(r).toHaveLength(2); + expect(calls).toContainEqual( + expect.objectContaining({ kind: 'expired_banner_shown', payload: { candidateCount: 2 } }) + ); + }); + + it('does NOT re-emit on second call with identical candidate set (dedup)', async () => { + addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z'); + addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z'); + await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + const showns = calls.filter((c) => c.kind === 'expired_banner_shown'); + expect(showns).toHaveLength(1); + }); + + it('re-emits when candidate set changes (count or first-3-ids)', async () => { + addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z'); + addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z'); + await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + addExpired('n3', '2026-04-23', '2026-04-30T12:00:00Z'); + await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + const showns = calls.filter((c) => c.kind === 'expired_banner_shown'); + expect(showns).toHaveLength(2); + expect(showns[1]!.payload).toMatchObject({ candidateCount: 3 }); + }); + + it('does NOT emit when candidates is empty', async () => { + const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z')); + expect(r).toEqual([]); + expect(calls.filter((c) => c.kind === 'expired_banner_shown')).toEqual([]); + }); +}); + +describe('CaptureService.trashExpiredBatch', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let calls: Array<{ kind: string; payload: any }>; + let svc: CaptureService; + + function addExpired(id: string, dueDate: string): void { + db.prepare( + `INSERT INTO notes + (id, raw_text, ai_status, due_date, created_at, updated_at) + VALUES (?, ?, 'done', ?, ?, ?)` + ).run(id, id, dueDate, '2026-04-30T10:00:00Z', '2026-04-30T10:00:00Z'); + } + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); + store = new MediaStore(tmp); + calls = []; + svc = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {}, + telemetry: { emit: async (input) => { calls.push(input as any); } } + }); + }); + + it('emits expired_batch_trash with trashedCount + no per-id trash emit', async () => { + addExpired('n1', '2026-04-20'); + addExpired('n2', '2026-04-22'); + const r = await svc.trashExpiredBatch(['n1', 'n2']); + expect(r.trashedCount).toBe(2); + expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([ + expect.objectContaining({ kind: 'expired_batch_trash', payload: { count: 2 } }) + ]); + expect(calls.filter((c) => c.kind === 'trash')).toEqual([]); + }); + + it('returns trashedCount=0 for empty array (no emit)', async () => { + const r = await svc.trashExpiredBatch([]); + expect(r.trashedCount).toBe(0); + expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([]); + }); +});