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 항목 없음. diff --git a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md index eda25a0..c19b18a 100644 --- a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +++ b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md @@ -91,7 +91,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]───── **Out:** 자동 업로드 / 원격 telemetry (모두 로컬), 실시간 대시보드 UI, opt-out 토글 (로컬이라 불필요), 14일 보존 기간 사용자 설정 -### #4 휴지통 (2번) +### #4 휴지통 (2번) ✓ 완료 **In:** - migration v3: `notes.deleted_at TEXT NULL` + `notes.last_recalled_at TEXT NULL` + `notes.recall_dismissed_at TEXT NULL` (3 컬럼 한 번) diff --git a/docs/superpowers/specs/2026-05-01-v023-trash-design.md b/docs/superpowers/specs/2026-05-01-v023-trash-design.md new file mode 100644 index 0000000..bd282f0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-v023-trash-design.md @@ -0,0 +1,385 @@ +# #4 휴지통 (soft delete + migration v3) 설계 + +**작성일:** 2026-05-01 +**저자:** 김태현 (dlsrks0734@gmail.com) +**문서 성격:** v0.2.3 로드맵의 두 번째 항목. mini-brainstorm 결과를 잠그고 구현 계획 (`writing-plans`) 으로 넘기는 분기 spec. 본 문서는 **데이터 모델·외부 API·UI 결정** 만 정의. 세부 코드 토폴로지는 plan 단계에서. + +**선행 문서:** +- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #4 — 본 항목의 In/Out 라인 + cross-cutting 정책 +- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §1 — schema migration v3, trash↔backup/export B 정책 +- `docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md` §5.1 — pre-v.bak snapshot 메커니즘 (v0.2.1 도입) +- v0.2.3 #7 telemetry skeleton (merged at `6f8ae75`) — 본 항목이 emit hook 대상 + +--- + +## 1. 결정 요약 + +| 영역 | 값 | 근거 | +|------|-----|------| +| Schema | **migration v3** — `deleted_at TEXT NULL` + `last_recalled_at TEXT NULL` + `recall_dismissed_at TEXT NULL` (#6 도 같이) | 한 migration 으로 #4+#6 cover. 별 v4 회피. | +| UI 위치 | **Inbox 상단 탭 toggle** (`Inbox(N) · 휴지통(M)`) | 현재 router 없음, single-page 구조 일관. v0.2.1 F2 태그 필터 패턴 (`tagFilter` zustand) 동일 흐름. | +| 쿼리 필터 전략 | **명시적 WHERE** — 모든 active query 에 `WHERE deleted_at IS NULL` 직접 박음 | 기존 SQL prepare 패턴 일관. grep audit 가능. C (silent at hydration) 의 AiWorker race window 회피. | +| AiWorker race | **C — pending_jobs cleanup + processJob 가드** (둘 다) | atomic + 이미 dequeue 한 race window 도 가드. result 적용 직전 재체크는 의도적 skip — restore 시 AI 결과 보존이 UX 유리. | +| 휴지통 액션 | **per-card 복구 + per-card 영구 삭제 + bulk 휴지통 비우기** | per-card 영구 삭제는 fine-grained 삭제 욕구 대응. roadmap §3 #4 의 4채널 → 5채널 (`permanentDelete` 추가) 으로 확장. | +| Confirm UX | **Electron `dialog.showMessageBox`** — F5/F6 패턴 일관 | 신규 React 모달 회피. native = 운영체제 톤. | +| 정렬 | **`deleted_at DESC`** | 회수 의도 매칭 (최근 삭제 먼저). | +| Card 차이 | **휴지통 카드 = read-only** — edit 액션 hidden, raw text 토글은 보존 | roadmap §3 #4 Out (`trash 안 노트 편집`) 일관. | +| F5 export | **`deleted_at IS NOT NULL` 제외** | trash B 정책 (roadmap §1). | +| F6-L1 backup | **byte-for-byte 자동 포함** | SQLite copy. 무수정. | +| F6-L3 import | **`deleted_at` source/dest 중 IS NOT NULL 우선** | 삭제 보존 invariant. | +| Restore 시 AI 결과 | **그대로 살아있음** (race window self-healing) | trash 도중 AI 결과 박힌 경우 restore 시 노트가 결과까지 함께 회수. UX positive. | + +### 1.1 v0.2.3 #4 roadmap 와의 차이 + +| 항목 | roadmap §3 #4 | 본 spec | +|------|---------------|---------| +| 휴지통 액션 | 복구 + bulk emptyTrash (4 IPC 채널) | + per-card 영구 삭제 (5 IPC 채널) | +| Telemetry kinds | `trash` / `restore` / `emptyTrash` (3) | + `permanent_delete` (4) | + +**근거:** mini-brainstorm 에서 사용자 결정 (B 옵션 — fine-grained 영구 삭제 추가). 본 spec 의 결정이 roadmap 보다 우선. + +--- + +## 2. Data model + +### 2.1 Migration v3 — `m003_soft_delete.ts` + +```sql +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 idx_notes_deleted_at ON notes(deleted_at); +``` + +- `deleted_at`: ISO timestamp (UTC). `NULL` = active, IS NOT NULL = trashed. +- `last_recalled_at`: #6 가 사용. v3 에서 컬럼만 추가, `Note` type 노출 + 사용은 #6. +- `recall_dismissed_at`: #6 가 사용. 위와 동일. +- `idx_notes_deleted_at`: `WHERE deleted_at IS NULL` 쿼리 다수, partial index 효과 기대. SQLite 가 NULL 스파스 인덱스 효율 잘 처리. + +m001/m002 와 같이 `version = 3` export 후 `migrations/index.ts` 의 array 에 등록. transaction 내 실행. 실패 시 트랜잭션 롤백 + 사용자에게 보고. + +**pre-v3 snapshot:** `.pre-v3.bak` 자동 생성 (v0.2.1 메커니즘 그대로). v0.2.2 → v0.2.3 첫 실행 시 한 번. + +### 2.2 `Note` 타입 (`@shared/types`) 확장 + +```typescript +export interface Note { + // ... 기존 필드 ... + deletedAt: string | null; // #4 가 사용 + lastRecalledAt: string | null; // #6 가 사용 (v0.2.3 #4 단계엔 항상 null 으로 hydrate) + recallDismissedAt: string | null; // #6 가 사용 (위와 동일) +} +``` + +세 필드 모두 v3 부터 schema 에 존재하므로 hydration 코드는 한 번에 추가. 사용은 단계별. + +### 2.3 Schema invariant 추가 + +slice §1.3 silent invariant 후보 (roadmap §6.3 에서 #4 머지 시 동봉 갱신): + +> **`deleted_at IS NULL` 망각 0회** — 모든 active query (Inbox / countToday / findByTag / search / F5 export / AiWorker 처리) 가 `WHERE deleted_at IS NULL` 을 빠뜨리지 않는다. 위반 시 dogfood-feedback 즉시 재오픈. + +--- + +## 3. NoteRepository 변경 + +### 3.1 신규 메서드 + +| 메서드 | SQL | 부수 효과 | +|--------|-----|-----------| +| `trash(id, deletedAt: string): void` | `UPDATE notes SET deleted_at=?, updated_at=? WHERE id=?` + `DELETE FROM pending_jobs WHERE note_id=?` (한 transaction) | AI 큐 깨끗. atomic. | +| `restore(id): void` | `UPDATE notes SET deleted_at=NULL, updated_at=? WHERE id=?` | 노트 active 복귀. AI 결과 보존됨. | +| `permanentDelete(id): void` | `DELETE FROM notes WHERE id=?` | cascade FK (`note_tags` / `media` / `pending_jobs`) 자동 정리. media 파일 정리는 caller (`CaptureService`) 책임. | +| `emptyTrash(): { noteIds: string[] }` | `SELECT id FROM notes WHERE deleted_at IS NOT NULL` → 각 id `permanentDelete` (한 transaction). 반환된 `noteIds` 로 caller 가 media 정리. | +| `listTrashed(opts: {limit, cursor?}): Note[]` | `WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC` | cursor = `deleted_at` 값 기준. | + +### 3.2 기존 메서드 변경 + +`delete(id)` 는 **deprecate** (호출 site 0건 보장). hard delete 는 `permanentDelete()` 로만. 단계적 cleanup — `delete()` 를 즉시 제거하지 않고 `@deprecated` 로 표시 후 v0.2.4 cut 시 삭제. + +### 3.3 Active query 일괄 변경 (`WHERE deleted_at IS NULL` 추가) + +| 메서드 | 현재 | 변경 후 | +|--------|------|---------| +| `list(opts)` | `ORDER BY created_at DESC LIMIT ?` | `WHERE deleted_at IS NULL ORDER BY ... LIMIT ?` | +| `listAll()` | `ORDER BY created_at ASC` | `WHERE deleted_at IS NULL ORDER BY ...` | +| `countToday(now?)` | KST today filter | `WHERE deleted_at IS NULL AND ...` | +| `getAllPendingJobs()` | `pending_jobs` 직접 select | **변경 없음** — `trash()` 가 atomic 하게 `pending_jobs` row 정리하는 invariant 가 자연 보장. AiWorker `processJob` 의 `deletedAt` 가드는 이미 dequeue 한 race 만 처리. | +| `findById(id)` | **변경 없음** — 휴지통 카드도 같은 메서드 사용. `Note.deletedAt` 으로 호출자가 분기. | + +NoteRepository 에는 현재 `findByTag` / search 메서드가 없다 — 태그 필터링은 renderer 의 `selectFilteredNotes` 에서 client-side 로 수행 (zustand `tagFilter` state). 따라서 active query 변경은 위 표의 3 메서드 (`list`, `listAll`, `countToday`) + AiWorker 가드 + `getAllPendingJobs` 의 invariant 보존이 전부. + +--- + +## 4. CaptureService 변경 + +### 4.1 메서드 변경 + +```typescript +async deleteNote(noteId: string): Promise { + this.repo.trash(noteId, new Date().toISOString()); + // media 는 그대로 둔다 (restore 시 필요) + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {}); + } +} +``` + +### 4.2 신규 메서드 + +```typescript +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 (e) { /* best-effort */ } + } + if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {}); + return { count: noteIds.length }; +} +``` + +### 4.3 Telemetry interface 확장 + +`CaptureService.ts` 의 `TelemetryEmitter` 인터페이스에 4 union 멤버 추가: + +```typescript +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; +} +``` + +`TelemetryService.ts` 의 `EmitInput` union 도 같은 4 추가. `telemetryEvents.ts` 의 zod `discriminatedUnion` 에도 4 새 멤버, 각 payload `.strict()`. **Privacy invariant** 그대로 — payload 에 `noteId` / `count` 만, raw text/title/summary/intent/tag name 절대 미포함. zod 가 거부. + +`stats.md` 집계 (`telemetryStats.aggregateStats`) 도 4 신규 카운트 컬럼 추가: + +``` +| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | +``` + +핵심 ratio: +- `restore / trash` — 휴지통이 회수 도구로 동작? + +--- + +## 5. AiWorker 가드 + +`processJob` 진입 시 deletedAt 체크 추가: + +```typescript +const note = this.repo.findById(job.noteId); +if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return; +``` + +`pending_jobs` 정리는 `trash()` 가 atomic 하게 처리하므로 정상 흐름에서 dead row 미발생. AiWorker 가 이미 dequeue 한 후 trash 된 race 만 본 가드가 cover. + +result 적용 (`updateAiResult`) 직전 재체크는 의도적으로 skip — restore 시 AI 결과 살아있어 UX 유리. + +--- + +## 6. IPC + +### 6.1 신규 채널 (5개) + +`src/main/ipc/inboxApi.ts` 의 `registerInboxApi` 에 추가: + +| 채널 | 핸들러 | 응답 | +|------|--------|------| +| `inbox:trash` | `(_, id: string) => capture.deleteNote(id)` | `void` | +| `inbox:restore` | `(_, id: string) => capture.restoreNote(id)` | `void` | +| `inbox:permanentDelete` | `(_, id: string) => capture.permanentDeleteNote(id)` | `void` | +| `inbox:emptyTrash` | `() => capture.emptyTrash()` | `{ count: number }` | +| `inbox:listTrash` | `(_, opts) => repo.listTrashed(opts)` | `Note[]` | + +confirm dialog (per-card 영구 삭제 / bulk emptyTrash) 는 main 프로세스에서 `dialog.showMessageBox` 호출 (트레이 export/import 와 동일 패턴). 사용자 confirm 후에야 IPC 가 실제 작업 수행. + +### 6.2 기존 `inbox:delete` 처리 + +기존 `inbox:delete` 는 그대로 유지하되 내부적으로 `capture.deleteNote(id)` 가 trash 호출 (변경된 동작). 채널 이름은 유지 — renderer 에서 `inboxApi.deleteNote` 호출하던 곳 (`NoteCard` 의 "🗑 삭제" 버튼) 이 그대로 동작 (의미만 hard → soft 로 변경). 단계적 마이그레이션 — v0.2.4 에서 `inbox:trash` 로 rename 검토. + +--- + +## 7. Renderer (Inbox) + +### 7.1 zustand store 확장 + +```typescript +interface InboxState { + // 기존 ... + showTrash: boolean; // false = Inbox view, true = 휴지통 view + trashNotes: Note[]; // 휴지통 노트 cache + trashCount: number; // 헤더 탭 라벨 (`휴지통(M)`) + + toggleShowTrash(): void; + loadTrash(): Promise; + restoreNote(id: string): Promise; + permanentDeleteNote(id: string): Promise; + confirmEmptyTrash(): Promise; +} +``` + +`toggleShowTrash` 가 `showTrash` 토글 + 진입 시 `loadTrash()` 호출. + +`confirmEmptyTrash` 는 IPC `inbox:emptyTrash` 호출 (main 이 dialog 띄움). 사용자 cancel 시 `count: 0` 반환. + +`upsertNote(note)` / `removeNote(id)` 가 `notes` 와 `trashNotes` 양쪽 다 갱신 — note 의 `deletedAt` 값으로 어느 list 에 들어갈지 결정. + +### 7.2 UI 추가 + +`App.tsx` 헤더 영역 (h1 + ContinuityBadge 옆): + +```tsx + + +``` + +`showTrash === true` 시: +- 상단에 "휴지통 비우기 (M개)" 버튼 (M=0 이면 disabled). 클릭 → `confirmEmptyTrash()`. +- `trashNotes.map(n => )` + +### 7.3 NoteCard prop `mode` + +```tsx +type NoteCardProps = { note: Note; mode?: 'inbox' | 'trash' }; +``` + +`mode === 'trash'` 시: +- DueDateBadge: read-only (날짜 텍스트만 표시, 클릭 무반응) +- IntentBanner: hidden +- 태그 chip: ✕ 버튼 hidden, 클릭 시 필터링 동작 X +- "🗑 삭제" 버튼 → "🔄 복구" + "🗑 영구 삭제" 두 버튼으로 교체 +- raw text 토글 (`▸ 원문 보기`): 보존 (read-only 도 본문 확인 필요) +- EditableField (title / summary): read-only 모드 (input 비활성) + +빈 휴지통 상태 (`trashNotes.length === 0` AND `showTrash`): +> "휴지통이 비어있습니다." + +### 7.4 Confirm dialog 카피 + +`dialog.showMessageBox` 옵션: + +**bulk emptyTrash:** +- type: `question` +- buttons: `['휴지통 비우기', '취소']` +- defaultId: 1, cancelId: 1 +- title: `Inkling` +- message: `휴지통의 노트 ${count}개를 영구 삭제합니다` +- detail: `이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.` + +**per-card 영구 삭제:** +- buttons: `['영구 삭제', '취소']` +- message: `이 노트를 영구 삭제합니다` +- detail: 위와 동일 + +--- + +## 8. F5 export / F6-L3 import / F6-L1 backup + +### 8.1 ExportService + +`repo.listAll()` 자체에 `WHERE deleted_at IS NULL` 추가 (active query exclusion 의 일환, §3.3 표 그대로). ExportService 코드는 무수정 — `repo.listAll()` 호출이 자동으로 trash 제외하게 됨. 휴지통 export 는 본 cut 범위 외 (Out, §10). + +### 8.2 ImportService + +`ImportNoteInput` interface 에 `deletedAt?: string | null` 추가. INSERT statement 에 컬럼 + 값 추가. fork 케이스 (raw_text 다름) 에서도 `deletedAt` 보존. + +충돌 해결 — id 동일 + raw_text 동일 (skip) 또는 raw_text 상이 (fork) 가 기존 정책. `deletedAt` 머지는 그 위에 추가: + +- **id 동일 + raw_text 동일** (skip 케이스): source 가 `deletedAt IS NOT NULL` 이고 dest 가 `IS NULL` 이면 dest 의 `deleted_at` 을 source 값으로 **갱신** (삭제 보존). 그 외는 그대로 skip. +- **id 동일 + raw_text 상이** (fork 케이스): source 의 `deletedAt` 을 새 fork 노트에 그대로 넣음 (raw_text invariant 보존이 우선이라 fork 자체는 기존대로). +- **id 신규** (insert 케이스): source 의 `deletedAt` 을 그대로 INSERT. +- **양쪽 IS NOT NULL** (skip 케이스 의 corner case): 단순화 — dest 값 유지 (skip). roadmap §1 의 "IS NOT NULL 우선" 은 한쪽이 NULL 일 때만 결정 영향, 양쪽 IS NOT NULL 시엔 dest 가 이미 trash 라 "삭제 보존" 자체는 만족. + +### 8.3 BackupService + +무수정. SQLite `db.backup()` 가 byte-for-byte 카피 — `deleted_at IS NOT NULL` 노트도 자동 포함. + +--- + +## 9. 단위 테스트 (TDD 가이드) + +### 9.1 Migration v3 +- 빈 DB v0 → v3 migrate 후 `deleted_at` / `last_recalled_at` / `recall_dismissed_at` 컬럼 + `idx_notes_deleted_at` 존재 확인 +- v2 DB → v3 migrate 시 기존 노트의 새 컬럼 모두 NULL +- migrate idempotent (이미 v3 인 DB 재실행 시 변경 없음) +- pre-v3.bak snapshot 자동 생성 (한 번만) + +### 9.2 NoteRepository +- `trash(id, deletedAt)` 가 `deleted_at` 설정 + `pending_jobs` row 정리 (atomic — 한 transaction 내 두 쿼리) +- `restore(id)` 가 `deleted_at` NULL 복원 +- `permanentDelete(id)` 가 cascade FK 통해 `note_tags` / `media` / `pending_jobs` 정리 +- `emptyTrash()` 가 IS NOT NULL 노트 모두 hard delete + 반환된 noteIds 정확 +- `listTrashed()` 가 `deleted_at DESC` 정렬, IS NOT NULL 만 반환 +- `list()` / `listAll()` / `countToday()` 가 `deleted_at IS NULL` 만 반환 (active query exclusion) +- `findById()` 는 휴지통 노트도 반환 (모든 노트) +- `getAllPendingJobs()` 가 trash 노트 미반환 (join 또는 trash cleanup invariant) + +### 9.3 AiWorker +- `processJob` 가 `deletedAt IS NOT NULL` 노트 즉시 return (provider.generate 미호출) +- 정상 노트는 그대로 처리 (회귀 없음) + +### 9.4 CaptureService +- `deleteNote` 가 trash 호출 + telemetry `trash` emit (media 미삭제) +- `restoreNote` 가 restore 호출 + telemetry `restore` emit +- `permanentDeleteNote` 가 hard delete + media 디렉터리 정리 + telemetry `permanent_delete` emit +- `emptyTrash` 가 모든 trash 노트 hard delete + 각 media 정리 + telemetry `empty_trash` emit (count 정확) + +### 9.5 ExportService (F5) +- 활성 노트만 export, trash 노트 제외 (frontmatter 마크다운 파일 미생성) +- `index.jsonl` 도 trash 미포함 + +### 9.6 ImportService (F6-L3) +- source 의 `deletedAt` 값이 import 후 보존 +- 충돌 해결 — source/dest 중 IS NOT NULL 우선 4가지 조합 모두 + +### 9.7 Telemetry events +- 4 신규 kind (`trash` / `restore` / `permanent_delete` / `empty_trash`) zod 검증 통과 +- payload `.strict()` 가 `rawText` / `title` / `summary` / `userIntent` / `tagNames` 포함 시 거부 (기존 invariant 유지) +- `aggregateStats` 가 4 신규 컬럼 카운트 정확, `restore / trash` ratio 계산 + +### 9.8 e2e smoke (Playwright) +- 노트 캡처 → trash 클릭 → Inbox 에서 사라지고 휴지통 탭(1) 표시 +- 휴지통 탭 진입 → 노트 보임, 복구 클릭 → 다시 Inbox +- per-card 영구 삭제 confirm → 노트 영구 사라짐, media 디렉터리 정리 + +--- + +## 10. Out (deferred to v0.2.4+) + +- 자동 비우기 정책 (사용자 트리거만 — 30일 자동 비우기 등은 차후) +- 휴지통 검색 (full-text 또는 태그 필터) +- trash 안 노트 편집 (read-only invariant 깨지면 회귀) +- per-note 영속 보호 플래그 (lock 같은 것) +- restore 시 AI 결과 보존 invariant 명시 — 본 spec 에 짧게 언급, 별 spec 화는 v0.2.4 reason 분포 본 후 +- `inbox:delete` 채널 → `inbox:trash` 로 rename (단계적 마이그레이션) +- 휴지통에서 다중 선택 (멀티 복구 / 멀티 영구 삭제) +- `last_recalled_at` / `recall_dismissed_at` 활용 — #6 가 사용 + +--- + +## 11. 변경 이력 + +| 일자 | 변경 | +|------|------| +| 2026-05-01 | 초안 — UI=A (Inbox 탭), 필터=A (명시적 WHERE), AiWorker race=C (cleanup+가드), 액션=B (per-card 영구 삭제 추가, 5 IPC 채널), confirm/정렬/카드차이 모두 A. roadmap §3 #4 의 4채널 → 5채널 확장 명시. | diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index fe1df6e..2787edb 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -121,7 +121,7 @@ export class AiWorker { const startMs = this.now().getTime(); try { const note = this.repo.findById(job.noteId); - if (!note || note.aiStatus !== 'pending') return; + if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return; const nowDate = this.now(); const todayDate = todayKstAsDate(nowDate); const todayIso = todayKstAsIso(nowDate); diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index 0ab8250..61129bd 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -1,8 +1,9 @@ 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]; +const migrations = [m001, m002, m003]; export function latestVersion(): number { return migrations[migrations.length - 1]!.version; diff --git a/src/main/db/migrations/m003_soft_delete.ts b/src/main/db/migrations/m003_soft_delete.ts new file mode 100644 index 0000000..0274ad0 --- /dev/null +++ b/src/main/db/migrations/m003_soft_delete.ts @@ -0,0 +1,15 @@ +// v3: soft delete (#4) introduces deleted_at. +// last_recalled_at + recall_dismissed_at are pre-allocated for #6 (recall) — +// dormant until then to avoid a v4 migration round-trip. +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); + `); +} diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index af5aa92..c83e73e 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -1,6 +1,6 @@ import electron from 'electron'; import type { BrowserWindow } from 'electron'; -const { ipcMain } = electron; +const { ipcMain, dialog } = electron; import type { NoteRepository } from '../repository/NoteRepository.js'; import type { ContinuityService } from '../services/ContinuityService.js'; import type { CaptureService } from '../services/CaptureService.js'; @@ -52,6 +52,56 @@ export function registerInboxApi(deps: InboxIpcDeps): void { ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount()); ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus()); ipcMain.handle('inbox:todayCount', () => deps.repo.countToday()); + + 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 fullCount = deps.repo.countTrashed(); + if (fullCount === 0) return { confirmed: true, count: 0 }; + const win = deps.getInboxWindow(); + const opts: Electron.MessageBoxOptions = { + type: 'question', + buttons: ['휴지통 비우기', '취소'], + defaultId: 1, + cancelId: 1, + title: 'Inkling', + message: `휴지통의 노트 ${fullCount}개를 영구 삭제합니다`, + 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.countTrashed()); } export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 13d26cb..f7b74b1 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -28,6 +28,7 @@ export interface ImportNoteInput { userIntent: string | null; intentPromptedAt: string | null; tags: { name: string; source: 'ai' | 'user' }[]; + deletedAt?: string | null; } export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked'; @@ -83,17 +84,25 @@ export class NoteRepository { const limit = Math.max(1, Math.min(200, opts.limit)); const rows = opts.cursor ? (this.db - .prepare(`SELECT * FROM notes WHERE created_at < ? ORDER BY created_at DESC, id DESC LIMIT ?`) + .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 ORDER BY created_at DESC, id DESC LIMIT ?`) + .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)); } listAll(): Note[] { const rows = this.db - .prepare(`SELECT * FROM notes ORDER BY created_at ASC, id ASC`) + .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)); } @@ -221,6 +230,58 @@ export class NoteRepository { .run(date, now, id); } + 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(); + } + + 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); + } + + permanentDelete(id: string): void { + this.db.prepare('DELETE FROM notes WHERE id=?').run(id); + } + + emptyTrash(): { noteIds: string[] } { + // Single DELETE ... RETURNING is atomic by itself (no explicit transaction needed) + // and avoids per-row prepare overhead. RETURNING is house-style elsewhere + // (updateAiResult/updateUserAiFields/getAllPendingJobs). + const rows = this.db + .prepare('DELETE FROM notes WHERE deleted_at IS NOT NULL RETURNING id') + .all() as Array<{ id: string }>; + return { noteIds: rows.map((r) => r.id) }; + } + + 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)); + } + + /** + * Cheap COUNT for trash UI badge / bulk-empty dialog. Does not hydrate + * tags/media — used in hot paths (loadInitial / refreshMeta / upsertNote + * follow-ups) where listTrashed() is wasteful. + */ + countTrashed(): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`) + .get() as { c: number }; + return row.c; + } + + /** @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); } @@ -239,6 +300,10 @@ export class NoteRepository { * - id present + raw_text identical → no-op (status: 'skipped') * - id present + raw_text differs → INSERT under fresh uuidv7 * to preserve the raw_text-immutable invariant (status: 'forked') + * + * deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선 + * (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신. + * insert/fork 는 source 의 deletedAt 그대로 보존. */ importNote(input: ImportNoteInput): ImportNoteResult { const existing = this.findRawTextById(input.id); @@ -246,6 +311,16 @@ export class NoteRepository { let status: ImportNoteStatus = 'inserted'; if (existing !== null) { if (existing === input.rawText) { + // skip — source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존). + // trash() 를 재사용해 pending_jobs cleanup invariant (§9.2) 도 동시에 만족. + if (input.deletedAt != null) { + 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.trash(input.id, input.deletedAt); + } + } return { id: input.id, status: 'skipped' }; } finalId = uuidv7(); @@ -257,8 +332,8 @@ export class NoteRepository { `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, created_at, updated_at) - VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?)` + user_intent, intent_prompted_at, deleted_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( finalId, @@ -271,6 +346,7 @@ export class NoteRepository { input.summaryEditedByUser ? 1 : 0, input.userIntent, input.intentPromptedAt, + input.deletedAt ?? null, input.createdAt, input.updatedAt ); @@ -297,7 +373,9 @@ export class NoteRepository { getPendingCount(): number { const row = this.db - .prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`) + .prepare( + `SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending' AND deleted_at IS NULL` + ) .get() as { c: number }; return row.c; } @@ -319,7 +397,10 @@ export class NoteRepository { const startIso = new Date(kstMidnightUtc).toISOString(); const endIso = new Date(nextKstMidnightUtc).toISOString(); const row = this.db - .prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND created_at < ?`) + .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; } @@ -375,6 +456,9 @@ export class NoteRepository { intentPromptedAt: row.intent_prompted_at, 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, updatedAt: row.updated_at, tags: tags as NoteTag[], diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 1342603..63e0b76 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -4,6 +4,10 @@ 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; } @@ -54,7 +58,7 @@ export class CaptureService { rawTextLength: input.text.length, hasMedia: input.images.length > 0 } - }); + }).catch(() => {}); } await this.deps.enqueue(id); this.deps.celebrate(id); @@ -62,7 +66,49 @@ export class CaptureService { } async deleteNote(noteId: string): Promise { - this.repo.delete(noteId); - await this.store.deleteNoteDirectory(noteId); + // v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요). + // 이미 trash 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지. + const note = this.repo.findById(noteId); + if (!note || note.deletedAt !== null) return; + 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 { + // 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지. + const note = this.repo.findById(noteId); + if (!note || note.deletedAt === null) return; + this.repo.restore(noteId); + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {}); + } + } + + async permanentDeleteNote(noteId: string): Promise { + // 존재하지 않는 노트는 emit skip — 메트릭 오염 방지. + const note = this.repo.findById(noteId); + if (!note) return; + this.repo.permanentDelete(noteId); + // best-effort media cleanup — disk 실패해도 telemetry/IPC 흐름은 그대로 (orphan dir + // 은 future janitor 가 정리). emptyTrash 와 동일 패턴. + try { await this.store.deleteNoteDirectory(noteId); } + catch { /* best-effort */ } + 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 }; } } diff --git a/src/main/services/ContinuityService.ts b/src/main/services/ContinuityService.ts index 6b2d739..54e4263 100644 --- a/src/main/services/ContinuityService.ts +++ b/src/main/services/ContinuityService.ts @@ -32,7 +32,9 @@ export class ContinuityService { get(): WeeklyContinuity { const rows = this.db - .prepare(`SELECT created_at FROM notes ORDER BY created_at ASC`) + .prepare( + `SELECT created_at FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC` + ) .all() as Array<{ created_at: string }>; const dates = rows.map((r) => new Date(r.created_at)); if (dates.length === 0) { diff --git a/src/main/services/ImportService.ts b/src/main/services/ImportService.ts index 5fcb935..93db353 100644 --- a/src/main/services/ImportService.ts +++ b/src/main/services/ImportService.ts @@ -39,7 +39,8 @@ function parsedToInput(parsed: ParsedNote): ImportNoteInput { aiGeneratedAt: parsed.aiGeneratedAt, userIntent: parsed.userIntent, intentPromptedAt: parsed.intentPromptedAt, - tags: parsed.tags + tags: parsed.tags, + deletedAt: parsed.deletedAt }; } diff --git a/src/main/services/MediaGc.ts b/src/main/services/MediaGc.ts index 7c8fcb5..52928a6 100644 --- a/src/main/services/MediaGc.ts +++ b/src/main/services/MediaGc.ts @@ -6,6 +6,9 @@ export class MediaGc { async run(): Promise<{ removed: number }> { const dirs = await this.store.listNoteDirs(); + // Intentionally does NOT filter `deleted_at IS NULL` — trashed notes still own + // their media until permanentDelete/emptyTrash. Removing dirs of soft-deleted + // notes here would defeat restore. const rows = this.db.prepare('SELECT id FROM notes').all() as Array<{ id: string }>; const known = new Set(rows.map((r) => r.id)); let removed = 0; diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index 9b2778a..936a80e 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -18,7 +18,11 @@ export interface TelemetryServiceOptions { 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: '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 } }; export class TelemetryService { constructor( diff --git a/src/main/services/importFormat.ts b/src/main/services/importFormat.ts index 2420e47..b64b7c4 100644 --- a/src/main/services/importFormat.ts +++ b/src/main/services/importFormat.ts @@ -33,6 +33,7 @@ export interface ParsedNote { aiGeneratedAt: string | null; userIntent: string | null; intentPromptedAt: string | null; + deletedAt: string | null; // 신규 v0.2.3 #4 tags: ParsedNoteTag[]; images: ParsedNoteImage[]; exportVersion: number; @@ -347,6 +348,7 @@ export function parseExportNote(markdown: string): ParsedNote { aiGeneratedAt: get('ai_generated_at'), userIntent: get('user_intent'), intentPromptedAt: get('intent_prompted_at'), + deletedAt: get('deleted_at'), tags: fm.tags, images: fm.images, exportVersion diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 4a90759..0d1bcf9 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -20,10 +20,22 @@ const AiFailedPayload = z.object({ 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('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; diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts index 08bcadf..0fbf768 100644 --- a/src/main/services/telemetryStats.ts +++ b/src/main/services/telemetryStats.ts @@ -14,6 +14,10 @@ interface DailyRow { capture: number; ai_succeeded: number; ai_failed: number; + trash: number; + restore: number; + permanent_delete: number; + empty_trash: number; } export interface StatsResult { @@ -28,11 +32,13 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta 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 }; + 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; @@ -44,12 +50,25 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta } 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(''); @@ -58,16 +77,17 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta lines.push(''); lines.push('## 일자별 카운트'); lines.push(''); - lines.push('| 일자 | capture | ai_succeeded | ai_failed |'); - 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} |`); + 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 }; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7d80085..7111851 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -19,6 +19,12 @@ const api: InklingApi = { getPendingCount: () => ipcRenderer.invoke('inbox:pendingCount'), getOllamaStatus: () => ipcRenderer.invoke('inbox:ollamaStatus'), getTodayCount: () => ipcRenderer.invoke('inbox:todayCount'), + // 신규 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) => { const listener = (_e: unknown, note: Note) => cb(note); ipcRenderer.on('note:updated', listener); diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 796c3b9..2451227 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -12,15 +12,10 @@ import { TagUndoToast } from './components/TagUndoToast.js'; export function App(): React.ReactElement { const { - notes, - loading, - loadInitial, - refreshMeta, - upsertNote, - removeNote, - continuity, - tagFilter, - setTagFilter + notes, trashNotes, trashCount, showTrash, + loading, loadInitial, refreshMeta, upsertNote, removeNote, + continuity, tagFilter, setTagFilter, + toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash } = useInbox(); const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); @@ -38,64 +33,114 @@ export function App(): React.ReactElement { 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

-
+
+ + +
+
- - { markRecoveryDismissed(); setRecoveryDismissed(true); }} - /> - - {tagFilter !== null && ( -
- 🔎 필터: #{tagFilter} - ({filtered.length}개) - -
+ {!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)} + /> + )) + )} + )} - {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)} + /> + )) + )} + )}
diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx index f7ca235..7e80bf3 100644 --- a/src/renderer/inbox/components/NoteCard.tsx +++ b/src/renderer/inbox/components/NoteCard.tsx @@ -10,6 +10,9 @@ interface Props { note: Note; onDeleted: () => void; onUpdated: (n: Note) => void; + mode?: 'inbox' | 'trash'; // default 'inbox' + onRestore?: () => void; + onPermanentDelete?: () => void; } const aiBadgeStyle: React.CSSProperties = { @@ -104,7 +107,8 @@ function DueDateBadge({ ); } -export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement { +export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement { + const isTrash = mode === 'trash'; const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done'); const [local, setLocal] = useState(note); @@ -183,7 +187,7 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
{formatted}
- {showIntentBanner && ( + {!isTrash && showIntentBanner && ( { @@ -206,86 +210,122 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem )} {local.aiStatus === 'done' && ( <> -
- - {!local.titleEditedByUser && AI} -
-
- - {!local.summaryEditedByUser && AI} -
-
- -
- {local.tags.length > 0 && ( -
- {local.tags.map((t) => ( - - filterByTag(t.name)} - style={{ cursor: 'pointer' }} - title={`#${t.name} 노트만 보기`} - > - {t.name}{t.source === 'ai' && AI} - - - - ))} -
- )} - {local.userIntent !== null && ( -
- 💡 - -
+ {isTrash ? ( + <> +
+

{local.aiTitle ?? '(제목 없음)'}

+
+
+ {local.aiSummary ?? '(요약 없음)'} +
+ {local.dueDate !== null && ( +
+ 📅 {local.dueDate} +
+ )} + {local.tags.length > 0 && ( +
+ {local.tags.map((t) => ( + + {t.name}{t.source === 'ai' && AI} + + ))} +
+ )} + + ) : ( + <> +
+ + {!local.titleEditedByUser && AI} +
+
+ + {!local.summaryEditedByUser && AI} +
+
+ +
+ {local.tags.length > 0 && ( +
+ {local.tags.map((t) => ( + + filterByTag(t.name)} + style={{ cursor: 'pointer' }} + title={`#${t.name} 노트만 보기`} + > + {t.name}{t.source === 'ai' && AI} + + + + ))} +
+ )} + {local.userIntent !== null && ( +
+ 💡 + +
+ )} + )} )} @@ -310,9 +350,32 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
- + {isTrash ? ( +
+ + +
+ ) : ( + + )}
); diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index f4efcb5..2c5e359 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -6,6 +6,9 @@ export { selectFilteredNotes } from './selectFilteredNotes.js'; interface InboxState { notes: Note[]; + trashNotes: Note[]; + trashCount: number; + showTrash: boolean; continuity: WeeklyContinuity; pendingCount: number; ollamaStatus: { ok: boolean; reason?: string }; @@ -17,6 +20,11 @@ interface InboxState { 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 = { @@ -26,6 +34,9 @@ const emptyContinuity: WeeklyContinuity = { export const useInbox = create((set, get) => ({ notes: [], + trashNotes: [], + trashCount: 0, + showTrash: false, continuity: emptyContinuity, pendingCount: 0, ollamaStatus: { ok: true }, @@ -34,38 +45,96 @@ export const useInbox = create((set, get) => ({ tagFilter: null, async loadInitial() { set({ loading: true }); - const [notes, continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([ + const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([ inboxApi.listNotes({ limit: 50 }), inboxApi.getContinuity(), inboxApi.getPendingCount(), inboxApi.getOllamaStatus(), - inboxApi.getTodayCount() + inboxApi.getTodayCount(), + inboxApi.getTrashCount() ]); - set({ notes, continuity, pendingCount, ollamaStatus, todayCount, loading: false }); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, loading: false }); }, async refreshMeta() { - const [continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([ + const [continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([ inboxApi.getContinuity(), inboxApi.getPendingCount(), inboxApi.getOllamaStatus(), - inboxApi.getTodayCount() + inboxApi.getTodayCount(), + inboxApi.getTrashCount() ]); - set({ continuity, pendingCount, ollamaStatus, todayCount }); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount }); }, upsertNote(note) { - const i = get().notes.findIndex((n) => n.id === note.id); - if (i >= 0) { - const next = get().notes.slice(); - next[i] = note; - set({ notes: next }); + // trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일 + // 때만 trashCount 를 local recompute. 그 외엔 server 값 (refreshMeta) 보존. + const showTrash = get().showTrash; + 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, + ...(showTrash ? { trashCount: nextTrash.length } : {}) + }); } else { - set({ notes: [note, ...get().notes] }); + // 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, + ...(showTrash ? { trashCount: cleanTrash.length } : {}) + }); } }, removeNote(id) { - set({ notes: get().notes.filter((n) => n.id !== id) }); + const cleanNotes = get().notes.filter((n) => n.id !== id); + const cleanTrash = get().trashNotes.filter((n) => n.id !== id); + const showTrash = get().showTrash; + set({ + notes: cleanNotes, + trashNotes: cleanTrash, + ...(showTrash ? { trashCount: cleanTrash.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); + // 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음 + // (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘. + // 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출). + 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 }); + } } })); diff --git a/src/shared/types.ts b/src/shared/types.ts index af566e9..e0b0179 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -33,6 +33,10 @@ export interface Note { intentPromptedAt: string | null; dueDate: string | null; dueDateEditedByUser: boolean; + // 신규 v3: + deletedAt: string | null; + lastRecalledAt: string | null; + recallDismissedAt: string | null; createdAt: string; updatedAt: string; tags: NoteTag[]; @@ -67,6 +71,12 @@ export interface InboxApi { getPendingCount(): Promise; getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>; getTodayCount(): Promise; + // 신규 v0.2.3 #4: + restoreNote(noteId: string): Promise; + permanentDeleteNote(noteId: string): Promise<{ confirmed: boolean }>; + emptyTrash(): Promise<{ confirmed: boolean; count: number }>; + listTrash(opts: { limit: number }): Promise; + getTrashCount(): Promise; onNoteUpdated(cb: (note: Note) => void): () => void; } diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index 7e3803f..b5fdbcf 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -278,3 +278,29 @@ describe('AiWorker telemetry emit', () => { expect(failed!.payload.reason).toBe('other'); }); }); + +describe('AiWorker — deletedAt guard (v0.2.3 #4)', () => { + 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'); + }); +}); diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts index f992cc5..fefcd0a 100644 --- a/tests/unit/CaptureService.test.ts +++ b/tests/unit/CaptureService.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; @@ -51,11 +51,11 @@ describe('CaptureService', () => { expect(celebrated).toHaveLength(0); }); - it('deleteNote removes db row + media dir', async () => { + it('deleteNote soft-deletes (sets deletedAt, preserves row)', async () => { const img = new Uint8Array([0, 1, 2, 3]).buffer; const { noteId } = await svc.submit({ text: 't', images: [img] }); await svc.deleteNote(noteId); - expect(repo.findById(noteId)).toBeNull(); + expect(repo.findById(noteId)!.deletedAt).not.toBeNull(); }); }); @@ -111,3 +111,100 @@ describe('CaptureService telemetry emit', () => { expect(events).toHaveLength(0); // events array stays empty since no telemetry was wired }); }); + +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); + }); +}); diff --git a/tests/unit/ContinuityService.test.ts b/tests/unit/ContinuityService.test.ts index 4cee360..f98974e 100644 --- a/tests/unit/ContinuityService.test.ts +++ b/tests/unit/ContinuityService.test.ts @@ -88,4 +88,18 @@ describe('ContinuityService', () => { const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00')); expect(svc.get().showRecoveryToast).toBe(false); }); + + it('excludes trashed notes from streak/recovery math (v0.2.3 #4)', () => { + const db = dbWithDates([ + '2026-04-22T10:00:00+09:00', + '2026-04-25T11:00:00+09:00' + ]); + // 22일 노트를 trash → 25일이 마지막. 22일 미만이라 weekCount 1 이지만 lastNoteAt + // 은 25일 (마지막 active) 이어야 함. trashed 노트가 무시되어야 함. + db.prepare(`UPDATE notes SET deleted_at='2026-04-26T00:00:00Z' WHERE id='n0'`).run(); + const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00')); + const r = svc.get(); + expect(r.weekCount).toBe(1); + expect(r.lastNoteAt).toBe('2026-04-25T02:00:00.000Z'); // KST 11:00 = UTC 02:00 + }); }); diff --git a/tests/unit/ExportService.test.ts b/tests/unit/ExportService.test.ts index 871a42a..8f6e768 100644 --- a/tests/unit/ExportService.test.ts +++ b/tests/unit/ExportService.test.ts @@ -138,4 +138,18 @@ describe('ExportService', () => { expect(readme).toContain('RAG'); expect(readme).toContain('inkling_export_version'); }); + + it('does NOT export trashed notes (listAll filter — v0.2.3 #4 회귀 가드)', async () => { + 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 r = await svc.export(outDir, { includeMedia: false }); + expect(r.noteCount).toBe(1); + // index.jsonl 도 trash 미포함 + const indexPath = join(outDir, 'index.jsonl'); + const lines = readFileSync(indexPath, 'utf8').trim().split('\n'); + expect(lines).toHaveLength(1); + }); }); diff --git a/tests/unit/ImportService.test.ts b/tests/unit/ImportService.test.ts index a88cee0..f401669 100644 --- a/tests/unit/ImportService.test.ts +++ b/tests/unit/ImportService.test.ts @@ -233,3 +233,81 @@ describe('ImportService', () => { expect(dbNote!.media[0]!.bytes).toBe(7); }); }); + +describe('ImportService — deletedAt preservation (v0.2.3 #4)', () => { + 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' }); + 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'); + }); +}); diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 5fde31d..6262ec0 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -215,3 +215,236 @@ describe('NoteRepository', () => { expect(n).toBeGreaterThanOrEqual(0); }); }); + +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(); + }); +}); + +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(); + }); +}); + +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); + }); +}); + +describe('NoteRepository.countTrashed', () => { + let db: Database.Database; + let repo: NoteRepository; + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + it('returns 0 when no trash', () => { + repo.create({ rawText: 'active' }); + expect(repo.countTrashed()).toBe(0); + }); + + it('counts only trashed notes', () => { + const a = repo.create({ rawText: 'a' }).id; + repo.create({ rawText: 'b (active)' }); + 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'); + expect(repo.countTrashed()).toBe(2); + }); + + it('returns count beyond listTrashed limit (no 200 cap drift)', () => { + // listTrashed limit cap is 200; countTrashed must reflect actual count. + for (let i = 0; i < 10; i++) { + const id = repo.create({ rawText: `n${i}` }).id; + repo.trash(id, `2026-05-01T${String(i).padStart(2, '0')}:00:00.000Z`); + } + expect(repo.countTrashed()).toBe(10); + expect(repo.listTrashed({ limit: 5 })).toHaveLength(5); + }); +}); + +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'); + }); + + it('getPendingCount() excludes trashed pending notes (drift guard)', () => { + const a = repo.create({ rawText: 'a' }).id; // ai_status=pending + repo.create({ rawText: 'b' }); // ai_status=pending + expect(repo.getPendingCount()).toBe(2); + // trash() 가 pending_jobs row 는 정리하지만 notes.ai_status 는 'pending' 그대로. + // getPendingCount 가 deleted_at IS NOT NULL 노트 포함하면 영구 over-count. + repo.trash(a, '2026-05-01T00:00:00.000Z'); + expect(repo.getPendingCount()).toBe(1); + }); +}); diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 8da4ea6..5a3198c 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -146,7 +146,8 @@ describe('TelemetryService.readAllRecent', () => { const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const events = await svc.readAllRecent(); expect(events).toHaveLength(3); - expect(events.map((e) => e.payload.noteId)).toEqual(['a', 'b', 'b']); + // discriminant narrowing — empty_trash 같은 noteId 없는 kind 가 섞이면 명시적으로 실패 + expect(events.map((e) => e.kind === 'empty_trash' ? null : e.payload.noteId)).toEqual(['a', 'b', 'b']); }); it('skips malformed lines (silent — invariant)', async () => { @@ -157,7 +158,9 @@ describe('TelemetryService.readAllRecent', () => { const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const events = await svc.readAllRecent(); expect(events).toHaveLength(1); - expect(events[0]!.payload.noteId).toBe('a'); + const ev = events[0]!; + expect(ev.kind).toBe('capture'); + if (ev.kind !== 'empty_trash') expect(ev.payload.noteId).toBe('a'); }); it('returns [] when dir missing', async () => { diff --git a/tests/unit/migrations.due_date.test.ts b/tests/unit/migrations.due_date.test.ts index 431ec74..5ceb60a 100644 --- a/tests/unit/migrations.due_date.test.ts +++ b/tests/unit/migrations.due_date.test.ts @@ -1,19 +1,11 @@ import { describe, it, expect } from 'vitest'; import Database from 'better-sqlite3'; -import { runMigrations, latestVersion } from '@main/db/migrations/index.js'; +import { runMigrations } from '@main/db/migrations/index.js'; describe('migrations m002 due_date', () => { - it('latestVersion returns 2', () => { - expect(latestVersion()).toBe(2); - }); - - it('runMigrations on fresh DB advances user_version to 2', () => { - const db = new Database(':memory:'); - runMigrations(db); - const row = db.pragma('user_version', { simple: true }); - expect(row).toBe(2); - }); - + // v3 (m003 soft_delete) lands in v0.2.3 #4 — latest version + user_version + // assertions migrate to migrations.test.ts. Here we keep only the m002-specific + // assertion (due_date column existence) which is version-stable. it('due_date column exists with NULL default', () => { const db = new Database(':memory:'); runMigrations(db); diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts index 11d4e51..b4b66a8 100644 --- a/tests/unit/migrations.test.ts +++ b/tests/unit/migrations.test.ts @@ -29,3 +29,47 @@ describe('migrations', () => { db.close(); }); }); + +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(); + }); +}); diff --git a/tests/unit/store.tagFilter.test.ts b/tests/unit/store.tagFilter.test.ts index e4f6765..66dbbb5 100644 --- a/tests/unit/store.tagFilter.test.ts +++ b/tests/unit/store.tagFilter.test.ts @@ -18,6 +18,9 @@ function sample(id: string, tags: string[]): Note { intentPromptedAt: null, dueDate: null, dueDateEditedByUser: false, + deletedAt: null, + lastRecalledAt: null, + recallDismissedAt: null, createdAt: '2026-04-26T00:00:00Z', updatedAt: '2026-04-26T00:00:00Z', tags: tags.map((name) => ({ name, source: 'ai' as const })), diff --git a/tests/unit/store.trash.test.ts b/tests/unit/store.trash.test.ts new file mode 100644 index 0000000..fff4a9a --- /dev/null +++ b/tests/unit/store.trash.test.ts @@ -0,0 +1,104 @@ +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('../../src/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('../../src/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('../../src/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('../../src/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('../../src/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 from trashNotes to notes (낙관적 갱신)', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z')); + expect(useInbox.getState().trashNotes).toHaveLength(1); + await useInbox.getState().restoreNote('a'); + expect(mockApi.restoreNote).toHaveBeenCalledWith('a'); + // main 은 restore 시 pushNoteUpdated 안 보냄 — store 자가 갱신 검증 + expect(useInbox.getState().trashNotes).toHaveLength(0); + expect(useInbox.getState().notes).toHaveLength(1); + expect(useInbox.getState().notes[0]!.deletedAt).toBeNull(); + }); + + it('upsertNote with showTrash=false preserves server trashCount (regression I1)', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + // server 가 trashCount=5 알려줬는데 trashNotes 는 미로드 (showTrash=false 기본) + useInbox.setState({ trashCount: 5, trashNotes: [] }); + useInbox.getState().upsertNote(noteStub('active-1')); + expect(useInbox.getState().trashCount).toBe(5); // server 값 보존 + expect(useInbox.getState().notes).toHaveLength(1); + }); + + it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => { + mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 }); + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z')); + await useInbox.getState().emptyTrash(); + expect(useInbox.getState().trashNotes).toHaveLength(1); + }); +}); diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index 2b8d25b..0b6ba41 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -87,3 +87,65 @@ describe('validateEvent — privacy invariant', () => { })).toThrow(); }); }); + +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(); + }); +}); diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts index 359efa7..ab16994 100644 --- a/tests/unit/telemetryStats.test.ts +++ b/tests/unit/telemetryStats.test.ts @@ -66,3 +66,38 @@ describe('aggregateStats', () => { expect(r.md).not.toContain('| 2026-05-01 |'); }); }); + +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'); + }); +});