feat(expiry): CaptureService listExpired/trashExpiredBatch + IPC 2 channels (#5 v0.2.3)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user