feat(expiry): CaptureService listExpired/trashExpiredBatch + IPC 2 channels (#5 v0.2.3)

This commit is contained in:
altair823
2026-05-02 00:13:49 +09:00
parent f76ca06d9e
commit 749235f65d
5 changed files with 190 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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<void>;
}
@@ -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<Note[]> {
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;
}
}

View File

@@ -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);

View File

@@ -77,6 +77,8 @@ export interface InboxApi {
emptyTrash(): Promise<{ confirmed: boolean; count: number }>;
listTrash(opts: { limit: number }): Promise<Note[]>;
getTrashCount(): Promise<number>;
listExpired(): Promise<Note[]>;
trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number; confirmed: boolean }>;
onNoteUpdated(cb: (note: Note) => void): () => void;
}

View File

@@ -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([]);
});
});