From b93185edd50a44e60c77d0cfaebe88bdd00e2531 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 20:16:26 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20#4=20=ED=9C=B4=EC=A7=80=ED=86=B5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20(v0.2.3=202/7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-05-01-v023-trash.md | 2276 +++++++++++++++++ 1 file changed, 2276 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-v023-trash.md diff --git a/docs/superpowers/plans/2026-05-01-v023-trash.md b/docs/superpowers/plans/2026-05-01-v023-trash.md new file mode 100644 index 0000000..34995df --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-v023-trash.md @@ -0,0 +1,2276 @@ +# #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` 끝에 추가 (기존 케이스 그대로): + +```typescript +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 생성** + +```typescript +// 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 등록** + +```typescript +// 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` 확장** + +```typescript +// 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 필드 추가: + +```typescript +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: 커밋** + +```bash +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: + +```typescript +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 가 없다면 다음 추가: + +```typescript +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()` 다음 위치에 추가: + +```typescript +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: 커밋** + +```bash +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', ...)` 다음에: + +```typescript +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` 메서드 직후에 추가: + +```typescript +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: 커밋** + +```bash +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: 실패 테스트 추가** + +```typescript +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` 메서드 직후에 추가: + +```typescript +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 표시: + +```typescript +/** @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: 커밋** + +```bash +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: 실패 테스트 추가** + +```typescript +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`) 변경: + +```typescript +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`) 변경: + +```typescript +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`) 변경: + +```typescript +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: 커밋** + +```bash +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` 끝에: + +```typescript +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` 진입 체크 변경: + +```typescript +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: 커밋** + +```bash +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` 끝에: + +```typescript +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` 확장** + +```typescript +// 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; +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` 타입 변경: + +```typescript +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: 커밋** + +```bash +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` 끝에: + +```typescript +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 라인: + +```typescript +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(); + 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: 커밋** + +```bash +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` 끝에: + +```typescript +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개 변경/신규: + +```typescript +// 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; +} + +export interface CaptureDeps { + enqueue: (noteId: string) => Promise; + 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 { + // 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 { + this.repo.restore(noteId); + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {}); + } + } + + async permanentDeleteNote(noteId: string): Promise { + 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: 커밋** + +```bash +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` 끝에: + +```typescript +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;` 추가: + +```typescript +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 컬럼: + +```typescript +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: 커밋** + +```bash +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` 끝에: + +```typescript +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: 커밋** + +```bash +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 메서드 추가: + +```typescript +export interface InboxApi { + // 기존 메서드 그대로 ... + listNotes(opts: { limit: number; cursor?: string }): Promise; + // ... + deleteNote(noteId: string): Promise; // 의미만 변경 (hard → soft) + // 신규 v0.2.3 #4: + restoreNote(noteId: string): Promise; + permanentDeleteNote(noteId: string): Promise<{ confirmed: boolean }>; // confirm 거부 시 confirmed=false + emptyTrash(): Promise<{ confirmed: boolean; count: number }>; + listTrash(opts: { limit: number }): Promise; + getTrashCount(): Promise; + // 기존 ... + onNoteUpdated(cb: (note: Note) => void): () => void; +} +``` + +`getTrashCount` 신규 — 헤더 탭 라벨 `휴지통(M)` 갱신용. `permanentDeleteNote` 와 `emptyTrash` 가 confirm dialog 거치므로 cancel 케이스 (`confirmed: false`) 가능. + +- [ ] **Step 2: `inboxApi.ts` 의 5 신규 채널 등록** + +```typescript +// 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 확장** + +```typescript +// 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: 커밋** + +```bash +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: 실패 테스트 추가** + +```typescript +// 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 변경** + +```typescript +// 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; + refreshMeta: () => Promise; + upsertNote: (note: Note) => void; + removeNote: (id: string) => void; + setTagFilter: (tag: string | null) => void; + toggleShowTrash: () => Promise; + loadTrash: () => Promise; + restoreNote: (id: string) => Promise; + permanentDeleteNote: (id: string) => Promise; + emptyTrash: () => Promise; +} + +const emptyContinuity: WeeklyContinuity = { + weekStart: '', weekCount: 0, weekTarget: 7, + consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null +}; + +export const useInbox = create((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: 커밋** + +```bash +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'` 추가. 컴포넌트 본문에서: + +```typescript +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 모드: + // + // {!isTrash && } + // + // +} +``` + +NoteCard 의 정확한 변경은 현재 컴포넌트 (line 107-319) 의 모든 액션/편집 슬롯을 `isTrash` 가드로 감싼다. 본문 700+ 줄 — 전체 코드 인라인 대신 변경 핵심 4 spot: + +1. `DueDateBadge` 호출 부분 — `readOnly={isTrash}` 추가 (DueDateBadge 내부에서 onClick 가드). +2. `IntentBanner` 호출 부분 — `{!isTrash && }` 로 감쌈. +3. tag chip 의 ✕ 버튼 — `{!isTrash && }`. +4. 카드 하단 "🗑 삭제" 버튼 (line 312) — 다음 블록으로 교체: + +```tsx +{isTrash ? ( +
+ + +
+) : ( + +)} +``` + +EditableField (`title` / `summary`) 도 `readOnly={isTrash}` 전달. EditableField 컴포넌트 내부에서 readOnly 시 input 비활성 + 더블 클릭 미반응. + +- [ ] **Step 2: App.tsx 의 헤더 탭 + 휴지통 view** + +`src/renderer/inbox/App.tsx` 변경: + +```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 ( + <> +
+

Inkling

+
+ + +
+
+ + +
+
+
+ {!showTrash && ( + <> + + { markRecoveryDismissed(); setRecoveryDismissed(true); }} + /> + + {tagFilter !== null && ( +
+ 🔎 필터: #{tagFilter} + ({filtered.length}개) + +
+ )} + {loading && notes.length === 0 ? ( +
불러오는 중…
+ ) : notes.length === 0 ? ( +
머릿속에 떠다니는 한 줄을 적어보세요. Ctrl+Shift+J
+ ) : filtered.length === 0 ? ( +
이 태그의 노트가 없습니다.
+ ) : ( + filtered.map((n) => ( + removeNote(n.id)} + onUpdated={(u) => upsertNote(u)} + /> + )) + )} + + )} + {showTrash && ( + <> +
+
+ {trashCount === 0 ? '휴지통이 비어있습니다.' : `${trashCount}개 보관 중`} +
+ +
+ {trashNotes.length === 0 ? null : ( + trashNotes.map((n) => ( + removeNote(n.id)} + onUpdated={(u) => upsertNote(u)} + onRestore={() => void restoreNote(n.id)} + onPermanentDelete={() => void permanentDeleteNote(n.id)} + /> + )) + )} + + )} +
+ + + ); +} +``` + +- [ ] **Step 3: typecheck + 기존 테스트 통과** + +Run: `npm run typecheck && npm test` +Expected: typecheck 0. 기존 테스트 모두 PASS. 신규 e2e smoke 가 깨지면 (예: 헤더 layout 변경 영향) Task 15 에서 처리. + +- [ ] **Step 4: 커밋** + +```bash +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: 종합 게이트** + +```bash +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** + +```bash +npm run dev +``` + +체크리스트: +- 노트 캡처 → Inbox 보임. "🗑 삭제" 클릭 → 노트 사라지고 헤더 "휴지통(1)" 표시. +- 휴지통 탭 클릭 → 노트 보임 (read-only — 편집 불가). "🔄 복구" 클릭 → Inbox 로 복귀, AI 결과 보존. +- 다시 trash → 휴지통 → "🗑 영구 삭제" → confirm → 사라짐. media 디렉터리 (`/Inkling/profiles/default/media/`) 도 사라짐 확인. +- 여러 노트 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 헤더에 `✓ 완료` 추가: + +```markdown +### #4 휴지통 (2번) ✓ 완료 +``` + +- [ ] **Step 4: closure 커밋** + +```bash +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 항목 없음.