feat(trash): #4 휴지통 + migration v3 (v0.2.3 2/7) #14

Merged
altair823 merged 26 commits from feat/v023-trash into main 2026-05-01 14:06:24 +00:00
34 changed files with 3993 additions and 194 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -91,7 +91,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]─────
**Out:** 자동 업로드 / 원격 telemetry (모두 로컬), 실시간 대시보드 UI, opt-out 토글 (로컬이라 불필요), 14일 보존 기간 사용자 설정
### #4 휴지통 (2번)
### #4 휴지통 (2번) ✓ 완료
**In:**
- migration v3: `notes.deleted_at TEXT NULL` + `notes.last_recalled_at TEXT NULL` + `notes.recall_dismissed_at TEXT NULL` (3 컬럼 한 번)

View File

@@ -0,0 +1,385 @@
# #4 휴지통 (soft delete + migration v3) 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.3 로드맵의 두 번째 항목. mini-brainstorm 결과를 잠그고 구현 계획 (`writing-plans`) 으로 넘기는 분기 spec. 본 문서는 **데이터 모델·외부 API·UI 결정** 만 정의. 세부 코드 토폴로지는 plan 단계에서.
**선행 문서:**
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #4 — 본 항목의 In/Out 라인 + cross-cutting 정책
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §1 — schema migration v3, trash↔backup/export B 정책
- `docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md` §5.1 — pre-v<N>.bak snapshot 메커니즘 (v0.2.1 도입)
- v0.2.3 #7 telemetry skeleton (merged at `6f8ae75`) — 본 항목이 emit hook 대상
---
## 1. 결정 요약
| 영역 | 값 | 근거 |
|------|-----|------|
| Schema | **migration v3**`deleted_at TEXT NULL` + `last_recalled_at TEXT NULL` + `recall_dismissed_at TEXT NULL` (#6 도 같이) | 한 migration 으로 #4+#6 cover. 별 v4 회피. |
| UI 위치 | **Inbox 상단 탭 toggle** (`Inbox(N) · 휴지통(M)`) | 현재 router 없음, single-page 구조 일관. v0.2.1 F2 태그 필터 패턴 (`tagFilter` zustand) 동일 흐름. |
| 쿼리 필터 전략 | **명시적 WHERE** — 모든 active query 에 `WHERE deleted_at IS NULL` 직접 박음 | 기존 SQL prepare 패턴 일관. grep audit 가능. C (silent at hydration) 의 AiWorker race window 회피. |
| AiWorker race | **C — pending_jobs cleanup + processJob 가드** (둘 다) | atomic + 이미 dequeue 한 race window 도 가드. result 적용 직전 재체크는 의도적 skip — restore 시 AI 결과 보존이 UX 유리. |
| 휴지통 액션 | **per-card 복구 + per-card 영구 삭제 + bulk 휴지통 비우기** | per-card 영구 삭제는 fine-grained 삭제 욕구 대응. roadmap §3 #4 의 4채널 → 5채널 (`permanentDelete` 추가) 으로 확장. |
| Confirm UX | **Electron `dialog.showMessageBox`** — F5/F6 패턴 일관 | 신규 React 모달 회피. native = 운영체제 톤. |
| 정렬 | **`deleted_at DESC`** | 회수 의도 매칭 (최근 삭제 먼저). |
| Card 차이 | **휴지통 카드 = read-only** — edit 액션 hidden, raw text 토글은 보존 | roadmap §3 #4 Out (`trash 안 노트 편집`) 일관. |
| F5 export | **`deleted_at IS NOT NULL` 제외** | trash B 정책 (roadmap §1). |
| F6-L1 backup | **byte-for-byte 자동 포함** | SQLite copy. 무수정. |
| F6-L3 import | **`deleted_at` source/dest 중 IS NOT NULL 우선** | 삭제 보존 invariant. |
| Restore 시 AI 결과 | **그대로 살아있음** (race window self-healing) | trash 도중 AI 결과 박힌 경우 restore 시 노트가 결과까지 함께 회수. UX positive. |
### 1.1 v0.2.3 #4 roadmap 와의 차이
| 항목 | roadmap §3 #4 | 본 spec |
|------|---------------|---------|
| 휴지통 액션 | 복구 + bulk emptyTrash (4 IPC 채널) | + per-card 영구 삭제 (5 IPC 채널) |
| Telemetry kinds | `trash` / `restore` / `emptyTrash` (3) | + `permanent_delete` (4) |
**근거:** mini-brainstorm 에서 사용자 결정 (B 옵션 — fine-grained 영구 삭제 추가). 본 spec 의 결정이 roadmap 보다 우선.
---
## 2. Data model
### 2.1 Migration v3 — `m003_soft_delete.ts`
```sql
ALTER TABLE notes ADD COLUMN deleted_at TEXT;
ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
CREATE INDEX idx_notes_deleted_at ON notes(deleted_at);
```
- `deleted_at`: ISO timestamp (UTC). `NULL` = active, IS NOT NULL = trashed.
- `last_recalled_at`: #6 가 사용. v3 에서 컬럼만 추가, `Note` type 노출 + 사용은 #6.
- `recall_dismissed_at`: #6 가 사용. 위와 동일.
- `idx_notes_deleted_at`: `WHERE deleted_at IS NULL` 쿼리 다수, partial index 효과 기대. SQLite 가 NULL 스파스 인덱스 효율 잘 처리.
m001/m002 와 같이 `version = 3` export 후 `migrations/index.ts` 의 array 에 등록. transaction 내 실행. 실패 시 트랜잭션 롤백 + 사용자에게 보고.
**pre-v3 snapshot:** `<dbFile>.pre-v3.bak` 자동 생성 (v0.2.1 메커니즘 그대로). v0.2.2 → v0.2.3 첫 실행 시 한 번.
### 2.2 `Note` 타입 (`@shared/types`) 확장
```typescript
export interface Note {
// ... 기존 필드 ...
deletedAt: string | null; // #4 가 사용
lastRecalledAt: string | null; // #6 가 사용 (v0.2.3 #4 단계엔 항상 null 으로 hydrate)
recallDismissedAt: string | null; // #6 가 사용 (위와 동일)
}
```
세 필드 모두 v3 부터 schema 에 존재하므로 hydration 코드는 한 번에 추가. 사용은 단계별.
### 2.3 Schema invariant 추가
slice §1.3 silent invariant 후보 (roadmap §6.3 에서 #4 머지 시 동봉 갱신):
> **`deleted_at IS NULL` 망각 0회** — 모든 active query (Inbox / countToday / findByTag / search / F5 export / AiWorker 처리) 가 `WHERE deleted_at IS NULL` 을 빠뜨리지 않는다. 위반 시 dogfood-feedback 즉시 재오픈.
---
## 3. NoteRepository 변경
### 3.1 신규 메서드
| 메서드 | SQL | 부수 효과 |
|--------|-----|-----------|
| `trash(id, deletedAt: string): void` | `UPDATE notes SET deleted_at=?, updated_at=? WHERE id=?` + `DELETE FROM pending_jobs WHERE note_id=?` (한 transaction) | AI 큐 깨끗. atomic. |
| `restore(id): void` | `UPDATE notes SET deleted_at=NULL, updated_at=? WHERE id=?` | 노트 active 복귀. AI 결과 보존됨. |
| `permanentDelete(id): void` | `DELETE FROM notes WHERE id=?` | cascade FK (`note_tags` / `media` / `pending_jobs`) 자동 정리. media 파일 정리는 caller (`CaptureService`) 책임. |
| `emptyTrash(): { noteIds: string[] }` | `SELECT id FROM notes WHERE deleted_at IS NOT NULL` → 각 id `permanentDelete` (한 transaction). 반환된 `noteIds` 로 caller 가 media 정리. |
| `listTrashed(opts: {limit, cursor?}): Note[]` | `WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC` | cursor = `deleted_at` 값 기준. |
### 3.2 기존 메서드 변경
`delete(id)`**deprecate** (호출 site 0건 보장). hard delete 는 `permanentDelete()` 로만. 단계적 cleanup — `delete()` 를 즉시 제거하지 않고 `@deprecated` 로 표시 후 v0.2.4 cut 시 삭제.
### 3.3 Active query 일괄 변경 (`WHERE deleted_at IS NULL` 추가)
| 메서드 | 현재 | 변경 후 |
|--------|------|---------|
| `list(opts)` | `ORDER BY created_at DESC LIMIT ?` | `WHERE deleted_at IS NULL ORDER BY ... LIMIT ?` |
| `listAll()` | `ORDER BY created_at ASC` | `WHERE deleted_at IS NULL ORDER BY ...` |
| `countToday(now?)` | KST today filter | `WHERE deleted_at IS NULL AND ...` |
| `getAllPendingJobs()` | `pending_jobs` 직접 select | **변경 없음**`trash()` 가 atomic 하게 `pending_jobs` row 정리하는 invariant 가 자연 보장. AiWorker `processJob``deletedAt` 가드는 이미 dequeue 한 race 만 처리. |
| `findById(id)` | **변경 없음** — 휴지통 카드도 같은 메서드 사용. `Note.deletedAt` 으로 호출자가 분기. |
NoteRepository 에는 현재 `findByTag` / search 메서드가 없다 — 태그 필터링은 renderer 의 `selectFilteredNotes` 에서 client-side 로 수행 (zustand `tagFilter` state). 따라서 active query 변경은 위 표의 3 메서드 (`list`, `listAll`, `countToday`) + AiWorker 가드 + `getAllPendingJobs` 의 invariant 보존이 전부.
---
## 4. CaptureService 변경
### 4.1 메서드 변경
```typescript
async deleteNote(noteId: string): Promise<void> {
this.repo.trash(noteId, new Date().toISOString());
// media 는 그대로 둔다 (restore 시 필요)
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
}
}
```
### 4.2 신규 메서드
```typescript
async restoreNote(noteId: string): Promise<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 (e) { /* best-effort */ }
}
if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
return { count: noteIds.length };
}
```
### 4.3 Telemetry interface 확장
`CaptureService.ts``TelemetryEmitter` 인터페이스에 4 union 멤버 추가:
```typescript
export interface TelemetryEmitter {
emit(input:
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } }
): Promise<void>;
}
```
`TelemetryService.ts``EmitInput` union 도 같은 4 추가. `telemetryEvents.ts` 의 zod `discriminatedUnion` 에도 4 새 멤버, 각 payload `.strict()`. **Privacy invariant** 그대로 — payload 에 `noteId` / `count` 만, raw text/title/summary/intent/tag name 절대 미포함. zod 가 거부.
`stats.md` 집계 (`telemetryStats.aggregateStats`) 도 4 신규 카운트 컬럼 추가:
```
| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash |
```
핵심 ratio:
- `restore / trash` — 휴지통이 회수 도구로 동작?
---
## 5. AiWorker 가드
`processJob` 진입 시 deletedAt 체크 추가:
```typescript
const note = this.repo.findById(job.noteId);
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
```
`pending_jobs` 정리는 `trash()` 가 atomic 하게 처리하므로 정상 흐름에서 dead row 미발생. AiWorker 가 이미 dequeue 한 후 trash 된 race 만 본 가드가 cover.
result 적용 (`updateAiResult`) 직전 재체크는 의도적으로 skip — restore 시 AI 결과 살아있어 UX 유리.
---
## 6. IPC
### 6.1 신규 채널 (5개)
`src/main/ipc/inboxApi.ts``registerInboxApi` 에 추가:
| 채널 | 핸들러 | 응답 |
|------|--------|------|
| `inbox:trash` | `(_, id: string) => capture.deleteNote(id)` | `void` |
| `inbox:restore` | `(_, id: string) => capture.restoreNote(id)` | `void` |
| `inbox:permanentDelete` | `(_, id: string) => capture.permanentDeleteNote(id)` | `void` |
| `inbox:emptyTrash` | `() => capture.emptyTrash()` | `{ count: number }` |
| `inbox:listTrash` | `(_, opts) => repo.listTrashed(opts)` | `Note[]` |
confirm dialog (per-card 영구 삭제 / bulk emptyTrash) 는 main 프로세스에서 `dialog.showMessageBox` 호출 (트레이 export/import 와 동일 패턴). 사용자 confirm 후에야 IPC 가 실제 작업 수행.
### 6.2 기존 `inbox:delete` 처리
기존 `inbox:delete` 는 그대로 유지하되 내부적으로 `capture.deleteNote(id)` 가 trash 호출 (변경된 동작). 채널 이름은 유지 — renderer 에서 `inboxApi.deleteNote` 호출하던 곳 (`NoteCard` 의 "🗑 삭제" 버튼) 이 그대로 동작 (의미만 hard → soft 로 변경). 단계적 마이그레이션 — v0.2.4 에서 `inbox:trash` 로 rename 검토.
---
## 7. Renderer (Inbox)
### 7.1 zustand store 확장
```typescript
interface InboxState {
// 기존 ...
showTrash: boolean; // false = Inbox view, true = 휴지통 view
trashNotes: Note[]; // 휴지통 노트 cache
trashCount: number; // 헤더 탭 라벨 (`휴지통(M)`)
toggleShowTrash(): void;
loadTrash(): Promise<void>;
restoreNote(id: string): Promise<void>;
permanentDeleteNote(id: string): Promise<void>;
confirmEmptyTrash(): Promise<void>;
}
```
`toggleShowTrash``showTrash` 토글 + 진입 시 `loadTrash()` 호출.
`confirmEmptyTrash` 는 IPC `inbox:emptyTrash` 호출 (main 이 dialog 띄움). 사용자 cancel 시 `count: 0` 반환.
`upsertNote(note)` / `removeNote(id)``notes``trashNotes` 양쪽 다 갱신 — note 의 `deletedAt` 값으로 어느 list 에 들어갈지 결정.
### 7.2 UI 추가
`App.tsx` 헤더 영역 (h1 + ContinuityBadge 옆):
```tsx
<button onClick={() => setShowTrash(false)} aria-pressed={!showTrash}>
Inbox({notes.length})
</button>
<button onClick={() => setShowTrash(true)} aria-pressed={showTrash}>
({trashCount})
</button>
```
`showTrash === true` 시:
- 상단에 "휴지통 비우기 (M개)" 버튼 (M=0 이면 disabled). 클릭 → `confirmEmptyTrash()`.
- `trashNotes.map(n => <NoteCard note={n} mode="trash" />)`
### 7.3 NoteCard prop `mode`
```tsx
type NoteCardProps = { note: Note; mode?: 'inbox' | 'trash' };
```
`mode === 'trash'` 시:
- DueDateBadge: read-only (날짜 텍스트만 표시, 클릭 무반응)
- IntentBanner: hidden
- 태그 chip: ✕ 버튼 hidden, 클릭 시 필터링 동작 X
- "🗑 삭제" 버튼 → "🔄 복구" + "🗑 영구 삭제" 두 버튼으로 교체
- raw text 토글 (`▸ 원문 보기`): 보존 (read-only 도 본문 확인 필요)
- EditableField (title / summary): read-only 모드 (input 비활성)
빈 휴지통 상태 (`trashNotes.length === 0` AND `showTrash`):
> "휴지통이 비어있습니다."
### 7.4 Confirm dialog 카피
`dialog.showMessageBox` 옵션:
**bulk emptyTrash:**
- type: `question`
- buttons: `['휴지통 비우기', '취소']`
- defaultId: 1, cancelId: 1
- title: `Inkling`
- message: `휴지통의 노트 ${count}개를 영구 삭제합니다`
- detail: `이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.`
**per-card 영구 삭제:**
- buttons: `['영구 삭제', '취소']`
- message: `이 노트를 영구 삭제합니다`
- detail: 위와 동일
---
## 8. F5 export / F6-L3 import / F6-L1 backup
### 8.1 ExportService
`repo.listAll()` 자체에 `WHERE deleted_at IS NULL` 추가 (active query exclusion 의 일환, §3.3 표 그대로). ExportService 코드는 무수정 — `repo.listAll()` 호출이 자동으로 trash 제외하게 됨. 휴지통 export 는 본 cut 범위 외 (Out, §10).
### 8.2 ImportService
`ImportNoteInput` interface 에 `deletedAt?: string | null` 추가. INSERT statement 에 컬럼 + 값 추가. fork 케이스 (raw_text 다름) 에서도 `deletedAt` 보존.
충돌 해결 — id 동일 + raw_text 동일 (skip) 또는 raw_text 상이 (fork) 가 기존 정책. `deletedAt` 머지는 그 위에 추가:
- **id 동일 + raw_text 동일** (skip 케이스): source 가 `deletedAt IS NOT NULL` 이고 dest 가 `IS NULL` 이면 dest 의 `deleted_at` 을 source 값으로 **갱신** (삭제 보존). 그 외는 그대로 skip.
- **id 동일 + raw_text 상이** (fork 케이스): source 의 `deletedAt` 을 새 fork 노트에 그대로 넣음 (raw_text invariant 보존이 우선이라 fork 자체는 기존대로).
- **id 신규** (insert 케이스): source 의 `deletedAt` 을 그대로 INSERT.
- **양쪽 IS NOT NULL** (skip 케이스 의 corner case): 단순화 — dest 값 유지 (skip). roadmap §1 의 "IS NOT NULL 우선" 은 한쪽이 NULL 일 때만 결정 영향, 양쪽 IS NOT NULL 시엔 dest 가 이미 trash 라 "삭제 보존" 자체는 만족.
### 8.3 BackupService
무수정. SQLite `db.backup()` 가 byte-for-byte 카피 — `deleted_at IS NOT NULL` 노트도 자동 포함.
---
## 9. 단위 테스트 (TDD 가이드)
### 9.1 Migration v3
- 빈 DB v0 → v3 migrate 후 `deleted_at` / `last_recalled_at` / `recall_dismissed_at` 컬럼 + `idx_notes_deleted_at` 존재 확인
- v2 DB → v3 migrate 시 기존 노트의 새 컬럼 모두 NULL
- migrate idempotent (이미 v3 인 DB 재실행 시 변경 없음)
- pre-v3.bak snapshot 자동 생성 (한 번만)
### 9.2 NoteRepository
- `trash(id, deletedAt)``deleted_at` 설정 + `pending_jobs` row 정리 (atomic — 한 transaction 내 두 쿼리)
- `restore(id)``deleted_at` NULL 복원
- `permanentDelete(id)` 가 cascade FK 통해 `note_tags` / `media` / `pending_jobs` 정리
- `emptyTrash()` 가 IS NOT NULL 노트 모두 hard delete + 반환된 noteIds 정확
- `listTrashed()``deleted_at DESC` 정렬, IS NOT NULL 만 반환
- `list()` / `listAll()` / `countToday()``deleted_at IS NULL` 만 반환 (active query exclusion)
- `findById()` 는 휴지통 노트도 반환 (모든 노트)
- `getAllPendingJobs()` 가 trash 노트 미반환 (join 또는 trash cleanup invariant)
### 9.3 AiWorker
- `processJob``deletedAt IS NOT NULL` 노트 즉시 return (provider.generate 미호출)
- 정상 노트는 그대로 처리 (회귀 없음)
### 9.4 CaptureService
- `deleteNote` 가 trash 호출 + telemetry `trash` emit (media 미삭제)
- `restoreNote` 가 restore 호출 + telemetry `restore` emit
- `permanentDeleteNote` 가 hard delete + media 디렉터리 정리 + telemetry `permanent_delete` emit
- `emptyTrash` 가 모든 trash 노트 hard delete + 각 media 정리 + telemetry `empty_trash` emit (count 정확)
### 9.5 ExportService (F5)
- 활성 노트만 export, trash 노트 제외 (frontmatter 마크다운 파일 미생성)
- `index.jsonl` 도 trash 미포함
### 9.6 ImportService (F6-L3)
- source 의 `deletedAt` 값이 import 후 보존
- 충돌 해결 — source/dest 중 IS NOT NULL 우선 4가지 조합 모두
### 9.7 Telemetry events
- 4 신규 kind (`trash` / `restore` / `permanent_delete` / `empty_trash`) zod 검증 통과
- payload `.strict()``rawText` / `title` / `summary` / `userIntent` / `tagNames` 포함 시 거부 (기존 invariant 유지)
- `aggregateStats` 가 4 신규 컬럼 카운트 정확, `restore / trash` ratio 계산
### 9.8 e2e smoke (Playwright)
- 노트 캡처 → trash 클릭 → Inbox 에서 사라지고 휴지통 탭(1) 표시
- 휴지통 탭 진입 → 노트 보임, 복구 클릭 → 다시 Inbox
- per-card 영구 삭제 confirm → 노트 영구 사라짐, media 디렉터리 정리
---
## 10. Out (deferred to v0.2.4+)
- 자동 비우기 정책 (사용자 트리거만 — 30일 자동 비우기 등은 차후)
- 휴지통 검색 (full-text 또는 태그 필터)
- trash 안 노트 편집 (read-only invariant 깨지면 회귀)
- per-note 영속 보호 플래그 (lock 같은 것)
- restore 시 AI 결과 보존 invariant 명시 — 본 spec 에 짧게 언급, 별 spec 화는 v0.2.4 reason 분포 본 후
- `inbox:delete` 채널 → `inbox:trash` 로 rename (단계적 마이그레이션)
- 휴지통에서 다중 선택 (멀티 복구 / 멀티 영구 삭제)
- `last_recalled_at` / `recall_dismissed_at` 활용 — #6 가 사용
---
## 11. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — UI=A (Inbox 탭), 필터=A (명시적 WHERE), AiWorker race=C (cleanup+가드), 액션=B (per-card 영구 삭제 추가, 5 IPC 채널), confirm/정렬/카드차이 모두 A. roadmap §3 #4 의 4채널 → 5채널 확장 명시. |

View File

@@ -121,7 +121,7 @@ export class AiWorker {
const startMs = this.now().getTime();
try {
const note = this.repo.findById(job.noteId);
if (!note || note.aiStatus !== 'pending') return;
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;

Race guard 가 provider.generate() 호출 직전 단일 시점만 검사. 네트워크 latency (1~30s) 동안 사용자가 trash 하면 updateAiResult 가 trashed 노트에 ai_status='done' 을 그대로 써서 deleted_at IS NOT NULL + ai_status='done' 모순 상태 발생 (DB 자체는 일관, telemetry ai_succeeded 만 '낭비'). updateAiResult 직전에 findById 재확인 + deletedAt !== null 시 early return 추가 권장. 빈도 낮지만 invariant 명시 차원.

Race guard 가 `provider.generate()` 호출 직전 단일 시점만 검사. 네트워크 latency (1~30s) 동안 사용자가 trash 하면 `updateAiResult` 가 trashed 노트에 ai_status='done' 을 그대로 써서 deleted_at IS NOT NULL + ai_status='done' 모순 상태 발생 (DB 자체는 일관, telemetry ai_succeeded 만 '낭비'). updateAiResult 직전에 `findById` 재확인 + `deletedAt !== null` 시 early return 추가 권장. 빈도 낮지만 invariant 명시 차원.
const nowDate = this.now();
const todayDate = todayKstAsDate(nowDate);
const todayIso = todayKstAsIso(nowDate);

View File

@@ -1,8 +1,9 @@
import type Database from 'better-sqlite3';
import * as m001 from './m001_initial.js';
import * as m002 from './m002_due_date.js';
import * as m003 from './m003_soft_delete.js';
const migrations = [m001, m002];
const migrations = [m001, m002, m003];
export function latestVersion(): number {
return migrations[migrations.length - 1]!.version;

View File

@@ -0,0 +1,15 @@
// v3: soft delete (#4) introduces deleted_at.
// last_recalled_at + recall_dismissed_at are pre-allocated for #6 (recall) —
// dormant until then to avoid a v4 migration round-trip.
import type Database from 'better-sqlite3';
export const version = 3;
export function up(db: Database.Database): void {
db.exec(`
ALTER TABLE notes ADD COLUMN deleted_at TEXT;
ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
`);
}

View File

@@ -1,6 +1,6 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
const { ipcMain } = electron;
const { ipcMain, dialog } = electron;
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { ContinuityService } from '../services/ContinuityService.js';
import type { CaptureService } from '../services/CaptureService.js';
@@ -52,6 +52,56 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount());
ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus());
ipcMain.handle('inbox:todayCount', () => deps.repo.countToday());
ipcMain.handle('inbox:restore', async (_e, noteId: string) => {
await deps.capture.restoreNote(noteId);
});
ipcMain.handle('inbox:permanentDelete', async (_e, noteId: string) => {
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['영구 삭제', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: '이 노트를 영구 삭제합니다',
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { confirmed: false };
await deps.capture.permanentDeleteNote(noteId);
return { confirmed: true };
});
ipcMain.handle('inbox:emptyTrash', async () => {
const fullCount = deps.repo.countTrashed();
if (fullCount === 0) return { confirmed: true, count: 0 };
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {

주석에서 인지한 trash > 200 시 dialog message 부정확 케이스가 실제로 존재함 — 사용자가 '500개 영구 삭제합니다' 를 '200개' 로 보게 됨. 코드는 정확히 동작 (SQL DELETE 무한정) 하지만 표시가 거짓말. repo.countTrashed() 가 추가되면 dialog 은 정확한 count 로, listTrashed 호출은 제거 가능. 사용자 신뢰 측면에서 우선순위 끌어올릴 가치 있음 — REQUEST_CHANGES driver.

주석에서 인지한 trash > 200 시 dialog message 부정확 케이스가 실제로 존재함 — 사용자가 '500개 영구 삭제합니다' 를 '200개' 로 보게 됨. 코드는 정확히 동작 (SQL DELETE 무한정) 하지만 표시가 거짓말. `repo.countTrashed()` 가 추가되면 dialog 은 정확한 count 로, listTrashed 호출은 제거 가능. 사용자 신뢰 측면에서 우선순위 끌어올릴 가치 있음 — REQUEST_CHANGES driver.
type: 'question',
buttons: ['휴지통 비우기', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: `휴지통의 노트 ${fullCount}개를 영구 삭제합니다`,
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { confirmed: false, count: 0 };
const result = await deps.capture.emptyTrash();
return { confirmed: true, count: result.count };
});
ipcMain.handle('inbox:listTrash', (_e, opts: { limit: number }) =>
deps.repo.listTrashed(opts)
);
ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed());
}
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {

View File

@@ -28,6 +28,7 @@ export interface ImportNoteInput {
userIntent: string | null;
intentPromptedAt: string | null;
tags: { name: string; source: 'ai' | 'user' }[];
deletedAt?: string | null;
}
export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked';
@@ -83,17 +84,25 @@ export class NoteRepository {
const limit = Math.max(1, Math.min(200, opts.limit));
const rows = opts.cursor
? (this.db
.prepare(`SELECT * FROM notes WHERE created_at < ? ORDER BY created_at DESC, id DESC LIMIT ?`)
.prepare(
`SELECT * FROM notes
WHERE deleted_at IS NULL AND created_at < ?
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(opts.cursor, limit) as any[])
: (this.db
.prepare(`SELECT * FROM notes ORDER BY created_at DESC, id DESC LIMIT ?`)
.prepare(
`SELECT * FROM notes
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(limit) as any[]);
return rows.map((r) => this.hydrate(r));
}
listAll(): Note[] {
const rows = this.db
.prepare(`SELECT * FROM notes ORDER BY created_at ASC, id ASC`)
.prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
.all() as any[];
return rows.map((r) => this.hydrate(r));
}
@@ -221,6 +230,58 @@ export class NoteRepository {
.run(date, now, id);
}
trash(id: string, deletedAt: string): void {
const tx = this.db.transaction(() => {
this.db
.prepare(`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?`)
.run(deletedAt, deletedAt, id);
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id = ?`).run(id);
});
tx();
}
restore(id: string): void {
const now = new Date().toISOString();
this.db
.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`)
.run(now, id);
}

restore() 가 active 노트에 대해서도 무조건 UPDATE 를 실행해 updated_at 만 변동시킴. CaptureService 레벨 idempotency guard 가 있어서 정상 ���름에선 문제 없으나, repo 직접 호출 (테스트/import) 시 의도치 않은 updated_at touch 가 발생함. WHERE id=? AND deleted_at IS NOT NULL 추가 시 repo 자체가 self-guarding 됨. v0.2.4 backlog candidate.

`restore()` 가 active 노트에 대해서도 무조건 UPDATE 를 실행해 `updated_at` 만 변동시킴. CaptureService 레벨 idempotency guard 가 있어서 정상 ���름에선 문제 없으나, repo 직접 호출 (테스트/import) 시 의도치 않은 updated_at touch 가 발생함. `WHERE id=? AND deleted_at IS NOT NULL` 추가 시 repo 자체가 self-guarding 됨. v0.2.4 backlog candidate.
permanentDelete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
emptyTrash(): { noteIds: string[] } {
// Single DELETE ... RETURNING is atomic by itself (no explicit transaction needed)
// and avoids per-row prepare overhead. RETURNING is house-style elsewhere

Praise — DELETE ... RETURNING id single-statement atomic 선택과 그 근거 (per-row prepare 회피 + RETURNING house-style) 를 주석에 명시한 점, emptyTrash 가 service 레이어 media cleanup loop 와 깨끗하게 분리된 점이 좋음. 트랜잭션 wrapping 없이도 SQLite 의 single-statement atomicity 로 충분하다는 판단도 정확.

Praise — `DELETE ... RETURNING id` single-statement atomic 선택과 그 근거 (per-row prepare 회피 + RETURNING house-style) 를 주석에 명시한 점, `emptyTrash` 가 service 레이어 media cleanup loop 와 깨끗하게 분리된 점이 좋음. 트랜잭션 wrapping 없이도 SQLite 의 single-statement atomicity 로 충분하다는 판단도 정확.
// (updateAiResult/updateUserAiFields/getAllPendingJobs).
const rows = this.db
.prepare('DELETE FROM notes WHERE deleted_at IS NOT NULL RETURNING id')
.all() as Array<{ id: string }>;
return { noteIds: rows.map((r) => r.id) };
}
listTrashed(opts: { limit: number }): Note[] {
const limit = Math.max(1, Math.min(200, opts.limit));
const rows = this.db
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
.all(limit) as any[];
return rows.map((r) => this.hydrate(r));
}
/**
* Cheap COUNT for trash UI badge / bulk-empty dialog. Does not hydrate
* tags/media — used in hot paths (loadInitial / refreshMeta / upsertNote
* follow-ups) where listTrashed() is wasteful.
*/
countTrashed(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`)
.get() as { c: number };
return row.c;
}
/** @deprecated v0.2.3 #4 부터 hard delete 는 permanentDelete() 사용. soft delete 는 trash(). 본 메서드는 v0.2.4 에서 제거 예정. */
delete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
@@ -239,6 +300,10 @@ export class NoteRepository {
* - id present + raw_text identical → no-op (status: 'skipped')
* - id present + raw_text differs → INSERT under fresh uuidv7
* to preserve the raw_text-immutable invariant (status: 'forked')
*
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선

Praise — importNote skip 케이스에서 trash() 를 재사용해 pending_jobs cleanup invariant 까지 자동 만족시키는 선택은 깔끔함. 다만 동일 케이스에서 findRawTextById + 별도 SELECT deleted_at 두 번 쿼리하는 대신 첫 SELECT 에서 raw_text, deleted_at 동시 반환하면 round-trip 1 회 절약 가능. 마이크로 개선이라 deferrable.

Praise — `importNote` skip 케이스에서 `trash()` 를 재사용해 `pending_jobs` cleanup invariant 까지 자동 만족시키는 선택은 깔끔함. 다만 동일 케이스에서 `findRawTextById` + 별도 `SELECT deleted_at` 두 번 쿼리하는 대신 첫 SELECT 에서 `raw_text, deleted_at` 동시 반환하면 round-trip 1 회 절약 가능. 마이크로 개선이라 deferrable.
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
* insert/fork 는 source 의 deletedAt 그대로 보존.
*/
importNote(input: ImportNoteInput): ImportNoteResult {
const existing = this.findRawTextById(input.id);
@@ -246,6 +311,16 @@ export class NoteRepository {
let status: ImportNoteStatus = 'inserted';
if (existing !== null) {
if (existing === input.rawText) {
// skip — source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존).
// trash() 를 재사용해 pending_jobs cleanup invariant (§9.2) 도 동시에 만족.
if (input.deletedAt != null) {
const destRow = this.db
.prepare('SELECT deleted_at FROM notes WHERE id=?')
.get(input.id) as { deleted_at: string | null } | undefined;
if (destRow && destRow.deleted_at === null) {
this.trash(input.id, input.deletedAt);
}
}
return { id: input.id, status: 'skipped' };
}
finalId = uuidv7();
@@ -257,8 +332,8 @@ export class NoteRepository {
`INSERT INTO notes
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
title_edited_by_user, summary_edited_by_user,
user_intent, intent_prompted_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?)`
user_intent, intent_prompted_at, deleted_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
finalId,
@@ -271,6 +346,7 @@ export class NoteRepository {
input.summaryEditedByUser ? 1 : 0,
input.userIntent,
input.intentPromptedAt,
input.deletedAt ?? null,
input.createdAt,
input.updatedAt
);
@@ -297,7 +373,9 @@ export class NoteRepository {
getPendingCount(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`)
.prepare(
`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending' AND deleted_at IS NULL`
)
.get() as { c: number };
return row.c;
}
@@ -319,7 +397,10 @@ export class NoteRepository {
const startIso = new Date(kstMidnightUtc).toISOString();
const endIso = new Date(nextKstMidnightUtc).toISOString();
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND created_at < ?`)
.prepare(
`SELECT COUNT(*) AS c FROM notes
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?`
)
.get(startIso, endIso) as { c: number };
return row.c;
}
@@ -375,6 +456,9 @@ export class NoteRepository {
intentPromptedAt: row.intent_prompted_at,
dueDate: row.due_date ?? null,
dueDateEditedByUser: row.due_date_edited_by_user === 1,
deletedAt: row.deleted_at ?? null,
lastRecalledAt: row.last_recalled_at ?? null,
recallDismissedAt: row.recall_dismissed_at ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
tags: tags as NoteTag[],

View File

@@ -4,6 +4,10 @@ import type { MediaStore } from './MediaStore.js';
export interface TelemetryEmitter {
emit(input:
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } }
): Promise<void>;
}
@@ -54,7 +58,7 @@ export class CaptureService {
rawTextLength: input.text.length,
hasMedia: input.images.length > 0
}
});
}).catch(() => {});
}
await this.deps.enqueue(id);
this.deps.celebrate(id);
@@ -62,7 +66,49 @@ export class CaptureService {
}
async deleteNote(noteId: string): Promise<void> {
this.repo.delete(noteId);
await this.store.deleteNoteDirectory(noteId);
// v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요).
// 이미 trash 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
const note = this.repo.findById(noteId);
if (!note || note.deletedAt !== null) return;

Praise — 3 메서드 모두 idempotency guard (!note || note.deletedAt !== null 등) 를 통해 telemetry ratio (회수율, trash count) 오염을 막는 패턴이 일관되게 적용됨. 주석에서 'restore/trash ratio 오염 방지' 동기를 명시해 미래 reviewer 가 guard 를 함부로 제거하지 못하게 한 것도 좋음.

Praise — 3 메서드 모두 idempotency guard (`!note || note.deletedAt !== null` 등) 를 통해 telemetry ratio (회수율, trash count) 오염을 막는 패턴이 일관되게 적용됨. 주석에서 'restore/trash ratio 오염 방지' 동기를 명시해 미래 reviewer 가 guard 를 함부로 제거하지 못하게 한 것도 좋음.
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> {
// 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
const note = this.repo.findById(noteId);
if (!note || note.deletedAt === null) return;
this.repo.restore(noteId);
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
}
}
async permanentDeleteNote(noteId: string): Promise<void> {
// 존재하지 않는 노트는 emit skip — 메트릭 오염 방지.
const note = this.repo.findById(noteId);
if (!note) return;
this.repo.permanentDelete(noteId);
// best-effort media cleanup — disk 실패해도 telemetry/IPC 흐름은 그대로 (orphan dir
// 은 future janitor 가 정리). emptyTrash 와 동일 패턴.
try { await this.store.deleteNoteDirectory(noteId); }
catch { /* best-effort */ }
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
}
}
async emptyTrash(): Promise<{ count: number }> {
const { noteIds } = this.repo.emptyTrash();
for (const id of noteIds) {
try { await this.store.deleteNoteDirectory(id); }
catch { /* best-effort */ }
}
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
}
return { count: noteIds.length };
}
}

View File

@@ -32,7 +32,9 @@ export class ContinuityService {
get(): WeeklyContinuity {
const rows = this.db
.prepare(`SELECT created_at FROM notes ORDER BY created_at ASC`)
.prepare(
`SELECT created_at FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC`
)
.all() as Array<{ created_at: string }>;
const dates = rows.map((r) => new Date(r.created_at));
if (dates.length === 0) {

View File

@@ -39,7 +39,8 @@ function parsedToInput(parsed: ParsedNote): ImportNoteInput {
aiGeneratedAt: parsed.aiGeneratedAt,
userIntent: parsed.userIntent,
intentPromptedAt: parsed.intentPromptedAt,
tags: parsed.tags
tags: parsed.tags,
deletedAt: parsed.deletedAt
};
}

View File

@@ -6,6 +6,9 @@ export class MediaGc {
async run(): Promise<{ removed: number }> {
const dirs = await this.store.listNoteDirs();
// Intentionally does NOT filter `deleted_at IS NULL` — trashed notes still own
// their media until permanentDelete/emptyTrash. Removing dirs of soft-deleted
// notes here would defeat restore.

Praise — 'deliberately does NOT filter deleted_at IS NULL' 주석으로 의도를 명시한 점이 매우 좋음. 미래 maintainer 가 무심코 WHERE deleted_at IS NULL 추가해서 trash 의 media 를 GC 로 날려버리는 회귀를 막아줌. invariant 가 코드 옆에 그대로 살아있는 모범 예시.

Praise — 'deliberately does NOT filter deleted_at IS NULL' 주석으로 의도를 명시한 점이 매우 좋음. 미래 maintainer 가 무심코 `WHERE deleted_at IS NULL` 추가해서 trash 의 media 를 GC 로 날려버리는 회귀를 막아줌. invariant 가 코드 옆에 그대로 살아있는 모범 예시.
const rows = this.db.prepare('SELECT id FROM notes').all() as Array<{ id: string }>;
const known = new Set(rows.map((r) => r.id));
let removed = 0;

View File

@@ -18,7 +18,11 @@ export interface TelemetryServiceOptions {
export type EmitInput =
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } };
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } };
export class TelemetryService {
constructor(

View File

@@ -33,6 +33,7 @@ export interface ParsedNote {
aiGeneratedAt: string | null;
userIntent: string | null;
intentPromptedAt: string | null;
deletedAt: string | null; // 신규 v0.2.3 #4
tags: ParsedNoteTag[];
images: ParsedNoteImage[];
exportVersion: number;
@@ -347,6 +348,7 @@ export function parseExportNote(markdown: string): ParsedNote {
aiGeneratedAt: get('ai_generated_at'),
userIntent: get('user_intent'),
intentPromptedAt: get('intent_prompted_at'),
deletedAt: get('deleted_at'),
tags: fm.tags,
images: fm.images,
exportVersion

View File

@@ -20,10 +20,22 @@ const AiFailedPayload = z.object({
attempts: z.number().int().nonnegative()
}).strict();
const NoteIdPayload = z.object({
noteId: z.string().min(1)
}).strict();
const EmptyTrashPayload = z.object({
count: z.number().int().nonnegative()
}).strict();
export const TelemetryEventSchema = z.discriminatedUnion('kind', [
z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict()
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict()
]);

Praise — 4 신규 kind 모두 discriminatedUnion + .strict() 로 추가되어 rawText/title/summary/userIntent/tagNames 가 payload 에 들어가면 zod 가 reject 하는 privacy invariant 가 구조적으로 유지됨. validateEvent 가 emit/read 양쪽에서 재사용되어 defense-in-depth 도 좋음.

Praise — 4 신규 kind 모두 `discriminatedUnion + .strict()` 로 추가되어 rawText/title/summary/userIntent/tagNames 가 payload 에 들어가면 zod 가 reject 하는 privacy invariant 가 구조적으로 유지됨. `validateEvent` 가 emit/read 양쪽에서 재사용되어 defense-in-depth 도 좋음.
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;

View File

@@ -14,6 +14,10 @@ interface DailyRow {
capture: number;
ai_succeeded: number;
ai_failed: number;
trash: number;
restore: number;
permanent_delete: number;
empty_trash: number;
}
export interface StatsResult {
@@ -28,11 +32,13 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
let aiFailed = 0;
let durationSum = 0;
let durationN = 0;
let trashCount = 0;
let restoreCount = 0;
for (const ev of events) {
const day = kstDate(ev.ts);
let row = byDay.get(day);
if (!row) {
row = { date: day, capture: 0, ai_succeeded: 0, ai_failed: 0 };
row = { date: day, capture: 0, ai_succeeded: 0, ai_failed: 0, trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0 };
byDay.set(day, row);
}
if (ev.kind === 'capture') row.capture += 1;
@@ -44,12 +50,25 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
} else if (ev.kind === 'ai_failed') {
row.ai_failed += 1;
aiFailed += 1;
} else if (ev.kind === 'trash') {
row.trash += 1;
trashCount += 1;
} else if (ev.kind === 'restore') {
row.restore += 1;
restoreCount += 1;
} else if (ev.kind === 'permanent_delete') {
row.permanent_delete += 1;
} else if (ev.kind === 'empty_trash') {
row.empty_trash += 1;
}
}
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
const aiTotal = aiSucceeded + aiFailed;
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
const trashRecoveryRate = trashCount === 0
? 'N/A'
: `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;
const lines: string[] = [];
lines.push('# Inkling Telemetry Stats');
lines.push('');
@@ -58,16 +77,17 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
lines.push('');
lines.push('## 일자별 카운트');
lines.push('');
lines.push('| 일자 | capture | ai_succeeded | ai_failed |');
lines.push('|------|---------|--------------|-----------|');
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash |');
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|');
for (const row of days) {
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} |`);
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} |`);
}
lines.push('');
lines.push('## 핵심 ratio');
lines.push('');
lines.push(`- AI 성공률: ${successRate}`);
lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`);
lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`);
lines.push('');
return { md: lines.join('\n'), eventCount };
}

View File

@@ -19,6 +19,12 @@ const api: InklingApi = {
getPendingCount: () => ipcRenderer.invoke('inbox:pendingCount'),
getOllamaStatus: () => ipcRenderer.invoke('inbox:ollamaStatus'),
getTodayCount: () => ipcRenderer.invoke('inbox:todayCount'),
// 신규 v0.2.3 #4:
restoreNote: (noteId) => ipcRenderer.invoke('inbox:restore', noteId),
permanentDeleteNote: (noteId) => ipcRenderer.invoke('inbox:permanentDelete', noteId),
emptyTrash: () => ipcRenderer.invoke('inbox:emptyTrash'),
listTrash: (opts) => ipcRenderer.invoke('inbox:listTrash', opts),
getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'),
onNoteUpdated: (cb) => {
const listener = (_e: unknown, note: Note) => cb(note);
ipcRenderer.on('note:updated', listener);

View File

@@ -12,15 +12,10 @@ import { TagUndoToast } from './components/TagUndoToast.js';
export function App(): React.ReactElement {
const {
notes,
loading,
loadInitial,
refreshMeta,
upsertNote,
removeNote,
continuity,
tagFilter,
setTagFilter
notes, trashNotes, trashCount, showTrash,
loading, loadInitial, refreshMeta, upsertNote, removeNote,
continuity, tagFilter, setTagFilter,
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
} = useInbox();
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
@@ -38,64 +33,114 @@ export function App(): React.ReactElement {
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
const filtered = selectFilteredNotes({ notes, tagFilter });
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
background: active ? '#0a4b80' : 'transparent',
color: active ? '#fff' : '#0a4b80',
border: '1px solid #0a4b80',
borderRadius: 4,
padding: '4px 10px',
fontSize: 12,
cursor: 'pointer'
});
return (
<>
<div className="header">
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
<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">
<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>
{!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)}
/>
))
)}
</>
)}
{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} 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 />

View File

@@ -10,6 +10,9 @@ interface Props {
note: Note;
onDeleted: () => void;
onUpdated: (n: Note) => void;
mode?: 'inbox' | 'trash'; // default 'inbox'
onRestore?: () => void;
onPermanentDelete?: () => void;
}
const aiBadgeStyle: React.CSSProperties = {
@@ -104,7 +107,8 @@ function DueDateBadge({
);
}
export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement {
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement {
const isTrash = mode === 'trash';
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
const [local, setLocal] = useState(note);
@@ -183,7 +187,7 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
<div style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>
{showIntentBanner && (
{!isTrash && showIntentBanner && (
<IntentBanner
noteId={note.id}
onResolved={(intentText) => {
@@ -206,86 +210,122 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
)}
{local.aiStatus === 'done' && (
<>
<div style={{ marginTop: 4 }}>
<EditableField
value={local.aiTitle ?? ''}
onSave={saveTitle}
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
singleLine
/>
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<EditableField
value={local.aiSummary ?? ''}
onSave={saveSummary}
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
singleLine={false}
/>
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<DueDateBadge
value={local.dueDate}
isEdited={local.dueDateEditedByUser}
today={todayKstIso()}
onSave={saveDueDate}
/>
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 4px 2px 8px',
borderRadius: 12,
fontSize: 12,
display: 'inline-flex',
alignItems: 'center',
gap: 4
}}
>
<span
onClick={() => filterByTag(t.name)}
style={{ cursor: 'pointer' }}
title={`#${t.name} 노트만 보기`}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
<button
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: 14,
padding: '0 2px',
lineHeight: 1,
opacity: 0.6
}}
title="태그 제거"
aria-label={`${t.name} 태그 제거`}
>
×
</button>
</span>
))}
</div>
)}
{local.userIntent !== null && (
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
<EditableField
value={local.userIntent}
onSave={saveIntent}
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
singleLine
/>
</div>
{isTrash ? (
<>
<div style={{ marginTop: 4 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{local.aiTitle ?? '(제목 없음)'}</h3>
</div>
<div style={{ marginTop: 6, fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}>
{local.aiSummary ?? '(요약 없음)'}
</div>
{local.dueDate !== null && (
<div style={{ marginTop: 6 }}>
<span style={{ fontSize: 11, color: '#666' }}>📅 {local.dueDate}</span>
</div>
)}
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 8px',
borderRadius: 12,
fontSize: 12
}}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
))}
</div>
)}
</>
) : (
<>
<div style={{ marginTop: 4 }}>
<EditableField
value={local.aiTitle ?? ''}
onSave={saveTitle}
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
singleLine
/>
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<EditableField
value={local.aiSummary ?? ''}
onSave={saveSummary}
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
singleLine={false}
/>
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<DueDateBadge
value={local.dueDate}
isEdited={local.dueDateEditedByUser}
today={todayKstIso()}
onSave={saveDueDate}
/>
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 4px 2px 8px',
borderRadius: 12,
fontSize: 12,
display: 'inline-flex',
alignItems: 'center',
gap: 4
}}
>
<span
onClick={() => filterByTag(t.name)}
style={{ cursor: 'pointer' }}
title={`#${t.name} 노트만 보기`}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
<button
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: 14,
padding: '0 2px',
lineHeight: 1,
opacity: 0.6
}}
title="태그 제거"
aria-label={`${t.name} 태그 제거`}
>
×
</button>
</span>
))}
</div>
)}
{local.userIntent !== null && (
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
<EditableField
value={local.userIntent}
onSave={saveIntent}
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
singleLine
/>
</div>
)}
</>
)}
</>
)}
@@ -310,9 +350,32 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
</div>
<div style={{ marginTop: 10, textAlign: 'right' }}>
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
🗑
</button>
{isTrash ? (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={onRestore}
style={{
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🔄
</button>
<button
onClick={onPermanentDelete}
style={{
background: 'none', border: '1px solid #c93030', color: '#c93030',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🗑
</button>
</div>
) : (
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
🗑
</button>
)}
</div>
</div>
);

View File

@@ -6,6 +6,9 @@ export { selectFilteredNotes } from './selectFilteredNotes.js';
interface InboxState {
notes: Note[];
trashNotes: Note[];
trashCount: number;
showTrash: boolean;
continuity: WeeklyContinuity;
pendingCount: number;
ollamaStatus: { ok: boolean; reason?: string };
@@ -17,6 +20,11 @@ interface InboxState {
upsertNote: (note: Note) => void;
removeNote: (id: string) => void;
setTagFilter: (tag: string | null) => void;
toggleShowTrash: () => Promise<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
emptyTrash: () => Promise<void>;
}
const emptyContinuity: WeeklyContinuity = {
@@ -26,6 +34,9 @@ const emptyContinuity: WeeklyContinuity = {
export const useInbox = create<InboxState>((set, get) => ({
notes: [],
trashNotes: [],
trashCount: 0,
showTrash: false,
continuity: emptyContinuity,
pendingCount: 0,
ollamaStatus: { ok: true },
@@ -34,38 +45,96 @@ export const useInbox = create<InboxState>((set, get) => ({
tagFilter: null,
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount()
inboxApi.getTodayCount(),
inboxApi.getTrashCount()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, loading: false });
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, loading: false });
},
async refreshMeta() {
const [continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount()
inboxApi.getTodayCount(),
inboxApi.getTrashCount()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount });
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount });
},
upsertNote(note) {
const i = get().notes.findIndex((n) => n.id === note.id);
if (i >= 0) {
const next = get().notes.slice();
next[i] = note;
set({ notes: next });
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
// 때만 trashCount 를 local recompute. 그 외엔 server 값 (refreshMeta) 보존.
const showTrash = get().showTrash;
if (note.deletedAt !== null) {
// trash 노트: notes 에서 제거 + trashNotes 에 upsert
const cleanNotes = get().notes.filter((n) => n.id !== note.id);
const ti = get().trashNotes.findIndex((n) => n.id === note.id);
const nextTrash = get().trashNotes.slice();
if (ti >= 0) nextTrash[ti] = note;
else nextTrash.unshift(note);
set({
notes: cleanNotes,
trashNotes: nextTrash,
...(showTrash ? { trashCount: nextTrash.length } : {})
});
} else {
set({ notes: [note, ...get().notes] });
// active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
const i = get().notes.findIndex((n) => n.id === note.id);
const nextNotes = get().notes.slice();
if (i >= 0) nextNotes[i] = note;
else nextNotes.unshift(note);
set({
notes: nextNotes,
trashNotes: cleanTrash,
...(showTrash ? { trashCount: cleanTrash.length } : {})
});
}
},
removeNote(id) {
set({ notes: get().notes.filter((n) => n.id !== id) });
const cleanNotes = get().notes.filter((n) => n.id !== id);
const cleanTrash = get().trashNotes.filter((n) => n.id !== id);
const showTrash = get().showTrash;
set({
notes: cleanNotes,
trashNotes: cleanTrash,
...(showTrash ? { trashCount: cleanTrash.length } : {})
});
},
setTagFilter(tag) {
set({ tagFilter: tag });
},
async toggleShowTrash() {
const next = !get().showTrash;
set({ showTrash: next });
if (next) await get().loadTrash();
},
async loadTrash() {
const trashNotes = await inboxApi.listTrash({ limit: 200 });

limit: 200 매직 넘버가 preload (listTrash), App.tsx (loadTrash), IPC (inbox:trashCount) 3 곳에 흩어져 있음. spec/plan 어디에도 이 200 의 근거가 없으므로 TRASH_VIEW_LIMIT = 200 같은 상수로 끌어내고 향후 paging 도입 시 한 곳만 고치도록 정리하면 좋겠음. nit.

`limit: 200` 매직 넘버가 preload (`listTrash`), App.tsx (`loadTrash`), IPC (`inbox:trashCount`) 3 곳에 흩어져 있음. spec/plan 어디에도 이 200 의 근거가 없으므로 `TRASH_VIEW_LIMIT = 200` 같은 상수로 끌어내고 향후 paging 도입 시 한 곳만 고치도록 정리하면 좋겠음. nit.
set({ trashNotes, trashCount: trashNotes.length });
},
async restoreNote(id) {
await inboxApi.restoreNote(id);
// 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음
// (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘.
// 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출).
const note = get().trashNotes.find((n) => n.id === id);
if (note) {
get().upsertNote({ ...note, deletedAt: null });
}
},
async permanentDeleteNote(id) {
const r = await inboxApi.permanentDeleteNote(id);
if (r.confirmed) get().removeNote(id);
},
async emptyTrash() {
const r = await inboxApi.emptyTrash();
if (r.confirmed) {
set({ trashNotes: [], trashCount: 0 });
}
}
}));

View File

@@ -33,6 +33,10 @@ export interface Note {
intentPromptedAt: string | null;
dueDate: string | null;
dueDateEditedByUser: boolean;
// 신규 v3:
deletedAt: string | null;
lastRecalledAt: string | null;
recallDismissedAt: string | null;
createdAt: string;
updatedAt: string;
tags: NoteTag[];
@@ -67,6 +71,12 @@ export interface InboxApi {
getPendingCount(): Promise<number>;
getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>;
getTodayCount(): Promise<number>;
// 신규 v0.2.3 #4:
restoreNote(noteId: string): Promise<void>;
permanentDeleteNote(noteId: string): Promise<{ confirmed: boolean }>;
emptyTrash(): Promise<{ confirmed: boolean; count: number }>;
listTrash(opts: { limit: number }): Promise<Note[]>;
getTrashCount(): Promise<number>;
onNoteUpdated(cb: (note: Note) => void): () => void;
}

View File

@@ -278,3 +278,29 @@ describe('AiWorker telemetry emit', () => {
expect(failed!.payload.reason).toBe('other');
});
});
describe('AiWorker — deletedAt guard (v0.2.3 #4)', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('skips notes with deleted_at IS NOT NULL — provider.generate not called', async () => {
const { id } = repo.create({ rawText: 'x' });
// 먼저 trash — pending_jobs cleanup 됨
repo.trash(id, '2026-05-01T12:00:00.000Z');
// 강제로 pending_jobs row 다시 삽입 (race 시뮬레이션 — AiWorker 가 이미 dequeue 한 상태 흉내)
db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, '2026-05-01T12:00:00.000Z');
const generate = vi.fn();
const provider = makeProvider({ generate: generate as any });
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
await w.loadFromDb();
await w.drain();
expect(generate).not.toHaveBeenCalled();
expect(repo.findById(id)!.aiStatus).toBe('pending');
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { mkdtempSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
@@ -51,11 +51,11 @@ describe('CaptureService', () => {
expect(celebrated).toHaveLength(0);
});
it('deleteNote removes db row + media dir', async () => {
it('deleteNote soft-deletes (sets deletedAt, preserves row)', async () => {
const img = new Uint8Array([0, 1, 2, 3]).buffer;
const { noteId } = await svc.submit({ text: 't', images: [img] });
await svc.deleteNote(noteId);
expect(repo.findById(noteId)).toBeNull();
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
});
});
@@ -111,3 +111,100 @@ describe('CaptureService telemetry emit', () => {
expect(events).toHaveLength(0); // events array stays empty since no telemetry was wired
});
});
describe('CaptureService trash flow (v0.2.3 #4)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let events: Array<{ kind: string; payload: any }>;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-trash-'));
store = new MediaStore(tmp);
events = [];
});
it('deleteNote sets deleted_at and emits trash event (no media cleanup)', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
events.length = 0; // clear capture event
await svc.deleteNote(noteId);
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('trash');
expect(events[0]!.payload.noteId).toBe(noteId);
// media 디렉터리 보존 확인 (restore 시 필요)
expect(existsSync(join(tmp, 'media', noteId))).toBe(true);
});
it('restoreNote clears deleted_at and emits restore event', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [] });
events.length = 0;
await svc.deleteNote(noteId);
events.length = 0;
await svc.restoreNote(noteId);
expect(repo.findById(noteId)!.deletedAt).toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('restore');
});
it('permanentDeleteNote hard-deletes + cleans media + emits permanent_delete', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
events.length = 0;
await svc.permanentDeleteNote(noteId);
expect(repo.findById(noteId)).toBeNull();
expect(existsSync(join(tmp, 'media', noteId))).toBe(false);
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('permanent_delete');
});
it('emptyTrash deletes all trashed + cleans each media + emits empty_trash with count', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const a = (await svc.submit({ text: 'a', images: [new ArrayBuffer(8)] })).noteId;
const b = (await svc.submit({ text: 'b', images: [new ArrayBuffer(8)] })).noteId;
await svc.submit({ text: 'c (active)', images: [] });
await svc.deleteNote(a);
await svc.deleteNote(b);
events.length = 0;
const r = await svc.emptyTrash();
expect(r.count).toBe(2);
expect(repo.findById(a)).toBeNull();
expect(repo.findById(b)).toBeNull();
expect(existsSync(join(tmp, 'media', a))).toBe(false);
expect(existsSync(join(tmp, 'media', b))).toBe(false);
const empty = events.find((e) => e.kind === 'empty_trash')!;
expect(empty.payload.count).toBe(2);
});
it('emptyTrash returns count=0 when trash empty', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const r = await svc.emptyTrash();
expect(r.count).toBe(0);
});
});

View File

@@ -88,4 +88,18 @@ describe('ContinuityService', () => {
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
expect(svc.get().showRecoveryToast).toBe(false);
});
it('excludes trashed notes from streak/recovery math (v0.2.3 #4)', () => {
const db = dbWithDates([
'2026-04-22T10:00:00+09:00',
'2026-04-25T11:00:00+09:00'
]);
// 22일 노트를 trash → 25일이 마지막. 22일 미만이라 weekCount 1 이지만 lastNoteAt
// 은 25일 (마지막 active) 이어야 함. trashed 노트가 무시되어야 함.
db.prepare(`UPDATE notes SET deleted_at='2026-04-26T00:00:00Z' WHERE id='n0'`).run();
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
const r = svc.get();
expect(r.weekCount).toBe(1);
expect(r.lastNoteAt).toBe('2026-04-25T02:00:00.000Z'); // KST 11:00 = UTC 02:00
});
});

View File

@@ -138,4 +138,18 @@ describe('ExportService', () => {
expect(readme).toContain('RAG');
expect(readme).toContain('inkling_export_version');
});
it('does NOT export trashed notes (listAll filter — v0.2.3 #4 회귀 가드)', async () => {
const a = repo.create({ rawText: 'active note' }).id;
const t = repo.create({ rawText: 'trashed note' }).id;
repo.updateAiResult(a, { title: '활성', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
repo.updateAiResult(t, { title: '버려짐', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
repo.trash(t, '2026-05-01T00:00:00.000Z');
const r = await svc.export(outDir, { includeMedia: false });
expect(r.noteCount).toBe(1);
// index.jsonl 도 trash 미포함
const indexPath = join(outDir, 'index.jsonl');
const lines = readFileSync(indexPath, 'utf8').trim().split('\n');
expect(lines).toHaveLength(1);
});
});

View File

@@ -233,3 +233,81 @@ describe('ImportService', () => {
expect(dbNote!.media[0]!.bytes).toBe(7);
});
});
describe('ImportService — deletedAt preservation (v0.2.3 #4)', () => {
it('id-collide skip: source deleted_at IS NOT NULL → dest deleted_at 갱신', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'identical' });
const r = repo.importNote({
id, rawText: 'identical',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('skipped');
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('id-collide skip: source deleted_at NULL + dest IS NOT NULL → dest 유지', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'identical' });
repo.trash(id, '2026-05-01T00:00:00.000Z');
repo.importNote({
id, rawText: 'identical',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: null
});
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
});
it('id-new insert: source deletedAt 보존', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const r = repo.importNote({
id: 'fresh-id', rawText: 'fresh',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('inserted');
expect(repo.findById('fresh-id')!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('id-collide forked: deletedAt 도 fork 노트에 보존', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'original' });
const r = repo.importNote({
id, rawText: 'different',
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null, aiSummary: null,
titleEditedByUser: false, summaryEditedByUser: false,
aiProvider: null, aiGeneratedAt: null,
userIntent: null, intentPromptedAt: null,
tags: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('forked');
expect(r.id).not.toBe(id);
expect(repo.findById(r.id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
});

View File

@@ -215,3 +215,236 @@ describe('NoteRepository', () => {
expect(n).toBeGreaterThanOrEqual(0);
});
});
describe('NoteRepository.trash', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('sets deleted_at and removes pending_jobs row atomically', () => {
const { id } = repo.create({ rawText: 'x' });
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const note = repo.findById(id)!;
expect(note.deletedAt).toBe('2026-05-01T12:00:00.000Z');
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
});
it('updates updated_at to deletedAt timestamp', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const note = repo.findById(id)!;
expect(note.updatedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('is no-op when note does not exist', () => {
expect(() => repo.trash('nonexistent', '2026-05-01T12:00:00.000Z')).not.toThrow();
});
});
describe('NoteRepository.restore', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('clears deleted_at on a trashed note', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
repo.restore(id);
const note = repo.findById(id)!;
expect(note.deletedAt).toBeNull();
});
it('updates updated_at', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const before = repo.findById(id)!.updatedAt;
repo.restore(id);
const after = repo.findById(id)!.updatedAt;
expect(after).not.toBe(before);
});
it('is no-op on already-active note', () => {
const { id } = repo.create({ rawText: 'x' });
expect(() => repo.restore(id)).not.toThrow();
expect(repo.findById(id)!.deletedAt).toBeNull();
});
});
describe('NoteRepository.permanentDelete', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('removes notes row + cascades note_tags / pending_jobs', () => {
const { id } = repo.create({ rawText: 'x' });
repo.updateAiResult(id, { title: 'T', summary: 'a\nb\nc', tags: ['tag-a'], provider: 'p', dueDate: null });
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
repo.permanentDelete(id);
expect(repo.findById(id)).toBeNull();
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
});
});
describe('NoteRepository.emptyTrash', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('hard-deletes all trashed notes and returns their ids', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T01:00:00.000Z');
const r = repo.emptyTrash();
expect(r.noteIds.sort()).toEqual([a, c].sort());
expect(repo.findById(a)).toBeNull();
expect(repo.findById(b)).not.toBeNull();
expect(repo.findById(c)).toBeNull();
});
it('returns empty array when trash is empty', () => {
expect(repo.emptyTrash()).toEqual({ noteIds: [] });
});
});
describe('NoteRepository.listTrashed', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('returns trashed notes ordered by deleted_at DESC', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T02:00:00.000Z');
repo.trash(b, '2026-05-01T01:00:00.000Z');
const r = repo.listTrashed({ limit: 50 });
expect(r.map((n) => n.id)).toEqual([c, b, a]);
});
it('excludes active notes', () => {
repo.create({ rawText: 'active' });
const r = repo.listTrashed({ limit: 50 });
expect(r).toEqual([]);
});
it('respects limit', () => {
for (let i = 0; i < 5; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.trash(id, `2026-05-01T0${i}:00:00.000Z`);
}
const r = repo.listTrashed({ limit: 3 });
expect(r).toHaveLength(3);
});
});
describe('NoteRepository.countTrashed', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('returns 0 when no trash', () => {
repo.create({ rawText: 'active' });
expect(repo.countTrashed()).toBe(0);
});
it('counts only trashed notes', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b (active)' });
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T01:00:00.000Z');
expect(repo.countTrashed()).toBe(2);
});
it('returns count beyond listTrashed limit (no 200 cap drift)', () => {
// listTrashed limit cap is 200; countTrashed must reflect actual count.
for (let i = 0; i < 10; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.trash(id, `2026-05-01T${String(i).padStart(2, '0')}:00:00.000Z`);
}
expect(repo.countTrashed()).toBe(10);
expect(repo.listTrashed({ limit: 5 })).toHaveLength(5);
});
});
describe('Active queries exclude deleted notes', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('list() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;

Praise — getPendingCount() excludes trashed pending notes (drift guard) 는 매우 영리한 테스트. trash() 가 pending_jobs row 만 정리하고 notes.ai_status 는 'pending' 으로 남기는 설계 선택이 query side 에서 제대로 보정되는지 명시적으로 잠그는 회귀 가드. 이런 invariant 테스트가 PR 신뢰도를 크게 끌어올림.

Praise — `getPendingCount() excludes trashed pending notes (drift guard)` 는 매우 영리한 테스트. trash() 가 pending_jobs row 만 정리하고 notes.ai_status 는 'pending' 으로 남기는 설계 선택이 query side 에서 제대로 보정되는지 명시적으로 잠그는 회귀 가드. 이런 invariant 테스트가 PR 신뢰도를 크게 끌어올림.
repo.trash(a, '2026-05-01T00:00:00.000Z');
const r = repo.list({ limit: 50 });
expect(r.map((n) => n.id)).toEqual([b]);
});
it('listAll() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b' });
repo.trash(a, '2026-05-01T00:00:00.000Z');
const r = repo.listAll();
expect(r.map((n) => n.rawText)).toEqual(['b']);
});
it('countToday() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b' });
repo.trash(a, new Date().toISOString());
expect(repo.countToday(new Date())).toBe(1);
});
it('findById() returns trashed notes (does NOT filter)', () => {
const { id } = repo.create({ rawText: 'a' });
repo.trash(id, '2026-05-01T00:00:00.000Z');
const note = repo.findById(id);
expect(note).not.toBeNull();
expect(note!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
});
it('getPendingCount() excludes trashed pending notes (drift guard)', () => {
const a = repo.create({ rawText: 'a' }).id; // ai_status=pending
repo.create({ rawText: 'b' }); // ai_status=pending
expect(repo.getPendingCount()).toBe(2);
// trash() 가 pending_jobs row 는 정리하지만 notes.ai_status 는 'pending' 그대로.
// getPendingCount 가 deleted_at IS NOT NULL 노트 포함하면 영구 over-count.
repo.trash(a, '2026-05-01T00:00:00.000Z');
expect(repo.getPendingCount()).toBe(1);
});
});

View File

@@ -146,7 +146,8 @@ describe('TelemetryService.readAllRecent', () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toHaveLength(3);
expect(events.map((e) => e.payload.noteId)).toEqual(['a', 'b', 'b']);
// discriminant narrowing — empty_trash 같은 noteId 없는 kind 가 섞이면 명시적으로 실패
expect(events.map((e) => e.kind === 'empty_trash' ? null : e.payload.noteId)).toEqual(['a', 'b', 'b']);
});
it('skips malformed lines (silent — invariant)', async () => {
@@ -157,7 +158,9 @@ describe('TelemetryService.readAllRecent', () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toHaveLength(1);
expect(events[0]!.payload.noteId).toBe('a');
const ev = events[0]!;
expect(ev.kind).toBe('capture');
if (ev.kind !== 'empty_trash') expect(ev.payload.noteId).toBe('a');
});
it('returns [] when dir missing', async () => {

View File

@@ -1,19 +1,11 @@
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations, latestVersion } from '@main/db/migrations/index.js';
import { runMigrations } from '@main/db/migrations/index.js';
describe('migrations m002 due_date', () => {
it('latestVersion returns 2', () => {
expect(latestVersion()).toBe(2);
});
it('runMigrations on fresh DB advances user_version to 2', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.pragma('user_version', { simple: true });
expect(row).toBe(2);
});
// v3 (m003 soft_delete) lands in v0.2.3 #4 — latest version + user_version
// assertions migrate to migrations.test.ts. Here we keep only the m002-specific
// assertion (due_date column existence) which is version-stable.
it('due_date column exists with NULL default', () => {
const db = new Database(':memory:');
runMigrations(db);

View File

@@ -29,3 +29,47 @@ describe('migrations', () => {
db.close();
});
});
describe('migration v3 — soft delete columns', () => {
it('adds deleted_at, last_recalled_at, recall_dismissed_at to notes', () => {
const db = new Database(':memory:');
runMigrations(db);
const cols = db.prepare(`PRAGMA table_info(notes)`).all().map((r: any) => r.name);
expect(cols).toEqual(
expect.arrayContaining(['deleted_at', 'last_recalled_at', 'recall_dismissed_at'])
);
db.close();
});
it('creates idx_notes_deleted_at index', () => {
const db = new Database(':memory:');
runMigrations(db);
const indexes = db
.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'`)
.all() as Array<{ name: string }>;
expect(indexes.map((i) => i.name)).toContain('idx_notes_deleted_at');
db.close();
});
it('user_version reaches 3', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
expect(row.user_version).toBe(3);
db.close();
});
it('all 3 new columns default to NULL', () => {
const db = new Database(':memory:');
runMigrations(db);
db.prepare(
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES ('n1', 't', 'pending', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')`
).run();
const row = db.prepare('SELECT deleted_at, last_recalled_at, recall_dismissed_at FROM notes WHERE id=?').get('n1') as any;
expect(row.deleted_at).toBeNull();
expect(row.last_recalled_at).toBeNull();
expect(row.recall_dismissed_at).toBeNull();
db.close();
});
});

View File

@@ -18,6 +18,9 @@ function sample(id: string, tags: string[]): Note {
intentPromptedAt: null,
dueDate: null,
dueDateEditedByUser: false,
deletedAt: null,
lastRecalledAt: null,
recallDismissedAt: null,
createdAt: '2026-04-26T00:00:00Z',
updatedAt: '2026-04-26T00:00:00Z',
tags: tags.map((name) => ({ name, source: 'ai' as const })),

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Note } from '@shared/types';
const mockApi = {
listNotes: vi.fn(async () => [] as Note[]),
listTrash: vi.fn(async () => [] as Note[]),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
getPendingCount: vi.fn(async () => 0),
getOllamaStatus: vi.fn(async () => ({ ok: true })),
getTodayCount: vi.fn(async () => 0),
restoreNote: vi.fn(async () => {}),
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
deleteNote: vi.fn(async () => {}),
onNoteUpdated: vi.fn(() => () => {}),
updateAiFields: vi.fn(async () => {}),
setDueDate: vi.fn(async () => {}),
setIntent: vi.fn(async () => {}),
dismissIntent: vi.fn(async () => {})
};
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
const noteStub = (id: string, deletedAt: string | null = null): Note => ({
id, rawText: 'x',
aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
aiProvider: null, aiGeneratedAt: null,
titleEditedByUser: false, summaryEditedByUser: false,
userIntent: null, intentPromptedAt: null,
dueDate: null, dueDateEditedByUser: false,
deletedAt, lastRecalledAt: null, recallDismissedAt: null,
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
tags: [], media: []
});
describe('useInbox — trash state (v0.2.3 #4)', () => {
beforeEach(async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.setState({
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
ollamaStatus: { ok: true },
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }
});
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
});
it('toggleShowTrash flips state and triggers loadTrash on enter', async () => {
mockApi.listTrash.mockResolvedValueOnce([noteStub('t1', '2026-05-01T00:00:00Z')]);
const { useInbox } = await import('../../src/renderer/inbox/store.js');
await useInbox.getState().toggleShowTrash();
expect(useInbox.getState().showTrash).toBe(true);
expect(useInbox.getState().trashNotes).toHaveLength(1);
expect(mockApi.listTrash).toHaveBeenCalled();
await useInbox.getState().toggleShowTrash();
expect(useInbox.getState().showTrash).toBe(false);
});
it('upsertNote routes to trashNotes when deletedAt IS NOT NULL', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
expect(useInbox.getState().notes).toHaveLength(0);
expect(useInbox.getState().trashNotes).toHaveLength(1);
});
it('upsertNote moves note from notes to trashNotes when trashed', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a'));
expect(useInbox.getState().notes).toHaveLength(1);
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
expect(useInbox.getState().notes).toHaveLength(0);
expect(useInbox.getState().trashNotes).toHaveLength(1);
});
it('restoreNote calls api + moves note from trashNotes to notes (낙관적 갱신)', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
expect(useInbox.getState().trashNotes).toHaveLength(1);
await useInbox.getState().restoreNote('a');
expect(mockApi.restoreNote).toHaveBeenCalledWith('a');
// main 은 restore 시 pushNoteUpdated 안 보냄 — store 자가 갱신 검증
expect(useInbox.getState().trashNotes).toHaveLength(0);
expect(useInbox.getState().notes).toHaveLength(1);
expect(useInbox.getState().notes[0]!.deletedAt).toBeNull();
});
it('upsertNote with showTrash=false preserves server trashCount (regression I1)', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
// server 가 trashCount=5 알려줬는데 trashNotes 는 미로드 (showTrash=false 기본)
useInbox.setState({ trashCount: 5, trashNotes: [] });
useInbox.getState().upsertNote(noteStub('active-1'));
expect(useInbox.getState().trashCount).toBe(5); // server 값 보존
expect(useInbox.getState().notes).toHaveLength(1);
});
it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => {
mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 });
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
await useInbox.getState().emptyTrash();
expect(useInbox.getState().trashNotes).toHaveLength(1);
});
});

View File

@@ -87,3 +87,65 @@ describe('validateEvent — privacy invariant', () => {
})).toThrow();
});
});
describe('validateEvent — trash family (v0.2.3 #4)', () => {
it('accepts trash event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'trash',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('trash');
});
it('accepts restore event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'restore',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('restore');
});
it('accepts permanent_delete event', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'permanent_delete',
payload: { noteId: 'n1' }
});
expect(e.kind).toBe('permanent_delete');
});
it('accepts empty_trash event with count', () => {
const e = validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: 7 }
});
expect(e.kind).toBe('empty_trash');
});
it('rejects trash payload with rawText leak', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'trash',
payload: { noteId: 'n1', rawText: 'leak' }
})).toThrow();
});
it('rejects empty_trash with negative count', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: -1 }
})).toThrow();
});
it('rejects empty_trash with non-integer count', () => {
expect(() => validateEvent({
ts: '2026-05-01T00:00:00.000Z',
kind: 'empty_trash',
payload: { count: 1.5 }
})).toThrow();
});
});

View File

@@ -66,3 +66,38 @@ describe('aggregateStats', () => {
expect(r.md).not.toContain('| 2026-05-01 |');
});
});
describe('aggregateStats — trash family (v0.2.3 #4)', () => {
it('counts trash/restore/permanent_delete/empty_trash per day', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'trash', { noteId: 'n1' }),
e('2026-05-01T01:00:00Z', 'trash', { noteId: 'n2' }),
e('2026-05-01T02:00:00Z', 'restore', { noteId: 'n1' }),
e('2026-05-01T03:00:00Z', 'permanent_delete', { noteId: 'n3' }),
e('2026-05-01T04:00:00Z', 'empty_trash', { count: 5 })
];
const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z'));
expect(r.eventCount).toBe(5);
expect(r.md).toContain('| 2026-05-01 | 0 | 0 | 0 | 2 | 1 | 1 | 1 |');
});
it('computes restore/trash ratio', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'trash', { noteId: 'a' }),
e('2026-05-01T00:00:01Z', 'trash', { noteId: 'b' }),
e('2026-05-01T00:00:02Z', 'trash', { noteId: 'c' }),
e('2026-05-01T00:00:03Z', 'trash', { noteId: 'd' }),
e('2026-05-01T00:00:04Z', 'restore', { noteId: 'a' })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('휴지통 회수율: 25.0% (1/4)');
});
it('휴지통 회수율 N/A when no trash events', () => {
const events: TelemetryEvent[] = [
e('2026-05-01T00:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false })
];
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
expect(r.md).toContain('휴지통 회수율: N/A');
});
});