15 task TDD plan — migration v3, Note type extension, NoteRepository 신규 4메서드 + active query 일괄 변경, AiWorker deletedAt guard, telemetry 4 new kinds + stats.md 회수율 ratio, CaptureService soft delete + 3 신규 메서드 + 4 emit, ImportService deletedAt 보존, ExportService 회귀 가드, IPC 5 신규 채널 + native dialog confirm, zustand store + 5 actions, Inbox 탭 toggle + NoteCard mode prop, 게이트 + closure marker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80 KiB
#4 휴지통 (soft delete + migration v3) 구현 plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: v0.2.3 두 번째 항목 — notes 테이블에 deleted_at 도입 후 hard delete → soft delete 전환. Inbox 상단 탭 toggle 로 휴지통 보기, 카드별 복구 / 영구 삭제 + bulk emptyTrash, F5 export 가 trash 제외, F6-L3 import 가 deleted_at 보존, AiWorker 가 trash 노트 skip, telemetry 4 new events.
Architecture: migration v3 가 3 컬럼 추가 (deleted_at + last_recalled_at + recall_dismissed_at — 후자 둘은 #6 가 사용, v3 에 미리 박음). Active query (list/listAll/countToday) 에 명시적 WHERE deleted_at IS NULL. trash 시 pending_jobs 동시 정리 + AiWorker.processJob deletedAt 가드 (race 양쪽 cover). 휴지통 카드는 read-only mode (NoteCard 의 mode prop). per-card / bulk 영구 삭제는 main process 에서 Electron dialog.showMessageBox 로 confirm.
Tech Stack: TypeScript / electron-vite / better-sqlite3 / zod 4.3.6 / vitest 4 / React 19 / zustand 5. 신규 dep 없음.
선행 spec: docs/superpowers/specs/2026-05-01-v023-trash-design.md
선행 cut: v0.2.3 #7 telemetry skeleton (commit 6f8ae75) — 본 plan 이 emit hook 4 신규 추가
File Structure
| 경로 | 책임 |
|---|---|
src/main/db/migrations/m003_soft_delete.ts (new) |
v3 — 3 컬럼 추가 + idx_notes_deleted_at index. |
src/main/db/migrations/index.ts (modify) |
m003 등록. |
src/shared/types.ts (modify) |
Note 타입에 deletedAt/lastRecalledAt/recallDismissedAt 3 필드 + InboxApi 에 신규 메서드 5개. |
src/main/repository/NoteRepository.ts (modify) |
trash/restore/permanentDelete/emptyTrash/listTrashed 신규 메서드 + list/listAll/countToday 에 WHERE deleted_at IS NULL + hydrate 가 3 신규 필드 매핑. |
src/main/ai/AiWorker.ts (modify) |
processJob 진입 시 deletedAt 가드 1줄. |
src/main/services/CaptureService.ts (modify) |
deleteNote 가 trash 호출 (hard → soft). restoreNote/permanentDeleteNote/emptyTrash 신규. TelemetryEmitter interface 에 4 union 멤버 추가. 각 메서드 끝에 emit. |
src/main/services/telemetryEvents.ts (modify) |
zod discriminatedUnion 에 trash/restore/permanent_delete/empty_trash 4 새 멤버, payload .strict(). |
src/main/services/TelemetryService.ts (modify) |
EmitInput union 에 4 추가 (TS 타입만, runtime 변경 없음). |
src/main/services/telemetryStats.ts (modify) |
DailyRow 에 4 카운터 + 표 컬럼 + restore/trash ratio 출력. |
src/main/services/ImportService.ts (modify) |
ImportNoteInput 에 deletedAt?: string | null + INSERT 컬럼 + skip 케이스 의 deletedAt 갱신 정책. |
src/main/ipc/inboxApi.ts (modify) |
5 신규 채널 (restore/permanentDelete/emptyTrash/listTrash) + inbox:emptyTrash / inbox:permanentDelete 가 main 에서 dialog.showMessageBox confirm 후에야 실제 실행. |
src/preload/index.ts (modify) |
InboxApi 신규 5 메서드 IPC bridge. |
src/renderer/inbox/store.ts (modify) |
showTrash/trashNotes/trashCount state + toggleShowTrash/loadTrash/restoreNote/permanentDeleteNote/emptyTrash actions. upsertNote / removeNote 가 양쪽 list (notes / trashNotes) 갱신. |
src/renderer/inbox/App.tsx (modify) |
헤더에 탭 toggle. showTrash 시 상단에 "휴지통 비우기 (M개)" 버튼 + trashNotes 렌더 + mode="trash" prop 전달. |
src/renderer/inbox/components/NoteCard.tsx (modify) |
mode?: 'inbox' | 'trash' prop. mode==='trash' 시 edit 액션 모두 hidden, "🔄 복구" + "🗑 영구 삭제" 두 버튼 표시. |
테스트:
tests/unit/migrations.test.ts(modify) — v3 컬럼 + index 검증.tests/unit/NoteRepository.test.ts(modify) —trash/restore/permanentDelete/emptyTrash/listTrashed+ active query exclusion.tests/unit/AiWorker.test.ts(modify) — deletedAt 가드 케이스.tests/unit/CaptureService.test.ts(modify) — soft delete 동작 + 3 신규 메서드 + 4 emit.tests/unit/telemetryEvents.test.ts(modify) — 4 신규 kind privacy invariant.tests/unit/telemetryStats.test.ts(modify) — 4 카운터 + restore/trash ratio.tests/unit/ExportService.test.ts(modify) — trash 노트 export 제외 검증.tests/unit/ImportService.test.ts(modify) — deletedAt 보존 + skip 머지 정책.
Task 1: Migration v3 + Note type + hydrate
Files:
-
Create:
src/main/db/migrations/m003_soft_delete.ts -
Modify:
src/main/db/migrations/index.ts -
Modify:
src/shared/types.ts -
Modify:
src/main/repository/NoteRepository.ts(hydrate만) -
Modify:
tests/unit/migrations.test.ts -
Step 1: Migration 테스트 추가
tests/unit/migrations.test.ts 끝에 추가 (기존 케이스 그대로):
describe('migration v3 — soft delete columns', () => {
it('adds deleted_at, last_recalled_at, recall_dismissed_at to notes', () => {
const db = new Database(':memory:');
runMigrations(db);
const cols = db.prepare(`PRAGMA table_info(notes)`).all().map((r: any) => r.name);
expect(cols).toEqual(
expect.arrayContaining(['deleted_at', 'last_recalled_at', 'recall_dismissed_at'])
);
db.close();
});
it('creates idx_notes_deleted_at index', () => {
const db = new Database(':memory:');
runMigrations(db);
const indexes = db
.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'`)
.all() as Array<{ name: string }>;
expect(indexes.map((i) => i.name)).toContain('idx_notes_deleted_at');
db.close();
});
it('user_version reaches 3', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
expect(row.user_version).toBe(3);
db.close();
});
it('all 3 new columns default to NULL', () => {
const db = new Database(':memory:');
runMigrations(db);
db.prepare(
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES ('n1', 't', 'pending', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')`
).run();
const row = db.prepare('SELECT deleted_at, last_recalled_at, recall_dismissed_at FROM notes WHERE id=?').get('n1') as any;
expect(row.deleted_at).toBeNull();
expect(row.last_recalled_at).toBeNull();
expect(row.recall_dismissed_at).toBeNull();
db.close();
});
});
- Step 2: 테스트 — FAIL (m003 미존재)
Run: npm test -- tests/unit/migrations.test.ts
Expected: FAIL — idx_notes_deleted_at index 없음 + user_version 2.
- Step 3: m003_soft_delete.ts 생성
// src/main/db/migrations/m003_soft_delete.ts
import type Database from 'better-sqlite3';
export const version = 3;
export function up(db: Database.Database): void {
db.exec(`
ALTER TABLE notes ADD COLUMN deleted_at TEXT;
ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
`);
}
- Step 4: migrations/index.ts 등록
// src/main/db/migrations/index.ts
import type Database from 'better-sqlite3';
import * as m001 from './m001_initial.js';
import * as m002 from './m002_due_date.js';
import * as m003 from './m003_soft_delete.js';
const migrations = [m001, m002, m003];
export function latestVersion(): number {
return migrations[migrations.length - 1]!.version;
}
export function runMigrations(db: Database.Database): void {
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
const current = row.user_version ?? 0;
for (const m of migrations) {
if (m.version > current) {
const tx = db.transaction(() => {
m.up(db);
db.pragma(`user_version = ${m.version}`);
});
tx();
}
}
}
- Step 5: shared/types.ts 의
Note확장
// src/shared/types.ts — Note interface 확장
export interface Note {
// 기존 필드 그대로 ...
dueDate: string | null;
dueDateEditedByUser: boolean;
// 신규 v3:
deletedAt: string | null;
lastRecalledAt: string | null;
recallDismissedAt: string | null;
createdAt: string;
updatedAt: string;
tags: NoteTag[];
media: NoteMedia[];
}
3 신규 필드 추가. dueDateEditedByUser 와 createdAt 사이 어디든 OK (그룹화 위해 dueDate 다음).
- Step 6: NoteRepository.hydrate 매핑
src/main/repository/NoteRepository.ts:350-383 의 hydrate 메서드 — return 객체에 3 필드 추가:
return {
// 기존 필드 그대로 ...
dueDate: row.due_date ?? null,
dueDateEditedByUser: row.due_date_edited_by_user === 1,
deletedAt: row.deleted_at ?? null,
lastRecalledAt: row.last_recalled_at ?? null,
recallDismissedAt: row.recall_dismissed_at ?? null,
createdAt: row.created_at,
// ... 그대로 ...
};
- Step 7: 테스트 — PASS
Run: npm run typecheck && npm test -- tests/unit/migrations.test.ts
Expected: typecheck 0 errors. 4 신규 케이스 + 기존 2 모두 PASS.
- Step 8: 커밋
git add src/main/db/migrations/m003_soft_delete.ts src/main/db/migrations/index.ts src/shared/types.ts src/main/repository/NoteRepository.ts tests/unit/migrations.test.ts
git commit -m "feat(trash): migration v3 + Note type extension (#4 v0.2.3)"
Task 2: NoteRepository.trash + pending_jobs cleanup (atomic)
Files:
-
Modify:
src/main/repository/NoteRepository.ts -
Modify:
tests/unit/NoteRepository.test.ts -
Step 1: 실패 테스트 추가
tests/unit/NoteRepository.test.ts 끝에 새 describe:
describe('NoteRepository.trash', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('sets deleted_at and removes pending_jobs row atomically', () => {
const { id } = repo.create({ rawText: 'x' });
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const note = repo.findById(id)!;
expect(note.deletedAt).toBe('2026-05-01T12:00:00.000Z');
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
});
it('updates updated_at to deletedAt timestamp', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const note = repo.findById(id)!;
expect(note.updatedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('is no-op when note does not exist', () => {
expect(() => repo.trash('nonexistent', '2026-05-01T12:00:00.000Z')).not.toThrow();
});
});
만약 기존 tests/unit/NoteRepository.test.ts 에 import 가 없다면 다음 추가:
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
- Step 2: 테스트 — FAIL
Run: npm test -- tests/unit/NoteRepository.test.ts
Expected: FAIL — repo.trash 미정의.
- Step 3: 구현
src/main/repository/NoteRepository.ts 에 delete() 직전 (line ~224) 또는 setDueDate() 다음 위치에 추가:
trash(id: string, deletedAt: string): void {
const tx = this.db.transaction(() => {
this.db
.prepare(`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?`)
.run(deletedAt, deletedAt, id);
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id = ?`).run(id);
});
tx();
}
- Step 4: 테스트 — PASS
Run: npm test -- tests/unit/NoteRepository.test.ts
Expected: 3 신규 케이스 + 기존 케이스 모두 PASS.
- Step 5: 커밋
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(trash): NoteRepository.trash with pending_jobs cleanup (#4 v0.2.3)"
Task 3: NoteRepository.restore
Files:
-
Modify:
src/main/repository/NoteRepository.ts -
Modify:
tests/unit/NoteRepository.test.ts -
Step 1: 실패 테스트 추가
tests/unit/NoteRepository.test.ts 의 describe('NoteRepository.trash', ...) 다음에:
describe('NoteRepository.restore', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('clears deleted_at on a trashed note', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
repo.restore(id);
const note = repo.findById(id)!;
expect(note.deletedAt).toBeNull();
});
it('updates updated_at', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const before = repo.findById(id)!.updatedAt;
repo.restore(id);
const after = repo.findById(id)!.updatedAt;
expect(after).not.toBe(before);
});
it('is no-op on already-active note', () => {
const { id } = repo.create({ rawText: 'x' });
expect(() => repo.restore(id)).not.toThrow();
expect(repo.findById(id)!.deletedAt).toBeNull();
});
});
- Step 2: 테스트 — FAIL
Run: npm test -- tests/unit/NoteRepository.test.ts
Expected: FAIL — repo.restore 미정의.
- Step 3: 구현
trash 메서드 직후에 추가:
restore(id: string): void {
const now = new Date().toISOString();
this.db
.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`)
.run(now, id);
}
- Step 4: 테스트 — PASS
Run: npm test -- tests/unit/NoteRepository.test.ts
Expected: 3 신규 + 기존 PASS.
- Step 5: 커밋
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(trash): NoteRepository.restore (#4 v0.2.3)"
Task 4: NoteRepository.permanentDelete + emptyTrash + listTrashed
Files:
-
Modify:
src/main/repository/NoteRepository.ts -
Modify:
tests/unit/NoteRepository.test.ts -
Step 1: 실패 테스트 추가
describe('NoteRepository.permanentDelete', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('removes notes row + cascades note_tags / pending_jobs', () => {
const { id } = repo.create({ rawText: 'x' });
repo.updateAiResult(id, { title: 'T', summary: 'a\nb\nc', tags: ['tag-a'], provider: 'p', dueDate: null });
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
repo.permanentDelete(id);
expect(repo.findById(id)).toBeNull();
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
});
});
describe('NoteRepository.emptyTrash', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('hard-deletes all trashed notes and returns their ids', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T01:00:00.000Z');
const r = repo.emptyTrash();
expect(r.noteIds.sort()).toEqual([a, c].sort());
expect(repo.findById(a)).toBeNull();
expect(repo.findById(b)).not.toBeNull();
expect(repo.findById(c)).toBeNull();
});
it('returns empty array when trash is empty', () => {
expect(repo.emptyTrash()).toEqual({ noteIds: [] });
});
});
describe('NoteRepository.listTrashed', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('returns trashed notes ordered by deleted_at DESC', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T02:00:00.000Z');
repo.trash(b, '2026-05-01T01:00:00.000Z');
const r = repo.listTrashed({ limit: 50 });
expect(r.map((n) => n.id)).toEqual([c, b, a]);
});
it('excludes active notes', () => {
repo.create({ rawText: 'active' });
const r = repo.listTrashed({ limit: 50 });
expect(r).toEqual([]);
});
it('respects limit', () => {
for (let i = 0; i < 5; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.trash(id, `2026-05-01T0${i}:00:00.000Z`);
}
const r = repo.listTrashed({ limit: 3 });
expect(r).toHaveLength(3);
});
});
- Step 2: 테스트 — FAIL
Run: npm test -- tests/unit/NoteRepository.test.ts
Expected: FAIL — permanentDelete/emptyTrash/listTrashed 미정의.
- Step 3: 구현
restore 메서드 직후에 추가:
permanentDelete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
emptyTrash(): { noteIds: string[] } {
const noteIds: string[] = [];
const tx = this.db.transaction(() => {
const rows = this.db
.prepare('SELECT id FROM notes WHERE deleted_at IS NOT NULL')
.all() as Array<{ id: string }>;
for (const r of rows) {
this.db.prepare('DELETE FROM notes WHERE id=?').run(r.id);
noteIds.push(r.id);
}
});
tx();
return { noteIds };
}
listTrashed(opts: { limit: number }): Note[] {
const limit = Math.max(1, Math.min(200, opts.limit));
const rows = this.db
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
.all(limit) as any[];
return rows.map((r) => this.hydrate(r));
}
기존 delete(id) 메서드 (NoteRepository.ts:224) 를 deprecate 표시:
/** @deprecated v0.2.3 #4 부터 hard delete 는 permanentDelete() 사용. soft delete 는 trash(). 본 메서드는 v0.2.4 에서 제거 예정. */
delete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
- Step 4: 테스트 — PASS
Run: npm test -- tests/unit/NoteRepository.test.ts
Expected: 7 신규 + 기존 PASS.
- Step 5: 커밋
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(trash): NoteRepository.permanentDelete/emptyTrash/listTrashed (#4 v0.2.3)"
Task 5: Active query filters (list / listAll / countToday)
Files:
-
Modify:
src/main/repository/NoteRepository.ts -
Modify:
tests/unit/NoteRepository.test.ts -
Step 1: 실패 테스트 추가
describe('Active queries exclude deleted notes', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('list() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
const r = repo.list({ limit: 50 });
expect(r.map((n) => n.id)).toEqual([b]);
});
it('listAll() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b' });
repo.trash(a, '2026-05-01T00:00:00.000Z');
const r = repo.listAll();
expect(r.map((n) => n.rawText)).toEqual(['b']);
});
it('countToday() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b' });
repo.trash(a, new Date().toISOString());
expect(repo.countToday(new Date())).toBe(1);
});
it('findById() returns trashed notes (does NOT filter)', () => {
const { id } = repo.create({ rawText: 'a' });
repo.trash(id, '2026-05-01T00:00:00.000Z');
const note = repo.findById(id);
expect(note).not.toBeNull();
expect(note!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
});
});
- Step 2: 테스트 — FAIL
Run: npm test -- tests/unit/NoteRepository.test.ts
Expected: FAIL — list/listAll/countToday 가 trash 노트를 포함.
- Step 3: 구현 —
list,listAll,countToday의 SQL 에WHERE deleted_at IS NULL추가
src/main/repository/NoteRepository.ts:82-92 (list) 변경:
list(opts: { limit: number; cursor?: string }): Note[] {
const limit = Math.max(1, Math.min(200, opts.limit));
const rows = opts.cursor
? (this.db
.prepare(
`SELECT * FROM notes
WHERE deleted_at IS NULL AND created_at < ?
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(opts.cursor, limit) as any[])
: (this.db
.prepare(
`SELECT * FROM notes
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(limit) as any[]);
return rows.map((r) => this.hydrate(r));
}
src/main/repository/NoteRepository.ts:94-99 (listAll) 변경:
listAll(): Note[] {
const rows = this.db
.prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
.all() as any[];
return rows.map((r) => this.hydrate(r));
}
src/main/repository/NoteRepository.ts:311-325 (countToday) 변경:
countToday(now: Date = new Date()): number {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
const kstYear = kstNow.getUTCFullYear();
const kstMonth = kstNow.getUTCMonth();
const kstDate = kstNow.getUTCDate();
const kstMidnightUtc = Date.UTC(kstYear, kstMonth, kstDate) - KST_OFFSET_MS;
const nextKstMidnightUtc = kstMidnightUtc + 24 * 60 * 60 * 1000;
const startIso = new Date(kstMidnightUtc).toISOString();
const endIso = new Date(nextKstMidnightUtc).toISOString();
const row = this.db
.prepare(
`SELECT COUNT(*) AS c FROM notes
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?`
)
.get(startIso, endIso) as { c: number };
return row.c;
}
- Step 4: 테스트 — PASS
Run: npm test -- tests/unit/NoteRepository.test.ts
Expected: 4 신규 + 기존 PASS.
- Step 5: 커밋
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(trash): active queries exclude deleted_at IS NOT NULL (#4 v0.2.3)"
Task 6: AiWorker.processJob deletedAt 가드
Files:
-
Modify:
src/main/ai/AiWorker.ts -
Modify:
tests/unit/AiWorker.test.ts -
Step 1: 실패 테스트 추가
tests/unit/AiWorker.test.ts 끝에:
describe('AiWorker — deletedAt guard', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('skips notes with deleted_at IS NOT NULL — provider.generate not called', async () => {
const { id } = repo.create({ rawText: 'x' });
// 먼저 trash — pending_jobs cleanup 됨
repo.trash(id, '2026-05-01T12:00:00.000Z');
// 강제로 pending_jobs row 다시 삽입 (race 시뮬레이션 — AiWorker 가 이미 dequeue 한 상태 흉내)
db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, '2026-05-01T12:00:00.000Z');
const generate = vi.fn();
const provider = makeProvider({ generate: generate as any });
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
await w.loadFromDb();
await w.drain();
expect(generate).not.toHaveBeenCalled();
expect(repo.findById(id)!.aiStatus).toBe('pending');
});
});
- Step 2: 테스트 — FAIL
Run: npm test -- tests/unit/AiWorker.test.ts
Expected: FAIL — provider.generate 가 호출됨 (가드 미존재).
- Step 3: 구현
src/main/ai/AiWorker.ts:99-100 의 processJob 진입 체크 변경:
const note = this.repo.findById(job.noteId);
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
- Step 4: 테스트 — PASS
Run: npm test -- tests/unit/AiWorker.test.ts
Expected: 신규 + 기존 PASS.
- Step 5: 커밋
git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts
git commit -m "feat(trash): AiWorker.processJob deletedAt guard (#4 v0.2.3)"
Task 7: telemetryEvents schema + TelemetryService EmitInput 확장 (4 신규 kind)
Files:
-
Modify:
src/main/services/telemetryEvents.ts -
Modify:
src/main/services/TelemetryService.ts -
Modify:
tests/unit/telemetryEvents.test.ts -
Step 1: 실패 테스트 추가
tests/unit/telemetryEvents.test.ts 끝에:
describe('validateEvent — trash family (v0.2.3 #4)', () => {
it('accepts trash event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'trash',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('trash');
});
it('accepts restore event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'restore',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('restore');
});
it('accepts permanent_delete event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'permanent_delete',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('permanent_delete');
});
it('accepts empty_trash event with count', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: 7 }
});
expect(e.kind).toBe('empty_trash');
});
it('rejects trash payload with rawText leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'trash',
payload: { noteId: 'n1', rawText: 'leak' }
})).toThrow();
});
it('rejects empty_trash with negative count', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: -1 }
})).toThrow();
});
it('rejects empty_trash with non-integer count', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: 1.5 }
})).toThrow();
});
});
- Step 2: 테스트 — FAIL
Run: npm test -- tests/unit/telemetryEvents.test.ts
Expected: FAIL — 4 신규 kind 미지원.
- Step 3: 구현 —
telemetryEvents.ts의discriminatedUnion확장
// src/main/services/telemetryEvents.ts
import { z } from 'zod';
const CapturePayload = z.object({
noteId: z.string().min(1),
rawTextLength: z.number().int().nonnegative(),
hasMedia: z.boolean()
}).strict();
const AiSucceededPayload = z.object({
noteId: z.string().min(1),
durationMs: z.number().nonnegative(),
attempts: z.number().int().nonnegative()
}).strict();
const AiFailedReason = z.enum(['unreachable', 'schema', 'timeout', 'other']);
const AiFailedPayload = z.object({
noteId: z.string().min(1),
reason: AiFailedReason,
attempts: z.number().int().nonnegative()
}).strict();
const NoteIdPayload = z.object({
noteId: z.string().min(1)
}).strict();
const EmptyTrashPayload = z.object({
count: z.number().int().nonnegative()
}).strict();
export const TelemetryEventSchema = z.discriminatedUnion('kind', [
z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict()
]);
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
export type TelemetryKind = TelemetryEvent['kind'];
export function validateEvent(raw: unknown): TelemetryEvent {
return TelemetryEventSchema.parse(raw);
}
- Step 4: TelemetryService 의 EmitInput union 확장
src/main/services/TelemetryService.ts 의 EmitInput 타입 변경:
export type EmitInput =
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } };
- Step 5: 테스트 — PASS + typecheck
Run: npm run typecheck && npm test -- tests/unit/telemetryEvents.test.ts
Expected: typecheck 0 errors. 7 신규 + 기존 PASS.
- Step 6: 커밋
git add src/main/services/telemetryEvents.ts src/main/services/TelemetryService.ts tests/unit/telemetryEvents.test.ts
git commit -m "feat(trash): telemetry 4 new kinds (trash/restore/permanent_delete/empty_trash) (#4 v0.2.3)"
Task 8: telemetryStats 4 카운터 + restore/trash ratio
Files:
-
Modify:
src/main/services/telemetryStats.ts -
Modify:
tests/unit/telemetryStats.test.ts -
Step 1: 실패 테스트 추가
tests/unit/telemetryStats.test.ts 끝에:
describe('aggregateStats — trash family (v0.2.3 #4)', () => {
it('counts trash/restore/permanent_delete/empty_trash per day', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'trash', { noteId: 'n1' }),
e('2026-05-01T01:00:00Z', 'trash', { noteId: 'n2' }),
e('2026-05-01T02:00:00Z', 'restore', { noteId: 'n1' }),
e('2026-05-01T03:00:00Z', 'permanent_delete', { noteId: 'n3' }),
e('2026-05-01T04:00:00Z', 'empty_trash', { count: 5 })
];
const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z'));
expect(r.eventCount).toBe(5);
expect(r.md).toContain('| 2026-05-01 | 0 | 0 | 0 | 2 | 1 | 1 | 1 |');
});
it('computes restore/trash ratio', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'trash', { noteId: 'a' }),
e('2026-05-01T00:00:01Z', 'trash', { noteId: 'b' }),
e('2026-05-01T00:00:02Z', 'trash', { noteId: 'c' }),
e('2026-05-01T00:00:03Z', 'trash', { noteId: 'd' }),
e('2026-05-01T00:00:04Z', 'restore', { noteId: 'a' })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('휴지통 회수율: 25.0% (1/4)');
});
it('휴지통 회수율 N/A when no trash events', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('휴지통 회수율: N/A');
});
});
- Step 2: 테스트 — FAIL
Run: npm test -- tests/unit/telemetryStats.test.ts
Expected: FAIL — 신규 kind 카운트 + ratio 미지원.
- Step 3: 구현
src/main/services/telemetryStats.ts 변경 — DailyRow 인터페이스에 4 필드 추가, 집계 if 블록 추가, 표 헤더/행 + ratio 라인:
import type { TelemetryEvent } from './telemetryEvents.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
function kstDate(ts: string): string {
const d = new Date(ts);
const k = new Date(d.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
.toISOString().slice(0, 10);
}
interface DailyRow {
date: string;
capture: number;
ai_succeeded: number;
ai_failed: number;
trash: number;
restore: number;
permanent_delete: number;
empty_trash: number;
}
export interface StatsResult {
md: string;
eventCount: number;
}
export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): StatsResult {
const eventCount = events.length;
const byDay = new Map<string, DailyRow>();
let aiSucceeded = 0;
let aiFailed = 0;
let durationSum = 0;
let durationN = 0;
let trashCount = 0;
let restoreCount = 0;
for (const ev of events) {
const day = kstDate(ev.ts);
let row = byDay.get(day);
if (!row) {
row = { date: day, capture: 0, ai_succeeded: 0, ai_failed: 0, trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0 };
byDay.set(day, row);
}
if (ev.kind === 'capture') row.capture += 1;
else if (ev.kind === 'ai_succeeded') {
row.ai_succeeded += 1;
aiSucceeded += 1;
durationSum += ev.payload.durationMs;
durationN += 1;
} else if (ev.kind === 'ai_failed') {
row.ai_failed += 1;
aiFailed += 1;
} else if (ev.kind === 'trash') {
row.trash += 1;
trashCount += 1;
} else if (ev.kind === 'restore') {
row.restore += 1;
restoreCount += 1;
} else if (ev.kind === 'permanent_delete') {
row.permanent_delete += 1;
} else if (ev.kind === 'empty_trash') {
row.empty_trash += 1;
}
}
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
const aiTotal = aiSucceeded + aiFailed;
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
const trashRecoveryRate = trashCount === 0
? 'N/A'
: `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;
const lines: string[] = [];
lines.push('# Inkling Telemetry Stats');
lines.push('');
lines.push(`생성: ${generatedAt.toISOString()}`);
lines.push(`총 이벤트: ${eventCount}`);
lines.push('');
lines.push('## 일자별 카운트');
lines.push('');
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash |');
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|');
for (const row of days) {
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} |`);
}
lines.push('');
lines.push('## 핵심 ratio');
lines.push('');
lines.push(`- AI 성공률: ${successRate}`);
lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`);
lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`);
lines.push('');
return { md: lines.join('\n'), eventCount };
}
- Step 4: 테스트 — PASS
Run: npm test -- tests/unit/telemetryStats.test.ts
Expected: 3 신규 + 기존 PASS.
- Step 5: 커밋
git add src/main/services/telemetryStats.ts tests/unit/telemetryStats.test.ts
git commit -m "feat(trash): telemetryStats 4 new counters + 휴지통 회수율 ratio (#4 v0.2.3)"
Task 9: CaptureService — soft delete + 3 신규 메서드 + 4 emit hooks
Files:
-
Modify:
src/main/services/CaptureService.ts -
Modify:
tests/unit/CaptureService.test.ts -
Step 1: 실패 테스트 추가
tests/unit/CaptureService.test.ts 끝에:
describe('CaptureService trash flow (v0.2.3 #4)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let events: Array<{ kind: string; payload: any }>;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-trash-'));
store = new MediaStore(tmp);
events = [];
});
it('deleteNote sets deleted_at and emits trash event (no media cleanup)', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
events.length = 0; // clear capture event
await svc.deleteNote(noteId);
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('trash');
expect(events[0]!.payload.noteId).toBe(noteId);
// media 디렉터리 보존 확인 (restore 시 필요)
expect(existsSync(join(tmp, 'media', noteId))).toBe(true);
});
it('restoreNote clears deleted_at and emits restore event', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [] });
events.length = 0;
await svc.deleteNote(noteId);
events.length = 0;
await svc.restoreNote(noteId);
expect(repo.findById(noteId)!.deletedAt).toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('restore');
});
it('permanentDeleteNote hard-deletes + cleans media + emits permanent_delete', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
events.length = 0;
await svc.permanentDeleteNote(noteId);
expect(repo.findById(noteId)).toBeNull();
expect(existsSync(join(tmp, 'media', noteId))).toBe(false);
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('permanent_delete');
});
it('emptyTrash deletes all trashed + cleans each media + emits empty_trash with count', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const a = (await svc.submit({ text: 'a', images: [new ArrayBuffer(8)] })).noteId;
const b = (await svc.submit({ text: 'b', images: [new ArrayBuffer(8)] })).noteId;
await svc.submit({ text: 'c (active)', images: [] });
await svc.deleteNote(a);
await svc.deleteNote(b);
events.length = 0;
const r = await svc.emptyTrash();
expect(r.count).toBe(2);
expect(repo.findById(a)).toBeNull();
expect(repo.findById(b)).toBeNull();
expect(existsSync(join(tmp, 'media', a))).toBe(false);
expect(existsSync(join(tmp, 'media', b))).toBe(false);
const empty = events.find((e) => e.kind === 'empty_trash')!;
expect(empty.payload.count).toBe(2);
});
it('emptyTrash returns count=0 when trash empty', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const r = await svc.emptyTrash();
expect(r.count).toBe(0);
});
});
(import 추가 필요 시 파일 head 의 기존 imports 참고: mkdtempSync, existsSync, tmpdir, join)
- Step 2: 테스트 — FAIL
Run: npm test -- tests/unit/CaptureService.test.ts
Expected: FAIL — restoreNote/permanentDeleteNote/emptyTrash 미정의 + deleteNote 가 hard delete 라 deletedAt 검증 깨짐.
- Step 3: 구현 —
CaptureService.ts변경
TelemetryEmitter interface 확장 + 메서드 4개 변경/신규:
// src/main/services/CaptureService.ts
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { MediaStore } from './MediaStore.js';
export interface TelemetryEmitter {
emit(input:
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } }
): Promise<void>;
}
export interface CaptureDeps {
enqueue: (noteId: string) => Promise<void>;
celebrate: (noteId: string) => void;
telemetry?: TelemetryEmitter;
}
export interface SubmitInput {
text: string;
images: ArrayBuffer[];
}
export class CaptureService {
constructor(
private repo: NoteRepository,
private store: MediaStore,
private deps: CaptureDeps
) {}
async submit(input: SubmitInput): Promise<{ noteId: string }> {
const trimmed = input.text.trim();
if (trimmed.length === 0 && input.images.length === 0) {
throw new Error('empty submission');
}
const { id } = this.repo.create({ rawText: input.text });
if (input.images.length > 0) {
const rows = [];
for (const img of input.images) {
const buf = Buffer.from(img);
const saved = await this.store.saveImage(id, buf, 'image/png');
rows.push({
noteId: id,
kind: 'image' as const,
relPath: saved.relPath,
mime: saved.mime,
bytes: saved.bytes
});
}
this.repo.insertMedia(rows);
}
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'capture',
payload: {
noteId: id,
rawTextLength: input.text.length,
hasMedia: input.images.length > 0
}
}).catch(() => {});
}
await this.deps.enqueue(id);
this.deps.celebrate(id);
return { noteId: id };
}
async deleteNote(noteId: string): Promise<void> {
// v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요).
this.repo.trash(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
}
}
async restoreNote(noteId: string): Promise<void> {
this.repo.restore(noteId);
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
}
}
async permanentDeleteNote(noteId: string): Promise<void> {
this.repo.permanentDelete(noteId);
await this.store.deleteNoteDirectory(noteId);
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
}
}
async emptyTrash(): Promise<{ count: number }> {
const { noteIds } = this.repo.emptyTrash();
for (const id of noteIds) {
try { await this.store.deleteNoteDirectory(id); }
catch { /* best-effort */ }
}
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
}
return { count: noteIds.length };
}
}
- Step 4: 테스트 — PASS
Run: npm test -- tests/unit/CaptureService.test.ts
Expected: 5 신규 + 기존 PASS.
- Step 5: 커밋
git add src/main/services/CaptureService.ts tests/unit/CaptureService.test.ts
git commit -m "feat(trash): CaptureService soft-delete + restore/permanent/empty + 4 emits (#4 v0.2.3)"
Task 10: ImportService — deletedAt 보존 + 충돌 정책
Files:
-
Modify:
src/main/repository/NoteRepository.ts(importNote만) -
Modify:
src/main/services/ImportService.ts -
Modify:
src/main/services/importFormat.ts(frontmatter 에deleted_at추가) -
Modify:
src/main/services/exportFormat.ts(frontmatter 출력 시deleted_at무시 — 이미 listAll filter 로 trash 제외이므로 always null) -
Modify:
tests/unit/ImportService.test.ts -
Step 1: 실패 테스트 추가
tests/unit/ImportService.test.ts 끝에:
describe('ImportService — deletedAt preservation (v0.2.3 #4)', () => {
// 이 시나리오는 F5 export 가 trash 를 제외하므로 source 의 deletedAt 은 항상 null.
// 단 외부에서 직접 frontmatter 에 deleted_at 을 넣은 경우 (수동 편집) 보존되어야 함.
it('id-collide skip: source deleted_at IS NOT NULL → dest deleted_at 갱신', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'identical' });
// import: 같은 id + 같은 raw_text + deletedAt 값 → dest 의 deleted_at 을 갱신
const r = repo.importNote({
id, rawText: 'identical',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('skipped');
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('id-collide skip: source deleted_at NULL + dest IS NOT NULL → dest 유지', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'identical' });
repo.trash(id, '2026-05-01T00:00:00.000Z');
repo.importNote({
id, rawText: 'identical',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: null
});
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
});
it('id-new insert: source deletedAt 보존', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const r = repo.importNote({
id: 'fresh-id', rawText: 'fresh',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('inserted');
expect(repo.findById('fresh-id')!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('id-collide forked: deletedAt 도 fork 노트에 보존', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'original' });
const r = repo.importNote({
id, rawText: 'different',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('forked');
expect(r.id).not.toBe(id);
expect(repo.findById(r.id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
});
- Step 2: 테스트 — FAIL
Run: npm test -- tests/unit/ImportService.test.ts
Expected: FAIL — ImportNoteInput.deletedAt 미지원 (typecheck) 또는 INSERT 가 컬럼 미포함.
- Step 3: NoteRepository.importNote 변경
ImportNoteInput interface (NoteRepository.ts:15-31) 에 deletedAt?: string | null; 추가:
export interface ImportNoteInput {
id: string;
rawText: string;
createdAt: string;
updatedAt: string;
aiTitle: string | null;
aiSummary: string | null;
titleEditedByUser: boolean;
summaryEditedByUser: boolean;
aiProvider: string | null;
aiGeneratedAt: string | null;
userIntent: string | null;
intentPromptedAt: string | null;
tags: { name: string; source: 'ai' | 'user' }[];
deletedAt?: string | null;
}
importNote 함수 (NoteRepository.ts:243-296) 변경 — skip 케이스의 deletedAt 갱신 로직 + INSERT 의 deleted_at 컬럼:
importNote(input: ImportNoteInput): ImportNoteResult {
const existing = this.findRawTextById(input.id);
let finalId = input.id;
let status: ImportNoteStatus = 'inserted';
if (existing !== null) {
if (existing === input.rawText) {
// skip — 단, source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존)
if (input.deletedAt) {
const destRow = this.db
.prepare('SELECT deleted_at FROM notes WHERE id=?')
.get(input.id) as { deleted_at: string | null } | undefined;
if (destRow && destRow.deleted_at === null) {
this.db
.prepare('UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?')
.run(input.deletedAt, input.deletedAt, input.id);
}
}
return { id: input.id, status: 'skipped' };
}
finalId = uuidv7();
status = 'forked';
}
const tx = this.db.transaction(() => {
this.db
.prepare(
`INSERT INTO notes
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
title_edited_by_user, summary_edited_by_user,
user_intent, intent_prompted_at, deleted_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
finalId,
input.rawText,
input.aiTitle,
input.aiSummary,
input.aiProvider,
input.aiGeneratedAt,
input.titleEditedByUser ? 1 : 0,
input.summaryEditedByUser ? 1 : 0,
input.userIntent,
input.intentPromptedAt,
input.deletedAt ?? null,
input.createdAt,
input.updatedAt
);
if (input.tags.length > 0) {
const getOrInsertTag = this.db.prepare(
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
);
const linkAi = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
);
const linkUser = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
);
for (const t of input.tags) {
const row = getOrInsertTag.get(t.name) as { id: number };
if (t.source === 'ai') linkAi.run(finalId, row.id);
else linkUser.run(finalId, row.id);
}
}
});
tx();
return { id: finalId, status };
}
- Step 4: ImportService 가 frontmatter 의
deleted_at을ImportNoteInput.deletedAt으로 전달
먼저 src/main/services/importFormat.ts 를 읽고 frontmatter 파싱 위치 확인:
Run: cat src/main/services/importFormat.ts | head -60
해당 위치에서 frontmatter 의 deleted_at 키를 ISO string 으로 추출 (없으면 null). 그 결과를 ImportService 가 repo.importNote({..., deletedAt: parsed.deletedAt ?? null}) 로 전달.
ImportService 변경 — 호출 site 에 deletedAt 추가. 정확한 위치는 ImportService.ts 에서 repo.importNote({...}) 를 호출하는 곳.
- Step 5: 테스트 — PASS
Run: npm run typecheck && npm test -- tests/unit/ImportService.test.ts
Expected: typecheck 0. 4 신규 + 기존 PASS.
- Step 6: 커밋
git add src/main/repository/NoteRepository.ts src/main/services/ImportService.ts src/main/services/importFormat.ts tests/unit/ImportService.test.ts
git commit -m "feat(trash): ImportService deletedAt preservation + skip-merge policy (#4 v0.2.3)"
Task 11: ExportService — listAll filter 검증 (no code change, test only)
Files:
- Modify:
tests/unit/ExportService.test.ts
ExportService 자체 코드는 무수정 — repo.listAll() 이 Task 5 에서 이미 WHERE deleted_at IS NULL 추가됨. 단 명시적 회귀 테스트 추가.
- Step 1: 실패 테스트 추가 (회귀 가드용)
tests/unit/ExportService.test.ts 끝에:
describe('ExportService — trash exclusion (v0.2.3 #4)', () => {
it('does NOT export trashed notes (listAll filter)', async () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const tmp = mkdtempSync(join(tmpdir(), 'inkling-export-trash-'));
const store = new MediaStore(tmp);
const a = repo.create({ rawText: 'active note' }).id;
const t = repo.create({ rawText: 'trashed note' }).id;
repo.updateAiResult(a, { title: '활성', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
repo.updateAiResult(t, { title: '버려짐', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
repo.trash(t, '2026-05-01T00:00:00.000Z');
const svc = new ExportService(repo, store);
const out = mkdtempSync(join(tmpdir(), 'inkling-export-out-'));
const r = await svc.export(out, { includeMedia: false });
expect(r.noteCount).toBe(1);
// index.jsonl 도 trash 미포함
const indexPath = join(out, 'index.jsonl');
const lines = readFileSync(indexPath, 'utf8').trim().split('\n');
expect(lines).toHaveLength(1);
rmSync(tmp, { recursive: true, force: true });
rmSync(out, { recursive: true, force: true });
});
});
- Step 2: 테스트 실행 — PASS (회귀 가드 즉시 그린)
Run: npm test -- tests/unit/ExportService.test.ts
Expected: 신규 + 기존 PASS — Task 5 의 listAll filter 가 자동으로 trash 제외.
- Step 3: 커밋
git add tests/unit/ExportService.test.ts
git commit -m "test(trash): ExportService excludes trashed notes (regression guard, #4 v0.2.3)"
Task 12: IPC 5 신규 채널 + native dialog confirm
Files:
-
Modify:
src/main/ipc/inboxApi.ts -
Modify:
src/preload/index.ts -
Modify:
src/shared/types.ts(InboxApi확장) -
Step 1:
InboxApi타입 확장 (src/shared/types.ts)
InboxApi interface 에 5 메서드 추가:
export interface InboxApi {
// 기존 메서드 그대로 ...
listNotes(opts: { limit: number; cursor?: string }): Promise<Note[]>;
// ...
deleteNote(noteId: string): Promise<void>; // 의미만 변경 (hard → soft)
// 신규 v0.2.3 #4:
restoreNote(noteId: string): Promise<void>;
permanentDeleteNote(noteId: string): Promise<{ confirmed: boolean }>; // confirm 거부 시 confirmed=false
emptyTrash(): Promise<{ confirmed: boolean; count: number }>;
listTrash(opts: { limit: number }): Promise<Note[]>;
getTrashCount(): Promise<number>;
// 기존 ...
onNoteUpdated(cb: (note: Note) => void): () => void;
}
getTrashCount 신규 — 헤더 탭 라벨 휴지통(M) 갱신용. permanentDeleteNote 와 emptyTrash 가 confirm dialog 거치므로 cancel 케이스 (confirmed: false) 가능.
- Step 2:
inboxApi.ts의 5 신규 채널 등록
// src/main/ipc/inboxApi.ts — registerInboxApi 마지막에 추가
import electron from 'electron';
const { ipcMain, dialog } = electron;
// ... 기존 imports ...
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 trashCount = deps.repo.listTrashed({ limit: 1000 }).length;
if (trashCount === 0) return { confirmed: true, count: 0 };
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['휴지통 비우기', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: `휴지통의 노트 ${trashCount}개를 영구 삭제합니다`,
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.listTrashed({ limit: 1 }).length === 0
? 0
: deps.repo.listTrashed({ limit: 200 }).length
);
inbox:trashCount 가 200 limit 위면 부정확하지만 실용 한도. 정확한 카운트가 필요해지면 v0.2.4 에서 repo.countTrashed() 추가.
- Step 3:
preload/index.ts의 InboxApi bridge 확장
// src/preload/index.ts — inbox 객체 안에 추가
const api: InklingApi = {
capture: { /* 그대로 */ },
inbox: {
// 기존 메서드 그대로 ...
deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
// 신규 v0.2.3 #4:
restoreNote: (noteId) => ipcRenderer.invoke('inbox:restore', noteId),
permanentDeleteNote: (noteId) => ipcRenderer.invoke('inbox:permanentDelete', noteId),
emptyTrash: () => ipcRenderer.invoke('inbox:emptyTrash'),
listTrash: (opts) => ipcRenderer.invoke('inbox:listTrash', opts),
getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'),
// 기존 ...
onNoteUpdated: (cb) => { /* 그대로 */ }
}
};
- Step 4: typecheck + 기존 테스트 회귀 없음 확인
Run: npm run typecheck && npm test
Expected: typecheck 0 errors. 모든 단위 테스트 PASS (renderer 변경 전이라 e2e smoke 영향 없음).
- Step 5: 커밋
git add src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts
git commit -m "feat(trash): IPC 5 channels + native dialog confirm + InboxApi extension (#4 v0.2.3)"
Task 13: Renderer store — showTrash/trashNotes/trashCount + actions
Files:
-
Modify:
src/renderer/inbox/store.ts -
Create:
tests/unit/store.trash.test.ts -
Step 1: 실패 테스트 추가
// tests/unit/store.trash.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Note } from '@shared/types';
const mockApi = {
listNotes: vi.fn(async () => [] as Note[]),
listTrash: vi.fn(async () => [] as Note[]),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
getPendingCount: vi.fn(async () => 0),
getOllamaStatus: vi.fn(async () => ({ ok: true })),
getTodayCount: vi.fn(async () => 0),
restoreNote: vi.fn(async () => {}),
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
deleteNote: vi.fn(async () => {}),
onNoteUpdated: vi.fn(() => () => {}),
updateAiFields: vi.fn(async () => {}),
setDueDate: vi.fn(async () => {}),
setIntent: vi.fn(async () => {}),
dismissIntent: vi.fn(async () => {})
};
vi.mock('@renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
const noteStub = (id: string, deletedAt: string | null = null): Note => ({
id, rawText: 'x',
aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
aiProvider: null, aiGeneratedAt: null,
titleEditedByUser: false, summaryEditedByUser: false,
userIntent: null, intentPromptedAt: null,
dueDate: null, dueDateEditedByUser: false,
deletedAt, lastRecalledAt: null, recallDismissedAt: null,
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
tags: [], media: []
});
describe('useInbox — trash state (v0.2.3 #4)', () => {
beforeEach(async () => {
const { useInbox } = await import('@renderer/inbox/store.js');
useInbox.setState({
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
ollamaStatus: { ok: true },
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }
});
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
});
it('toggleShowTrash flips state and triggers loadTrash on enter', async () => {
mockApi.listTrash.mockResolvedValueOnce([noteStub('t1', '2026-05-01T00:00:00Z')]);
const { useInbox } = await import('@renderer/inbox/store.js');
await useInbox.getState().toggleShowTrash();
expect(useInbox.getState().showTrash).toBe(true);
expect(useInbox.getState().trashNotes).toHaveLength(1);
expect(mockApi.listTrash).toHaveBeenCalled();
await useInbox.getState().toggleShowTrash();
expect(useInbox.getState().showTrash).toBe(false);
});
it('upsertNote routes to trashNotes when deletedAt IS NOT NULL', async () => {
const { useInbox } = await import('@renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
expect(useInbox.getState().notes).toHaveLength(0);
expect(useInbox.getState().trashNotes).toHaveLength(1);
});
it('upsertNote moves note from notes to trashNotes when trashed', async () => {
const { useInbox } = await import('@renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a'));
expect(useInbox.getState().notes).toHaveLength(1);
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
expect(useInbox.getState().notes).toHaveLength(0);
expect(useInbox.getState().trashNotes).toHaveLength(1);
});
it('restoreNote calls api + moves note back', async () => {
const { useInbox } = await import('@renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
await useInbox.getState().restoreNote('a');
expect(mockApi.restoreNote).toHaveBeenCalledWith('a');
// 노트 자체 이동은 onNoteUpdated 이벤트로 처리되므로 store 자체엔 즉시 반영 안 됨 OK
});
it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => {
mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 });
const { useInbox } = await import('@renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
await useInbox.getState().emptyTrash();
expect(useInbox.getState().trashNotes).toHaveLength(1);
});
});
- Step 2: 테스트 — FAIL
Run: npm test -- tests/unit/store.trash.test.ts
Expected: FAIL — trashNotes/trashCount/showTrash 미정의.
- Step 3: store.ts 변경
// src/renderer/inbox/store.ts
import { create } from 'zustand';
import type { Note, WeeklyContinuity } from '@shared/types';
import { inboxApi } from './api.js';
export { selectFilteredNotes } from './selectFilteredNotes.js';
interface InboxState {
notes: Note[];
trashNotes: Note[];
trashCount: number;
showTrash: boolean;
continuity: WeeklyContinuity;
pendingCount: number;
ollamaStatus: { ok: boolean; reason?: string };
todayCount: number;
loading: boolean;
tagFilter: string | null;
loadInitial: () => Promise<void>;
refreshMeta: () => Promise<void>;
upsertNote: (note: Note) => void;
removeNote: (id: string) => void;
setTagFilter: (tag: string | null) => void;
toggleShowTrash: () => Promise<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
emptyTrash: () => Promise<void>;
}
const emptyContinuity: WeeklyContinuity = {
weekStart: '', weekCount: 0, weekTarget: 7,
consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
};
export const useInbox = create<InboxState>((set, get) => ({
notes: [],
trashNotes: [],
trashCount: 0,
showTrash: false,
continuity: emptyContinuity,
pendingCount: 0,
ollamaStatus: { ok: true },
todayCount: 0,
loading: false,
tagFilter: null,
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, loading: false });
},
async refreshMeta() {
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount });
},
upsertNote(note) {
if (note.deletedAt !== null) {
// trash 노트: notes 에서 제거 + trashNotes 에 upsert
const cleanNotes = get().notes.filter((n) => n.id !== note.id);
const ti = get().trashNotes.findIndex((n) => n.id === note.id);
const nextTrash = get().trashNotes.slice();
if (ti >= 0) nextTrash[ti] = note;
else nextTrash.unshift(note);
set({ notes: cleanNotes, trashNotes: nextTrash, trashCount: nextTrash.length });
} else {
// active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
const i = get().notes.findIndex((n) => n.id === note.id);
const nextNotes = get().notes.slice();
if (i >= 0) nextNotes[i] = note;
else nextNotes.unshift(note);
set({ notes: nextNotes, trashNotes: cleanTrash, trashCount: cleanTrash.length });
}
},
removeNote(id) {
set({
notes: get().notes.filter((n) => n.id !== id),
trashNotes: get().trashNotes.filter((n) => n.id !== id),
trashCount: get().trashNotes.filter((n) => n.id !== id).length
});
},
setTagFilter(tag) {
set({ tagFilter: tag });
},
async toggleShowTrash() {
const next = !get().showTrash;
set({ showTrash: next });
if (next) await get().loadTrash();
},
async loadTrash() {
const trashNotes = await inboxApi.listTrash({ limit: 200 });
set({ trashNotes, trashCount: trashNotes.length });
},
async restoreNote(id) {
await inboxApi.restoreNote(id);
// onNoteUpdated 이벤트 미수신 케이스 대비 (renderer 자가 갱신)
const note = get().trashNotes.find((n) => n.id === id);
if (note) {
get().upsertNote({ ...note, deletedAt: null });
}
},
async permanentDeleteNote(id) {
const r = await inboxApi.permanentDeleteNote(id);
if (r.confirmed) get().removeNote(id);
},
async emptyTrash() {
const r = await inboxApi.emptyTrash();
if (r.confirmed) {
set({ trashNotes: [], trashCount: 0 });
}
}
}));
- Step 4: 테스트 — PASS
Run: npm test -- tests/unit/store.trash.test.ts
Expected: 5 신규 PASS.
- Step 5: 커밋
git add src/renderer/inbox/store.ts tests/unit/store.trash.test.ts
git commit -m "feat(trash): zustand store — showTrash/trashNotes/trashCount + 5 actions (#4 v0.2.3)"
Task 14: Renderer App.tsx 탭 toggle + bulk emptyTrash 버튼
Files:
-
Modify:
src/renderer/inbox/App.tsx -
Modify:
src/renderer/inbox/components/NoteCard.tsx(mode prop) -
Step 1: NoteCard mode prop
src/renderer/inbox/components/NoteCard.tsx 의 Props interface 에 mode?: 'inbox' | 'trash' 추가. 컴포넌트 본문에서:
interface NoteCardProps {
note: Note;
onDeleted: () => void;
onUpdated: (note: Note) => void;
mode?: 'inbox' | 'trash';
onRestore?: () => void;
onPermanentDelete?: () => void;
}
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: NoteCardProps): React.ReactElement {
const isTrash = mode === 'trash';
// ...
// due date editing — disabled in trash
// intent banner — hidden in trash
// tag chips — ✕ 버튼 hidden in trash, click 무반응
// raw text 토글 — 그대로
// 액션 영역:
// if isTrash: "🔄 복구" + "🗑 영구 삭제" 두 버튼
// else: 기존 "🗑 삭제" 한 버튼
// 모든 inline 편집 컴포넌트도 isTrash 시 read-only 모드:
// <DueDateBadge readOnly={isTrash} ... />
// {!isTrash && <IntentBanner ... />}
// <TagChip removable={!isTrash} ... />
// <EditableField readOnly={isTrash} ... />
}
NoteCard 의 정확한 변경은 현재 컴포넌트 (line 107-319) 의 모든 액션/편집 슬롯을 isTrash 가드로 감싼다. 본문 700+ 줄 — 전체 코드 인라인 대신 변경 핵심 4 spot:
DueDateBadge호출 부분 —readOnly={isTrash}추가 (DueDateBadge 내부에서 onClick 가드).IntentBanner호출 부분 —{!isTrash && <IntentBanner .../>}로 감쌈.- tag chip 의 ✕ 버튼 —
{!isTrash && <button>✕</button>}. - 카드 하단 "🗑 삭제" 버튼 (line 312) — 다음 블록으로 교체:
{isTrash ? (
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={onRestore} style={{ /* 기존 스타일 카피 — 파란 톤 */ }}>
🔄 복구
</button>
<button onClick={onPermanentDelete} style={{ /* 빨간 톤 */ }}>
🗑 영구 삭제
</button>
</div>
) : (
<button onClick={async () => {
// 기존 deleteNote 호출 (이제 trash)
await inboxApi.deleteNote(note.id);
onDeleted();
}} style={{ /* 빨간 톤 */ }}>
🗑 삭제
</button>
)}
EditableField (title / summary) 도 readOnly={isTrash} 전달. EditableField 컴포넌트 내부에서 readOnly 시 input 비활성 + 더블 클릭 미반응.
- Step 2: App.tsx 의 헤더 탭 + 휴지통 view
src/renderer/inbox/App.tsx 변경:
import React, { useEffect, useState } from 'react';
import { useInbox, selectFilteredNotes } from './store.js';
import { inboxApi } from './api.js';
import { isRecoveryDismissedToday, markRecoveryDismissed } from './recoveryToast.js';
import { NoteCard } from './components/NoteCard.js';
import { ContinuityBadge } from './components/ContinuityBadge.js';
import { IdentityCounter } from './components/IdentityCounter.js';
import { PendingBanner } from './components/PendingBanner.js';
import { OllamaBanner } from './components/OllamaBanner.js';
import { RecoveryToast } from './components/RecoveryToast.js';
import { TagUndoToast } from './components/TagUndoToast.js';
export function App(): React.ReactElement {
const {
notes, trashNotes, trashCount, showTrash,
loading, loadInitial, refreshMeta, upsertNote, removeNote,
continuity, tagFilter, setTagFilter,
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
} = useInbox();
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
useEffect(() => {
void loadInitial();
const unsub = inboxApi.onNoteUpdated((note) => {
upsertNote(note);
void refreshMeta();
});
const onFocus = () => { void refreshMeta(); };
window.addEventListener('focus', onFocus);
return () => { unsub(); window.removeEventListener('focus', onFocus); };
}, [loadInitial, refreshMeta, upsertNote]);
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
const filtered = selectFilteredNotes({ notes, tagFilter });
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
background: active ? '#0a4b80' : 'transparent',
color: active ? '#fff' : '#0a4b80',
border: '1px solid #0a4b80',
borderRadius: 4,
padding: '4px 10px',
fontSize: 12,
cursor: 'pointer'
});
return (
<>
<div className="header">
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
<button
onClick={() => { if (showTrash) void toggleShowTrash(); }}
aria-pressed={!showTrash}
style={tabBtnStyle(!showTrash)}
>
Inbox({notes.length})
</button>
<button
onClick={() => { if (!showTrash) void toggleShowTrash(); }}
aria-pressed={showTrash}
style={tabBtnStyle(showTrash)}
>
휴지통({trashCount})
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
<ContinuityBadge />
<IdentityCounter />
</div>
</div>
<main className="main">
{!showTrash && (
<>
<OllamaBanner />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
{tagFilter !== null && (
<div style={{
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
borderRadius: 6, margin: '8px 0', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8
}}>
<span>🔎 필터: <strong>#{tagFilter}</strong></span>
<span style={{ color: '#666' }}>({filtered.length}개)</span>
<button
onClick={() => setTagFilter(null)}
style={{ marginLeft: 'auto', background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12 }}
title="필터 해제"
>
✕ 해제
</button>
</div>
)}
{loading && notes.length === 0 ? (
<div className="empty">불러오는 중…</div>
) : notes.length === 0 ? (
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
) : filtered.length === 0 ? (
<div className="empty">이 태그의 노트가 없습니다.</div>
) : (
filtered.map((n) => (
<NoteCard
key={n.id} note={n} mode="inbox"
onDeleted={() => removeNote(n.id)}
onUpdated={(u) => upsertNote(u)}
/>
))
)}
</>
)}
{showTrash && (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '8px 0' }}>
<div style={{ fontSize: 13, color: '#666' }}>
{trashCount === 0 ? '휴지통이 비어있습니다.' : `${trashCount}개 보관 중`}
</div>
<button
onClick={() => void emptyTrash()}
disabled={trashCount === 0}
style={{
background: trashCount === 0 ? '#666' : '#a33', color: '#fff',
border: 'none', borderRadius: 4, padding: '4px 10px',
fontSize: 12, cursor: trashCount === 0 ? 'not-allowed' : 'pointer'
}}
>
휴지통 비우기 ({trashCount}개)
</button>
</div>
{trashNotes.length === 0 ? null : (
trashNotes.map((n) => (
<NoteCard
key={n.id} note={n} mode="trash"
onDeleted={() => removeNote(n.id)}
onUpdated={(u) => upsertNote(u)}
onRestore={() => void restoreNote(n.id)}
onPermanentDelete={() => void permanentDeleteNote(n.id)}
/>
))
)}
</>
)}
</main>
<TagUndoToast />
</>
);
}
- Step 3: typecheck + 기존 테스트 통과
Run: npm run typecheck && npm test
Expected: typecheck 0. 기존 테스트 모두 PASS. 신규 e2e smoke 가 깨지면 (예: 헤더 layout 변경 영향) Task 15 에서 처리.
- Step 4: 커밋
git add src/renderer/inbox/App.tsx src/renderer/inbox/components/NoteCard.tsx
git commit -m "feat(trash): Inbox 탭 toggle + 휴지통 view + NoteCard mode prop (#4 v0.2.3)"
Task 15: 게이트 + closure marker
Files:
-
Modify:
docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md -
Step 1: 종합 게이트
npm run typecheck && npm test && npm run test:e2e
Expected:
- typecheck: 0 errors
- 단위: 245 (v0.2.3 #7) + 신규 ≥ 30 = ~275+ PASS
- e2e: 1/1 PASS (탭 헤더 추가가 smoke 깨지 않아야 — Inbox 진입 시 active 노트 list 가 그대로 보이면 OK)
만약 e2e 가 깨지면 selector 갱신 또는 기본 view (showTrash: false) 가 v0.2.2 와 동일 layout 인지 확인.
- Step 2: 수동 sanity check
npm run dev
체크리스트:
-
노트 캡처 → Inbox 보임. "🗑 삭제" 클릭 → 노트 사라지고 헤더 "휴지통(1)" 표시.
-
휴지통 탭 클릭 → 노트 보임 (read-only — 편집 불가). "🔄 복구" 클릭 → Inbox 로 복귀, AI 결과 보존.
-
다시 trash → 휴지통 → "🗑 영구 삭제" → confirm → 사라짐. media 디렉터리 (
<userData>/Inkling/profiles/default/media/<noteId>) 도 사라짐 확인. -
여러 노트 trash → 휴지통 → "휴지통 비우기" → confirm → 모두 사라짐.
-
트레이 → 사용 로그 내보내기 →
events.jsonl에 4 신규 kind (trash/restore/permanent_delete/empty_trash) 라인 존재 + privacy invariant grep 0 hits 확인 (grep -E 'rawText|"title"|"summary"|userIntent|tagNames'). -
Step 3: roadmap closure marker
docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md 의 §3 #4 헤더에 ✓ 완료 추가:
### #4 휴지통 (2번) ✓ 완료
- Step 4: closure 커밋
git add docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md
git commit -m "docs(spec): mark #4 trash as completed (v0.2.3 2/7)"
Self-Review
1. Spec coverage:
| spec section | task |
|---|---|
| §2 Data model — migration v3 + Note type | T1 |
| §3.1 신규 메서드 trash | T2 |
| §3.1 신규 restore | T3 |
| §3.1 신규 permanentDelete + emptyTrash + listTrashed | T4 |
| §3.3 Active query 일괄 변경 | T5 |
| §5 AiWorker 가드 | T6 |
| §4.3 Telemetry 4 new kinds | T7 |
| §4.3 stats.md 4 카운터 + ratio | T8 |
| §4.1/4.2 CaptureService 메서드 변경/신규 + 4 emit | T9 |
| §8.2 ImportService deletedAt 보존 + 충돌 정책 | T10 |
| §8.1 ExportService trash 제외 (회귀 가드) | T11 |
| §6 IPC 5 채널 + native confirm dialog | T12 |
| §7.1 zustand store + actions | T13 |
| §7.2/7.3 App.tsx 탭 + NoteCard mode | T14 |
| §7.4 Confirm dialog 카피 | T12 (IPC 핸들러에 inline) |
| 게이트 + roadmap closure | T15 |
2. Placeholder scan: "TODO" / "TBD" 0 hit. 단 Task 14 의 NoteCard 변경은 4 spot 변경으로 명시했으나 정확한 현재 line 들은 plan 시점에 확인 필요 — 본문이 700+ 줄. plan 단계에서 "isTrash 가드를 4 슬롯에 박는다" 의 의미는 명확하므로 placeholder 아님.
3. Type consistency:
trash(id, deletedAt: string): void— T2 / T9 / T13 모두 string 인자.restore(id): void— T3 / T9 / T13 일관.permanentDelete(id): void(repo) /permanentDeleteNote(id): Promise<{confirmed}>(renderer) — 분리됨, 의도적.emptyTrash(): { noteIds: string[] }(repo) /emptyTrash(): { count: number }(CaptureService) /emptyTrash(): Promise<{confirmed, count}>(api) — 각각 다른 layer, 의도적.listTrashed(opts: { limit })(repo) /listTrash(opts: { limit })(api) — 약간의 naming 차이 의도적 (repo 는 "trashed notes" 의미, api 는 "list trash" surface).Note.deletedAt: string | null— T1 정의, T2-T14 일관.- Telemetry kind 이름:
trash/restore/permanent_delete/empty_trash— snake_case (permanent_delete/empty_trash) 와 단순 동사 (trash/restore) 혼재. 의도적 —permanent_delete는 두 단어 합성. T7/T8/T9 모두 일치.
NoteRepository 의 list({limit, cursor}) 의 cursor 가 trash exclusion 후에도 잘 동작 (cursor=created_at 비교 + AND deleted_at IS NULL). T5 에서 명시.
확인 OK.
Spec 의 IPC inbox:emptyTrash 의 confirm 동작: spec §6.1 은 confirm 후 작업 수행 명시. T12 가 native dialog 호출 → 사용자 confirm → 그 후 service 호출 패턴을 정확히 구현. confirmed/cancelled 정보 renderer 로 반환.
수정 필요 inline 항목 없음.