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>
2277 lines
80 KiB
Markdown
2277 lines
80 KiB
Markdown
# #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<typeof TelemetryEventSchema>;
|
|
export type TelemetryKind = TelemetryEvent['kind'];
|
|
|
|
export function validateEvent(raw: unknown): TelemetryEvent {
|
|
return TelemetryEventSchema.parse(raw);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: TelemetryService 의 EmitInput union 확장**
|
|
|
|
`src/main/services/TelemetryService.ts` 의 `EmitInput` 타입 변경:
|
|
|
|
```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<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: 커밋**
|
|
|
|
```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<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: 커밋**
|
|
|
|
```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<Note[]>;
|
|
// ...
|
|
deleteNote(noteId: string): Promise<void>; // 의미만 변경 (hard → soft)
|
|
// 신규 v0.2.3 #4:
|
|
restoreNote(noteId: string): Promise<void>;
|
|
permanentDeleteNote(noteId: string): Promise<{ confirmed: boolean }>; // confirm 거부 시 confirmed=false
|
|
emptyTrash(): Promise<{ confirmed: boolean; count: number }>;
|
|
listTrash(opts: { limit: number }): Promise<Note[]>;
|
|
getTrashCount(): Promise<number>;
|
|
// 기존 ...
|
|
onNoteUpdated(cb: (note: Note) => void): () => void;
|
|
}
|
|
```
|
|
|
|
`getTrashCount` 신규 — 헤더 탭 라벨 `휴지통(M)` 갱신용. `permanentDeleteNote` 와 `emptyTrash` 가 confirm dialog 거치므로 cancel 케이스 (`confirmed: false`) 가능.
|
|
|
|
- [ ] **Step 2: `inboxApi.ts` 의 5 신규 채널 등록**
|
|
|
|
```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<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: 커밋**
|
|
|
|
```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 모드:
|
|
// <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) — 다음 블록으로 교체:
|
|
|
|
```tsx
|
|
{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` 변경:
|
|
|
|
```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: 커밋**
|
|
|
|
```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 디렉터리 (`<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 헤더에 `✓ 완료` 추가:
|
|
|
|
```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 항목 없음.
|