Merge pull request 'feat(trash): #4 휴지통 + migration v3 (v0.2.3 2/7)' (#14) from feat/v023-trash into main
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2276
docs/superpowers/plans/2026-05-01-v023-trash.md
Normal file
2276
docs/superpowers/plans/2026-05-01-v023-trash.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 컬럼 한 번)
|
||||
|
||||
385
docs/superpowers/specs/2026-05-01-v023-trash-design.md
Normal file
385
docs/superpowers/specs/2026-05-01-v023-trash-design.md
Normal 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채널 확장 명시. |
|
||||
@@ -121,7 +121,7 @@ export class AiWorker {
|
||||
const startMs = this.now().getTime();
|
||||
try {
|
||||
const note = this.repo.findById(job.noteId);
|
||||
if (!note || note.aiStatus !== 'pending') return;
|
||||
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
|
||||
const nowDate = this.now();
|
||||
const todayDate = todayKstAsDate(nowDate);
|
||||
const todayIso = todayKstAsIso(nowDate);
|
||||
|
||||
@@ -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;
|
||||
|
||||
15
src/main/db/migrations/m003_soft_delete.ts
Normal file
15
src/main/db/migrations/m003_soft_delete.ts
Normal 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);
|
||||
`);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
const { ipcMain } = electron;
|
||||
const { ipcMain, dialog } = electron;
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { ContinuityService } from '../services/ContinuityService.js';
|
||||
import type { CaptureService } from '../services/CaptureService.js';
|
||||
@@ -52,6 +52,56 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount());
|
||||
ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus());
|
||||
ipcMain.handle('inbox:todayCount', () => deps.repo.countToday());
|
||||
|
||||
ipcMain.handle('inbox:restore', async (_e, noteId: string) => {
|
||||
await deps.capture.restoreNote(noteId);
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:permanentDelete', async (_e, noteId: string) => {
|
||||
const win = deps.getInboxWindow();
|
||||
const opts: Electron.MessageBoxOptions = {
|
||||
type: 'question',
|
||||
buttons: ['영구 삭제', '취소'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
title: 'Inkling',
|
||||
message: '이 노트를 영구 삭제합니다',
|
||||
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
|
||||
};
|
||||
const r = win
|
||||
? await dialog.showMessageBox(win, opts)
|
||||
: await dialog.showMessageBox(opts);
|
||||
if (r.response !== 0) return { confirmed: false };
|
||||
await deps.capture.permanentDeleteNote(noteId);
|
||||
return { confirmed: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:emptyTrash', async () => {
|
||||
const fullCount = deps.repo.countTrashed();
|
||||
if (fullCount === 0) return { confirmed: true, count: 0 };
|
||||
const win = deps.getInboxWindow();
|
||||
const opts: Electron.MessageBoxOptions = {
|
||||
type: 'question',
|
||||
buttons: ['휴지통 비우기', '취소'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
title: 'Inkling',
|
||||
message: `휴지통의 노트 ${fullCount}개를 영구 삭제합니다`,
|
||||
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
|
||||
};
|
||||
const r = win
|
||||
? await dialog.showMessageBox(win, opts)
|
||||
: await dialog.showMessageBox(opts);
|
||||
if (r.response !== 0) return { confirmed: false, count: 0 };
|
||||
const result = await deps.capture.emptyTrash();
|
||||
return { confirmed: true, count: result.count };
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:listTrash', (_e, opts: { limit: number }) =>
|
||||
deps.repo.listTrashed(opts)
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed());
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ImportNoteInput {
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
tags: { name: string; source: 'ai' | 'user' }[];
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked';
|
||||
@@ -83,17 +84,25 @@ export class NoteRepository {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit));
|
||||
const rows = opts.cursor
|
||||
? (this.db
|
||||
.prepare(`SELECT * FROM notes WHERE created_at < ? ORDER BY created_at DESC, id DESC LIMIT ?`)
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE deleted_at IS NULL AND created_at < ?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(opts.cursor, limit) as any[])
|
||||
: (this.db
|
||||
.prepare(`SELECT * FROM notes ORDER BY created_at DESC, id DESC LIMIT ?`)
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(limit) as any[]);
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
listAll(): Note[] {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes ORDER BY created_at ASC, id ASC`)
|
||||
.prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
|
||||
.all() as any[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
@@ -221,6 +230,58 @@ export class NoteRepository {
|
||||
.run(date, now, id);
|
||||
}
|
||||
|
||||
trash(id: string, deletedAt: string): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(deletedAt, deletedAt, id);
|
||||
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id = ?`).run(id);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
restore(id: string): void {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`)
|
||||
.run(now, id);
|
||||
}
|
||||
|
||||
permanentDelete(id: string): void {
|
||||
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
|
||||
}
|
||||
|
||||
emptyTrash(): { noteIds: string[] } {
|
||||
// Single DELETE ... RETURNING is atomic by itself (no explicit transaction needed)
|
||||
// and avoids per-row prepare overhead. RETURNING is house-style elsewhere
|
||||
// (updateAiResult/updateUserAiFields/getAllPendingJobs).
|
||||
const rows = this.db
|
||||
.prepare('DELETE FROM notes WHERE deleted_at IS NOT NULL RETURNING id')
|
||||
.all() as Array<{ id: string }>;
|
||||
return { noteIds: rows.map((r) => r.id) };
|
||||
}
|
||||
|
||||
listTrashed(opts: { limit: number }): Note[] {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit));
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
|
||||
.all(limit) as any[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap COUNT for trash UI badge / bulk-empty dialog. Does not hydrate
|
||||
* tags/media — used in hot paths (loadInitial / refreshMeta / upsertNote
|
||||
* follow-ups) where listTrashed() is wasteful.
|
||||
*/
|
||||
countTrashed(): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`)
|
||||
.get() as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
|
||||
/** @deprecated v0.2.3 #4 부터 hard delete 는 permanentDelete() 사용. soft delete 는 trash(). 본 메서드는 v0.2.4 에서 제거 예정. */
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
|
||||
}
|
||||
@@ -239,6 +300,10 @@ export class NoteRepository {
|
||||
* - id present + raw_text identical → no-op (status: 'skipped')
|
||||
* - id present + raw_text differs → INSERT under fresh uuidv7
|
||||
* to preserve the raw_text-immutable invariant (status: 'forked')
|
||||
*
|
||||
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
|
||||
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
|
||||
* insert/fork 는 source 의 deletedAt 그대로 보존.
|
||||
*/
|
||||
importNote(input: ImportNoteInput): ImportNoteResult {
|
||||
const existing = this.findRawTextById(input.id);
|
||||
@@ -246,6 +311,16 @@ export class NoteRepository {
|
||||
let status: ImportNoteStatus = 'inserted';
|
||||
if (existing !== null) {
|
||||
if (existing === input.rawText) {
|
||||
// skip — source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존).
|
||||
// trash() 를 재사용해 pending_jobs cleanup invariant (§9.2) 도 동시에 만족.
|
||||
if (input.deletedAt != null) {
|
||||
const destRow = this.db
|
||||
.prepare('SELECT deleted_at FROM notes WHERE id=?')
|
||||
.get(input.id) as { deleted_at: string | null } | undefined;
|
||||
if (destRow && destRow.deleted_at === null) {
|
||||
this.trash(input.id, input.deletedAt);
|
||||
}
|
||||
}
|
||||
return { id: input.id, status: 'skipped' };
|
||||
}
|
||||
finalId = uuidv7();
|
||||
@@ -257,8 +332,8 @@ export class NoteRepository {
|
||||
`INSERT INTO notes
|
||||
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user,
|
||||
user_intent, intent_prompted_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
user_intent, intent_prompted_at, deleted_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
finalId,
|
||||
@@ -271,6 +346,7 @@ export class NoteRepository {
|
||||
input.summaryEditedByUser ? 1 : 0,
|
||||
input.userIntent,
|
||||
input.intentPromptedAt,
|
||||
input.deletedAt ?? null,
|
||||
input.createdAt,
|
||||
input.updatedAt
|
||||
);
|
||||
@@ -297,7 +373,9 @@ export class NoteRepository {
|
||||
|
||||
getPendingCount(): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`)
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending' AND deleted_at IS NULL`
|
||||
)
|
||||
.get() as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
@@ -319,7 +397,10 @@ export class NoteRepository {
|
||||
const startIso = new Date(kstMidnightUtc).toISOString();
|
||||
const endIso = new Date(nextKstMidnightUtc).toISOString();
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND created_at < ?`)
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS c FROM notes
|
||||
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?`
|
||||
)
|
||||
.get(startIso, endIso) as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
@@ -375,6 +456,9 @@ export class NoteRepository {
|
||||
intentPromptedAt: row.intent_prompted_at,
|
||||
dueDate: row.due_date ?? null,
|
||||
dueDateEditedByUser: row.due_date_edited_by_user === 1,
|
||||
deletedAt: row.deleted_at ?? null,
|
||||
lastRecalledAt: row.last_recalled_at ?? null,
|
||||
recallDismissedAt: row.recall_dismissed_at ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
tags: tags as NoteTag[],
|
||||
|
||||
@@ -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;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ export class MediaGc {
|
||||
|
||||
async run(): Promise<{ removed: number }> {
|
||||
const dirs = await this.store.listNoteDirs();
|
||||
// Intentionally does NOT filter `deleted_at IS NULL` — trashed notes still own
|
||||
// their media until permanentDelete/emptyTrash. Removing dirs of soft-deleted
|
||||
// notes here would defeat restore.
|
||||
const rows = this.db.prepare('SELECT id FROM notes').all() as Array<{ id: string }>;
|
||||
const known = new Set(rows.map((r) => r.id));
|
||||
let removed = 0;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,10 +20,22 @@ const AiFailedPayload = z.object({
|
||||
attempts: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const NoteIdPayload = z.object({
|
||||
noteId: z.string().min(1)
|
||||
}).strict();
|
||||
|
||||
const EmptyTrashPayload = z.object({
|
||||
count: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
export const TelemetryEventSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict()
|
||||
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict()
|
||||
]);
|
||||
|
||||
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,3 +215,236 @@ describe('NoteRepository', () => {
|
||||
expect(n).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.trash', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('sets deleted_at and removes pending_jobs row atomically', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
|
||||
repo.trash(id, '2026-05-01T12:00:00.000Z');
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.deletedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
|
||||
});
|
||||
|
||||
it('updates updated_at to deletedAt timestamp', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.trash(id, '2026-05-01T12:00:00.000Z');
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.updatedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('is no-op when note does not exist', () => {
|
||||
expect(() => repo.trash('nonexistent', '2026-05-01T12:00:00.000Z')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.restore', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('clears deleted_at on a trashed note', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.trash(id, '2026-05-01T12:00:00.000Z');
|
||||
repo.restore(id);
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('updates updated_at', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.trash(id, '2026-05-01T12:00:00.000Z');
|
||||
const before = repo.findById(id)!.updatedAt;
|
||||
repo.restore(id);
|
||||
const after = repo.findById(id)!.updatedAt;
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it('is no-op on already-active note', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
expect(() => repo.restore(id)).not.toThrow();
|
||||
expect(repo.findById(id)!.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.permanentDelete', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('removes notes row + cascades note_tags / pending_jobs', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.updateAiResult(id, { title: 'T', summary: 'a\nb\nc', tags: ['tag-a'], provider: 'p', dueDate: null });
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
|
||||
repo.permanentDelete(id);
|
||||
expect(repo.findById(id)).toBeNull();
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.emptyTrash', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('hard-deletes all trashed notes and returns their ids', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
repo.trash(c, '2026-05-01T01:00:00.000Z');
|
||||
const r = repo.emptyTrash();
|
||||
expect(r.noteIds.sort()).toEqual([a, c].sort());
|
||||
expect(repo.findById(a)).toBeNull();
|
||||
expect(repo.findById(b)).not.toBeNull();
|
||||
expect(repo.findById(c)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty array when trash is empty', () => {
|
||||
expect(repo.emptyTrash()).toEqual({ noteIds: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.listTrashed', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('returns trashed notes ordered by deleted_at DESC', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
repo.trash(c, '2026-05-01T02:00:00.000Z');
|
||||
repo.trash(b, '2026-05-01T01:00:00.000Z');
|
||||
const r = repo.listTrashed({ limit: 50 });
|
||||
expect(r.map((n) => n.id)).toEqual([c, b, a]);
|
||||
});
|
||||
|
||||
it('excludes active notes', () => {
|
||||
repo.create({ rawText: 'active' });
|
||||
const r = repo.listTrashed({ limit: 50 });
|
||||
expect(r).toEqual([]);
|
||||
});
|
||||
|
||||
it('respects limit', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = repo.create({ rawText: `n${i}` }).id;
|
||||
repo.trash(id, `2026-05-01T0${i}:00:00.000Z`);
|
||||
}
|
||||
const r = repo.listTrashed({ limit: 3 });
|
||||
expect(r).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.countTrashed', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('returns 0 when no trash', () => {
|
||||
repo.create({ rawText: 'active' });
|
||||
expect(repo.countTrashed()).toBe(0);
|
||||
});
|
||||
|
||||
it('counts only trashed notes', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.create({ rawText: 'b (active)' });
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
repo.trash(c, '2026-05-01T01:00:00.000Z');
|
||||
expect(repo.countTrashed()).toBe(2);
|
||||
});
|
||||
|
||||
it('returns count beyond listTrashed limit (no 200 cap drift)', () => {
|
||||
// listTrashed limit cap is 200; countTrashed must reflect actual count.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const id = repo.create({ rawText: `n${i}` }).id;
|
||||
repo.trash(id, `2026-05-01T${String(i).padStart(2, '0')}:00:00.000Z`);
|
||||
}
|
||||
expect(repo.countTrashed()).toBe(10);
|
||||
expect(repo.listTrashed({ limit: 5 })).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active queries exclude deleted notes', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('list() excludes trashed', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
const r = repo.list({ limit: 50 });
|
||||
expect(r.map((n) => n.id)).toEqual([b]);
|
||||
});
|
||||
|
||||
it('listAll() excludes trashed', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.create({ rawText: 'b' });
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
const r = repo.listAll();
|
||||
expect(r.map((n) => n.rawText)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('countToday() excludes trashed', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.create({ rawText: 'b' });
|
||||
repo.trash(a, new Date().toISOString());
|
||||
expect(repo.countToday(new Date())).toBe(1);
|
||||
});
|
||||
|
||||
it('findById() returns trashed notes (does NOT filter)', () => {
|
||||
const { id } = repo.create({ rawText: 'a' });
|
||||
repo.trash(id, '2026-05-01T00:00:00.000Z');
|
||||
const note = repo.findById(id);
|
||||
expect(note).not.toBeNull();
|
||||
expect(note!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('getPendingCount() excludes trashed pending notes (drift guard)', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id; // ai_status=pending
|
||||
repo.create({ rawText: 'b' }); // ai_status=pending
|
||||
expect(repo.getPendingCount()).toBe(2);
|
||||
// trash() 가 pending_jobs row 는 정리하지만 notes.ai_status 는 'pending' 그대로.
|
||||
// getPendingCount 가 deleted_at IS NOT NULL 노트 포함하면 영구 over-count.
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
expect(repo.getPendingCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
104
tests/unit/store.trash.test.ts
Normal file
104
tests/unit/store.trash.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user