Files
inkling/docs/superpowers/plans/2026-05-01-v023-trash.md
altair823 b93185edd5 docs(plan): #4 휴지통 구현 계획 (v0.2.3 2/7)
15 task TDD plan — migration v3, Note type extension, NoteRepository 신규
4메서드 + active query 일괄 변경, AiWorker deletedAt guard, telemetry 4 new
kinds + stats.md 회수율 ratio, CaptureService soft delete + 3 신규 메서드
+ 4 emit, ImportService deletedAt 보존, ExportService 회귀 가드, IPC 5 신규
채널 + native dialog confirm, zustand store + 5 actions, Inbox 탭 toggle +
NoteCard mode prop, 게이트 + closure marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:16:26 +09:00

80 KiB

#4 휴지통 (soft delete + migration v3) 구현 plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: v0.2.3 두 번째 항목 — notes 테이블에 deleted_at 도입 후 hard delete → soft delete 전환. Inbox 상단 탭 toggle 로 휴지통 보기, 카드별 복구 / 영구 삭제 + bulk emptyTrash, F5 export 가 trash 제외, F6-L3 import 가 deleted_at 보존, AiWorker 가 trash 노트 skip, telemetry 4 new events.

Architecture: migration v3 가 3 컬럼 추가 (deleted_at + last_recalled_at + recall_dismissed_at — 후자 둘은 #6 가 사용, v3 에 미리 박음). Active query (list/listAll/countToday) 에 명시적 WHERE deleted_at IS NULL. trash 시 pending_jobs 동시 정리 + AiWorker.processJob deletedAt 가드 (race 양쪽 cover). 휴지통 카드는 read-only mode (NoteCardmode 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/countTodayWHERE deleted_at IS NULL + hydrate 가 3 신규 필드 매핑.
src/main/ai/AiWorker.ts (modify) processJob 진입 시 deletedAt 가드 1줄.
src/main/services/CaptureService.ts (modify) deleteNotetrash 호출 (hard → soft). restoreNote/permanentDeleteNote/emptyTrash 신규. TelemetryEmitter interface 에 4 union 멤버 추가. 각 메서드 끝에 emit.
src/main/services/telemetryEvents.ts (modify) zod discriminatedUniontrash/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) ImportNoteInputdeletedAt?: string | null + INSERT 컬럼 + skip 케이스 의 deletedAt 갱신 정책.
src/main/ipc/inboxApi.ts (modify) 5 신규 채널 (restore/permanentDelete/emptyTrash/listTrash) + inbox:emptyTrash / inbox:permanentDelete 가 main 에서 dialog.showMessageBox confirm 후에야 실제 실행.
src/preload/index.ts (modify) InboxApi 신규 5 메서드 IPC bridge.
src/renderer/inbox/store.ts (modify) showTrash/trashNotes/trashCount state + toggleShowTrash/loadTrash/restoreNote/permanentDeleteNote/emptyTrash actions. upsertNote / removeNote 가 양쪽 list (notes / trashNotes) 갱신.
src/renderer/inbox/App.tsx (modify) 헤더에 탭 toggle. showTrash 시 상단에 "휴지통 비우기 (M개)" 버튼 + trashNotes 렌더 + mode="trash" prop 전달.
src/renderer/inbox/components/NoteCard.tsx (modify) mode?: 'inbox' | 'trash' prop. mode==='trash' 시 edit 액션 모두 hidden, "🔄 복구" + "🗑 영구 삭제" 두 버튼 표시.

테스트:

  • tests/unit/migrations.test.ts (modify) — v3 컬럼 + index 검증.
  • tests/unit/NoteRepository.test.ts (modify) — trash/restore/permanentDelete/emptyTrash/listTrashed + active query exclusion.
  • tests/unit/AiWorker.test.ts (modify) — deletedAt 가드 케이스.
  • tests/unit/CaptureService.test.ts (modify) — soft delete 동작 + 3 신규 메서드 + 4 emit.
  • tests/unit/telemetryEvents.test.ts (modify) — 4 신규 kind privacy invariant.
  • tests/unit/telemetryStats.test.ts (modify) — 4 카운터 + restore/trash ratio.
  • tests/unit/ExportService.test.ts (modify) — trash 노트 export 제외 검증.
  • tests/unit/ImportService.test.ts (modify) — deletedAt 보존 + skip 머지 정책.

Task 1: Migration v3 + Note type + hydrate

Files:

  • Create: src/main/db/migrations/m003_soft_delete.ts

  • Modify: src/main/db/migrations/index.ts

  • Modify: src/shared/types.ts

  • Modify: src/main/repository/NoteRepository.ts (hydrate 만)

  • Modify: tests/unit/migrations.test.ts

  • Step 1: Migration 테스트 추가

tests/unit/migrations.test.ts 끝에 추가 (기존 케이스 그대로):

describe('migration v3 — soft delete columns', () => {
  it('adds deleted_at, last_recalled_at, recall_dismissed_at to notes', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    const cols = db.prepare(`PRAGMA table_info(notes)`).all().map((r: any) => r.name);
    expect(cols).toEqual(
      expect.arrayContaining(['deleted_at', 'last_recalled_at', 'recall_dismissed_at'])
    );
    db.close();
  });

  it('creates idx_notes_deleted_at index', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    const indexes = db
      .prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'`)
      .all() as Array<{ name: string }>;
    expect(indexes.map((i) => i.name)).toContain('idx_notes_deleted_at');
    db.close();
  });

  it('user_version reaches 3', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
    expect(row.user_version).toBe(3);
    db.close();
  });

  it('all 3 new columns default to NULL', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    db.prepare(
      `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
       VALUES ('n1', 't', 'pending', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')`
    ).run();
    const row = db.prepare('SELECT deleted_at, last_recalled_at, recall_dismissed_at FROM notes WHERE id=?').get('n1') as any;
    expect(row.deleted_at).toBeNull();
    expect(row.last_recalled_at).toBeNull();
    expect(row.recall_dismissed_at).toBeNull();
    db.close();
  });
});
  • Step 2: 테스트 — FAIL (m003 미존재)

Run: npm test -- tests/unit/migrations.test.ts Expected: FAIL — idx_notes_deleted_at index 없음 + user_version 2.

  • Step 3: m003_soft_delete.ts 생성
// src/main/db/migrations/m003_soft_delete.ts
import type Database from 'better-sqlite3';

export const version = 3;

export function up(db: Database.Database): void {
  db.exec(`
    ALTER TABLE notes ADD COLUMN deleted_at TEXT;
    ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
    ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
    CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
  `);
}
  • Step 4: migrations/index.ts 등록
// src/main/db/migrations/index.ts
import type Database from 'better-sqlite3';
import * as m001 from './m001_initial.js';
import * as m002 from './m002_due_date.js';
import * as m003 from './m003_soft_delete.js';

const migrations = [m001, m002, m003];

export function latestVersion(): number {
  return migrations[migrations.length - 1]!.version;
}

export function runMigrations(db: Database.Database): void {
  const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
  const current = row.user_version ?? 0;
  for (const m of migrations) {
    if (m.version > current) {
      const tx = db.transaction(() => {
        m.up(db);
        db.pragma(`user_version = ${m.version}`);
      });
      tx();
    }
  }
}
  • Step 5: shared/types.ts 의 Note 확장
// src/shared/types.ts — Note interface 확장
export interface Note {
  // 기존 필드 그대로 ...
  dueDate: string | null;
  dueDateEditedByUser: boolean;
  // 신규 v3:
  deletedAt: string | null;
  lastRecalledAt: string | null;
  recallDismissedAt: string | null;
  createdAt: string;
  updatedAt: string;
  tags: NoteTag[];
  media: NoteMedia[];
}

3 신규 필드 추가. dueDateEditedByUsercreatedAt 사이 어디든 OK (그룹화 위해 dueDate 다음).

  • Step 6: NoteRepository.hydrate 매핑

src/main/repository/NoteRepository.ts:350-383hydrate 메서드 — return 객체에 3 필드 추가:

return {
  // 기존 필드 그대로 ...
  dueDate: row.due_date ?? null,
  dueDateEditedByUser: row.due_date_edited_by_user === 1,
  deletedAt: row.deleted_at ?? null,
  lastRecalledAt: row.last_recalled_at ?? null,
  recallDismissedAt: row.recall_dismissed_at ?? null,
  createdAt: row.created_at,
  // ... 그대로 ...
};
  • Step 7: 테스트 — PASS

Run: npm run typecheck && npm test -- tests/unit/migrations.test.ts Expected: typecheck 0 errors. 4 신규 케이스 + 기존 2 모두 PASS.

  • Step 8: 커밋
git add src/main/db/migrations/m003_soft_delete.ts src/main/db/migrations/index.ts src/shared/types.ts src/main/repository/NoteRepository.ts tests/unit/migrations.test.ts
git commit -m "feat(trash): migration v3 + Note type extension (#4 v0.2.3)"

Task 2: NoteRepository.trash + pending_jobs cleanup (atomic)

Files:

  • Modify: src/main/repository/NoteRepository.ts

  • Modify: tests/unit/NoteRepository.test.ts

  • Step 1: 실패 테스트 추가

tests/unit/NoteRepository.test.ts 끝에 새 describe:

describe('NoteRepository.trash', () => {
  let db: Database.Database;
  let repo: NoteRepository;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('sets deleted_at and removes pending_jobs row atomically', () => {
    const { id } = repo.create({ rawText: 'x' });
    expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
    repo.trash(id, '2026-05-01T12:00:00.000Z');
    const note = repo.findById(id)!;
    expect(note.deletedAt).toBe('2026-05-01T12:00:00.000Z');
    expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
  });

  it('updates updated_at to deletedAt timestamp', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.trash(id, '2026-05-01T12:00:00.000Z');
    const note = repo.findById(id)!;
    expect(note.updatedAt).toBe('2026-05-01T12:00:00.000Z');
  });

  it('is no-op when note does not exist', () => {
    expect(() => repo.trash('nonexistent', '2026-05-01T12:00:00.000Z')).not.toThrow();
  });
});

만약 기존 tests/unit/NoteRepository.test.ts 에 import 가 없다면 다음 추가:

import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
  • Step 2: 테스트 — FAIL

Run: npm test -- tests/unit/NoteRepository.test.ts Expected: FAIL — repo.trash 미정의.

  • Step 3: 구현

src/main/repository/NoteRepository.tsdelete() 직전 (line ~224) 또는 setDueDate() 다음 위치에 추가:

trash(id: string, deletedAt: string): void {
  const tx = this.db.transaction(() => {
    this.db
      .prepare(`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?`)
      .run(deletedAt, deletedAt, id);
    this.db.prepare(`DELETE FROM pending_jobs WHERE note_id = ?`).run(id);
  });
  tx();
}
  • Step 4: 테스트 — PASS

Run: npm test -- tests/unit/NoteRepository.test.ts Expected: 3 신규 케이스 + 기존 케이스 모두 PASS.

  • Step 5: 커밋
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(trash): NoteRepository.trash with pending_jobs cleanup (#4 v0.2.3)"

Task 3: NoteRepository.restore

Files:

  • Modify: src/main/repository/NoteRepository.ts

  • Modify: tests/unit/NoteRepository.test.ts

  • Step 1: 실패 테스트 추가

tests/unit/NoteRepository.test.tsdescribe('NoteRepository.trash', ...) 다음에:

describe('NoteRepository.restore', () => {
  let db: Database.Database;
  let repo: NoteRepository;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('clears deleted_at on a trashed note', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.trash(id, '2026-05-01T12:00:00.000Z');
    repo.restore(id);
    const note = repo.findById(id)!;
    expect(note.deletedAt).toBeNull();
  });

  it('updates updated_at', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.trash(id, '2026-05-01T12:00:00.000Z');
    const before = repo.findById(id)!.updatedAt;
    repo.restore(id);
    const after = repo.findById(id)!.updatedAt;
    expect(after).not.toBe(before);
  });

  it('is no-op on already-active note', () => {
    const { id } = repo.create({ rawText: 'x' });
    expect(() => repo.restore(id)).not.toThrow();
    expect(repo.findById(id)!.deletedAt).toBeNull();
  });
});
  • Step 2: 테스트 — FAIL

Run: npm test -- tests/unit/NoteRepository.test.ts Expected: FAIL — repo.restore 미정의.

  • Step 3: 구현

trash 메서드 직후에 추가:

restore(id: string): void {
  const now = new Date().toISOString();
  this.db
    .prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`)
    .run(now, id);
}
  • Step 4: 테스트 — PASS

Run: npm test -- tests/unit/NoteRepository.test.ts Expected: 3 신규 + 기존 PASS.

  • Step 5: 커밋
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(trash): NoteRepository.restore (#4 v0.2.3)"

Task 4: NoteRepository.permanentDelete + emptyTrash + listTrashed

Files:

  • Modify: src/main/repository/NoteRepository.ts

  • Modify: tests/unit/NoteRepository.test.ts

  • Step 1: 실패 테스트 추가

describe('NoteRepository.permanentDelete', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('removes notes row + cascades note_tags / pending_jobs', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.updateAiResult(id, { title: 'T', summary: 'a\nb\nc', tags: ['tag-a'], provider: 'p', dueDate: null });
    expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
    repo.permanentDelete(id);
    expect(repo.findById(id)).toBeNull();
    expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
    expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
  });
});

describe('NoteRepository.emptyTrash', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('hard-deletes all trashed notes and returns their ids', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const c = repo.create({ rawText: 'c' }).id;
    repo.trash(a, '2026-05-01T00:00:00.000Z');
    repo.trash(c, '2026-05-01T01:00:00.000Z');
    const r = repo.emptyTrash();
    expect(r.noteIds.sort()).toEqual([a, c].sort());
    expect(repo.findById(a)).toBeNull();
    expect(repo.findById(b)).not.toBeNull();
    expect(repo.findById(c)).toBeNull();
  });

  it('returns empty array when trash is empty', () => {
    expect(repo.emptyTrash()).toEqual({ noteIds: [] });
  });
});

describe('NoteRepository.listTrashed', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('returns trashed notes ordered by deleted_at DESC', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const c = repo.create({ rawText: 'c' }).id;
    repo.trash(a, '2026-05-01T00:00:00.000Z');
    repo.trash(c, '2026-05-01T02:00:00.000Z');
    repo.trash(b, '2026-05-01T01:00:00.000Z');
    const r = repo.listTrashed({ limit: 50 });
    expect(r.map((n) => n.id)).toEqual([c, b, a]);
  });

  it('excludes active notes', () => {
    repo.create({ rawText: 'active' });
    const r = repo.listTrashed({ limit: 50 });
    expect(r).toEqual([]);
  });

  it('respects limit', () => {
    for (let i = 0; i < 5; i++) {
      const id = repo.create({ rawText: `n${i}` }).id;
      repo.trash(id, `2026-05-01T0${i}:00:00.000Z`);
    }
    const r = repo.listTrashed({ limit: 3 });
    expect(r).toHaveLength(3);
  });
});
  • Step 2: 테스트 — FAIL

Run: npm test -- tests/unit/NoteRepository.test.ts Expected: FAIL — permanentDelete/emptyTrash/listTrashed 미정의.

  • Step 3: 구현

restore 메서드 직후에 추가:

permanentDelete(id: string): void {
  this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}

emptyTrash(): { noteIds: string[] } {
  const noteIds: string[] = [];
  const tx = this.db.transaction(() => {
    const rows = this.db
      .prepare('SELECT id FROM notes WHERE deleted_at IS NOT NULL')
      .all() as Array<{ id: string }>;
    for (const r of rows) {
      this.db.prepare('DELETE FROM notes WHERE id=?').run(r.id);
      noteIds.push(r.id);
    }
  });
  tx();
  return { noteIds };
}

listTrashed(opts: { limit: number }): Note[] {
  const limit = Math.max(1, Math.min(200, opts.limit));
  const rows = this.db
    .prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
    .all(limit) as any[];
  return rows.map((r) => this.hydrate(r));
}

기존 delete(id) 메서드 (NoteRepository.ts:224) 를 deprecate 표시:

/** @deprecated v0.2.3 #4 부터 hard delete 는 permanentDelete() 사용. soft delete 는 trash(). 본 메서드는 v0.2.4 에서 제거 예정. */
delete(id: string): void {
  this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
  • Step 4: 테스트 — PASS

Run: npm test -- tests/unit/NoteRepository.test.ts Expected: 7 신규 + 기존 PASS.

  • Step 5: 커밋
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(trash): NoteRepository.permanentDelete/emptyTrash/listTrashed (#4 v0.2.3)"

Task 5: Active query filters (list / listAll / countToday)

Files:

  • Modify: src/main/repository/NoteRepository.ts

  • Modify: tests/unit/NoteRepository.test.ts

  • Step 1: 실패 테스트 추가

describe('Active queries exclude deleted notes', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('list() excludes trashed', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    repo.trash(a, '2026-05-01T00:00:00.000Z');
    const r = repo.list({ limit: 50 });
    expect(r.map((n) => n.id)).toEqual([b]);
  });

  it('listAll() excludes trashed', () => {
    const a = repo.create({ rawText: 'a' }).id;
    repo.create({ rawText: 'b' });
    repo.trash(a, '2026-05-01T00:00:00.000Z');
    const r = repo.listAll();
    expect(r.map((n) => n.rawText)).toEqual(['b']);
  });

  it('countToday() excludes trashed', () => {
    const a = repo.create({ rawText: 'a' }).id;
    repo.create({ rawText: 'b' });
    repo.trash(a, new Date().toISOString());
    expect(repo.countToday(new Date())).toBe(1);
  });

  it('findById() returns trashed notes (does NOT filter)', () => {
    const { id } = repo.create({ rawText: 'a' });
    repo.trash(id, '2026-05-01T00:00:00.000Z');
    const note = repo.findById(id);
    expect(note).not.toBeNull();
    expect(note!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
  });
});
  • Step 2: 테스트 — FAIL

Run: npm test -- tests/unit/NoteRepository.test.ts Expected: FAIL — list/listAll/countToday 가 trash 노트를 포함.

  • Step 3: 구현 — list, listAll, countToday 의 SQL 에 WHERE deleted_at IS NULL 추가

src/main/repository/NoteRepository.ts:82-92 (list) 변경:

list(opts: { limit: number; cursor?: string }): Note[] {
  const limit = Math.max(1, Math.min(200, opts.limit));
  const rows = opts.cursor
    ? (this.db
        .prepare(
          `SELECT * FROM notes
            WHERE deleted_at IS NULL AND created_at < ?
            ORDER BY created_at DESC, id DESC LIMIT ?`
        )
        .all(opts.cursor, limit) as any[])
    : (this.db
        .prepare(
          `SELECT * FROM notes
            WHERE deleted_at IS NULL
            ORDER BY created_at DESC, id DESC LIMIT ?`
        )
        .all(limit) as any[]);
  return rows.map((r) => this.hydrate(r));
}

src/main/repository/NoteRepository.ts:94-99 (listAll) 변경:

listAll(): Note[] {
  const rows = this.db
    .prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
    .all() as any[];
  return rows.map((r) => this.hydrate(r));
}

src/main/repository/NoteRepository.ts:311-325 (countToday) 변경:

countToday(now: Date = new Date()): number {
  const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
  const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
  const kstYear = kstNow.getUTCFullYear();
  const kstMonth = kstNow.getUTCMonth();
  const kstDate = kstNow.getUTCDate();
  const kstMidnightUtc = Date.UTC(kstYear, kstMonth, kstDate) - KST_OFFSET_MS;
  const nextKstMidnightUtc = kstMidnightUtc + 24 * 60 * 60 * 1000;
  const startIso = new Date(kstMidnightUtc).toISOString();
  const endIso = new Date(nextKstMidnightUtc).toISOString();
  const row = this.db
    .prepare(
      `SELECT COUNT(*) AS c FROM notes
        WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?`
    )
    .get(startIso, endIso) as { c: number };
  return row.c;
}
  • Step 4: 테스트 — PASS

Run: npm test -- tests/unit/NoteRepository.test.ts Expected: 4 신규 + 기존 PASS.

  • Step 5: 커밋
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(trash): active queries exclude deleted_at IS NOT NULL (#4 v0.2.3)"

Task 6: AiWorker.processJob deletedAt 가드

Files:

  • Modify: src/main/ai/AiWorker.ts

  • Modify: tests/unit/AiWorker.test.ts

  • Step 1: 실패 테스트 추가

tests/unit/AiWorker.test.ts 끝에:

describe('AiWorker — deletedAt guard', () => {
  let db: Database.Database;
  let repo: NoteRepository;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('skips notes with deleted_at IS NOT NULL — provider.generate not called', async () => {
    const { id } = repo.create({ rawText: 'x' });
    // 먼저 trash — pending_jobs cleanup 됨
    repo.trash(id, '2026-05-01T12:00:00.000Z');
    // 강제로 pending_jobs row 다시 삽입 (race 시뮬레이션 — AiWorker 가 이미 dequeue 한 상태 흉내)
    db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, '2026-05-01T12:00:00.000Z');
    const generate = vi.fn();
    const provider = makeProvider({ generate: generate as any });
    const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
    await w.loadFromDb();
    await w.drain();
    expect(generate).not.toHaveBeenCalled();
    expect(repo.findById(id)!.aiStatus).toBe('pending');
  });
});
  • Step 2: 테스트 — FAIL

Run: npm test -- tests/unit/AiWorker.test.ts Expected: FAIL — provider.generate 가 호출됨 (가드 미존재).

  • Step 3: 구현

src/main/ai/AiWorker.ts:99-100processJob 진입 체크 변경:

const note = this.repo.findById(job.noteId);
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
  • Step 4: 테스트 — PASS

Run: npm test -- tests/unit/AiWorker.test.ts Expected: 신규 + 기존 PASS.

  • Step 5: 커밋
git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts
git commit -m "feat(trash): AiWorker.processJob deletedAt guard (#4 v0.2.3)"

Task 7: telemetryEvents schema + TelemetryService EmitInput 확장 (4 신규 kind)

Files:

  • Modify: src/main/services/telemetryEvents.ts

  • Modify: src/main/services/TelemetryService.ts

  • Modify: tests/unit/telemetryEvents.test.ts

  • Step 1: 실패 테스트 추가

tests/unit/telemetryEvents.test.ts 끝에:

describe('validateEvent — trash family (v0.2.3 #4)', () => {
  it('accepts trash event', () => {
    const e = validateEvent({
      ts: '2026-05-01T00:00:00.000Z',
      kind: 'trash',
      payload: { noteId: 'n1' }
    });
    expect(e.kind).toBe('trash');
  });

  it('accepts restore event', () => {
    const e = validateEvent({
      ts: '2026-05-01T00:00:00.000Z',
      kind: 'restore',
      payload: { noteId: 'n1' }
    });
    expect(e.kind).toBe('restore');
  });

  it('accepts permanent_delete event', () => {
    const e = validateEvent({
      ts: '2026-05-01T00:00:00.000Z',
      kind: 'permanent_delete',
      payload: { noteId: 'n1' }
    });
    expect(e.kind).toBe('permanent_delete');
  });

  it('accepts empty_trash event with count', () => {
    const e = validateEvent({
      ts: '2026-05-01T00:00:00.000Z',
      kind: 'empty_trash',
      payload: { count: 7 }
    });
    expect(e.kind).toBe('empty_trash');
  });

  it('rejects trash payload with rawText leak', () => {
    expect(() => validateEvent({
      ts: '2026-05-01T00:00:00.000Z',
      kind: 'trash',
      payload: { noteId: 'n1', rawText: 'leak' }
    })).toThrow();
  });

  it('rejects empty_trash with negative count', () => {
    expect(() => validateEvent({
      ts: '2026-05-01T00:00:00.000Z',
      kind: 'empty_trash',
      payload: { count: -1 }
    })).toThrow();
  });

  it('rejects empty_trash with non-integer count', () => {
    expect(() => validateEvent({
      ts: '2026-05-01T00:00:00.000Z',
      kind: 'empty_trash',
      payload: { count: 1.5 }
    })).toThrow();
  });
});
  • Step 2: 테스트 — FAIL

Run: npm test -- tests/unit/telemetryEvents.test.ts Expected: FAIL — 4 신규 kind 미지원.

  • Step 3: 구현 — telemetryEvents.tsdiscriminatedUnion 확장
// src/main/services/telemetryEvents.ts
import { z } from 'zod';

const CapturePayload = z.object({
  noteId: z.string().min(1),
  rawTextLength: z.number().int().nonnegative(),
  hasMedia: z.boolean()
}).strict();

const AiSucceededPayload = z.object({
  noteId: z.string().min(1),
  durationMs: z.number().nonnegative(),
  attempts: z.number().int().nonnegative()
}).strict();

const AiFailedReason = z.enum(['unreachable', 'schema', 'timeout', 'other']);

const AiFailedPayload = z.object({
  noteId: z.string().min(1),
  reason: AiFailedReason,
  attempts: z.number().int().nonnegative()
}).strict();

const NoteIdPayload = z.object({
  noteId: z.string().min(1)
}).strict();

const EmptyTrashPayload = z.object({
  count: z.number().int().nonnegative()
}).strict();

export const TelemetryEventSchema = z.discriminatedUnion('kind', [
  z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict()
]);

export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
export type TelemetryKind = TelemetryEvent['kind'];

export function validateEvent(raw: unknown): TelemetryEvent {
  return TelemetryEventSchema.parse(raw);
}
  • Step 4: TelemetryService 의 EmitInput union 확장

src/main/services/TelemetryService.tsEmitInput 타입 변경:

export type EmitInput =
  | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
  | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
  | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
  | { kind: 'trash'; payload: { noteId: string } }
  | { kind: 'restore'; payload: { noteId: string } }
  | { kind: 'permanent_delete'; payload: { noteId: string } }
  | { kind: 'empty_trash'; payload: { count: number } };
  • Step 5: 테스트 — PASS + typecheck

Run: npm run typecheck && npm test -- tests/unit/telemetryEvents.test.ts Expected: typecheck 0 errors. 7 신규 + 기존 PASS.

  • Step 6: 커밋
git add src/main/services/telemetryEvents.ts src/main/services/TelemetryService.ts tests/unit/telemetryEvents.test.ts
git commit -m "feat(trash): telemetry 4 new kinds (trash/restore/permanent_delete/empty_trash) (#4 v0.2.3)"

Task 8: telemetryStats 4 카운터 + restore/trash ratio

Files:

  • Modify: src/main/services/telemetryStats.ts

  • Modify: tests/unit/telemetryStats.test.ts

  • Step 1: 실패 테스트 추가

tests/unit/telemetryStats.test.ts 끝에:

describe('aggregateStats — trash family (v0.2.3 #4)', () => {
  it('counts trash/restore/permanent_delete/empty_trash per day', () => {
    const events: TelemetryEvent[] = [
      e('2026-05-01T00:00:00Z', 'trash', { noteId: 'n1' }),
      e('2026-05-01T01:00:00Z', 'trash', { noteId: 'n2' }),
      e('2026-05-01T02:00:00Z', 'restore', { noteId: 'n1' }),
      e('2026-05-01T03:00:00Z', 'permanent_delete', { noteId: 'n3' }),
      e('2026-05-01T04:00:00Z', 'empty_trash', { count: 5 })
    ];
    const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z'));
    expect(r.eventCount).toBe(5);
    expect(r.md).toContain('| 2026-05-01 | 0 | 0 | 0 | 2 | 1 | 1 | 1 |');
  });

  it('computes restore/trash ratio', () => {
    const events: TelemetryEvent[] = [
      e('2026-05-01T00:00:00Z', 'trash', { noteId: 'a' }),
      e('2026-05-01T00:00:01Z', 'trash', { noteId: 'b' }),
      e('2026-05-01T00:00:02Z', 'trash', { noteId: 'c' }),
      e('2026-05-01T00:00:03Z', 'trash', { noteId: 'd' }),
      e('2026-05-01T00:00:04Z', 'restore', { noteId: 'a' })
    ];
    const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
    expect(r.md).toContain('휴지통 회수율: 25.0% (1/4)');
  });

  it('휴지통 회수율 N/A when no trash events', () => {
    const events: TelemetryEvent[] = [
      e('2026-05-01T00:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false })
    ];
    const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
    expect(r.md).toContain('휴지통 회수율: N/A');
  });
});
  • Step 2: 테스트 — FAIL

Run: npm test -- tests/unit/telemetryStats.test.ts Expected: FAIL — 신규 kind 카운트 + ratio 미지원.

  • Step 3: 구현

src/main/services/telemetryStats.ts 변경 — DailyRow 인터페이스에 4 필드 추가, 집계 if 블록 추가, 표 헤더/행 + ratio 라인:

import type { TelemetryEvent } from './telemetryEvents.js';

const KST_OFFSET_MS = 9 * 60 * 60 * 1000;

function kstDate(ts: string): string {
  const d = new Date(ts);
  const k = new Date(d.getTime() + KST_OFFSET_MS);
  return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
    .toISOString().slice(0, 10);
}

interface DailyRow {
  date: string;
  capture: number;
  ai_succeeded: number;
  ai_failed: number;
  trash: number;
  restore: number;
  permanent_delete: number;
  empty_trash: number;
}

export interface StatsResult {
  md: string;
  eventCount: number;
}

export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): StatsResult {
  const eventCount = events.length;
  const byDay = new Map<string, DailyRow>();
  let aiSucceeded = 0;
  let aiFailed = 0;
  let durationSum = 0;
  let durationN = 0;
  let trashCount = 0;
  let restoreCount = 0;
  for (const ev of events) {
    const day = kstDate(ev.ts);
    let row = byDay.get(day);
    if (!row) {
      row = { date: day, capture: 0, ai_succeeded: 0, ai_failed: 0, trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0 };
      byDay.set(day, row);
    }
    if (ev.kind === 'capture') row.capture += 1;
    else if (ev.kind === 'ai_succeeded') {
      row.ai_succeeded += 1;
      aiSucceeded += 1;
      durationSum += ev.payload.durationMs;
      durationN += 1;
    } else if (ev.kind === 'ai_failed') {
      row.ai_failed += 1;
      aiFailed += 1;
    } else if (ev.kind === 'trash') {
      row.trash += 1;
      trashCount += 1;
    } else if (ev.kind === 'restore') {
      row.restore += 1;
      restoreCount += 1;
    } else if (ev.kind === 'permanent_delete') {
      row.permanent_delete += 1;
    } else if (ev.kind === 'empty_trash') {
      row.empty_trash += 1;
    }
  }
  const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
  const aiTotal = aiSucceeded + aiFailed;
  const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
  const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
  const trashRecoveryRate = trashCount === 0
    ? 'N/A'
    : `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;
  const lines: string[] = [];
  lines.push('# Inkling Telemetry Stats');
  lines.push('');
  lines.push(`생성: ${generatedAt.toISOString()}`);
  lines.push(`총 이벤트: ${eventCount}`);
  lines.push('');
  lines.push('## 일자별 카운트');
  lines.push('');
  lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash |');
  lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|');
  for (const row of days) {
    lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} |`);
  }
  lines.push('');
  lines.push('## 핵심 ratio');
  lines.push('');
  lines.push(`- AI 성공률: ${successRate}`);
  lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`);
  lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`);
  lines.push('');
  return { md: lines.join('\n'), eventCount };
}
  • Step 4: 테스트 — PASS

Run: npm test -- tests/unit/telemetryStats.test.ts Expected: 3 신규 + 기존 PASS.

  • Step 5: 커밋
git add src/main/services/telemetryStats.ts tests/unit/telemetryStats.test.ts
git commit -m "feat(trash): telemetryStats 4 new counters + 휴지통 회수율 ratio (#4 v0.2.3)"

Task 9: CaptureService — soft delete + 3 신규 메서드 + 4 emit hooks

Files:

  • Modify: src/main/services/CaptureService.ts

  • Modify: tests/unit/CaptureService.test.ts

  • Step 1: 실패 테스트 추가

tests/unit/CaptureService.test.ts 끝에:

describe('CaptureService trash flow (v0.2.3 #4)', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  let store: MediaStore;
  let tmp: string;
  let events: Array<{ kind: string; payload: any }>;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
    tmp = mkdtempSync(join(tmpdir(), 'inkling-trash-'));
    store = new MediaStore(tmp);
    events = [];
  });

  it('deleteNote sets deleted_at and emits trash event (no media cleanup)', async () => {
    const svc = new CaptureService(repo, store, {
      enqueue: async () => {},
      celebrate: () => {},
      telemetry: { emit: async (ev) => { events.push(ev); } }
    });
    const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
    events.length = 0; // clear capture event
    await svc.deleteNote(noteId);
    expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
    expect(events).toHaveLength(1);
    expect(events[0]!.kind).toBe('trash');
    expect(events[0]!.payload.noteId).toBe(noteId);
    // media 디렉터리 보존 확인 (restore 시 필요)
    expect(existsSync(join(tmp, 'media', noteId))).toBe(true);
  });

  it('restoreNote clears deleted_at and emits restore event', async () => {
    const svc = new CaptureService(repo, store, {
      enqueue: async () => {},
      celebrate: () => {},
      telemetry: { emit: async (ev) => { events.push(ev); } }
    });
    const { noteId } = await svc.submit({ text: 'hi', images: [] });
    events.length = 0;
    await svc.deleteNote(noteId);
    events.length = 0;
    await svc.restoreNote(noteId);
    expect(repo.findById(noteId)!.deletedAt).toBeNull();
    expect(events).toHaveLength(1);
    expect(events[0]!.kind).toBe('restore');
  });

  it('permanentDeleteNote hard-deletes + cleans media + emits permanent_delete', async () => {
    const svc = new CaptureService(repo, store, {
      enqueue: async () => {},
      celebrate: () => {},
      telemetry: { emit: async (ev) => { events.push(ev); } }
    });
    const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
    events.length = 0;
    await svc.permanentDeleteNote(noteId);
    expect(repo.findById(noteId)).toBeNull();
    expect(existsSync(join(tmp, 'media', noteId))).toBe(false);
    expect(events).toHaveLength(1);
    expect(events[0]!.kind).toBe('permanent_delete');
  });

  it('emptyTrash deletes all trashed + cleans each media + emits empty_trash with count', async () => {
    const svc = new CaptureService(repo, store, {
      enqueue: async () => {},
      celebrate: () => {},
      telemetry: { emit: async (ev) => { events.push(ev); } }
    });
    const a = (await svc.submit({ text: 'a', images: [new ArrayBuffer(8)] })).noteId;
    const b = (await svc.submit({ text: 'b', images: [new ArrayBuffer(8)] })).noteId;
    await svc.submit({ text: 'c (active)', images: [] });
    await svc.deleteNote(a);
    await svc.deleteNote(b);
    events.length = 0;
    const r = await svc.emptyTrash();
    expect(r.count).toBe(2);
    expect(repo.findById(a)).toBeNull();
    expect(repo.findById(b)).toBeNull();
    expect(existsSync(join(tmp, 'media', a))).toBe(false);
    expect(existsSync(join(tmp, 'media', b))).toBe(false);
    const empty = events.find((e) => e.kind === 'empty_trash')!;
    expect(empty.payload.count).toBe(2);
  });

  it('emptyTrash returns count=0 when trash empty', async () => {
    const svc = new CaptureService(repo, store, {
      enqueue: async () => {},
      celebrate: () => {},
      telemetry: { emit: async (ev) => { events.push(ev); } }
    });
    const r = await svc.emptyTrash();
    expect(r.count).toBe(0);
  });
});

(import 추가 필요 시 파일 head 의 기존 imports 참고: mkdtempSync, existsSync, tmpdir, join)

  • Step 2: 테스트 — FAIL

Run: npm test -- tests/unit/CaptureService.test.ts Expected: FAIL — restoreNote/permanentDeleteNote/emptyTrash 미정의 + deleteNote 가 hard delete 라 deletedAt 검증 깨짐.

  • Step 3: 구현 — CaptureService.ts 변경

TelemetryEmitter interface 확장 + 메서드 4개 변경/신규:

// src/main/services/CaptureService.ts
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { MediaStore } from './MediaStore.js';

export interface TelemetryEmitter {
  emit(input:
    | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
    | { kind: 'trash'; payload: { noteId: string } }
    | { kind: 'restore'; payload: { noteId: string } }
    | { kind: 'permanent_delete'; payload: { noteId: string } }
    | { kind: 'empty_trash'; payload: { count: number } }
  ): Promise<void>;
}

export interface CaptureDeps {
  enqueue: (noteId: string) => Promise<void>;
  celebrate: (noteId: string) => void;
  telemetry?: TelemetryEmitter;
}

export interface SubmitInput {
  text: string;
  images: ArrayBuffer[];
}

export class CaptureService {
  constructor(
    private repo: NoteRepository,
    private store: MediaStore,
    private deps: CaptureDeps
  ) {}

  async submit(input: SubmitInput): Promise<{ noteId: string }> {
    const trimmed = input.text.trim();
    if (trimmed.length === 0 && input.images.length === 0) {
      throw new Error('empty submission');
    }
    const { id } = this.repo.create({ rawText: input.text });
    if (input.images.length > 0) {
      const rows = [];
      for (const img of input.images) {
        const buf = Buffer.from(img);
        const saved = await this.store.saveImage(id, buf, 'image/png');
        rows.push({
          noteId: id,
          kind: 'image' as const,
          relPath: saved.relPath,
          mime: saved.mime,
          bytes: saved.bytes
        });
      }
      this.repo.insertMedia(rows);
    }
    if (this.deps.telemetry) {
      await this.deps.telemetry.emit({
        kind: 'capture',
        payload: {
          noteId: id,
          rawTextLength: input.text.length,
          hasMedia: input.images.length > 0
        }
      }).catch(() => {});
    }
    await this.deps.enqueue(id);
    this.deps.celebrate(id);
    return { noteId: id };
  }

  async deleteNote(noteId: string): Promise<void> {
    // v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요).
    this.repo.trash(noteId, new Date().toISOString());
    if (this.deps.telemetry) {
      await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
    }
  }

  async restoreNote(noteId: string): Promise<void> {
    this.repo.restore(noteId);
    if (this.deps.telemetry) {
      await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
    }
  }

  async permanentDeleteNote(noteId: string): Promise<void> {
    this.repo.permanentDelete(noteId);
    await this.store.deleteNoteDirectory(noteId);
    if (this.deps.telemetry) {
      await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
    }
  }

  async emptyTrash(): Promise<{ count: number }> {
    const { noteIds } = this.repo.emptyTrash();
    for (const id of noteIds) {
      try { await this.store.deleteNoteDirectory(id); }
      catch { /* best-effort */ }
    }
    if (this.deps.telemetry) {
      await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
    }
    return { count: noteIds.length };
  }
}
  • Step 4: 테스트 — PASS

Run: npm test -- tests/unit/CaptureService.test.ts Expected: 5 신규 + 기존 PASS.

  • Step 5: 커밋
git add src/main/services/CaptureService.ts tests/unit/CaptureService.test.ts
git commit -m "feat(trash): CaptureService soft-delete + restore/permanent/empty + 4 emits (#4 v0.2.3)"

Task 10: ImportService — deletedAt 보존 + 충돌 정책

Files:

  • Modify: src/main/repository/NoteRepository.ts (importNote 만)

  • Modify: src/main/services/ImportService.ts

  • Modify: src/main/services/importFormat.ts (frontmatter 에 deleted_at 추가)

  • Modify: src/main/services/exportFormat.ts (frontmatter 출력 시 deleted_at 무시 — 이미 listAll filter 로 trash 제외이므로 always null)

  • Modify: tests/unit/ImportService.test.ts

  • Step 1: 실패 테스트 추가

tests/unit/ImportService.test.ts 끝에:

describe('ImportService — deletedAt preservation (v0.2.3 #4)', () => {
  // 이 시나리오는 F5 export 가 trash 를 제외하므로 source 의 deletedAt 은 항상 null.
  // 단 외부에서 직접 frontmatter 에 deleted_at 을 넣은 경우 (수동 편집) 보존되어야 함.

  it('id-collide skip: source deleted_at IS NOT NULL → dest deleted_at 갱신', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    const repo = new NoteRepository(db);
    const { id } = repo.create({ rawText: 'identical' });
    // import: 같은 id + 같은 raw_text + deletedAt 값 → dest 의 deleted_at 을 갱신
    const r = repo.importNote({
      id, rawText: 'identical',
      createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
      aiTitle: null, aiSummary: null,
      titleEditedByUser: false, summaryEditedByUser: false,
      aiProvider: null, aiGeneratedAt: null,
      userIntent: null, intentPromptedAt: null,
      tags: [],
      deletedAt: '2026-05-01T12:00:00.000Z'
    });
    expect(r.status).toBe('skipped');
    expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
  });

  it('id-collide skip: source deleted_at NULL + dest IS NOT NULL → dest 유지', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    const repo = new NoteRepository(db);
    const { id } = repo.create({ rawText: 'identical' });
    repo.trash(id, '2026-05-01T00:00:00.000Z');
    repo.importNote({
      id, rawText: 'identical',
      createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
      aiTitle: null, aiSummary: null,
      titleEditedByUser: false, summaryEditedByUser: false,
      aiProvider: null, aiGeneratedAt: null,
      userIntent: null, intentPromptedAt: null,
      tags: [],
      deletedAt: null
    });
    expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
  });

  it('id-new insert: source deletedAt 보존', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    const repo = new NoteRepository(db);
    const r = repo.importNote({
      id: 'fresh-id', rawText: 'fresh',
      createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
      aiTitle: null, aiSummary: null,
      titleEditedByUser: false, summaryEditedByUser: false,
      aiProvider: null, aiGeneratedAt: null,
      userIntent: null, intentPromptedAt: null,
      tags: [],
      deletedAt: '2026-05-01T12:00:00.000Z'
    });
    expect(r.status).toBe('inserted');
    expect(repo.findById('fresh-id')!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
  });

  it('id-collide forked: deletedAt 도 fork 노트에 보존', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    const repo = new NoteRepository(db);
    const { id } = repo.create({ rawText: 'original' });
    const r = repo.importNote({
      id, rawText: 'different',
      createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
      aiTitle: null, aiSummary: null,
      titleEditedByUser: false, summaryEditedByUser: false,
      aiProvider: null, aiGeneratedAt: null,
      userIntent: null, intentPromptedAt: null,
      tags: [],
      deletedAt: '2026-05-01T12:00:00.000Z'
    });
    expect(r.status).toBe('forked');
    expect(r.id).not.toBe(id);
    expect(repo.findById(r.id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
  });
});
  • Step 2: 테스트 — FAIL

Run: npm test -- tests/unit/ImportService.test.ts Expected: FAIL — ImportNoteInput.deletedAt 미지원 (typecheck) 또는 INSERT 가 컬럼 미포함.

  • Step 3: NoteRepository.importNote 변경

ImportNoteInput interface (NoteRepository.ts:15-31) 에 deletedAt?: string | null; 추가:

export interface ImportNoteInput {
  id: string;
  rawText: string;
  createdAt: string;
  updatedAt: string;
  aiTitle: string | null;
  aiSummary: string | null;
  titleEditedByUser: boolean;
  summaryEditedByUser: boolean;
  aiProvider: string | null;
  aiGeneratedAt: string | null;
  userIntent: string | null;
  intentPromptedAt: string | null;
  tags: { name: string; source: 'ai' | 'user' }[];
  deletedAt?: string | null;
}

importNote 함수 (NoteRepository.ts:243-296) 변경 — skip 케이스의 deletedAt 갱신 로직 + INSERT 의 deleted_at 컬럼:

importNote(input: ImportNoteInput): ImportNoteResult {
  const existing = this.findRawTextById(input.id);
  let finalId = input.id;
  let status: ImportNoteStatus = 'inserted';
  if (existing !== null) {
    if (existing === input.rawText) {
      // skip — 단, source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존)
      if (input.deletedAt) {
        const destRow = this.db
          .prepare('SELECT deleted_at FROM notes WHERE id=?')
          .get(input.id) as { deleted_at: string | null } | undefined;
        if (destRow && destRow.deleted_at === null) {
          this.db
            .prepare('UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?')
            .run(input.deletedAt, input.deletedAt, input.id);
        }
      }
      return { id: input.id, status: 'skipped' };
    }
    finalId = uuidv7();
    status = 'forked';
  }
  const tx = this.db.transaction(() => {
    this.db
      .prepare(
        `INSERT INTO notes
           (id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
            title_edited_by_user, summary_edited_by_user,
            user_intent, intent_prompted_at, deleted_at, created_at, updated_at)
         VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
      )
      .run(
        finalId,
        input.rawText,
        input.aiTitle,
        input.aiSummary,
        input.aiProvider,
        input.aiGeneratedAt,
        input.titleEditedByUser ? 1 : 0,
        input.summaryEditedByUser ? 1 : 0,
        input.userIntent,
        input.intentPromptedAt,
        input.deletedAt ?? null,
        input.createdAt,
        input.updatedAt
      );
    if (input.tags.length > 0) {
      const getOrInsertTag = this.db.prepare(
        `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
      );
      const linkAi = this.db.prepare(
        `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
      );
      const linkUser = this.db.prepare(
        `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
      );
      for (const t of input.tags) {
        const row = getOrInsertTag.get(t.name) as { id: number };
        if (t.source === 'ai') linkAi.run(finalId, row.id);
        else linkUser.run(finalId, row.id);
      }
    }
  });
  tx();
  return { id: finalId, status };
}
  • Step 4: ImportService 가 frontmatter 의 deleted_atImportNoteInput.deletedAt 으로 전달

먼저 src/main/services/importFormat.ts 를 읽고 frontmatter 파싱 위치 확인:

Run: cat src/main/services/importFormat.ts | head -60

해당 위치에서 frontmatter 의 deleted_at 키를 ISO string 으로 추출 (없으면 null). 그 결과를 ImportServicerepo.importNote({..., deletedAt: parsed.deletedAt ?? null}) 로 전달.

ImportService 변경 — 호출 site 에 deletedAt 추가. 정확한 위치는 ImportService.ts 에서 repo.importNote({...}) 를 호출하는 곳.

  • Step 5: 테스트 — PASS

Run: npm run typecheck && npm test -- tests/unit/ImportService.test.ts Expected: typecheck 0. 4 신규 + 기존 PASS.

  • Step 6: 커밋
git add src/main/repository/NoteRepository.ts src/main/services/ImportService.ts src/main/services/importFormat.ts tests/unit/ImportService.test.ts
git commit -m "feat(trash): ImportService deletedAt preservation + skip-merge policy (#4 v0.2.3)"

Task 11: ExportService — listAll filter 검증 (no code change, test only)

Files:

  • Modify: tests/unit/ExportService.test.ts

ExportService 자체 코드는 무수정 — repo.listAll() 이 Task 5 에서 이미 WHERE deleted_at IS NULL 추가됨. 단 명시적 회귀 테스트 추가.

  • Step 1: 실패 테스트 추가 (회귀 가드용)

tests/unit/ExportService.test.ts 끝에:

describe('ExportService — trash exclusion (v0.2.3 #4)', () => {
  it('does NOT export trashed notes (listAll filter)', async () => {
    const db = new Database(':memory:');
    runMigrations(db);
    const repo = new NoteRepository(db);
    const tmp = mkdtempSync(join(tmpdir(), 'inkling-export-trash-'));
    const store = new MediaStore(tmp);
    const a = repo.create({ rawText: 'active note' }).id;
    const t = repo.create({ rawText: 'trashed note' }).id;
    repo.updateAiResult(a, { title: '활성', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
    repo.updateAiResult(t, { title: '버려짐', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
    repo.trash(t, '2026-05-01T00:00:00.000Z');
    const svc = new ExportService(repo, store);
    const out = mkdtempSync(join(tmpdir(), 'inkling-export-out-'));
    const r = await svc.export(out, { includeMedia: false });
    expect(r.noteCount).toBe(1);
    // index.jsonl 도 trash 미포함
    const indexPath = join(out, 'index.jsonl');
    const lines = readFileSync(indexPath, 'utf8').trim().split('\n');
    expect(lines).toHaveLength(1);
    rmSync(tmp, { recursive: true, force: true });
    rmSync(out, { recursive: true, force: true });
  });
});
  • Step 2: 테스트 실행 — PASS (회귀 가드 즉시 그린)

Run: npm test -- tests/unit/ExportService.test.ts Expected: 신규 + 기존 PASS — Task 5 의 listAll filter 가 자동으로 trash 제외.

  • Step 3: 커밋
git add tests/unit/ExportService.test.ts
git commit -m "test(trash): ExportService excludes trashed notes (regression guard, #4 v0.2.3)"

Task 12: IPC 5 신규 채널 + native dialog confirm

Files:

  • Modify: src/main/ipc/inboxApi.ts

  • Modify: src/preload/index.ts

  • Modify: src/shared/types.ts (InboxApi 확장)

  • Step 1: InboxApi 타입 확장 (src/shared/types.ts)

InboxApi interface 에 5 메서드 추가:

export interface InboxApi {
  // 기존 메서드 그대로 ...
  listNotes(opts: { limit: number; cursor?: string }): Promise<Note[]>;
  // ...
  deleteNote(noteId: string): Promise<void>;  // 의미만 변경 (hard → soft)
  // 신규 v0.2.3 #4:
  restoreNote(noteId: string): Promise<void>;
  permanentDeleteNote(noteId: string): Promise<{ confirmed: boolean }>;  // confirm 거부 시 confirmed=false
  emptyTrash(): Promise<{ confirmed: boolean; count: number }>;
  listTrash(opts: { limit: number }): Promise<Note[]>;
  getTrashCount(): Promise<number>;
  // 기존 ...
  onNoteUpdated(cb: (note: Note) => void): () => void;
}

getTrashCount 신규 — 헤더 탭 라벨 휴지통(M) 갱신용. permanentDeleteNoteemptyTrash 가 confirm dialog 거치므로 cancel 케이스 (confirmed: false) 가능.

  • Step 2: inboxApi.ts 의 5 신규 채널 등록
// src/main/ipc/inboxApi.ts — registerInboxApi 마지막에 추가
import electron from 'electron';
const { ipcMain, dialog } = electron;
// ... 기존 imports ...

ipcMain.handle('inbox:restore', async (_e, noteId: string) => {
  await deps.capture.restoreNote(noteId);
});

ipcMain.handle('inbox:permanentDelete', async (_e, noteId: string) => {
  const win = deps.getInboxWindow();
  const opts: Electron.MessageBoxOptions = {
    type: 'question',
    buttons: ['영구 삭제', '취소'],
    defaultId: 1,
    cancelId: 1,
    title: 'Inkling',
    message: '이 노트를 영구 삭제합니다',
    detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
  };
  const r = win
    ? await dialog.showMessageBox(win, opts)
    : await dialog.showMessageBox(opts);
  if (r.response !== 0) return { confirmed: false };
  await deps.capture.permanentDeleteNote(noteId);
  return { confirmed: true };
});

ipcMain.handle('inbox:emptyTrash', async () => {
  const trashCount = deps.repo.listTrashed({ limit: 1000 }).length;
  if (trashCount === 0) return { confirmed: true, count: 0 };
  const win = deps.getInboxWindow();
  const opts: Electron.MessageBoxOptions = {
    type: 'question',
    buttons: ['휴지통 비우기', '취소'],
    defaultId: 1,
    cancelId: 1,
    title: 'Inkling',
    message: `휴지통의 노트 ${trashCount}개를 영구 삭제합니다`,
    detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
  };
  const r = win
    ? await dialog.showMessageBox(win, opts)
    : await dialog.showMessageBox(opts);
  if (r.response !== 0) return { confirmed: false, count: 0 };
  const result = await deps.capture.emptyTrash();
  return { confirmed: true, count: result.count };
});

ipcMain.handle('inbox:listTrash', (_e, opts: { limit: number }) =>
  deps.repo.listTrashed(opts)
);

ipcMain.handle('inbox:trashCount', () =>
  deps.repo.listTrashed({ limit: 1 }).length === 0
    ? 0
    : deps.repo.listTrashed({ limit: 200 }).length
);

inbox:trashCount 가 200 limit 위면 부정확하지만 실용 한도. 정확한 카운트가 필요해지면 v0.2.4 에서 repo.countTrashed() 추가.

  • Step 3: preload/index.ts 의 InboxApi bridge 확장
// src/preload/index.ts — inbox 객체 안에 추가
const api: InklingApi = {
  capture: { /* 그대로 */ },
  inbox: {
    // 기존 메서드 그대로 ...
    deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
    // 신규 v0.2.3 #4:
    restoreNote: (noteId) => ipcRenderer.invoke('inbox:restore', noteId),
    permanentDeleteNote: (noteId) => ipcRenderer.invoke('inbox:permanentDelete', noteId),
    emptyTrash: () => ipcRenderer.invoke('inbox:emptyTrash'),
    listTrash: (opts) => ipcRenderer.invoke('inbox:listTrash', opts),
    getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'),
    // 기존 ...
    onNoteUpdated: (cb) => { /* 그대로 */ }
  }
};
  • Step 4: typecheck + 기존 테스트 회귀 없음 확인

Run: npm run typecheck && npm test Expected: typecheck 0 errors. 모든 단위 테스트 PASS (renderer 변경 전이라 e2e smoke 영향 없음).

  • Step 5: 커밋
git add src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts
git commit -m "feat(trash): IPC 5 channels + native dialog confirm + InboxApi extension (#4 v0.2.3)"

Task 13: Renderer store — showTrash/trashNotes/trashCount + actions

Files:

  • Modify: src/renderer/inbox/store.ts

  • Create: tests/unit/store.trash.test.ts

  • Step 1: 실패 테스트 추가

// tests/unit/store.trash.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Note } from '@shared/types';

const mockApi = {
  listNotes: vi.fn(async () => [] as Note[]),
  listTrash: vi.fn(async () => [] as Note[]),
  getTrashCount: vi.fn(async () => 0),
  getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
  getPendingCount: vi.fn(async () => 0),
  getOllamaStatus: vi.fn(async () => ({ ok: true })),
  getTodayCount: vi.fn(async () => 0),
  restoreNote: vi.fn(async () => {}),
  permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
  emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
  deleteNote: vi.fn(async () => {}),
  onNoteUpdated: vi.fn(() => () => {}),
  updateAiFields: vi.fn(async () => {}),
  setDueDate: vi.fn(async () => {}),
  setIntent: vi.fn(async () => {}),
  dismissIntent: vi.fn(async () => {})
};

vi.mock('@renderer/inbox/api.js', () => ({ inboxApi: mockApi }));

const noteStub = (id: string, deletedAt: string | null = null): Note => ({
  id, rawText: 'x',
  aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
  aiProvider: null, aiGeneratedAt: null,
  titleEditedByUser: false, summaryEditedByUser: false,
  userIntent: null, intentPromptedAt: null,
  dueDate: null, dueDateEditedByUser: false,
  deletedAt, lastRecalledAt: null, recallDismissedAt: null,
  createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
  tags: [], media: []
});

describe('useInbox — trash state (v0.2.3 #4)', () => {
  beforeEach(async () => {
    const { useInbox } = await import('@renderer/inbox/store.js');
    useInbox.setState({
      notes: [], trashNotes: [], trashCount: 0, showTrash: false,
      loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
      ollamaStatus: { ok: true },
      continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }
    });
    Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
  });

  it('toggleShowTrash flips state and triggers loadTrash on enter', async () => {
    mockApi.listTrash.mockResolvedValueOnce([noteStub('t1', '2026-05-01T00:00:00Z')]);
    const { useInbox } = await import('@renderer/inbox/store.js');
    await useInbox.getState().toggleShowTrash();
    expect(useInbox.getState().showTrash).toBe(true);
    expect(useInbox.getState().trashNotes).toHaveLength(1);
    expect(mockApi.listTrash).toHaveBeenCalled();
    await useInbox.getState().toggleShowTrash();
    expect(useInbox.getState().showTrash).toBe(false);
  });

  it('upsertNote routes to trashNotes when deletedAt IS NOT NULL', async () => {
    const { useInbox } = await import('@renderer/inbox/store.js');
    useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
    expect(useInbox.getState().notes).toHaveLength(0);
    expect(useInbox.getState().trashNotes).toHaveLength(1);
  });

  it('upsertNote moves note from notes to trashNotes when trashed', async () => {
    const { useInbox } = await import('@renderer/inbox/store.js');
    useInbox.getState().upsertNote(noteStub('a'));
    expect(useInbox.getState().notes).toHaveLength(1);
    useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
    expect(useInbox.getState().notes).toHaveLength(0);
    expect(useInbox.getState().trashNotes).toHaveLength(1);
  });

  it('restoreNote calls api + moves note back', async () => {
    const { useInbox } = await import('@renderer/inbox/store.js');
    useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
    await useInbox.getState().restoreNote('a');
    expect(mockApi.restoreNote).toHaveBeenCalledWith('a');
    // 노트 자체 이동은 onNoteUpdated 이벤트로 처리되므로 store 자체엔 즉시 반영 안 됨 OK
  });

  it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => {
    mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 });
    const { useInbox } = await import('@renderer/inbox/store.js');
    useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
    await useInbox.getState().emptyTrash();
    expect(useInbox.getState().trashNotes).toHaveLength(1);
  });
});
  • Step 2: 테스트 — FAIL

Run: npm test -- tests/unit/store.trash.test.ts Expected: FAIL — trashNotes/trashCount/showTrash 미정의.

  • Step 3: store.ts 변경
// src/renderer/inbox/store.ts
import { create } from 'zustand';
import type { Note, WeeklyContinuity } from '@shared/types';
import { inboxApi } from './api.js';

export { selectFilteredNotes } from './selectFilteredNotes.js';

interface InboxState {
  notes: Note[];
  trashNotes: Note[];
  trashCount: number;
  showTrash: boolean;
  continuity: WeeklyContinuity;
  pendingCount: number;
  ollamaStatus: { ok: boolean; reason?: string };
  todayCount: number;
  loading: boolean;
  tagFilter: string | null;
  loadInitial: () => Promise<void>;
  refreshMeta: () => Promise<void>;
  upsertNote: (note: Note) => void;
  removeNote: (id: string) => void;
  setTagFilter: (tag: string | null) => void;
  toggleShowTrash: () => Promise<void>;
  loadTrash: () => Promise<void>;
  restoreNote: (id: string) => Promise<void>;
  permanentDeleteNote: (id: string) => Promise<void>;
  emptyTrash: () => Promise<void>;
}

const emptyContinuity: WeeklyContinuity = {
  weekStart: '', weekCount: 0, weekTarget: 7,
  consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
};

export const useInbox = create<InboxState>((set, get) => ({
  notes: [],
  trashNotes: [],
  trashCount: 0,
  showTrash: false,
  continuity: emptyContinuity,
  pendingCount: 0,
  ollamaStatus: { ok: true },
  todayCount: 0,
  loading: false,
  tagFilter: null,
  async loadInitial() {
    set({ loading: true });
    const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
      inboxApi.listNotes({ limit: 50 }),
      inboxApi.getContinuity(),
      inboxApi.getPendingCount(),
      inboxApi.getOllamaStatus(),
      inboxApi.getTodayCount(),
      inboxApi.getTrashCount()
    ]);
    set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, loading: false });
  },
  async refreshMeta() {
    const [continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
      inboxApi.getContinuity(),
      inboxApi.getPendingCount(),
      inboxApi.getOllamaStatus(),
      inboxApi.getTodayCount(),
      inboxApi.getTrashCount()
    ]);
    set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount });
  },
  upsertNote(note) {
    if (note.deletedAt !== null) {
      // trash 노트: notes 에서 제거 + trashNotes 에 upsert
      const cleanNotes = get().notes.filter((n) => n.id !== note.id);
      const ti = get().trashNotes.findIndex((n) => n.id === note.id);
      const nextTrash = get().trashNotes.slice();
      if (ti >= 0) nextTrash[ti] = note;
      else nextTrash.unshift(note);
      set({ notes: cleanNotes, trashNotes: nextTrash, trashCount: nextTrash.length });
    } else {
      // active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
      const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
      const i = get().notes.findIndex((n) => n.id === note.id);
      const nextNotes = get().notes.slice();
      if (i >= 0) nextNotes[i] = note;
      else nextNotes.unshift(note);
      set({ notes: nextNotes, trashNotes: cleanTrash, trashCount: cleanTrash.length });
    }
  },
  removeNote(id) {
    set({
      notes: get().notes.filter((n) => n.id !== id),
      trashNotes: get().trashNotes.filter((n) => n.id !== id),
      trashCount: get().trashNotes.filter((n) => n.id !== id).length
    });
  },
  setTagFilter(tag) {
    set({ tagFilter: tag });
  },
  async toggleShowTrash() {
    const next = !get().showTrash;
    set({ showTrash: next });
    if (next) await get().loadTrash();
  },
  async loadTrash() {
    const trashNotes = await inboxApi.listTrash({ limit: 200 });
    set({ trashNotes, trashCount: trashNotes.length });
  },
  async restoreNote(id) {
    await inboxApi.restoreNote(id);
    // onNoteUpdated 이벤트 미수신 케이스 대비 (renderer 자가 갱신)
    const note = get().trashNotes.find((n) => n.id === id);
    if (note) {
      get().upsertNote({ ...note, deletedAt: null });
    }
  },
  async permanentDeleteNote(id) {
    const r = await inboxApi.permanentDeleteNote(id);
    if (r.confirmed) get().removeNote(id);
  },
  async emptyTrash() {
    const r = await inboxApi.emptyTrash();
    if (r.confirmed) {
      set({ trashNotes: [], trashCount: 0 });
    }
  }
}));
  • Step 4: 테스트 — PASS

Run: npm test -- tests/unit/store.trash.test.ts Expected: 5 신규 PASS.

  • Step 5: 커밋
git add src/renderer/inbox/store.ts tests/unit/store.trash.test.ts
git commit -m "feat(trash): zustand store — showTrash/trashNotes/trashCount + 5 actions (#4 v0.2.3)"

Task 14: Renderer App.tsx 탭 toggle + bulk emptyTrash 버튼

Files:

  • Modify: src/renderer/inbox/App.tsx

  • Modify: src/renderer/inbox/components/NoteCard.tsx (mode prop)

  • Step 1: NoteCard mode prop

src/renderer/inbox/components/NoteCard.tsxProps interface 에 mode?: 'inbox' | 'trash' 추가. 컴포넌트 본문에서:

interface NoteCardProps {
  note: Note;
  onDeleted: () => void;
  onUpdated: (note: Note) => void;
  mode?: 'inbox' | 'trash';
  onRestore?: () => void;
  onPermanentDelete?: () => void;
}

export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: NoteCardProps): React.ReactElement {
  const isTrash = mode === 'trash';
  // ...

  // due date editing — disabled in trash
  // intent banner — hidden in trash
  // tag chips — ✕ 버튼 hidden in trash, click 무반응
  // raw text 토글 — 그대로
  // 액션 영역:
  //   if isTrash: "🔄 복구" + "🗑 영구 삭제" 두 버튼
  //   else: 기존 "🗑 삭제" 한 버튼

  // 모든 inline 편집 컴포넌트도 isTrash 시 read-only 모드:
  //   <DueDateBadge readOnly={isTrash} ... />
  //   {!isTrash && <IntentBanner ... />}
  //   <TagChip removable={!isTrash} ... />
  //   <EditableField readOnly={isTrash} ... />
}

NoteCard 의 정확한 변경은 현재 컴포넌트 (line 107-319) 의 모든 액션/편집 슬롯을 isTrash 가드로 감싼다. 본문 700+ 줄 — 전체 코드 인라인 대신 변경 핵심 4 spot:

  1. DueDateBadge 호출 부분 — readOnly={isTrash} 추가 (DueDateBadge 내부에서 onClick 가드).
  2. IntentBanner 호출 부분 — {!isTrash && <IntentBanner .../>} 로 감쌈.
  3. tag chip 의 ✕ 버튼 — {!isTrash && <button>✕</button>}.
  4. 카드 하단 "🗑 삭제" 버튼 (line 312) — 다음 블록으로 교체:
{isTrash ? (
  <div style={{ display: 'flex', gap: 8 }}>
    <button onClick={onRestore} style={{ /* 기존 스타일 카피 — 파란 톤 */ }}>
      🔄 복구
    </button>
    <button onClick={onPermanentDelete} style={{ /* 빨간 톤 */ }}>
      🗑 영구 삭제
    </button>
  </div>
) : (
  <button onClick={async () => {
    // 기존 deleteNote 호출 (이제 trash)
    await inboxApi.deleteNote(note.id);
    onDeleted();
  }} style={{ /* 빨간 톤 */ }}>
    🗑 삭제
  </button>
)}

EditableField (title / summary) 도 readOnly={isTrash} 전달. EditableField 컴포넌트 내부에서 readOnly 시 input 비활성 + 더블 클릭 미반응.

  • Step 2: App.tsx 의 헤더 탭 + 휴지통 view

src/renderer/inbox/App.tsx 변경:

import React, { useEffect, useState } from 'react';
import { useInbox, selectFilteredNotes } from './store.js';
import { inboxApi } from './api.js';
import { isRecoveryDismissedToday, markRecoveryDismissed } from './recoveryToast.js';
import { NoteCard } from './components/NoteCard.js';
import { ContinuityBadge } from './components/ContinuityBadge.js';
import { IdentityCounter } from './components/IdentityCounter.js';
import { PendingBanner } from './components/PendingBanner.js';
import { OllamaBanner } from './components/OllamaBanner.js';
import { RecoveryToast } from './components/RecoveryToast.js';
import { TagUndoToast } from './components/TagUndoToast.js';

export function App(): React.ReactElement {
  const {
    notes, trashNotes, trashCount, showTrash,
    loading, loadInitial, refreshMeta, upsertNote, removeNote,
    continuity, tagFilter, setTagFilter,
    toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
  } = useInbox();
  const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());

  useEffect(() => {
    void loadInitial();
    const unsub = inboxApi.onNoteUpdated((note) => {
      upsertNote(note);
      void refreshMeta();
    });
    const onFocus = () => { void refreshMeta(); };
    window.addEventListener('focus', onFocus);
    return () => { unsub(); window.removeEventListener('focus', onFocus); };
  }, [loadInitial, refreshMeta, upsertNote]);

  const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
  const filtered = selectFilteredNotes({ notes, tagFilter });

  const tabBtnStyle = (active: boolean): React.CSSProperties => ({
    background: active ? '#0a4b80' : 'transparent',
    color: active ? '#fff' : '#0a4b80',
    border: '1px solid #0a4b80',
    borderRadius: 4,
    padding: '4px 10px',
    fontSize: 12,
    cursor: 'pointer'
  });

  return (
    <>
      <div className="header">
        <h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
        <div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
          <button
            onClick={() => { if (showTrash) void toggleShowTrash(); }}
            aria-pressed={!showTrash}
            style={tabBtnStyle(!showTrash)}
          >
            Inbox({notes.length})
          </button>
          <button
            onClick={() => { if (!showTrash) void toggleShowTrash(); }}
            aria-pressed={showTrash}
            style={tabBtnStyle(showTrash)}
          >
            휴지통({trashCount})
          </button>
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
          <ContinuityBadge />
          <IdentityCounter />
        </div>
      </div>
      <main className="main">
        {!showTrash && (
          <>
            <OllamaBanner />
            <RecoveryToast
              show={showRecovery}
              onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
            />
            <PendingBanner />
            {tagFilter !== null && (
              <div style={{
                background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
                borderRadius: 6, margin: '8px 0', fontSize: 12,
                display: 'flex', alignItems: 'center', gap: 8
              }}>
                <span>🔎 필터: <strong>#{tagFilter}</strong></span>
                <span style={{ color: '#666' }}>({filtered.length})</span>
                <button
                  onClick={() => setTagFilter(null)}
                  style={{ marginLeft: 'auto', background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12 }}
                  title="필터 해제"
                >
                   해제
                </button>
              </div>
            )}
            {loading && notes.length === 0 ? (
              <div className="empty">불러오는 중…</div>
            ) : notes.length === 0 ? (
              <div className="empty">머릿속에 떠다니는  줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
            ) : filtered.length === 0 ? (
              <div className="empty"> 태그의 노트가 없습니다.</div>
            ) : (
              filtered.map((n) => (
                <NoteCard
                  key={n.id} note={n} mode="inbox"
                  onDeleted={() => removeNote(n.id)}
                  onUpdated={(u) => upsertNote(u)}
                />
              ))
            )}
          </>
        )}
        {showTrash && (
          <>
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '8px 0' }}>
              <div style={{ fontSize: 13, color: '#666' }}>
                {trashCount === 0 ? '휴지통이 비어있습니다.' : `${trashCount}개 보관 중`}
              </div>
              <button
                onClick={() => void emptyTrash()}
                disabled={trashCount === 0}
                style={{
                  background: trashCount === 0 ? '#666' : '#a33', color: '#fff',
                  border: 'none', borderRadius: 4, padding: '4px 10px',
                  fontSize: 12, cursor: trashCount === 0 ? 'not-allowed' : 'pointer'
                }}
              >
                휴지통 비우기 ({trashCount})
              </button>
            </div>
            {trashNotes.length === 0 ? null : (
              trashNotes.map((n) => (
                <NoteCard
                  key={n.id} note={n} mode="trash"
                  onDeleted={() => removeNote(n.id)}
                  onUpdated={(u) => upsertNote(u)}
                  onRestore={() => void restoreNote(n.id)}
                  onPermanentDelete={() => void permanentDeleteNote(n.id)}
                />
              ))
            )}
          </>
        )}
      </main>
      <TagUndoToast />
    </>
  );
}
  • Step 3: typecheck + 기존 테스트 통과

Run: npm run typecheck && npm test Expected: typecheck 0. 기존 테스트 모두 PASS. 신규 e2e smoke 가 깨지면 (예: 헤더 layout 변경 영향) Task 15 에서 처리.

  • Step 4: 커밋
git add src/renderer/inbox/App.tsx src/renderer/inbox/components/NoteCard.tsx
git commit -m "feat(trash): Inbox 탭 toggle + 휴지통 view + NoteCard mode prop (#4 v0.2.3)"

Task 15: 게이트 + closure marker

Files:

  • Modify: docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md

  • Step 1: 종합 게이트

npm run typecheck && npm test && npm run test:e2e

Expected:

  • typecheck: 0 errors
  • 단위: 245 (v0.2.3 #7) + 신규 ≥ 30 = ~275+ PASS
  • e2e: 1/1 PASS (탭 헤더 추가가 smoke 깨지 않아야 — Inbox 진입 시 active 노트 list 가 그대로 보이면 OK)

만약 e2e 가 깨지면 selector 갱신 또는 기본 view (showTrash: false) 가 v0.2.2 와 동일 layout 인지 확인.

  • Step 2: 수동 sanity check
npm run dev

체크리스트:

  • 노트 캡처 → Inbox 보임. "🗑 삭제" 클릭 → 노트 사라지고 헤더 "휴지통(1)" 표시.

  • 휴지통 탭 클릭 → 노트 보임 (read-only — 편집 불가). "🔄 복구" 클릭 → Inbox 로 복귀, AI 결과 보존.

  • 다시 trash → 휴지통 → "🗑 영구 삭제" → confirm → 사라짐. media 디렉터리 (<userData>/Inkling/profiles/default/media/<noteId>) 도 사라짐 확인.

  • 여러 노트 trash → 휴지통 → "휴지통 비우기" → confirm → 모두 사라짐.

  • 트레이 → 사용 로그 내보내기 → events.jsonl 에 4 신규 kind (trash / restore / permanent_delete / empty_trash) 라인 존재 + privacy invariant grep 0 hits 확인 (grep -E 'rawText|"title"|"summary"|userIntent|tagNames').

  • Step 3: roadmap closure marker

docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md 의 §3 #4 헤더에 ✓ 완료 추가:

### #4 휴지통 (2번) ✓ 완료
  • Step 4: closure 커밋
git add docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md
git commit -m "docs(spec): mark #4 trash as completed (v0.2.3 2/7)"

Self-Review

1. Spec coverage:

spec section task
§2 Data model — migration v3 + Note type T1
§3.1 신규 메서드 trash T2
§3.1 신규 restore T3
§3.1 신규 permanentDelete + emptyTrash + listTrashed T4
§3.3 Active query 일괄 변경 T5
§5 AiWorker 가드 T6
§4.3 Telemetry 4 new kinds T7
§4.3 stats.md 4 카운터 + ratio T8
§4.1/4.2 CaptureService 메서드 변경/신규 + 4 emit T9
§8.2 ImportService deletedAt 보존 + 충돌 정책 T10
§8.1 ExportService trash 제외 (회귀 가드) T11
§6 IPC 5 채널 + native confirm dialog T12
§7.1 zustand store + actions T13
§7.2/7.3 App.tsx 탭 + NoteCard mode T14
§7.4 Confirm dialog 카피 T12 (IPC 핸들러에 inline)
게이트 + roadmap closure T15

2. Placeholder scan: "TODO" / "TBD" 0 hit. 단 Task 14 의 NoteCard 변경은 4 spot 변경으로 명시했으나 정확한 현재 line 들은 plan 시점에 확인 필요 — 본문이 700+ 줄. plan 단계에서 "isTrash 가드를 4 슬롯에 박는다" 의 의미는 명확하므로 placeholder 아님.

3. Type consistency:

  • trash(id, deletedAt: string): void — T2 / T9 / T13 모두 string 인자.
  • restore(id): void — T3 / T9 / T13 일관.
  • permanentDelete(id): void (repo) / permanentDeleteNote(id): Promise<{confirmed}> (renderer) — 분리됨, 의도적.
  • emptyTrash(): { noteIds: string[] } (repo) / emptyTrash(): { count: number } (CaptureService) / emptyTrash(): Promise<{confirmed, count}> (api) — 각각 다른 layer, 의도적.
  • listTrashed(opts: { limit }) (repo) / listTrash(opts: { limit }) (api) — 약간의 naming 차이 의도적 (repo 는 "trashed notes" 의미, api 는 "list trash" surface).
  • Note.deletedAt: string | null — T1 정의, T2-T14 일관.
  • Telemetry kind 이름: trash / restore / permanent_delete / empty_trash — snake_case (permanent_delete/empty_trash) 와 단순 동사 (trash/restore) 혼재. 의도적 — permanent_delete 는 두 단어 합성. T7/T8/T9 모두 일치.

NoteRepository 의 list({limit, cursor}) 의 cursor 가 trash exclusion 후에도 잘 동작 (cursor=created_at 비교 + AND deleted_at IS NULL). T5 에서 명시.

확인 OK.

Spec 의 IPC inbox:emptyTrash 의 confirm 동작: spec §6.1 은 confirm 후 작업 수행 명시. T12 가 native dialog 호출 → 사용자 confirm → 그 후 service 호출 패턴을 정확히 구현. confirmed/cancelled 정보 renderer 로 반환.

수정 필요 inline 항목 없음.