Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33b539325a | ||
|
|
07892aea3c | ||
|
|
7b39c5a41c | ||
|
|
c6cd897d82 | ||
|
|
4cfaa31bc3 | ||
|
|
f9c7c960d5 | ||
|
|
eca91a1e7c | ||
|
|
fb17704570 | ||
|
|
d40880de5b | ||
|
|
da6d296b77 | ||
|
|
cc11ee8cad | ||
|
|
862da4f15a | ||
|
|
343624dceb | ||
|
|
a7a90b8701 | ||
|
|
274c171ee8 | ||
|
|
96174f84c9 | ||
|
|
1edc99cf2b | ||
|
|
8ffb2408e4 | ||
|
|
7b3450d0d5 | ||
|
|
53a1579266 | ||
|
|
9dfca6edf2 | ||
|
|
7aef46dc1a | ||
|
|
359d94e7e6 | ||
|
|
14ab135425 | ||
|
|
b9fec25b9d | ||
|
|
a0e6bc53b2 | ||
|
|
4d070bb6c7 | ||
|
|
d01cd5f350 | ||
|
|
4c39a38ed5 | ||
|
|
caa4728e21 | ||
|
|
7b943e2455 | ||
|
|
c99795c9e4 | ||
|
|
b860187b37 | ||
|
|
dbc0acbaf5 | ||
|
|
ce27f7c500 | ||
|
|
7418eb9363 | ||
|
|
906e9b6f7d | ||
|
|
2b5ba8a50e | ||
|
|
3c731cc754 | ||
|
|
352457189e | ||
|
|
a68feae20e | ||
|
|
64935d943c | ||
|
|
9cf6cafab2 | ||
|
|
d3bc972783 | ||
|
|
30b14d2b74 | ||
|
|
431b35a72a | ||
|
|
e34f036f20 | ||
|
|
c616555d7d | ||
|
|
218868206b | ||
|
|
b2be29bd33 | ||
|
|
4266376b23 | ||
|
|
bd71bba2da | ||
|
|
713553a038 | ||
|
|
d3cf018f62 | ||
|
|
f676c1638e |
194
CHANGELOG.md
194
CHANGELOG.md
@@ -3,6 +3,200 @@
|
||||
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
|
||||
|
||||
## [0.4.0] — 2026-05-15 (force re-tag, dogfood UX 2회 보완)
|
||||
|
||||
### 추가 dogfood UX 2회차 (2026-05-15 force re-tag)
|
||||
|
||||
dogfood 2일차 피드백 묶음. force re-tag 로 같은 v0.4.0 에 추가.
|
||||
|
||||
- **AI 정리하기 (default notebook batch 분류)** — 사이드바 default notebook 선택 시 main 영역 상단에 "🪄 AI 정리하기" 버튼. 클릭 → AI 가 default 의 active 노트들을 한 prompt 에 묶어 분석 → BatchMoveModal 에 noteId 별 추천 notebook + checkbox → 사용자 confirm 후 일괄 moveNote. top N=50 cap, 한국어 prompt, hallucinated notebook 이름은 null 로 coerce.
|
||||
- **노트북 순서 변경** — `notebooks.sort_order` 컬럼 (m009 마이그레이션) + NotebookList row hover 시 ↑↓ 버튼. 인접 sort_order swap. drag-drop 은 v0.5+ 후보.
|
||||
- **이동 UX 정리** — NoteCard 의 notebook chip 을 tag 영역 → footer 의 "이동" 버튼 옆으로 이동. tag(키워드) / notebook(컨텍스트) 의미 분리. dropdown 도 위쪽으로 펼침.
|
||||
|
||||
### 추가 dogfood UX 1회차 (2026-05-15)
|
||||
|
||||
- **NotebookChip 시각 강화** — 청색 배경 + 📓 아이콘 + ▾ caret + dropdown 헤더 "이동할 노트북". chip 클릭 시 다른 notebook 선택 dropdown 이 보이는 affordance 명확화. 다른 notebook 없으면 disabled.
|
||||
- **헤더 좌측 ☰ 햄버거 버튼** — 마우스 클릭으로 사이드바 토글 (Cmd/Ctrl+B 단축키와 동일).
|
||||
- **사이드바 default visible** — 새 사용자가 처음부터 사이드바 보이게 (`settings.getSidebarVisible` default false → true). 기존 사용자가 명시적으로 false 저장했다면 그 값 그대로.
|
||||
- **inboxWindow 기본 크기 확장** — 900×720 → 1200×800. 사이드바 240px 가 default 가시화되므로 main 영역 확보.
|
||||
|
||||
### 게이트 (2회차 후)
|
||||
|
||||
- 단위 877 PASS
|
||||
- typecheck 0 errors
|
||||
|
||||
---
|
||||
|
||||
### 원본 release (2026-05-15)
|
||||
|
||||
Notebooks + Lifecycle Simplification. 19일 dogfood 데이터 (archived 0건 / mlx-ops tag 가 사실상 컨텍스트 그룹 역할) 가 묶음 변경의 근거.
|
||||
|
||||
### 추가
|
||||
|
||||
- **Notebook 카테고리** — 좌측 사이드바 (Cmd+B / Ctrl+B 토글), 색 + count badge. notebooks 테이블 + notes.notebook_id FK RESTRICT. m008 마이그레이션이 default "기본" notebook 자동 생성 후 모든 기존 노트 배치.
|
||||
- **AI 자동 fit 매칭** — 매 capture 시 AI 가 prompt 의 notebooks 목록을 보고 best-fit 자동 배치 (응답의 notebook_match 필드). NoteCard 의 chip 으로 1-click 변경 가능.
|
||||
- **Promotion 제안** — 같은 tag 가 3건 이상 default notebook 에 누적되면 "새 notebook 으로 분리?" banner 표시. 24h snooze + 영구 dismiss 지원 (settings 의 promotion_snoozed_until_ms / promotion_dismissed_tags 영속화).
|
||||
- **사이드바 UX** — NotebookCreateModal (이름 + 6색 palette + 중복 검증), NotebookList (선택 highlight + count), 모달/사이드바 toggle 단축키.
|
||||
- **검색 scope 토글** — "이 노트북" / "모든 노트북" dropdown.
|
||||
|
||||
### 변경
|
||||
|
||||
- **lifecycle 3분기** — `archived` status 제거. 헤더 탭이 Inbox / 완료 / 휴지통 (3탭). MoveStatusModal 의 "보관" 옵션도 제거. classifyStatus 의 AI 응답이 archived 면 completed 로 coerce. ImportService 가 backward compat 위해 archived 노트 import 시 completed 로 변환.
|
||||
- **NoteRepository.findExpiredCandidates** 의 scope 가 inbox 만 → notebook 별 scope (Task 4 의 countByStatus 옵션 등 통합 효과).
|
||||
- selectedNotebookId 변경 시 list / counts 자동 refresh.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 테스트 851 PASS (m008 마이그레이션 / NotebookRepository / AI prompt + schema / AiWorker matching / store + settings 영속화 / Sidebar / NotebookCreateModal / PromotionBanner / NoteCard chip / SearchBox scope / archived 제거 회귀 가드).
|
||||
- typecheck 0 errors.
|
||||
- 신규 npm dependency 0.
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.14 인스톨러 위에 v0.4.0 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. m008 마이그레이션이 첫 launch 시 자동 실행되어 default notebook "기본" 생성 + 모든 기존 노트 배치.
|
||||
|
||||
## [0.3.14] — 2026-05-12 (force re-tag: 2026-05-14, 추가 dogfood fixes 7건)
|
||||
|
||||
### 추가 dogfood fixes (2026-05-14 force re-tag)
|
||||
|
||||
force re-tag 로 같은 v0.3.14 안에 묶인 후속 변경. 새 minor 안 늘리고 동일 release notes 확장.
|
||||
|
||||
- **fix(capture): QuickCapture blur-on-hide 제거.** 다른 창 클릭해도 ESC / Cmd+Enter 까지 창 유지. alwaysOnTop + screen-saver level 로 다른 앱 위에 떠 있음.
|
||||
- **chore(ux): macOS 사용자 위해 Cmd 키 hint 안내.** Inbox empty state 와 QuickCapture 하단 hint 가 platform-aware 로 분기 — Mac 에선 `Cmd+Shift+J` / `Cmd+Enter`.
|
||||
- **fix(macos): hidden autostart dock indicator + autostart mismatch false positive.** ① LoginItems `--hidden` spawn 시 NSPanel 만 떠 있어 dock 점 안 보이던 문제 — inboxWindow 를 hidden 으로 미리 create + `showInactive → hide` trick 으로 NSApp register. ② SettingsPage 의 자동실행 mismatch 경고가 macOS 13+ SMAppService 한계로 false positive 떠 있던 문제 — willLaunch 신호는 win32 에서만 mismatch 판정에 사용.
|
||||
- **feat(notes): 원문 편집/이력 복원 시 AI 재처리.** `NoteRepository.markAiPendingForReprocess` 신설 — done/failed/pending 노트를 pending reset + pending_jobs 재투입. disabled 는 사용자 의도 존중 no-op. NoteCard.saveRaw 가 optimistic 으로 `aiStatus='pending'` 표시. updateAiResult 의 user-edit 가드로 사용자가 직접 편집한 필드는 보존.
|
||||
- **feat(expiry): 마감 알림 inbox 제한 + 오늘 당일 포함 + 헤딩/라벨/메모 바로가기.** ① `findExpiredCandidates` 가 `due_date <= today` + `status='active'` 로 변경, 완료/보관 노트는 제외. 정렬도 `due_date DESC` 로 오늘 → 어제 순. ② ExpiryBanner 헤딩 분리 카운트 "오늘 마감 X · 지난 Y", 노트 라벨 [오늘] / [N일 지남]. ③ 노트 제목 클릭 → `note-{id}` smooth scroll.
|
||||
- **fix(sync): manifest.exported_at 제거 — no-op push 회피.** 노트 변경 0건이어도 매 sync 마다 timestamp 1줄 commit + push 가 쌓이던 문제. `composeManifest` 에서 cosmetic 필드 제거. 이제 진짜 변경 있을 때만 commit.
|
||||
- **feat(settings): SectionIntro + SyncHelpModal 풀어쓰기.** 설정 페이지 6 section 상단에 1-2 문장 안내. SyncHelpModal 의 기술 용어 (rebase, fast-forward, NTP) 를 사용자 언어로 풀어쓰기.
|
||||
|
||||
### 게이트 (추가 fix 후)
|
||||
|
||||
- 단위 763 PASS
|
||||
- typecheck 0 errors
|
||||
|
||||
---
|
||||
|
||||
### 원본 release (2026-05-12)
|
||||
|
||||
AI 처리 fail 원인 가시화. 이전엔 ai_error 가 NoteCard tooltip (title attribute) 에만 있어 사용자가 마우스 오버해야 보이는 데다 raw 메시지만 노출 → 무엇이 fail 했는지 불명.
|
||||
|
||||
### 수정
|
||||
|
||||
- **NoteCard failed 노트에 "원인 보기" 접힘 섹션 (P1).** `<details>` summary 클릭하면 `<pre>` 로 `ai_error` 전체 노출. wrap + word-break 적용. 사용자가 직접 메시지를 보고 모델/네트워크/JSON 등 fail 카테고리 진단 가능.
|
||||
- **`ai_error` 에 reason + provider name prefix 추가.** AiWorker 의 markAiFailed 시 `[schema|other] local-ollama/gemma4:26b\n<원본 message>` 형식. 사용자가 어느 카테고리에서, 어느 모델로 실패했는지 즉시 식별. log 의 ai.failed 에도 reason/provider 필드 함께 출력.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 752 PASS (ai_error 포맷 변경은 test 영향 없음 — 기존 test 가 정확한 prefix 매칭 안 함)
|
||||
- typecheck 0 errors
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 사용자 안내
|
||||
|
||||
이미지 AI 처리가 fail 한다면 NoteCard 의 "정리 보류" 옆 "원인 보기" 클릭 → 표시되는 메시지로:
|
||||
- `[timeout] ...` → vision 모델 cold-start 가 5분 초과. `ollama run gemma4:26b` 으로 한번 warm-up 후 재시도
|
||||
- `[schema] title must contain Korean characters` → vision 모델이 영어 title 반환. prompt 가 한국어 강조했지만 일부 모델은 여전히 영어. `gemma3:27b` 등 다른 vision 모델로 대체 고려
|
||||
- `[schema] unparseable response: ...` → vision 모델 JSON 출력 안 따름. v0.3.12 의 loose parse 가 실패한 경우
|
||||
- `[other] missing response field` → Ollama 가 빈 응답 반환. 모델 자체 문제
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.13 인스톨러 위에 v0.3.14 인스톨러를 같은 위치에 실행하면 in-place 업그레이드.
|
||||
|
||||
## [0.3.13] — 2026-05-12
|
||||
|
||||
대형 vision 모델 (gemma4:26b 등) 의 cold-start timeout 으로 인한 AI 처리 실패 fix.
|
||||
|
||||
### 수정
|
||||
|
||||
- **Vision generate 의 timeout 확장 120s → 300s (P1).** `gemma4:26b` (25B MoE 가중치 + vision encoder 550M) 같은 대형 vision 모델은 첫 generate 시 모델 load + 이미지 encoding 으로 60-180s 소요. 기본 120s timeout 으로 첫 호출 시 abort → fail 빈번. vision path 에 한해 `Math.max(timeoutMs, 300_000)` 적용 (text-only path 영향 없음).
|
||||
|
||||
확인: gemma4 family 는 [공식 release](https://blog.google/innovation-and-ai/technology/developers-tools/gemma-4/) — 26B variant 가 Text+Image 양 modality 지원 ([ollama library](https://ollama.com/library/gemma4:26b)). 본 코드의 `VisionDetect` 가 'gemma4' family 인식하므로 사용자가 settings → Vision 섹션에서 선택 가능.
|
||||
|
||||
### 사용자 안내
|
||||
|
||||
이미지 AI 처리가 여전히 실패한다면:
|
||||
1. 설정 → AI 제공자 → Vision 섹션에서 `gemma4:26b` (또는 vision-capable 모델) 가 선택돼있는지 확인
|
||||
2. `ollama list` 로 모델 실제 설치 여부 확인 (`ollama pull gemma4:26b` 필요)
|
||||
3. NoteCard 의 failed 노트 텍스트 위에 마우스 오버 → tooltip 의 `ai_error` 확인 (구체 fail mode 진단)
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 752 PASS (timeout 상수만 변경 — 회귀 없음)
|
||||
- typecheck 0 errors
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.12 인스톨러 위에 v0.3.13 인스톨러를 같은 위치에 실행하면 in-place 업그레이드.
|
||||
|
||||
## [0.3.12] — 2026-05-12
|
||||
|
||||
이미지 AI 처리 실패 fix. vision model 의 응답이 strict JSON 이 아닌 경우 (markdown fence / prose 섞임) 가 흔해 schema parse 단계에서 throw → `ai_status='failed'` 도달.
|
||||
|
||||
### 수정
|
||||
|
||||
- **Vision model 응답 JSON loose parse (P1).** `LocalOllamaProvider.generate` 의 `JSON.parse` 가 strict 라 vision-tuned 모델의 markdown 코드 펜스 / 앞뒤 prose 응답에서 throw. `parseJsonLoose` 헬퍼 추가 — 첫 `{` ~ 마지막 `}` substring 추출 fallback. 실패 시 raw response 200자 snippet 포함한 에러 throw (디버깅 가시화).
|
||||
- **Vision prompt 강화 (P1).** `buildVisionPrompt` 가 "title 한국어로 요약" 만 명시 → schema 의 KOREAN_REGEX/KEBAB_REGEX 강제와 불일치. 규칙 4건 명시: title 한국어 60자, summary 3줄, tags 영문 kebab-case 3개, due_date ISO 또는 null. "markdown 코드 펜스 금지" 명시로 JSON-only 출력 강제.
|
||||
|
||||
알려진 한계:
|
||||
- `gemma4:26b` 같이 Ollama 에 실제로 release 안 된 모델명 사용 시 healthCheck 통과해도 generate 가 unknown model 로 throw. 모델 설치 여부는 사용자가 `ollama list` 확인 필요.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 750 → **752 PASS** (+2: markdown fence 추출 + prose 추출)
|
||||
- typecheck 0 errors
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.11 인스톨러 위에 v0.3.12 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [0.3.11] — 2026-05-12
|
||||
|
||||
붙여넣은 이미지 / 저장된 이미지가 양쪽 창에서 표시 안 되던 CSP 누락 hotfix.
|
||||
|
||||
### 수정
|
||||
|
||||
- **QuickCapture: paste 이미지 thumbnail 렌더 실패.** `quickcapture/index.html` 의 CSP 가 `img-src` 미지정 → `default-src 'self'` fallback → `URL.createObjectURL` 의 `blob:` URL 차단. `img-src 'self' data: blob:` 추가.
|
||||
- **Inbox: 저장된 노트 이미지 렌더 실패.** `inbox/index.html` 의 CSP `img-src 'self' data: blob: file:` 가 `inkling-media:` 미허용 → `NoteCard` 의 `<img src="inkling-media://media/..." />` 차단 (custom protocol 자체는 main 에서 등록됐지만 renderer CSP 별도). `inkling-media:` 추가.
|
||||
|
||||
v0.3.0 (Cut A 이미지 첨부) 이후 양쪽 창에서 paste/render 가 잠재적으로 깨져있던 회귀. 사용자가 dogfood 중 발견.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 750 PASS (CSP meta tag 만 변경 — 코드 path 영향 없음)
|
||||
- typecheck 0 errors
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.10 인스톨러 위에 v0.3.11 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [0.3.10] — 2026-05-12
|
||||
|
||||
macOS fullscreen 환경에서 QuickCapture 핫키 (Cmd+Shift+J) 가 작동하지만 강제로 홈 데스크탑으로 Space 전환 후 표시되던 버그 fix.
|
||||
|
||||
### 수정
|
||||
|
||||
- **macOS fullscreen Space 위에 QuickCapture 표시 (P1).** 기본 BrowserWindow 는 첫 생성된 Space (홈 데스크탑) 에만 표시 → 사용자가 다른 앱 fullscreen 중에 핫키 누르면 macOS 가 강제로 Space 전환 → 사용자 흐름 단절. `quickCaptureWindow.ts` 에 darwin 분기 추가:
|
||||
- `type: 'panel'` — fullscreen Space 위에 floating 가능한 macOS native panel
|
||||
- `setAlwaysOnTop(true, 'screen-saver')` — fullscreen app 위에 띄울 수 있는 가장 높은 level
|
||||
- `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })` — 현재 Space (fullscreen 포함) 에 함께 표시, Space 전환 차단
|
||||
|
||||
Windows / Linux 동작은 변경 없음 (darwin 분기만).
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 750 PASS (변경 없음 — main window 코드는 단위 테스트 대상 아님)
|
||||
- typecheck 0 errors (src)
|
||||
- 신규 npm dependency 0
|
||||
- macOS 사용자 수동 검증 완료
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.9 인스톨러 위에 v0.3.10 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [0.3.9] — 2026-05-11
|
||||
|
||||
v0.3.8 audit 의 미수정 edge case 3건 완료. AI 처리 흐름의 사용자 unblock path + FTS5 query 안전성.
|
||||
|
||||
2168
docs/superpowers/plans/2026-05-14-v04-notebooks-lifecycle.md
Normal file
2168
docs/superpowers/plans/2026-05-14-v04-notebooks-lifecycle.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
# v0.3.2 — Cut G Design (사이드바 + notebook 카테고리)
|
||||
|
||||
> **[Deprecated 2026-05-14]** 본 cut 은 [v0.4 design](2026-05-14-v04-notebooks-lifecycle-design.md) 으로 승격되며 lifecycle 단순화 (archived 제거) 를 함께 다룸. dogfood 데이터 (archived 0건 / 19일) 가 archived 차원의 dead code 임을 입증해 묶음 변경이 정신 부담 측면에서 더 정합. 본 문서는 history 참조용으로 유지.
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
# v0.4 — Notebooks + Lifecycle Simplification Design
|
||||
|
||||
**작성일:** 2026-05-14
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` F25 (raw idea)
|
||||
- `docs/superpowers/specs/2026-05-09-v032-cut-g-design.md` (v0.3.2 Cut G — **deprecate**, 본 문서로 승격)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut G position
|
||||
|
||||
---
|
||||
|
||||
## 1. 정체성
|
||||
|
||||
inbox 분류 layer 도입 + lifecycle 단순화. 단일 cut 으로 묶음 — 두 변경이 같은 UI 영역 (헤더 탭 + status 모델) 을 동시 손대므로 분리 시 중간 상태가 어색.
|
||||
|
||||
- **분류 (notebook)**: cross-cutting 컨텍스트. "회사" / "개인" / "학습" 같은 공간 구분. 단일 DB 안 `notebook_id` 컬럼 (옵션 B — Cut G 결정 승계).
|
||||
- **lifecycle 단순화**: status 4분기 → 3분기. `archived` 제거.
|
||||
|
||||
---
|
||||
|
||||
## 2. dogfood 근거 (왜 이 시점에 이 cut)
|
||||
|
||||
2026-04-25 ~ 2026-05-14 (19일) telemetry + DB:
|
||||
|
||||
| 신호 | 수치 | 의미 |
|
||||
|------|------|------|
|
||||
| `archived` 노트 | 0건 | 4분기 lifecycle 중 1차원 dead — 사용자가 한 번도 사용 안 함 |
|
||||
| `active` 노트 | 10건 | 사용 중 |
|
||||
| `completed` 노트 | 8건 | 적극 사용 |
|
||||
| `trashed` 노트 | 20건 | 적극 사용 |
|
||||
| top tag `mlx-ops` | 6건 | 단일 tag 가 사실상 "MLX팀 메모" 컨텍스트 그룹 역할 — tag 의 분류 욕구 명확 |
|
||||
| tag vocab 적중률 | 32.8% (19/58) | 새 tag 빈번 생성 — 분류 욕구 일관성 부족, grouping UI 필요 |
|
||||
|
||||
→ **archived 는 제거 안전** (마이그레이션 영향 0건). **분류 욕구는 데이터로 확인**되었으나 tag 만으로 충족 어려움 (적중률 낮음).
|
||||
|
||||
사용자 우려 ("분류 × lifecycle 햇갈림") 의 대응: archived 제거로 한 차원 줄임. 결과 layer = (notebook × 3분기 × tag) — 3차원이지만 archived 0건 빼서 정신 부담 줄음.
|
||||
|
||||
---
|
||||
|
||||
## 3. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|------|------|
|
||||
| **분류 모델** | 옵션 B — `notebook_id` 카테고리, 단일 DB. 옵션 A (다중 profile + 비밀번호 잠금) 는 v0.5+ 후보. |
|
||||
| **lifecycle 차원** | 3분기 — `active` (inbox) / `completed` (완료) / `trashed` (휴지통). `archived` 제거. |
|
||||
| **default notebook** | 마이그레이션 시 "기본" 1개 자동 생성. 모든 기존 노트 배치. |
|
||||
| **사이드바 가시성** | 사용자 토글 + last state (`settings.sidebar_visible`). Cmd+B / Ctrl+B 단축키. |
|
||||
| **사이드바 내용** | 상단 notebook 목록 + 하단 메모 list (compact view, current notebook + current view 필터). |
|
||||
| **사이드바 폭** | default 240px, `settings.sidebar_width` 조정 가능 (180-400). |
|
||||
| **notebook 삭제** | FK RESTRICT — 메모 잔류 시 throw. UI 가 "메모 N건 이동 후 재시도" 안내. |
|
||||
| **search scope** | 기본 current notebook, 사용자가 dropdown 으로 "모든 노트북" 전환 가능. |
|
||||
| **새 capture 의 notebook** | current `selectedNotebookId`. 사용자가 다른 notebook 에 있으면 거기 저장. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema 마이그레이션 (m008)
|
||||
|
||||
```sql
|
||||
-- 1. notebooks 테이블 생성
|
||||
CREATE TABLE notebooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_notebooks_name ON notebooks(name);
|
||||
|
||||
-- 2. notes.notebook_id 추가 (FK, NOT NULL — default 통해 보장)
|
||||
ALTER TABLE notes ADD COLUMN notebook_id TEXT
|
||||
REFERENCES notebooks(id) ON DELETE RESTRICT;
|
||||
|
||||
-- 3. default notebook "기본" 생성
|
||||
INSERT INTO notebooks (id, name, created_at, updated_at)
|
||||
VALUES ('<uuidv7>', '기본', '<now>', '<now>');
|
||||
|
||||
-- 4. 모든 기존 노트를 default notebook 에 배치
|
||||
UPDATE notes SET notebook_id = '<default-id>' WHERE notebook_id IS NULL;
|
||||
|
||||
-- 5. NOT NULL 제약 추가 (마이그레이션 후)
|
||||
-- SQLite ALTER 제약: 새 테이블 만들고 복사 패턴 사용 (better-sqlite3 표준)
|
||||
|
||||
-- 6. archived → completed 정리 (실제 0건이라 no-op, 안전 위해 명시)
|
||||
UPDATE notes SET status = 'completed' WHERE status = 'archived';
|
||||
|
||||
-- 7. status CHECK constraint 갱신: archived 제거
|
||||
-- 마찬가지로 새 테이블 만들고 복사 패턴
|
||||
```
|
||||
|
||||
마이그레이션 안전성: 기존 archived 노트 0건 확인 (telemetry + DB query). default notebook 자동 생성으로 기존 사용자 영향 없음.
|
||||
|
||||
---
|
||||
|
||||
## 5. NotebookRepository
|
||||
|
||||
```ts
|
||||
interface Notebook {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
noteCount: number; // active 노트만 (trashed 제외)
|
||||
}
|
||||
|
||||
class NotebookRepository {
|
||||
list(): Notebook[]; // count 포함, name ASC
|
||||
findById(id: string): Notebook | null;
|
||||
create(input: { name: string; color?: string }): Notebook;
|
||||
rename(id: string, name: string): void; // UNIQUE name violation throw
|
||||
setColor(id: string, color: string | null): void;
|
||||
delete(id: string): { ok: true } | { ok: false; reason: 'has_notes' };
|
||||
// 메모 잔류 시 FK RESTRICT throw → ok:false 변환
|
||||
|
||||
moveNote(noteId: string, notebookId: string): void;
|
||||
countByNotebook(): Map<string, number>; // 한번 호출로 전체 count
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UI — 사이드바
|
||||
|
||||
```
|
||||
┌───────────────┬─────────────────────────────────────┐
|
||||
│ [≡] Inkling │ [Inbox(N) 완료(N) 휴지통(N)] [🔍] [⚙] │
|
||||
├───────────────┼─────────────────────────────────────┤
|
||||
│ ● 기본 (5) │ │
|
||||
│ ● 회사 (12) │ NoteCard list (main pane) │
|
||||
│ ● 학습 (3) │ │
|
||||
│ + 새 노트북 │ │
|
||||
│ ───── │ │
|
||||
│ 메모 빠른 list │ │
|
||||
│ ・제목1 │ │
|
||||
│ ・제목2 #tag │ │
|
||||
│ ・제목3 │ │
|
||||
└───────────────┴─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 좌측, 폭 240px (사용자 조정 가능)
|
||||
- 헤더 좌측 `≡` 클릭 → 토글. 단축키 `Cmd+B` / `Ctrl+B`
|
||||
- 상단: notebook 목록 (active 노트 count badge). 클릭 → `selectedNotebookId` 변경
|
||||
- "+ 새 노트북" 클릭 → NotebookCreateModal (name + optional color)
|
||||
- 노트북 우클릭 / hover icon → rename / color 변경 / delete
|
||||
- 하단: 메모 list — `selectedNotebookId` + `selectedView` (status 탭) 필터 결과의 compact view. 클릭 → main pane 의 noteCard 로 scroll.
|
||||
|
||||
---
|
||||
|
||||
## 7. UI — lifecycle 단순화
|
||||
|
||||
| 변경 | 내용 |
|
||||
|------|------|
|
||||
| 헤더 탭 | "Inbox / 완료 / 보관 / 휴지통" → **"Inbox / 완료 / 휴지통"** (3탭) |
|
||||
| MoveStatusModal | "보관" 옵션 제거. 사용자 선택지: "완료" / "휴지통" (또는 "복원" — trash mode 에서) |
|
||||
| countsByStatus IPC | `archived` 필드 제거. 기존 호출자 (헤더 badge) 적용 |
|
||||
| NoteRepository.setStatus | `archived` 인자 받으면 throw (defensive). 마이그레이션 후 호출자 없음 |
|
||||
| 데이터 호환성 | 기존 archived 노트 0건이라 마이그레이션 단계에서 `completed` 로 일괄 이동 |
|
||||
|
||||
---
|
||||
|
||||
## 8. store / IPC
|
||||
|
||||
```ts
|
||||
interface InboxStore {
|
||||
notebooks: Notebook[];
|
||||
selectedNotebookId: string | null; // null = "모든 노트북" 검색 결과 등 특수 모드
|
||||
sidebarVisible: boolean;
|
||||
sidebarWidth: number;
|
||||
loadNotebooks: () => Promise<void>;
|
||||
selectNotebook: (id: string) => void;
|
||||
createNotebook: (name: string, color?: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
renameNotebook: (id: string, name: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
setNotebookColor: (id: string, color: string | null) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
moveNoteToNotebook: (noteId: string, notebookId: string) => Promise<void>;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
IPC channels (신설):
|
||||
- `notebook:list` / `notebook:create` / `notebook:rename` / `notebook:set-color` / `notebook:delete`
|
||||
- `notebook:move-note`
|
||||
|
||||
기존 변경:
|
||||
- `inbox:list` / `inbox:list-by-status` 에 `notebookId?` 옵션 추가 (없으면 모든 노트북)
|
||||
- `inbox:counts-by-status` 도 `notebookId?` 옵션 + `archived` 필드 제거
|
||||
|
||||
---
|
||||
|
||||
## 9. search 통합
|
||||
|
||||
- 기본 scope: `selectedNotebookId` 안 검색
|
||||
- search box 옆 dropdown: "이 노트북" / "모든 노트북"
|
||||
- `inbox:search` 에 `notebookId?` 옵션 추가
|
||||
- search 결과 NoteCard 가 notebook 표시 (모든 노트북 검색 시) — 작은 색 chip + 이름
|
||||
|
||||
---
|
||||
|
||||
## 10. sync 영향 (Cut E 이후)
|
||||
|
||||
- export 시 frontmatter 에 `notebook` 필드 추가 (이름 — 다기기 간 id 충돌 회피)
|
||||
- import 시 notebook 이름이 없으면 생성, 있으면 reuse
|
||||
- 충돌: 같은 이름 + 다른 id 면 import side 의 id 우선 (deterministic)
|
||||
|
||||
---
|
||||
|
||||
## 11. AI × Notebook 통합
|
||||
|
||||
### 11-1. fit 매칭 (매 capture)
|
||||
|
||||
- `buildPrompt` 가 현재 notebooks 목록 (이름) 을 prompt 에 포함
|
||||
- AI 응답 JSON 에 `notebook_match` 필드 (기존 notebook 이름 또는 null) 추가
|
||||
- schema (Zod) 갱신: `notebook_match: z.string().nullable().optional()`
|
||||
- 매치 성공 시 자동 배치 (Zero-Effort 가치 우선). NoteCard 의 notebook chip 클릭으로 1-click 변경 가능
|
||||
- 매치 실패 / null → default "기본" notebook
|
||||
|
||||
prompt 추가 라인 예시 (한국어 자연어):
|
||||
|
||||
```
|
||||
사용 가능한 노트북: 기본, 회사, 학습
|
||||
이 노트가 위 노트북 중 하나에 명확히 속하면 그 이름을 "notebook_match" 에 반환. 그렇지 않으면 null.
|
||||
새 노트북 이름을 만들지 말 것 — 기존 목록 안에서만 선택.
|
||||
```
|
||||
|
||||
### 11-2. promotion 제안
|
||||
|
||||
**trigger** — 같은 tag 가 **active 노트 안 3건 이상** 누적되고 그 노트들이 모두 default "기본" notebook 에 있을 때 (`active` + `notebook_id = default` + 동일 tag).
|
||||
|
||||
분석 timing — inbox 열릴 때 lazy. 단순 SQL aggregation 이라 cost 무시 가능.
|
||||
|
||||
```sql
|
||||
SELECT t.name, COUNT(DISTINCT n.id) AS cnt
|
||||
FROM tags t
|
||||
JOIN note_tags nt ON nt.tag_id = t.id
|
||||
JOIN notes n ON n.id = nt.note_id
|
||||
WHERE n.status = 'active'
|
||||
AND n.notebook_id = '<default-id>'
|
||||
GROUP BY t.id
|
||||
HAVING cnt >= 3
|
||||
```
|
||||
|
||||
매치 발생 시 InboxStore 에 `promotionCandidate: { tag, noteIds, suggestedName }` 채움. banner 가 표시.
|
||||
|
||||
**v0.4 first release 는 rule only** — LLM 의 semantic cluster 분석은 cost 큼. dogfood 결과 보고 v0.5+ 검토.
|
||||
|
||||
### 11-3. UI — PromotionBanner
|
||||
|
||||
- Inbox 상단, RecallBanner / ExpiryBanner 와 같은 위치
|
||||
- 문구 예: "💡 `mlx-ops` 관련 노트 6개가 모였어요. 새 노트북 **MLX Ops** 로 분리할까요?"
|
||||
- 액션: `[수락]` `[나중에]` `[숨기기]`
|
||||
- 수락 → 작은 modal 띄움 (이름 inline 수정 + color 선택) → notebook 생성 + 해당 noteIds 일괄 이동 + 사이드바 자동 열어 새 notebook 가시화
|
||||
- 나중에 → 24h snooze (RecallBanner 패턴)
|
||||
- 숨기기 → 해당 tag 영구 dismiss (`settings.promotion_dismissed_tags` array)
|
||||
|
||||
### 11-4. notebook 이름 generation
|
||||
|
||||
- default: tag 이름을 Title Case 변환 (예: `mlx-ops` → `MLX Ops`, `user-management` → `User Management`)
|
||||
- 사용자가 수락 modal 에서 inline 수정 가능
|
||||
|
||||
### 11-5. promotion 후 status 보존
|
||||
|
||||
- 이동된 노트의 `status` 그대로 유지 (active → active, completed → completed)
|
||||
- `notebook_id` 만 변경
|
||||
|
||||
### 11-6. 새 노트의 자동 fit 매칭이 promoted notebook 도 인식
|
||||
|
||||
- promotion 후 notebooks 목록에 새 notebook 포함 → 다음 capture 의 prompt 에 자동 반영
|
||||
- 그 후 같은 주제 노트는 자동으로 새 notebook 에 들어감
|
||||
|
||||
---
|
||||
|
||||
## 12. 테스트 전략
|
||||
|
||||
| 영역 | 케이스 |
|
||||
|------|--------|
|
||||
| NotebookRepository | CRUD + count + delete RESTRICT |
|
||||
| migration m008 | default notebook 생성 + 기존 노트 마이그레이션 + archived → completed |
|
||||
| store actions | 토글 + 선택 변경 + create/rename/delete 흐름 |
|
||||
| UI 사이드바 | notebook 목록 render + 클릭 selectedNotebookId 변경 + count badge |
|
||||
| 메모 list 필터 | notebook + view 필터 combination |
|
||||
| search scope | current / all 옵션별 결과 |
|
||||
| MoveStatusModal | archived 옵션 부재 |
|
||||
| 헤더 탭 | 3탭 + count |
|
||||
| AI prompt notebook_match | prompt 에 notebooks 목록 포함 + AI 응답 schema 의 notebook_match 필드 |
|
||||
| capture 자동 fit | AI 응답 notebook_match 매치 시 자동 배치, 미매치 시 default |
|
||||
| promotion query | tag 3건 이상 + default notebook 클러스터 검출 |
|
||||
| PromotionBanner | 수락 → notebook 생성 + 일괄 이동 / 나중에 → 24h snooze / 숨기기 → tag dismiss 영구 저장 |
|
||||
|
||||
---
|
||||
|
||||
## 13. risks
|
||||
|
||||
| risk | 대응 |
|
||||
|------|------|
|
||||
| migration 의 status check constraint 갱신 실패 | SQLite 새 테이블 복사 패턴 + 검증 query 후 commit |
|
||||
| 좁은 화면 (1280×720) 에서 사이드바 부담 | settings.sidebar_visible default false (사용자가 켜야 보임) |
|
||||
| notebook 삭제 시 RESTRICT error UX | "메모 N건 이동 후 다시 시도" + 이동 dialog 제공 |
|
||||
| sync (Cut E) 와 결합 시 notebook 정합성 | frontmatter notebook 이름 기반 — 다기기 간 ID 충돌 회피 |
|
||||
| tag 와 notebook 의미 혼동 | UI text 명시: "노트북 = 컨텍스트 (회사 / 개인)", "태그 = 주제 키워드 (mlx-ops, keycloak)" — 설정 페이지 도움말에 안내 |
|
||||
| 사용자가 다중 notebook 실제 안 만들면 default 1개 + 사이드바 = noise | default sidebar hidden + 사용자가 2번째 notebook 만들 때 자동 reveal hint |
|
||||
| AI 가 notebook_match 에 새 이름 ad-hoc 생성 시도 | prompt 에 "기존 목록 안에서만 선택, 새 이름 만들지 말 것" 명시 + schema 검증 단계에서 notebooks 목록과 매치 안 되는 값은 null 로 coerce |
|
||||
| 같은 tag 의 promotion 이 반복 노출되어 사용자 피로 | "숨기기" → 영구 dismiss (settings 의 array). RecallBanner 의 24h snooze 와 별개 영구 저장소 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 게이트
|
||||
|
||||
- 단위 테스트 — notebook CRUD + migration + UI 사이드바 + 메모 list 필터 + status 3분기 회귀 + AI fit/promotion 분기
|
||||
- 마이그레이션 verify — fresh DB + 기존 DB 두 시나리오에서 default notebook 생성 + archived 정리
|
||||
- dogfood — 2-3 일 후 (1) 사이드바 열려있는 비율, (2) notebook 갯수 (수동 vs promoted), (3) notebook 간 메모 이동 빈도, (4) promotion 수락률 측정
|
||||
|
||||
---
|
||||
|
||||
## 15. 작업 분해 (writing-plans 입력)
|
||||
|
||||
1. **m008 마이그레이션** — notebooks 테이블 + notes.notebook_id + archived 정리 + status enum
|
||||
2. **NotebookRepository** + IPC 핸들러
|
||||
3. **NoteRepository** 변경 — notebook_id 필터, list/search/counts 옵션 확장
|
||||
4. **store** — notebooks state + actions + promotionCandidate state
|
||||
5. **AI prompt 변경** — buildPrompt 에 notebooks 목록 주입, schema 의 notebook_match 필드, AiWorker 가 응답 매치하여 자동 배치 (또는 default 로 coerce)
|
||||
6. **promotion 분석** — NoteRepository.findPromotionCandidates 쿼리 + store action (inbox open 시 lazy)
|
||||
7. **PromotionBanner UI** — Inbox 상단, 수락 modal, 24h snooze, 영구 dismiss
|
||||
8. **사이드바 UI** — Sidebar.tsx + NotebookList + NotebookCreateModal
|
||||
9. **헤더 / MoveStatusModal** — archived 제거
|
||||
10. **search** — scope 옵션 통합
|
||||
11. **sync (옵션 deferred)** — frontmatter notebook 필드. v0.4 본체 머지 후 별도 작업으로 가능
|
||||
12. **CHANGELOG + release notes** — dogfood 근거 명시
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.14",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.3.9",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { NotebookRepository } from '../repository/NotebookRepository.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { AiFailedReason } from '../services/telemetryEvents.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
@@ -48,6 +49,8 @@ export interface AiWorkerOptions {
|
||||
settings?: Pick<SettingsService, 'getVisionModel'>;
|
||||
/** v0.3.1 Cut F — 첨부 이미지 절대경로 변환. settings 와 함께 전달 시 vision 활성. */
|
||||
mediaStore?: Pick<MediaStore, 'absolutePath'>;
|
||||
/** v0.4 — AI 응답의 notebook_match 처리용. 미전달 시 notebook 매칭 skip. */
|
||||
notebookRepo?: Pick<NotebookRepository, 'list' | 'findByName' | 'moveNote'>;
|
||||
}
|
||||
|
||||
interface Job { noteId: string; attempts: number; }
|
||||
@@ -65,6 +68,7 @@ export class AiWorker {
|
||||
private telemetry?: AiTelemetryEmitter;
|
||||
private settings?: Pick<SettingsService, 'getVisionModel'>;
|
||||
private mediaStore?: Pick<MediaStore, 'absolutePath'>;
|
||||
private notebookRepo?: Pick<NotebookRepository, 'list' | 'findByName' | 'moveNote'>;
|
||||
|
||||
constructor(
|
||||
private repo: NoteRepository,
|
||||
@@ -79,6 +83,7 @@ export class AiWorker {
|
||||
this.telemetry = opts.telemetry;
|
||||
this.settings = opts.settings;
|
||||
this.mediaStore = opts.mediaStore;
|
||||
this.notebookRepo = opts.notebookRepo;
|
||||
}
|
||||
|
||||
async enqueue(noteId: string): Promise<void> {
|
||||
@@ -137,6 +142,23 @@ export class AiWorker {
|
||||
const nowDate = this.now();
|
||||
const todayDate = kstTodayAsDate(nowDate);
|
||||
const todayIso = kstTodayIso(nowDate);
|
||||
|
||||
// v0.3.14 — 본문 빈 + 이미지만 첨부 케이스는 모델이 의미 있는 응답 못 함
|
||||
// (gemma4:26b 등 vision 모델의 한계 확인). AI 호출 skip, 자동 placeholder 적용 후
|
||||
// 즉시 done. 사용자가 후에 NoteCard 의 EditableField 로 제목/요약 편집 가능.
|
||||
const rawEmpty = note.rawText.trim().length === 0;
|
||||
if (rawEmpty && note.media.length > 0) {
|
||||
const n = note.media.length;
|
||||
const title = n === 1 ? '첨부 이미지' : `첨부 이미지 ${n}장`;
|
||||
const summary = `이미지 ${n}장이 첨부된 메모입니다.\n원문 영역에서 이미지 확인할 수 있습니다.\n제목과 요약을 클릭해 직접 편집할 수 있습니다.`;
|
||||
this.repo.updateAiResult(job.noteId, {
|
||||
title, summary, tags: [], provider: 'image-only-skip', dueDate: null
|
||||
});
|
||||
this.logger.info('ai.skip.image-only', { noteId: job.noteId, mediaCount: n });
|
||||
this.emit(job.noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = parseAllCandidates(note.rawText, todayDate);
|
||||
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
|
||||
// v0.3.1 Cut F — vision path: visionModel + note.media → base64 images
|
||||
@@ -144,7 +166,17 @@ export class AiWorker {
|
||||
// 5MB cap 초과 시 throw → AiWorker 의 'other' 분기 → markAiFailed 도달.
|
||||
const visionModel = this.settings ? await this.settings.getVisionModel() : null;
|
||||
let images: Array<{ base64: string; mime: string }> | undefined;
|
||||
if (visionModel && note.media.length > 0 && this.mediaStore) {
|
||||
const visionActive = !!(visionModel && note.media.length > 0 && this.mediaStore);
|
||||
// v0.3.14 — vision 활성 여부 진단 로그. 사용자가 vision_model 미설정으로 text-only
|
||||
// path 가 도는지 / 이미지가 모델에 전달되는지 확인 가능 (logs/main.log).
|
||||
this.logger.info('ai.vision.decide', {
|
||||
noteId: job.noteId,
|
||||
visionActive,
|
||||
visionModelConfigured: !!visionModel,
|
||||
mediaCount: note.media.length,
|
||||
mediaStorePresent: !!this.mediaStore
|
||||
});
|
||||
if (visionActive) {
|
||||
const oversize = note.media.find((m) => m.bytes > 5 * 1024 * 1024);
|
||||
if (oversize) {
|
||||
throw new Error(`image ${oversize.relPath} exceeds 5MB cap (${oversize.bytes} bytes)`);
|
||||
@@ -156,8 +188,9 @@ export class AiWorker {
|
||||
})
|
||||
);
|
||||
}
|
||||
const notebookNames = this.notebookRepo ? this.notebookRepo.list().map((nb) => nb.name) : [];
|
||||
const res = await this.holder.get().generate(
|
||||
{ text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab },
|
||||
{ text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab, notebooks: notebookNames },
|
||||
{ visionModel: visionModel ?? undefined }
|
||||
);
|
||||
// AI primary: AI's dueDate is final (no rule merge)
|
||||
@@ -168,6 +201,16 @@ export class AiWorker {
|
||||
provider: this.holder.get().name,
|
||||
dueDate: res.dueDate ?? null
|
||||
});
|
||||
// AI 의 notebook_match 가 valid 이름이면 자동 이동.
|
||||
if (res.notebookMatch && this.notebookRepo) {
|
||||
const nb = this.notebookRepo.findByName(res.notebookMatch);
|
||||
if (nb) {
|
||||
this.notebookRepo.moveNote(job.noteId, nb.id);
|
||||
this.logger.info('ai.notebook.match', { noteId: job.noteId, notebook: nb.name });
|
||||
} else {
|
||||
this.logger.info('ai.notebook.miss', { noteId: job.noteId, attempted: res.notebookMatch });
|
||||
}
|
||||
}
|
||||
this.unreachableBackoffStep = 0; // 성공 시 step reset
|
||||
this.logger.info('ai.done', {
|
||||
noteId: job.noteId,
|
||||
@@ -233,8 +276,13 @@ export class AiWorker {
|
||||
const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString();
|
||||
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
|
||||
if (isLast) {
|
||||
this.repo.markAiFailed(job.noteId, msg);
|
||||
this.logger.error('ai.failed', { noteId: job.noteId, err: msg });
|
||||
// v0.3.14 — ai_error 에 reason + provider name prefix 추가. NoteCard 의 "원인 보기"
|
||||
// 가 사용자에게 보여주는 raw 메시지에 context (timeout/unreachable/schema/other +
|
||||
// 어느 모델이 fail 했는지) 가 포함되어 진단성 향상.
|
||||
const provider = this.holder.get().name;
|
||||
const annotated = `[${reason}] ${provider}\n${msg}`;
|
||||
this.repo.markAiFailed(job.noteId, annotated);
|
||||
this.logger.error('ai.failed', { noteId: job.noteId, err: msg, reason, provider });
|
||||
if (this.telemetry) {
|
||||
await this.telemetry.emit({
|
||||
kind: 'ai_failed',
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface GenerateInput {
|
||||
vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리.
|
||||
// v0.3.1 Cut F — 첨부 이미지. 미전달 시 텍스트 전용 처리.
|
||||
images?: Array<{ base64: string; mime: string }>;
|
||||
notebooks?: string[]; // v0.4 Task 8 — 사용 가능한 노트북 목록. 미전달 시 빈 배열로 처리.
|
||||
}
|
||||
|
||||
export interface GenerateOptions {
|
||||
|
||||
@@ -13,6 +13,29 @@ function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other'
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드
|
||||
* 펜스 / prose 가 섞인 응답을 반환할 때 fallback. 첫 '{' ~ 마지막 '}' substring 만
|
||||
* 추출해서 JSON.parse 재시도.
|
||||
*
|
||||
* v0.3.14 — fail 시 throw 대신 `{}` 반환. schema 의 graceful coerce 가 빈 객체를
|
||||
* placeholder title/summary 로 채움 → 사용자 데이터 손실 없이 노트 보관 (raw_text 그대로).
|
||||
* 모델이 repetition loop 로 num_predict cap 도달해 JSON truncate 된 케이스에 robust.
|
||||
* 원본 응답 snippet 은 console.warn 으로 로그 (디버그성).
|
||||
*/
|
||||
function parseJsonLoose(raw: string): unknown {
|
||||
try { return JSON.parse(raw); } catch { /* fallback below */ }
|
||||
const first = raw.indexOf('{');
|
||||
const last = raw.lastIndexOf('}');
|
||||
if (first >= 0 && last > first) {
|
||||
const slice = raw.slice(first, last + 1);
|
||||
try { return JSON.parse(slice); } catch { /* fall through */ }
|
||||
}
|
||||
// 빈 객체 반환 → schema coerce 로 placeholder 적용. 원본 일부는 stderr 에 남김.
|
||||
console.warn(`[LocalOllamaProvider] unparseable response, falling back to {}: ${raw.slice(0, 200).replace(/\s+/g, ' ')}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
export interface LocalOllamaOptions {
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
@@ -44,17 +67,24 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
const model = useVision ? opts!.visionModel! : this.model;
|
||||
const prompt = useVision
|
||||
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates.map((c) => c.iso ?? c.matchedToken ?? ''), input.vocab ?? [])
|
||||
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);
|
||||
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [], input.notebooks ?? []);
|
||||
|
||||
// v0.3.13 — vision model 은 cold-start (모델 load + 이미지 encoding) 가 매우 느려
|
||||
// 120s 기본 timeout 으로 첫 호출 fail 빈번. gemma4:26b (MoE 25B) 같은 대형 vision
|
||||
// 모델은 첫 generate 가 60-180s 소요. 5분 (300s) 으로 확장.
|
||||
const effectiveTimeout = useVision ? Math.max(this.timeoutMs, 300_000) : this.timeoutMs;
|
||||
this.abortController = new AbortController();
|
||||
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
|
||||
const timer = setTimeout(() => this.abortController?.abort(), effectiveTimeout);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
prompt,
|
||||
format: 'json',
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
// v0.3.14 — repeat_penalty 추가. vision 모델 (특히 gemma 시리즈) 이 "기기기기..."
|
||||
// 같은 repetition loop 에 빠져 num_predict cap 도달 → JSON truncate → unparseable.
|
||||
// 1.15 는 Ollama 권장 범위 (1.0-1.3) 안쪽 conservative 값.
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict, repeat_penalty: 1.15 }
|
||||
};
|
||||
if (useVision) {
|
||||
body.images = input.images!.map((i) => i.base64);
|
||||
@@ -70,9 +100,8 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
}
|
||||
const responseBody = (await res.body.json()) as { response?: string };
|
||||
if (!responseBody.response) throw new Error('missing response field');
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(responseBody.response); }
|
||||
catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
|
||||
// v0.3.11 — vision model 응답이 markdown fence / prose 섞인 경우 fallback 추출.
|
||||
const parsed = parseJsonLoose(responseBody.response);
|
||||
return parseAiResponse(parsed);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
|
||||
178
src/main/ai/batchClassify.ts
Normal file
178
src/main/ai/batchClassify.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* v0.4 T4 — default notebook 의 active 노트들을 단일 AI prompt 로 일괄 분류.
|
||||
* N notes × 1 call 대신 한 번에 묶어 호출한다.
|
||||
*
|
||||
* Top N cap: 50 (prompt 크기 제한).
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { NotebookRepository } from '../repository/NotebookRepository.js';
|
||||
|
||||
export interface BatchClassifyResult {
|
||||
assignments: Array<{ noteId: string; notebookId: string | null; notebookName: string | null }>;
|
||||
skippedReason?: string;
|
||||
}
|
||||
|
||||
export interface BatchClassifyDeps {
|
||||
noteRepo: NoteRepository;
|
||||
notebookRepo: NotebookRepository;
|
||||
provider: { generateRaw: (prompt: string) => Promise<string> };
|
||||
}
|
||||
|
||||
/** 한 번에 전달할 노트 최대 개수. prompt 크기 제한 대응. */
|
||||
const TOP_N_CAP = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod schema for AI response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AssignmentSchema = z.object({
|
||||
id: z.string(),
|
||||
notebook: z.string().nullable()
|
||||
});
|
||||
|
||||
const BatchResponseSchema = z.object({
|
||||
assignments: z.array(AssignmentSchema)
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 한국어 batch 분류 prompt.
|
||||
* 기존 buildPrompt 와 별도 함수 — 개별 메타데이터 생성이 아니라 batch 분류 전용.
|
||||
*/
|
||||
export function buildBatchClassifyPrompt(
|
||||
notes: Array<{ id: string; snippet: string }>,
|
||||
notebookNames: string[]
|
||||
): string {
|
||||
const notebookList = notebookNames.join(', ');
|
||||
const noteLines = notes
|
||||
.map((n) => `- ${n.id}: "${n.snippet}"`)
|
||||
.join('\n');
|
||||
|
||||
return `당신은 노트를 노트북으로 정리하는 AI 어시스턴트입니다. \
|
||||
아래 노트 목록과 사용 가능한 노트북 목록을 보고 각 노트가 어느 노트북에 가장 잘 맞는지 추천해주세요. \
|
||||
명확히 속하지 않으면 null 을 반환하세요.
|
||||
|
||||
사용 가능한 노트북: ${notebookList}
|
||||
|
||||
노트 목록 (id 와 짧은 제목/내용):
|
||||
${noteLines}
|
||||
|
||||
규칙:
|
||||
- "assignments" 배열을 포함한 JSON 객체만 반환하세요.
|
||||
- 각 assignment 의 id 는 입력 id 와 정확히 일치해야 합니다.
|
||||
- notebook 은 위 사용 가능한 노트북 목록 중 하나 (대소문자 무관) 또는 null 이어야 합니다.
|
||||
- 새 노트북 이름을 만들지 마세요.
|
||||
- 마크다운 코드펜스나 설명 없이 JSON 만 반환하세요.
|
||||
|
||||
예시:
|
||||
{"assignments": [{"id": "N1", "notebook": "회사"}, {"id": "N2", "notebook": null}]}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loose JSON extract (classifyStatus 와 동일한 패턴)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseJsonLoose(raw: string): unknown {
|
||||
try { return JSON.parse(raw); } catch { /* fallback below */ }
|
||||
const first = raw.indexOf('{');
|
||||
const last = raw.lastIndexOf('}');
|
||||
if (first >= 0 && last > first) {
|
||||
const slice = raw.slice(first, last + 1);
|
||||
try { return JSON.parse(slice); } catch { /* fall through */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main service function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function batchClassifyDefault(deps: BatchClassifyDeps): Promise<BatchClassifyResult> {
|
||||
const { noteRepo, notebookRepo, provider } = deps;
|
||||
|
||||
// 1. default notebook 조회
|
||||
const defaultNb = notebookRepo.getDefault();
|
||||
if (!defaultNb) {
|
||||
return { assignments: [], skippedReason: 'no_default_notebook' };
|
||||
}
|
||||
|
||||
// 2. 다른 notebook 목록 (default 제외) — 후보 notebook
|
||||
const allNotebooks = notebookRepo.list();
|
||||
const otherNotebooks = allNotebooks.filter((nb) => nb.id !== defaultNb.id);
|
||||
if (otherNotebooks.length === 0) {
|
||||
return { assignments: [], skippedReason: 'no_other_notebooks' };
|
||||
}
|
||||
|
||||
// 3. default notebook 의 active 노트 조회 (cap 적용)
|
||||
const notes = noteRepo.listByStatus('active', { notebookId: defaultNb.id, limit: TOP_N_CAP });
|
||||
if (notes.length === 0) {
|
||||
return { assignments: [], skippedReason: 'no_notes' };
|
||||
}
|
||||
|
||||
// 4. 노트 snippet 생성 — aiTitle 우선, 없으면 rawText 앞 50자
|
||||
const noteSnippets = notes.map((n) => ({
|
||||
id: n.id,
|
||||
snippet: (n.aiTitle ?? n.rawText).slice(0, 50)
|
||||
}));
|
||||
|
||||
// 5. prompt 구성
|
||||
const notebookNames = otherNotebooks.map((nb) => nb.name);
|
||||
const prompt = buildBatchClassifyPrompt(noteSnippets, notebookNames);
|
||||
|
||||
// 6. AI 호출
|
||||
let rawJson: string;
|
||||
try {
|
||||
rawJson = await provider.generateRaw(prompt);
|
||||
} catch {
|
||||
return { assignments: [], skippedReason: 'ai_error' };
|
||||
}
|
||||
|
||||
// 7. JSON parse
|
||||
const parsed = parseJsonLoose(rawJson);
|
||||
if (parsed === null) {
|
||||
return { assignments: [], skippedReason: 'parse_error' };
|
||||
}
|
||||
|
||||
// 8. Zod validate
|
||||
const validated = BatchResponseSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
return { assignments: [], skippedReason: 'parse_error' };
|
||||
}
|
||||
|
||||
// 9. 응답 매핑
|
||||
const inputIds = new Set(notes.map((n) => n.id));
|
||||
const assignments: BatchClassifyResult['assignments'] = [];
|
||||
|
||||
for (const item of validated.data.assignments) {
|
||||
// input list 에 없는 id 는 skip
|
||||
if (!inputIds.has(item.id)) continue;
|
||||
|
||||
if (item.notebook === null) {
|
||||
assignments.push({ noteId: item.id, notebookId: null, notebookName: null });
|
||||
continue;
|
||||
}
|
||||
|
||||
// notebook name → NotebookRepository.findByName (case-insensitive)
|
||||
const matched = notebookRepo.findByName(item.notebook);
|
||||
if (!matched) {
|
||||
// hallucinate 된 이름 — null 로 처리 (skip 대신 null 매핑으로 결과에는 포함)
|
||||
assignments.push({ noteId: item.id, notebookId: null, notebookName: null });
|
||||
continue;
|
||||
}
|
||||
|
||||
// default notebook 으로 매핑된 경우는 null 처리 (이미 default 에 있음)
|
||||
if (matched.id === defaultNb.id) {
|
||||
assignments.push({ noteId: item.id, notebookId: null, notebookName: null });
|
||||
continue;
|
||||
}
|
||||
|
||||
assignments.push({ noteId: item.id, notebookId: matched.id, notebookName: matched.name });
|
||||
}
|
||||
|
||||
return { assignments };
|
||||
}
|
||||
@@ -13,15 +13,16 @@ export interface ClassifyStatusOutput {
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
const VALID: readonly NoteStatus[] = ['completed', 'archived', 'trashed'];
|
||||
// v0.4 Task 16 — 'archived' 제거. completed / trashed 만 유효 추천값.
|
||||
// AI 응답이 'archived' 를 반환해도 VALID 에 없으므로 FALLBACK(completed) 로 coerce됨.
|
||||
const VALID: readonly NoteStatus[] = ['completed', 'trashed'];
|
||||
|
||||
const PROMPT_TEMPLATE = `다음 메모를 분류하세요.
|
||||
가능한 status:
|
||||
- completed: 작업이 끝났고 더 이상 행동 불필요
|
||||
- archived: 장기 보관 — 회수 가능, 지금은 보지 않음
|
||||
- trashed: 불필요, 의미 없는 메모
|
||||
|
||||
JSON 출력만 하세요: { "recommended": "completed|archived|trashed", "rationale": "<한 문장 한국어>" }
|
||||
JSON 출력만 하세요: { "recommended": "completed|trashed", "rationale": "<한 문장 한국어>" }
|
||||
|
||||
메모 본문:
|
||||
{{rawText}}
|
||||
@@ -32,17 +33,18 @@ JSON 출력만 하세요: { "recommended": "completed|archived|trashed", "ration
|
||||
사용자 이동 사유:
|
||||
{{reason}}`;
|
||||
|
||||
// v0.4 Task 16 — fallback 을 'completed' 로 변경 ('archived' 제거).
|
||||
const FALLBACK: ClassifyStatusOutput = {
|
||||
recommended: 'archived',
|
||||
rationale: '판단 실패 — 안전하게 보관 추천'
|
||||
recommended: 'completed',
|
||||
rationale: '판단 실패 — 완료 처리 추천'
|
||||
};
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
||||
*
|
||||
* provider.generateRaw 가 있으면 raw JSON 응답 사용, 없으면 generate() 재사용 시도
|
||||
* (그 경우 응답 형태 불일치로 보통 fallback). 에러/parse 실패 시 'archived' 안전 default
|
||||
* (사용자 데이터 보존 우선).
|
||||
* (그 경우 응답 형태 불일치로 보통 fallback). 에러/parse 실패 시 'completed' fallback
|
||||
* (v0.4 Task 16 — 'archived' 제거, 안전 default 를 completed 로 변경).
|
||||
*/
|
||||
export async function classifyStatus(
|
||||
input: ClassifyStatusInput
|
||||
|
||||
@@ -6,7 +6,8 @@ export function buildPrompt(
|
||||
rawText: string,
|
||||
todayKst: string,
|
||||
candidates: ParseResult[] = [],
|
||||
vocab: string[] = []
|
||||
vocab: string[] = [],
|
||||
notebooks: string[] = []
|
||||
): string {
|
||||
const candidateBlock = candidates.length > 0
|
||||
? `\nDate candidates extracted by a Korean rule parser (these are HINTS — you decide which is correct, or pick null):
|
||||
@@ -17,11 +18,19 @@ ${candidates.map((c, i) => ` ${i + 1}. ${c.iso ?? '(ambiguous)'} — matched to
|
||||
? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n`
|
||||
: '';
|
||||
|
||||
// candidateBlock & vocabBlock are self-delimited with leading/trailing \n
|
||||
const notebookBlock = notebooks.length > 0
|
||||
? `\n사용 가능한 노트북: ${notebooks.join(', ')}\n이 노트가 위 노트북 중 하나에 명확히 속하면 "notebook_match" 에 그 이름을, 그렇지 않으면 null 을 반환. 기존 목록 안에서만 선택 — 새 이름 만들지 말 것.\n`
|
||||
: '';
|
||||
|
||||
const notebookKeySpec = notebooks.length > 0
|
||||
? `\n- "notebook_match": 위 사용 가능한 노트북 목록 중 하나의 이름 또는 null`
|
||||
: '';
|
||||
|
||||
// candidateBlock & vocabBlock & notebookBlock are self-delimited with leading/trailing \n
|
||||
return `You organize raw personal notes into structured metadata.
|
||||
|
||||
Today's date in Korea Standard Time (KST): ${todayKst}
|
||||
${candidateBlock}${vocabBlock}
|
||||
${candidateBlock}${vocabBlock}${notebookBlock}
|
||||
Input note (raw text, may be fragmented, any language):
|
||||
---
|
||||
${rawText}
|
||||
@@ -31,7 +40,7 @@ Return a JSON object with EXACTLY these keys:
|
||||
- "title": concise title in KOREAN (max 60 chars)
|
||||
- "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\\n".
|
||||
- "tags": array of 0 to 3 tags in lowercase kebab-case (English letters and digits only, e.g., "api-timeout", "weekly-retro"). Empty array if no clear tags.
|
||||
- "due_date": ISO YYYY-MM-DD if you are CONFIDENT about a deadline, else null. Consider rule candidates above as hints but use your own judgment — if multiple ambiguous candidates ("내일 모레", "이번 주 다음 주"), prefer null. If the user wrote "오늘 PR 리뷰" with no deadline implication, return null.
|
||||
- "due_date": ISO YYYY-MM-DD if you are CONFIDENT about a deadline, else null. Consider rule candidates above as hints but use your own judgment — if multiple ambiguous candidates ("내일 모레", "이번 주 다음 주"), prefer null. If the user wrote "오늘 PR 리뷰" with no deadline implication, return null.${notebookKeySpec}
|
||||
|
||||
Rules:
|
||||
- title and summary MUST be written in Korean regardless of input language.
|
||||
|
||||
@@ -8,7 +8,8 @@ const RawResponseSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
summary: z.string().min(1),
|
||||
tags: z.array(z.string()).default([]),
|
||||
due_date: z.string().regex(ISO_DATE_REGEX).nullable().optional()
|
||||
due_date: z.string().regex(ISO_DATE_REGEX).nullable().optional(),
|
||||
notebook_match: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export interface AiResponse {
|
||||
@@ -16,6 +17,7 @@ export interface AiResponse {
|
||||
summary: string;
|
||||
tags: string[];
|
||||
dueDate: string | null;
|
||||
notebookMatch: string | null;
|
||||
}
|
||||
|
||||
function normalizeSummary(raw: string): string {
|
||||
@@ -39,15 +41,37 @@ function validateDueDate(d: string | null | undefined): string | null {
|
||||
return d;
|
||||
}
|
||||
|
||||
export function parseAiResponse(raw: unknown): AiResponse {
|
||||
const parsed = RawResponseSchema.parse(raw);
|
||||
if (!KOREAN_REGEX.test(parsed.title)) {
|
||||
throw new Error('title must contain Korean characters');
|
||||
/**
|
||||
* vision 모델 (gemma4:26b 등) 이 본문 빈 케이스에 title/summary null 반환하는 케이스
|
||||
* 대응. null → placeholder 한국어 문자열로 coerce 후 schema 통과. 빈 string / empty regex
|
||||
* dueDate 도 null 로 normalize. raw_text 는 호출자가 보존하므로 사용자 데이터 손실 없음.
|
||||
*/
|
||||
function coerceNullable(raw: unknown): unknown {
|
||||
if (typeof raw !== 'object' || raw === null) return raw;
|
||||
const obj = { ...(raw as Record<string, unknown>) };
|
||||
if (obj.title === null || obj.title === undefined || obj.title === '') obj.title = '(첨부 메모)';
|
||||
if (obj.summary === null || obj.summary === undefined || obj.summary === '') obj.summary = '내용을 자동으로 정리하지 못했습니다.';
|
||||
// due_date 의 빈 string / regex mismatch 도 null 로 강제 (schema 가 거부하지 않게).
|
||||
if (obj.due_date === '' || (typeof obj.due_date === 'string' && !ISO_DATE_REGEX.test(obj.due_date))) {
|
||||
obj.due_date = null;
|
||||
}
|
||||
// tags 가 null 이면 빈 배열로.
|
||||
if (obj.tags === null || obj.tags === undefined) obj.tags = [];
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function parseAiResponse(raw: unknown): AiResponse {
|
||||
const coerced = coerceNullable(raw);
|
||||
const parsed = RawResponseSchema.parse(coerced);
|
||||
// title 이 한국어 0 자면 fallback placeholder 적용 (영어 title 도 fail 안 함).
|
||||
// placeholder 는 한국어 포함이라 자기 자신 통과.
|
||||
const titleHasKorean = KOREAN_REGEX.test(parsed.title);
|
||||
const finalTitle = titleHasKorean ? parsed.title : '(첨부 메모)';
|
||||
return {
|
||||
title: parsed.title.slice(0, 60),
|
||||
title: finalTitle.slice(0, 60),
|
||||
summary: normalizeSummary(parsed.summary),
|
||||
tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3),
|
||||
dueDate: validateDueDate(parsed.due_date)
|
||||
dueDate: validateDueDate(parsed.due_date),
|
||||
notebookMatch: parsed.notebook_match ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,13 +4,33 @@ export function buildVisionPrompt(
|
||||
dueCandidates: string[],
|
||||
vocab: string[]
|
||||
): string {
|
||||
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
|
||||
// v0.3.14 — 본문 빈 케이스에 one-shot 예시 추가. gemma4:26b 등이 본문 없이
|
||||
// 이미지만 받으면 null 반환하는 한계 우회. 예시 입력/출력 구조 따라가도록 유도.
|
||||
const bodySection = text
|
||||
? `메모 본문:\n${text}\n\n첨부 이미지도 함께 분석해 요약에 반영하세요.`
|
||||
: `본문이 없습니다. 첨부 이미지의 내용 (텍스트/사람/장면/문서 등) 만으로 한국어 title 과 summary 를 작성하세요. null 반환 절대 금지.
|
||||
|
||||
메모 본문 (비어 있을 수 있음):
|
||||
${text || '(이미지만 있음)'}
|
||||
예시 (이미지: 갈색 강아지가 잔디 위에 앉은 사진):
|
||||
{"title":"잔디 위 강아지","summary":"갈색 강아지가 잔디 위에 앉아 있다.\\n배경에 나무가 보인다.\\n날씨가 맑다.","tags":["pet"],"due_date":null}
|
||||
|
||||
예시 (이미지: 회의실 화이트보드 사진):
|
||||
{"title":"회의실 화이트보드","summary":"화이트보드에 일정과 안건이 적혀 있다.\\n프로젝트 이름이 보인다.\\n다이어그램이 그려져 있다.","tags":["meeting"],"due_date":null}
|
||||
|
||||
이제 첨부된 실제 이미지를 보고 같은 형식으로 작성하세요.`;
|
||||
|
||||
return `다음 메모를 한국어로 분석해 JSON 으로 정리하세요.
|
||||
|
||||
${bodySection}
|
||||
|
||||
규칙 (위반 시 재시도):
|
||||
- "title": 한국어 문자열 필수, null 금지. 60자 이내. 영어 단독 금지.
|
||||
- "summary": 한국어 문자열 필수, null 금지. 3줄. 이미지 시각 정보 (텍스트/사람/장면) 포함.
|
||||
- "tags": 영문 kebab-case 배열 (예: ["meeting-notes"]), 최대 3개. 한국어 태그 금지. 없으면 [].
|
||||
- "due_date": ISO YYYY-MM-DD 또는 null. 빈 문자열 금지.
|
||||
|
||||
오직 JSON 객체 하나만 출력. markdown 코드 펜스 (\`\`\`) / 설명 prose 금지.
|
||||
출력 형식: {"title":"...","summary":"...","tags":[],"due_date":null}
|
||||
|
||||
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
|
||||
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
|
||||
오늘: ${todayKst}
|
||||
가능한 due 후보: ${dueCandidates.join(', ')}
|
||||
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
|
||||
|
||||
@@ -6,8 +6,10 @@ import * as m004 from './m004_status.js';
|
||||
import * as m005 from './m005_ai_disabled.js';
|
||||
import * as m006 from './m006_revisions.js';
|
||||
import * as m007 from './m007_fts.js';
|
||||
import * as m008 from './m008_notebooks.js';
|
||||
import * as m009 from './m009_notebook_order.js';
|
||||
|
||||
const migrations = [m001, m002, m003, m004, m005, m006, m007];
|
||||
const migrations = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
|
||||
|
||||
export function latestVersion(): number {
|
||||
return migrations[migrations.length - 1]!.version;
|
||||
|
||||
37
src/main/db/migrations/m008_notebooks.ts
Normal file
37
src/main/db/migrations/m008_notebooks.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// v8: notebooks 테이블 + notes.notebook_id (FK) + archived → completed 정리.
|
||||
// CHECK 제약 없는 status 컬럼이라 SQL 변경은 데이터 정리만, enum 단속은 TypeScript 측에서.
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
export const version = 8;
|
||||
|
||||
const DEFAULT_NOTEBOOK_NAME = '기본';
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
const now = new Date().toISOString();
|
||||
const defaultId = uuidv7();
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE notebooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_notebooks_name ON notebooks(name COLLATE NOCASE);
|
||||
|
||||
ALTER TABLE notes ADD COLUMN notebook_id TEXT
|
||||
REFERENCES notebooks(id) ON DELETE RESTRICT;
|
||||
CREATE INDEX idx_notes_notebook_id ON notes(notebook_id);
|
||||
`);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO notebooks (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`
|
||||
).run(defaultId, DEFAULT_NOTEBOOK_NAME, now, now);
|
||||
|
||||
db.prepare(`UPDATE notes SET notebook_id = ? WHERE notebook_id IS NULL`).run(defaultId);
|
||||
|
||||
// archived 잔류 (dogfood 0건 확인됐지만 defensive) → completed 로 통합.
|
||||
db.prepare(`UPDATE notes SET status='completed' WHERE status='archived'`).run();
|
||||
}
|
||||
17
src/main/db/migrations/m009_notebook_order.ts
Normal file
17
src/main/db/migrations/m009_notebook_order.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// v9: notebooks.sort_order 컬럼 추가 + 기존 notebooks 를 created_at 순서로 backfill.
|
||||
// NotebookList 사이드바 의 순서 변경 (T2/T3) 의 schema 기반.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 9;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
ALTER TABLE notebooks ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX idx_notebooks_sort_order ON notebooks(sort_order, created_at);
|
||||
`);
|
||||
|
||||
// 기존 notebooks 를 created_at 순서로 0,1,2,... 채움
|
||||
const rows = db.prepare(`SELECT id FROM notebooks ORDER BY created_at ASC, id ASC`).all() as Array<{ id: string }>;
|
||||
const update = db.prepare(`UPDATE notebooks SET sort_order = ? WHERE id = ?`);
|
||||
rows.forEach((r, idx) => update.run(idx, r.id));
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { refreshVisionCache } from './services/VisionDetect.js';
|
||||
import { registerCaptureApi } from './ipc/captureApi.js';
|
||||
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
|
||||
import { registerSettingsApi, navigateInbox } from './ipc/settingsApi.js';
|
||||
import { NotebookRepository } from './repository/NotebookRepository.js';
|
||||
import { registerNotebookApi } from './ipc/notebookApi.js';
|
||||
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
|
||||
import {
|
||||
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
|
||||
@@ -101,6 +103,8 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
const db = openDb(paths.dbFile);
|
||||
const repo = new NoteRepository(db);
|
||||
const notebookRepo = new NotebookRepository(db);
|
||||
registerNotebookApi({ repo: notebookRepo });
|
||||
const store = new MediaStore(paths.profileDir);
|
||||
const continuity = new ContinuityService(db);
|
||||
const intent = new IntentService(repo);
|
||||
@@ -158,7 +162,9 @@ app.whenReady().then(async () => {
|
||||
telemetry,
|
||||
// v0.3.1 Cut F — vision 지원
|
||||
settings: settingsSvc,
|
||||
mediaStore: store
|
||||
mediaStore: store,
|
||||
// v0.4 — notebook_match 자동 이동
|
||||
notebookRepo: notebookRepo
|
||||
});
|
||||
|
||||
const notify = new NotificationService({
|
||||
@@ -181,7 +187,9 @@ app.whenReady().then(async () => {
|
||||
getInboxWindow, settings: settingsSvc, providerHolder,
|
||||
paths: { profileDir: paths.profileDir },
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신.
|
||||
enqueue: (id) => worker.enqueue(id)
|
||||
enqueue: (id) => worker.enqueue(id),
|
||||
// v0.4 Task 11 — promotion candidates IPC 가 default notebook 식별에 사용.
|
||||
notebookRepo
|
||||
});
|
||||
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
|
||||
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.
|
||||
@@ -193,9 +201,10 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
if (!reg.ok) logger.warn('hotkey.register.failed', { reason: reg.reason });
|
||||
|
||||
if (!startedHidden) {
|
||||
createInboxWindow();
|
||||
}
|
||||
// macOS LoginItems autostart 시 startedHidden=true 로 spawn — 그대로 두면 quickCapture
|
||||
// (NSPanel) 만 떠 있어 dock running indicator 미표출. inboxWindow 를 hidden 상태로
|
||||
// 미리 create 하면 NSApp register → 점 표출 + 사용자가 dock 아이콘 확인으로 앱 살아있음 인지.
|
||||
createInboxWindow({ visible: !startedHidden });
|
||||
createQuickCaptureWindow();
|
||||
await worker.loadFromDb();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron';
|
||||
const { ipcMain, dialog, shell } = electron;
|
||||
import { join, normalize, sep } from 'node:path';
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { NotebookRepository } from '../repository/NotebookRepository.js';
|
||||
import type { ContinuityService } from '../services/ContinuityService.js';
|
||||
import type { CaptureService } from '../services/CaptureService.js';
|
||||
import type { HealthChecker } from '../services/HealthChecker.js';
|
||||
@@ -11,6 +12,7 @@ import type { Note, NoteStatus } from '@shared/types';
|
||||
import type { HealthResult } from '../ai/InferenceProvider.js';
|
||||
import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js';
|
||||
import { classifyStatus } from '../ai/classifyStatus.js';
|
||||
import { batchClassifyDefault } from '../ai/batchClassify.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
import type { ProviderHolder } from '../ai/ProviderHolder.js';
|
||||
|
||||
@@ -29,10 +31,13 @@ export interface InboxIpcDeps {
|
||||
// 미주입 시 fire-and-forget skip (다음 launch 의 loadFromDb 가 처리). 본 hook 은
|
||||
// AiWorker 인스턴스 직접 주입을 피해 IPC 모듈이 worker import 를 갖지 않도록 분리.
|
||||
enqueue?: (noteId: string) => Promise<void>;
|
||||
// v0.4 Task 11 — promotion candidates IPC 가 default notebook 식별에 사용.
|
||||
// 미주입 시 list-promotion-candidates 는 빈 배열 반환 (graceful fallback).
|
||||
notebookRepo?: NotebookRepository;
|
||||
}
|
||||
|
||||
export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
ipcMain.handle('inbox:list', (_e, opts: { limit: number; cursor?: string }) =>
|
||||
ipcMain.handle('inbox:list', (_e, opts: { limit: number; cursor?: string; notebookId?: string }) =>
|
||||
deps.repo.list(opts)
|
||||
);
|
||||
|
||||
@@ -197,21 +202,24 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 4 — status 별 노트 목록.
|
||||
// v0.4 — notebookId 옵션 추가. archived 는 v0.4 에서 제거된 status — graceful fallback.
|
||||
ipcMain.handle(
|
||||
'inbox:list-by-status',
|
||||
(_e, status: NoteStatus, opts: { limit?: number } = {}) => {
|
||||
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
|
||||
(_e, status: NoteStatus, opts: { limit?: number; notebookId?: string } = {}) => {
|
||||
// archived 는 v0.4 에서 completed 로 통합 — 빈 결과로 graceful fallback (caller 안전).
|
||||
if ((status as string) === 'archived') return [] as Note[];
|
||||
const VALID: readonly NoteStatus[] = ['active', 'completed', 'trashed'];
|
||||
if (!VALID.includes(status)) return [] as Note[];
|
||||
return deps.repo.listByStatus(status, opts);
|
||||
}
|
||||
);
|
||||
|
||||
// v0.2.9 Cut B Task 4 — 4 status counts (헤더 4탭 badge).
|
||||
ipcMain.handle('inbox:counts-by-status', () => ({
|
||||
active: deps.repo.countByStatus('active'),
|
||||
completed: deps.repo.countByStatus('completed'),
|
||||
archived: deps.repo.countByStatus('archived'),
|
||||
trashed: deps.repo.countByStatus('trashed')
|
||||
// v0.2.9 Cut B Task 4 — status counts (헤더 탭 badge).
|
||||
// v0.4 — archived 제거 (3 status 만 반환). notebookId 옵션 추가.
|
||||
ipcMain.handle('inbox:counts-by-status', (_e, opts: { notebookId?: string } = {}) => ({
|
||||
active: deps.repo.countByStatus('active', opts),
|
||||
completed: deps.repo.countByStatus('completed', opts),
|
||||
trashed: deps.repo.countByStatus('trashed', opts)
|
||||
}));
|
||||
|
||||
// v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함).
|
||||
@@ -223,7 +231,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
ipcMain.handle(
|
||||
'inbox:set-status',
|
||||
async (_e, id: string, status: NoteStatus, reason: string | null) => {
|
||||
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
|
||||
const VALID: readonly NoteStatus[] = ['active', 'completed', 'trashed'];
|
||||
if (!VALID.includes(status)) {
|
||||
return { ok: false as const, reason: 'invalid status' as const };
|
||||
}
|
||||
@@ -235,14 +243,14 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
);
|
||||
|
||||
// v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
||||
// Ollama provider.generateRaw 호출 + JSON 응답 파싱. 에러/실패 시 archived fallback
|
||||
// (사용자 데이터 보존 우선). 자세한 prompt + parse 로직은 src/main/ai/classifyStatus.ts.
|
||||
// Ollama provider.generateRaw 호출 + JSON 응답 파싱. 에러/실패 시 completed fallback
|
||||
// (v0.4 Task 16 — 'archived' 제거). 자세한 prompt + parse 로직은 src/main/ai/classifyStatus.ts.
|
||||
ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => {
|
||||
const note = deps.repo.findById(id);
|
||||
if (note === null) {
|
||||
return {
|
||||
recommended: 'archived' as const,
|
||||
rationale: '메모를 찾을 수 없음 — 안전하게 보관 추천'
|
||||
recommended: 'completed' as const,
|
||||
rationale: '메모를 찾을 수 없음 — 완료 처리 추천'
|
||||
};
|
||||
}
|
||||
const provider = deps.providerHolder.get();
|
||||
@@ -254,6 +262,20 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
});
|
||||
});
|
||||
|
||||
// v0.4 T4 — AI batch classify: default notebook 노트 일괄 fit 매칭 (단일 prompt).
|
||||
ipcMain.handle('inbox:batch-classify-default', async () => {
|
||||
if (!deps.notebookRepo) return { assignments: [], skippedReason: 'no_notebook_repo' };
|
||||
const provider = deps.providerHolder.get();
|
||||
if (typeof provider.generateRaw !== 'function') {
|
||||
return { assignments: [], skippedReason: 'no_generate_raw' };
|
||||
}
|
||||
return batchClassifyDefault({
|
||||
noteRepo: deps.repo,
|
||||
notebookRepo: deps.notebookRepo,
|
||||
provider: provider as { generateRaw: (prompt: string) => Promise<string> }
|
||||
});
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 (ai_enabled OFF 시기 캡처) 일괄 재투입.
|
||||
// OFF→ON 전환 후 사용자가 "지금 모두 처리" 버튼 클릭 path. repo.requeueDisabled 가
|
||||
// ai_status='pending' + pending_jobs row 보장, worker.enqueue 가 in-memory queue 갱신.
|
||||
@@ -277,6 +299,30 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
|
||||
ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled'));
|
||||
|
||||
// v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
|
||||
ipcMain.handle('inbox:list-promotion-candidates', () => {
|
||||
if (!deps.notebookRepo) return [];
|
||||
const defaultNb = deps.notebookRepo.getDefault();
|
||||
if (!defaultNb) return [];
|
||||
return deps.repo.findPromotionCandidates(defaultNb.id);
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:get-promotion-dismissed-tags', () =>
|
||||
deps.settings.getPromotionDismissedTags()
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:add-promotion-dismissed-tag', (_e, tag: string) =>
|
||||
deps.settings.addPromotionDismissedTag(tag)
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:get-promotion-snoozed-until', () =>
|
||||
deps.settings.getPromotionSnoozeUntil()
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:set-promotion-snoozed-until', (_e, ms: number) =>
|
||||
deps.settings.setPromotionSnoozeUntil(ms)
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
|
||||
// 검증: 새 인스턴스로 healthCheck
|
||||
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
|
||||
@@ -303,6 +349,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
return { ok: false as const, reason: 'empty' as const };
|
||||
}
|
||||
deps.repo.updateRawText(id, newText);
|
||||
await reprocessAi(deps, id);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
@@ -311,6 +358,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => {
|
||||
try {
|
||||
deps.repo.restoreRevision(id, revId);
|
||||
await reprocessAi(deps, id);
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: (e as Error).message };
|
||||
@@ -318,9 +366,10 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
});
|
||||
|
||||
// v0.2.11 Cut D — FTS5 검색 + 회고 aggregate.
|
||||
// v0.4 — notebookId 옵션 추가.
|
||||
ipcMain.handle(
|
||||
'inbox:search',
|
||||
(_e, query: string, opts: { limit?: number; status?: NoteStatus } = {}) =>
|
||||
(_e, query: string, opts: { limit?: number; status?: NoteStatus; notebookId?: string } = {}) =>
|
||||
deps.repo.search(query, opts)
|
||||
);
|
||||
|
||||
@@ -344,6 +393,19 @@ export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note):
|
||||
w.webContents.send('note:updated', note);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문 변경 후 AI 재처리 트리거. ai_status='pending' 으로 reset + pending_jobs 재투입 +
|
||||
* worker enqueue + renderer push. disabled 노트는 사용자 명시 비활성화 의도 존중하여 skip.
|
||||
*/
|
||||
async function reprocessAi(deps: InboxIpcDeps, id: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const r = deps.repo.markAiPendingForReprocess(id, now);
|
||||
if (!r.ok) return;
|
||||
if (deps.enqueue) await deps.enqueue(id);
|
||||
const updated = deps.repo.findById(id);
|
||||
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
|
||||
}
|
||||
|
||||
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
|
||||
const w = getWin();
|
||||
if (!w || w.isDestroyed()) return;
|
||||
|
||||
53
src/main/ipc/notebookApi.ts
Normal file
53
src/main/ipc/notebookApi.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import electron from 'electron';
|
||||
const { ipcMain } = electron;
|
||||
import type { NotebookRepository } from '../repository/NotebookRepository.js';
|
||||
|
||||
export interface NotebookIpcDeps {
|
||||
repo: NotebookRepository;
|
||||
}
|
||||
|
||||
export function registerNotebookApi(deps: NotebookIpcDeps): void {
|
||||
ipcMain.handle('notebook:list', () => deps.repo.list());
|
||||
|
||||
ipcMain.handle('notebook:create', (_e, input: { name: string; color?: string }) => {
|
||||
if (!input.name || input.name.trim().length === 0) {
|
||||
return { ok: false as const, reason: 'empty_name' };
|
||||
}
|
||||
try {
|
||||
const nb = deps.repo.create({ name: input.name.trim(), color: input.color ?? null });
|
||||
return { ok: true as const, notebook: nb };
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes('UNIQUE')) return { ok: false as const, reason: 'duplicate_name' };
|
||||
return { ok: false as const, reason: msg };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('notebook:rename', (_e, id: string, name: string) => {
|
||||
if (!name || name.trim().length === 0) {
|
||||
return { ok: false as const, reason: 'empty_name' };
|
||||
}
|
||||
try {
|
||||
deps.repo.rename(id, name.trim());
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes('UNIQUE')) return { ok: false as const, reason: 'duplicate_name' };
|
||||
return { ok: false as const, reason: msg };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('notebook:set-color', (_e, id: string, color: string | null) => {
|
||||
deps.repo.setColor(id, color);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('notebook:delete', (_e, id: string) => deps.repo.delete(id));
|
||||
|
||||
ipcMain.handle('notebook:move-note', (_e, noteId: string, notebookId: string) => {
|
||||
deps.repo.moveNote(noteId, notebookId);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('notebook:reorder', (_e, id: string, direction: 'up' | 'down') => deps.repo.reorder(id, direction));
|
||||
}
|
||||
@@ -112,6 +112,17 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// v0.4 — Sidebar UI state 영속화
|
||||
ipcMain.handle('settings:set-sidebar-visible', async (_e, visible: boolean) => {
|
||||
await settings.setSidebarVisible(visible);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-sidebar-width', async (_e, width: number) => {
|
||||
await settings.setSidebarWidth(width);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-sync-auto-enabled', async (_e, value: boolean) => {
|
||||
await deps.settings.setAutoSyncEnabled(value);
|
||||
await deps.syncTimer?.reconfigure();
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface CreateNoteInput {
|
||||
* 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat).
|
||||
*/
|
||||
aiStatus?: AiStatus;
|
||||
/**
|
||||
* v0.4 — 미지정 시 가장 오래된 notebook (default notebook) 의 id 자동 사용.
|
||||
*/
|
||||
notebookId?: string;
|
||||
}
|
||||
|
||||
export interface NewMediaRow {
|
||||
@@ -83,11 +87,12 @@ export class NoteRepository {
|
||||
const id = uuidv7();
|
||||
const ts = now.toISOString();
|
||||
const aiStatus: AiStatus = input.aiStatus ?? 'pending';
|
||||
const notebookId = input.notebookId ?? this.getDefaultNotebookId();
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(id, input.rawText, aiStatus, ts, ts);
|
||||
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, notebook_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, input.rawText, aiStatus, ts, ts, notebookId);
|
||||
this.db
|
||||
.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`)
|
||||
@@ -104,6 +109,16 @@ export class NoteRepository {
|
||||
return { id };
|
||||
}
|
||||
|
||||
private getDefaultNotebookId(): string {
|
||||
const r = this.db
|
||||
.prepare(`SELECT id FROM notebooks ORDER BY created_at ASC LIMIT 1`)
|
||||
.get() as { id: string } | undefined;
|
||||
if (!r) {
|
||||
throw new Error('No default notebook found — m008 migration may not have run');
|
||||
}
|
||||
return r.id;
|
||||
}
|
||||
|
||||
insertMedia(rows: NewMediaRow[]): void {
|
||||
if (rows.length === 0) return;
|
||||
const now = new Date().toISOString();
|
||||
@@ -125,23 +140,15 @@ export class NoteRepository {
|
||||
return this.hydrate(row);
|
||||
}
|
||||
|
||||
list(opts: { limit: number; cursor?: string }): Note[] {
|
||||
list(opts: { limit: number; cursor?: string; notebookId?: string }): Note[] {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit));
|
||||
const rows = opts.cursor
|
||||
? (this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE deleted_at IS NULL AND created_at < ?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(opts.cursor, limit) as Record<string, unknown>[])
|
||||
: (this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(limit) as Record<string, unknown>[]);
|
||||
const params: unknown[] = [];
|
||||
let sql = `SELECT * FROM notes WHERE deleted_at IS NULL`;
|
||||
if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); }
|
||||
if (opts.cursor) { sql += ` AND created_at < ?`; params.push(opts.cursor); }
|
||||
sql += ` ORDER BY created_at DESC, id DESC LIMIT ?`;
|
||||
params.push(limit);
|
||||
const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
@@ -269,6 +276,29 @@ export class NoteRepository {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* raw_text 편집/복원 직후 AI 재처리 트리거. done/failed/pending 노트를 pending 으로
|
||||
* reset + pending_jobs row 보장 (attempts=0). disabled 는 사용자의 명시적 비활성화
|
||||
* 의도 존중 — no-op. updateAiResult 의 user-edit 가드 (title_edited_by_user 등) 가
|
||||
* 사용자가 직접 편집한 필드는 새 AI 결과로 덮어쓰지 않음.
|
||||
*/
|
||||
markAiPendingForReprocess(id: string, now: string): { ok: boolean } {
|
||||
const row = this.db
|
||||
.prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`)
|
||||
.get(id) as { ai_status: string } | undefined;
|
||||
if (!row || row.ai_status === 'disabled') return { ok: false };
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`)
|
||||
.run(now, id);
|
||||
this.db
|
||||
.prepare(`INSERT OR REPLACE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`)
|
||||
.run(id, now);
|
||||
});
|
||||
tx();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.9 — pending 노트의 AI 처리 cancel. ai_status='disabled' 로 전환 + pending_jobs 삭제.
|
||||
* raw_text 는 보존. 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path.
|
||||
@@ -640,48 +670,54 @@ export class NoteRepository {
|
||||
/**
|
||||
* v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용.
|
||||
* tags/media hydrate 없음 (cheap path, listByStatus 와 별도).
|
||||
* v0.4 — notebookId 옵션 추가. 미지정 시 전체 notebook.
|
||||
*/
|
||||
countByStatus(status: NoteStatus): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`)
|
||||
.get(status) as { c: number };
|
||||
return row.c;
|
||||
countByStatus(status: NoteStatus, opts: { notebookId?: string } = {}): number {
|
||||
const params: unknown[] = [status];
|
||||
let sql = `SELECT COUNT(*) AS c FROM notes WHERE status = ?`;
|
||||
if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); }
|
||||
const r = this.db.prepare(sql).get(...params) as { c: number };
|
||||
return r.c;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선),
|
||||
* NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일).
|
||||
* v0.4 — notebookId 옵션 추가. 미지정 시 전체 notebook.
|
||||
*/
|
||||
listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] {
|
||||
listByStatus(status: NoteStatus, opts: { limit?: number; notebookId?: string } = {}): Note[] {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit ?? 200));
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE status = ?
|
||||
ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(status, limit) as Record<string, unknown>[];
|
||||
const params: unknown[] = [status];
|
||||
let sql = `SELECT * FROM notes WHERE status = ?`;
|
||||
if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); }
|
||||
sql += ` ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC LIMIT ?`;
|
||||
params.push(limit);
|
||||
const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외.
|
||||
* 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize.
|
||||
* v0.4 — notebookId 옵션 추가. 미지정 시 전체 notebook.
|
||||
*/
|
||||
search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] {
|
||||
search(query: string, opts: { limit?: number; status?: NoteStatus; notebookId?: string } = {}): Note[] {
|
||||
const sanitized = sanitizeFtsQuery(query);
|
||||
if (sanitized.length === 0) return [];
|
||||
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
|
||||
const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`;
|
||||
const notebookClause = opts.notebookId ? `AND n.notebook_id = ?` : ``;
|
||||
const sql = `
|
||||
SELECT n.* FROM notes n
|
||||
JOIN notes_fts f ON n.id = f.note_id
|
||||
WHERE notes_fts MATCH ? ${statusClause}
|
||||
WHERE notes_fts MATCH ? ${statusClause} ${notebookClause}
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`;
|
||||
const args: unknown[] = opts.status ? [sanitized, opts.status, limit] : [sanitized, limit];
|
||||
const args: unknown[] = [sanitized];
|
||||
if (opts.status) args.push(opts.status);
|
||||
if (opts.notebookId) args.push(opts.notebookId);
|
||||
args.push(limit);
|
||||
const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
@@ -874,14 +910,15 @@ export class NoteRepository {
|
||||
finalId = uuidv7();
|
||||
status = 'forked';
|
||||
}
|
||||
const notebookId = this.getDefaultNotebookId();
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO notes
|
||||
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user,
|
||||
user_intent, intent_prompted_at, deleted_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
user_intent, intent_prompted_at, deleted_at, created_at, updated_at, notebook_id)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
finalId,
|
||||
@@ -896,7 +933,8 @@ export class NoteRepository {
|
||||
input.intentPromptedAt,
|
||||
input.deletedAt ?? null,
|
||||
input.createdAt,
|
||||
input.updatedAt
|
||||
input.updatedAt,
|
||||
notebookId
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
@@ -947,6 +985,7 @@ export class NoteRepository {
|
||||
|
||||
if (!existing) {
|
||||
// INSERT path
|
||||
const notebookId = this.getDefaultNotebookId();
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
@@ -956,8 +995,8 @@ export class NoteRepository {
|
||||
user_intent, intent_prompted_at,
|
||||
created_at, updated_at,
|
||||
due_date, due_date_edited_by_user,
|
||||
status, status_changed_at, move_reason)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
status, status_changed_at, move_reason, notebook_id)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.id,
|
||||
@@ -976,7 +1015,8 @@ export class NoteRepository {
|
||||
input.dueDateEditedByUser ? 1 : 0,
|
||||
input.status,
|
||||
input.statusChangedAt,
|
||||
input.moveReason
|
||||
input.moveReason,
|
||||
notebookId
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
@@ -1098,9 +1138,12 @@ export class NoteRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes whose due_date is strictly before today (KST calendar) and that are
|
||||
* still active (not trashed) and AI-processed. Includes both AI-extracted and
|
||||
* user-edited due_date (v0.2.3 #5 spec §1 Q1=B).
|
||||
* Notes whose due_date is today (KST calendar) or already past, that are still
|
||||
* active (inbox only — completed/archived/trashed 제외), and AI-processed.
|
||||
* Includes both AI-extracted and user-edited due_date.
|
||||
*
|
||||
* 정렬: due_date DESC → 오늘 당일 먼저, 그 다음 어제, 그 전... 같은 due_date 내에선
|
||||
* created_at DESC, id DESC tiebreak.
|
||||
*
|
||||
* Caller may inject `now` for testability; defaults to `new Date()`.
|
||||
*/
|
||||
@@ -1110,15 +1153,38 @@ export class NoteRepository {
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE due_date IS NOT NULL
|
||||
AND due_date < ?
|
||||
AND due_date <= ?
|
||||
AND deleted_at IS NULL
|
||||
AND status = 'active'
|
||||
AND ai_status = 'done'
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
ORDER BY due_date DESC, created_at DESC, id DESC`
|
||||
)
|
||||
.all(today) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4 Task 5 — default notebook 안 active 노트를 tag 기준으로 cluster 하여
|
||||
* threshold 이상인 tag + noteIds 배열 반환. notebook 승격 제안 트리거용.
|
||||
* completed/trashed/non-default-notebook 제외.
|
||||
*/
|
||||
findPromotionCandidates(
|
||||
defaultNotebookId: string,
|
||||
threshold: number = 3
|
||||
): Array<{ tag: string; noteIds: string[] }> {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT t.name AS tag, GROUP_CONCAT(n.id) AS ids, COUNT(DISTINCT n.id) AS cnt
|
||||
FROM tags t
|
||||
JOIN note_tags nt ON nt.tag_id = t.id
|
||||
JOIN notes n ON n.id = nt.note_id
|
||||
WHERE n.status = 'active'
|
||||
AND n.notebook_id = ?
|
||||
GROUP BY t.id
|
||||
HAVING cnt >= ?`
|
||||
).all(defaultNotebookId, threshold) as Array<{ tag: string; ids: string; cnt: number }>;
|
||||
return rows.map((r) => ({ tag: r.tag, noteIds: r.ids.split(',') }));
|
||||
}
|
||||
|
||||
getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
|
||||
@@ -1196,7 +1262,8 @@ export class NoteRepository {
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string,
|
||||
tags: tags as NoteTag[],
|
||||
media
|
||||
media,
|
||||
notebookId: row.notebook_id as string
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
123
src/main/repository/NotebookRepository.ts
Normal file
123
src/main/repository/NotebookRepository.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
import type { Notebook } from '@shared/types';
|
||||
|
||||
export class NotebookRepository {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
list(): Notebook[] {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
|
||||
(SELECT COUNT(*) FROM notes n
|
||||
WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
|
||||
FROM notebooks nb
|
||||
ORDER BY nb.sort_order ASC, nb.name ASC`
|
||||
).all() as Array<Record<string, unknown>>;
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
findById(id: string): Notebook | null {
|
||||
const r = this.db.prepare(
|
||||
`SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
|
||||
(SELECT COUNT(*) FROM notes n
|
||||
WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
|
||||
FROM notebooks nb WHERE nb.id = ?`
|
||||
).get(id) as Record<string, unknown> | undefined;
|
||||
return r ? this.hydrate(r) : null;
|
||||
}
|
||||
|
||||
/** name 은 COLLATE NOCASE UNIQUE — case-insensitive 중복 거부. */
|
||||
create(input: { name: string; color?: string | null }): Notebook {
|
||||
const id = uuidv7();
|
||||
const now = new Date().toISOString();
|
||||
const maxRow = this.db.prepare(`SELECT COALESCE(MAX(sort_order), -1) AS m FROM notebooks`).get() as { m: number };
|
||||
const sortOrder = maxRow.m + 1;
|
||||
this.db.prepare(
|
||||
`INSERT INTO notebooks(id, name, color, created_at, updated_at, sort_order) VALUES(?,?,?,?,?,?)`
|
||||
).run(id, input.name, input.color ?? null, now, now, sortOrder);
|
||||
return { id, name: input.name, color: input.color ?? null, createdAt: now, updatedAt: now, noteCount: 0 };
|
||||
}
|
||||
|
||||
reorder(id: string, direction: 'up' | 'down'): { ok: boolean } {
|
||||
const cur = this.db.prepare(`SELECT sort_order FROM notebooks WHERE id = ?`).get(id) as { sort_order: number } | undefined;
|
||||
if (!cur) return { ok: false };
|
||||
const neighbor = direction === 'up'
|
||||
? this.db.prepare(`SELECT id, sort_order FROM notebooks WHERE sort_order < ? ORDER BY sort_order DESC LIMIT 1`).get(cur.sort_order)
|
||||
: this.db.prepare(`SELECT id, sort_order FROM notebooks WHERE sort_order > ? ORDER BY sort_order ASC LIMIT 1`).get(cur.sort_order);
|
||||
if (!neighbor) return { ok: false }; // 이미 끝
|
||||
const n = neighbor as { id: string; sort_order: number };
|
||||
const now = new Date().toISOString();
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db.prepare(`UPDATE notebooks SET sort_order = ?, updated_at = ? WHERE id = ?`).run(n.sort_order, now, id);
|
||||
this.db.prepare(`UPDATE notebooks SET sort_order = ?, updated_at = ? WHERE id = ?`).run(cur.sort_order, now, n.id);
|
||||
});
|
||||
tx();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
rename(id: string, name: string): void {
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`UPDATE notebooks SET name=?, updated_at=? WHERE id=?`).run(name, now, id);
|
||||
}
|
||||
|
||||
setColor(id: string, color: string | null): void {
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`UPDATE notebooks SET color=?, updated_at=? WHERE id=?`).run(color, now, id);
|
||||
}
|
||||
|
||||
/** FK RESTRICT 가 메모 잔류 시 throw — ok:false 로 변환. */
|
||||
delete(id: string): { ok: true } | { ok: false; reason: 'has_notes' | 'not_found' } {
|
||||
const exists = this.db.prepare(`SELECT 1 FROM notebooks WHERE id=?`).get(id);
|
||||
if (!exists) return { ok: false, reason: 'not_found' };
|
||||
try {
|
||||
this.db.prepare(`DELETE FROM notebooks WHERE id=?`).run(id);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes('FOREIGN KEY')) return { ok: false, reason: 'has_notes' };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** name 으로 notebook 조회 (COLLATE NOCASE — case-insensitive). */
|
||||
findByName(name: string): Notebook | null {
|
||||
const r = this.db.prepare(
|
||||
`SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
|
||||
(SELECT COUNT(*) FROM notes n
|
||||
WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
|
||||
FROM notebooks nb WHERE nb.name = ? COLLATE NOCASE`
|
||||
).get(name) as Record<string, unknown> | undefined;
|
||||
return r ? this.hydrate(r) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4 Task 11 — 가장 오래된 (created_at ASC LIMIT 1) notebook = default.
|
||||
* m008 마이그레이션이 기존 노트를 자동으로 이 notebook 에 할당.
|
||||
*/
|
||||
getDefault(): Notebook | null {
|
||||
const r = this.db.prepare(
|
||||
`SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
|
||||
(SELECT COUNT(*) FROM notes n
|
||||
WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
|
||||
FROM notebooks nb ORDER BY nb.created_at ASC LIMIT 1`
|
||||
).get() as Record<string, unknown> | undefined;
|
||||
return r ? this.hydrate(r) : null;
|
||||
}
|
||||
|
||||
/** notes.notebook_id 갱신만 (status 등은 보존). */
|
||||
moveNote(noteId: string, notebookId: string): void {
|
||||
this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`)
|
||||
.run(notebookId, new Date().toISOString(), noteId);
|
||||
}
|
||||
|
||||
private hydrate(r: Record<string, unknown>): Notebook {
|
||||
return {
|
||||
id: r.id as string,
|
||||
name: r.name as string,
|
||||
color: (r.color as string | null) ?? null,
|
||||
createdAt: r.created_at as string,
|
||||
updatedAt: r.updated_at as string,
|
||||
noteCount: r.note_count as number
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ export interface AutostartState {
|
||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
execPath: string;
|
||||
/**
|
||||
* 플랫폼 분기용. macOS 13+ 의 SMAppService API 는 args 옵션 무시 + unsigned/Electron
|
||||
* 앱에 대해 executableWillLaunchAtLogin 이 false 를 반환할 수 있어, mismatch 판정에서
|
||||
* 해당 신호를 제외해야 false positive 방지 가능.
|
||||
*/
|
||||
platform: NodeJS.Platform;
|
||||
registryPath?: string;
|
||||
registryValue?: string | null;
|
||||
}
|
||||
@@ -26,7 +32,8 @@ export async function collectAutostartState(): Promise<AutostartState> {
|
||||
const state: AutostartState = {
|
||||
withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin },
|
||||
noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin },
|
||||
execPath: process.execPath
|
||||
execPath: process.execPath,
|
||||
platform: process.platform
|
||||
};
|
||||
if (process.platform === 'win32') {
|
||||
state.registryPath = `${WIN_REGISTRY_PATH}\\${WIN_REGISTRY_KEY}`;
|
||||
|
||||
@@ -146,9 +146,9 @@ export class CaptureService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료 후보 (due_date < today KST, active, ai_status=done) 조회.
|
||||
* 마감 임박 후보 (due_date ≤ today KST, status=active inbox, ai_status=done) 조회.
|
||||
* 오늘 당일 마감 메모도 포함하여 사용자에게 미리 인지시킨다 (정렬은 due_date DESC).
|
||||
* candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit.
|
||||
* v0.2.3 #5 spec §6.2 — dedup 위치 main 통합.
|
||||
*/
|
||||
async listExpired(now: Date = new Date()): Promise<Note[]> {
|
||||
const candidates = this.repo.findExpiredCandidates(now);
|
||||
|
||||
@@ -136,7 +136,6 @@ export class ExportService {
|
||||
totalBytes += Buffer.byteLength(indexJsonl, 'utf8');
|
||||
|
||||
const manifest = composeManifest({
|
||||
exportedAt: this.now().toISOString(),
|
||||
noteCount: notes.length,
|
||||
mediaCount
|
||||
});
|
||||
|
||||
@@ -150,7 +150,9 @@ export class ImportService {
|
||||
userIntent: parsed.userIntent,
|
||||
intentPromptedAt: parsed.intentPromptedAt,
|
||||
tags: parsed.tags,
|
||||
status: parsed.status,
|
||||
// v0.4 Task 16 — 'archived' 는 NoteStatus 에서 제거됨. 기존 내보내기 파일의
|
||||
// status=archived 를 읽을 때 completed 로 coerce (m008 과 동일 정책).
|
||||
status: parsed.status === 'archived' ? 'completed' : parsed.status,
|
||||
statusChangedAt: parsed.statusChangedAt,
|
||||
moveReason: parsed.moveReason,
|
||||
dueDate: parsed.dueDate,
|
||||
|
||||
@@ -21,7 +21,12 @@ const SettingsSchema = z.object({
|
||||
// v0.3.1 Cut F
|
||||
vision_model: z.string().nullable().optional(),
|
||||
vision_capable_cache: z.array(z.string()).optional(),
|
||||
vision_cache_at: z.string().optional()
|
||||
vision_cache_at: z.string().optional(),
|
||||
// v0.4 Task 11 — promotion candidate 영속화 + sidebar 레이아웃.
|
||||
promotion_dismissed_tags: z.array(z.string()).optional(),
|
||||
promotion_snoozed_until_ms: z.number().int().optional(),
|
||||
sidebar_visible: z.boolean().optional(),
|
||||
sidebar_width: z.number().int().min(180).max(400).optional()
|
||||
}).strict();
|
||||
|
||||
export type Settings = z.infer<typeof SettingsSchema>;
|
||||
@@ -155,6 +160,50 @@ export class SettingsService {
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
// v0.4 Task 11 — promotion candidate 영속화.
|
||||
async getPromotionDismissedTags(): Promise<string[]> {
|
||||
const s = await this.load();
|
||||
return s.promotion_dismissed_tags ?? [];
|
||||
}
|
||||
|
||||
async addPromotionDismissedTag(tag: string): Promise<void> {
|
||||
const s = await this.load();
|
||||
const list = new Set(s.promotion_dismissed_tags ?? []);
|
||||
list.add(tag);
|
||||
await this.persist({ ...s, promotion_dismissed_tags: [...list] });
|
||||
}
|
||||
|
||||
async getPromotionSnoozeUntil(): Promise<number> {
|
||||
const s = await this.load();
|
||||
return s.promotion_snoozed_until_ms ?? 0;
|
||||
}
|
||||
|
||||
async setPromotionSnoozeUntil(ms: number): Promise<void> {
|
||||
const s = await this.load();
|
||||
await this.persist({ ...s, promotion_snoozed_until_ms: ms });
|
||||
}
|
||||
|
||||
// v0.4 Task 15 — sidebar 레이아웃 영속화.
|
||||
async getSidebarVisible(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.sidebar_visible ?? true;
|
||||
}
|
||||
|
||||
async setSidebarVisible(v: boolean): Promise<void> {
|
||||
const s = await this.load();
|
||||
await this.persist({ ...s, sidebar_visible: v });
|
||||
}
|
||||
|
||||
async getSidebarWidth(): Promise<number> {
|
||||
const s = await this.load();
|
||||
return s.sidebar_width ?? 240;
|
||||
}
|
||||
|
||||
async setSidebarWidth(v: number): Promise<void> {
|
||||
const s = await this.load();
|
||||
await this.persist({ ...s, sidebar_width: v });
|
||||
}
|
||||
|
||||
private async persist(next: Settings): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
const tmpPath = this.filePath + '.tmp';
|
||||
|
||||
@@ -253,14 +253,15 @@ export function composeIndexJsonl(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function composeManifest(input: {
|
||||
exportedAt: string;
|
||||
noteCount: number;
|
||||
mediaCount: number;
|
||||
}): string {
|
||||
// exported_at 필드 의도적 제외 — note 변경 없이도 git sync 가 매 호출마다
|
||||
// timestamp 갱신 1줄 commit 을 만들어 history 노이즈와 불필요한 push 유발.
|
||||
// import path 는 inkling_export_version 만 read 하므로 안전.
|
||||
return JSON.stringify(
|
||||
{
|
||||
inkling_export_version: 1,
|
||||
exported_at: input.exportedAt,
|
||||
note_count: input.noteCount,
|
||||
media_count: input.mediaCount
|
||||
},
|
||||
|
||||
@@ -11,16 +11,19 @@ export function getInboxWindow(): BrowserWindowType | null {
|
||||
return inboxWindow;
|
||||
}
|
||||
|
||||
export function createInboxWindow(): BrowserWindowType {
|
||||
export function createInboxWindow(opts: { visible?: boolean } = {}): BrowserWindowType {
|
||||
const visible = opts.visible ?? true;
|
||||
if (inboxWindow && !inboxWindow.isDestroyed()) {
|
||||
inboxWindow.show();
|
||||
inboxWindow.focus();
|
||||
if (visible) {
|
||||
inboxWindow.show();
|
||||
inboxWindow.focus();
|
||||
}
|
||||
return inboxWindow;
|
||||
}
|
||||
|
||||
inboxWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 720,
|
||||
width: 1440,
|
||||
height: 900,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
@@ -43,6 +46,19 @@ export function createInboxWindow(): BrowserWindowType {
|
||||
}
|
||||
});
|
||||
|
||||
inboxWindow.once('ready-to-show', () => inboxWindow?.show());
|
||||
inboxWindow.once('ready-to-show', () => {
|
||||
if (visible) {
|
||||
inboxWindow?.show();
|
||||
return;
|
||||
}
|
||||
// macOS hidden autostart: regular NSWindow 를 NSApp 에 register 해야 dock running
|
||||
// indicator (점) 가 표출된다. panel type 의 quickCapture 만 있으면 NSPanel 미인지 →
|
||||
// dock 점이 안 보여 "앱이 안 떠 있는 것처럼" 보이는 버그. showInactive 로 focus 점유
|
||||
// 없이 짧게 표출 후 즉시 hide — 사용자 화면 깜빡임 최소화.
|
||||
if (process.platform === 'darwin') {
|
||||
inboxWindow?.showInactive();
|
||||
inboxWindow?.hide();
|
||||
}
|
||||
});
|
||||
return inboxWindow;
|
||||
}
|
||||
|
||||
@@ -16,23 +16,35 @@ export function createQuickCaptureWindow(): BrowserWindowType {
|
||||
const x = Math.round((primary.workArea.width - W) / 2 + primary.workArea.x);
|
||||
const y = Math.round((primary.workArea.height - H) / 3 + primary.workArea.y);
|
||||
|
||||
// v0.3.10 — macOS fullscreen Space 위에 quick capture 띄우기.
|
||||
// 기본 BrowserWindow 는 첫 생성된 Space (홈 데스크탑) 에만 표시되므로
|
||||
// 사용자가 다른 앱 fullscreen 중일 때 macOS 가 강제 Space 전환 → 사용자 경험 깨짐.
|
||||
// 'panel' 타입 + 'screen-saver' level + visibleOnFullScreen 조합으로 현재 Space 위에 overlay.
|
||||
const isMac = process.platform === 'darwin';
|
||||
win = new BrowserWindow({
|
||||
width: W, height: H, x, y,
|
||||
frame: false, show: false, alwaysOnTop: true,
|
||||
skipTaskbar: true, resizable: false,
|
||||
...(isMac ? { type: 'panel' as const } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true, nodeIntegration: false, sandbox: false
|
||||
}
|
||||
});
|
||||
|
||||
if (isMac) {
|
||||
// 'screen-saver' level — fullscreen app 위에 띄울 수 있는 가장 높은 level.
|
||||
// visibleOnFullScreen: true — 현재 fullscreen Space 에 함께 표시 (Space 전환 안 함).
|
||||
win.setAlwaysOnTop(true, 'screen-saver');
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
}
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/quickcapture/index.html`);
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../renderer/quickcapture/index.html'));
|
||||
}
|
||||
|
||||
win.on('blur', () => { if (win?.isVisible()) win.hide(); });
|
||||
return win;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const api: InklingApi = {
|
||||
hide: () => ipcRenderer.send('capture:hide')
|
||||
},
|
||||
inbox: {
|
||||
listNotes: (opts) => ipcRenderer.invoke('inbox:list', opts),
|
||||
listNotes: (opts: { limit: number; cursor?: string; notebookId?: string }) => ipcRenderer.invoke('inbox:list', opts),
|
||||
updateAiFields: (noteId, fields) =>
|
||||
ipcRenderer.invoke('inbox:updateAi', { noteId, fields }),
|
||||
setDueDate: (noteId, date) => ipcRenderer.invoke('inbox:setDueDate', { noteId, date }),
|
||||
@@ -71,8 +71,9 @@ const api: InklingApi = {
|
||||
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
|
||||
openMedia: (relPath: string) => ipcRenderer.invoke('inbox:open-media', relPath),
|
||||
// v0.2.9 Cut B Task 4 — status 별 list + counts.
|
||||
// v0.4 — notebookId 옵션 pass-through. countsByStatus opts 추가.
|
||||
listByStatus: (status, opts) => ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}),
|
||||
countsByStatus: () => ipcRenderer.invoke('inbox:counts-by-status'),
|
||||
countsByStatus: (opts?: { notebookId?: string }) => ipcRenderer.invoke('inbox:counts-by-status', opts ?? {}),
|
||||
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
|
||||
setStatus: (id, status, reason) => ipcRenderer.invoke('inbox:set-status', id, status, reason),
|
||||
classifyStatus: (id, reason) => ipcRenderer.invoke('ai:classify-status', id, reason),
|
||||
@@ -80,6 +81,9 @@ const api: InklingApi = {
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
setAiEnabled: (enabled: boolean) => ipcRenderer.invoke('settings:set-ai-enabled', enabled),
|
||||
setOnboardingCompleted: (completed: boolean) => ipcRenderer.invoke('settings:set-onboarding-completed', completed),
|
||||
// v0.4 — Sidebar UI state 영속화
|
||||
setSidebarVisible: (visible: boolean) => ipcRenderer.invoke('settings:set-sidebar-visible', visible),
|
||||
setSidebarWidth: (width: number) => ipcRenderer.invoke('settings:set-sidebar-width', width),
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 재투입 + count.
|
||||
enqueueDisabled: () => ipcRenderer.invoke('inbox:enqueue-disabled'),
|
||||
getDisabledCount: () => ipcRenderer.invoke('inbox:get-disabled-count'),
|
||||
@@ -88,6 +92,7 @@ const api: InklingApi = {
|
||||
listRevisions: (noteId: string) => ipcRenderer.invoke('inbox:list-revisions', noteId),
|
||||
restoreRevision: (noteId: string, revId: number) => ipcRenderer.invoke('inbox:restore-revision', noteId, revId),
|
||||
// v0.2.11 Cut D — search + 회고 aggregate.
|
||||
// v0.4 — notebookId 옵션 pass-through.
|
||||
search: (query, opts) => ipcRenderer.invoke('inbox:search', query, opts ?? {}),
|
||||
reviewAggregate: (period) => ipcRenderer.invoke('inbox:review-aggregate', period),
|
||||
// v0.3.0 Cut E — 양방향 sync.
|
||||
@@ -103,6 +108,24 @@ const api: InklingApi = {
|
||||
getVisionModels: () => ipcRenderer.invoke('settings:get-vision-models'),
|
||||
setVisionModel: (value: string | null) => ipcRenderer.invoke('settings:set-vision-model', value),
|
||||
refreshVisionCache: () => ipcRenderer.invoke('settings:refresh-vision-cache'),
|
||||
// v0.4 T4 — AI batch classify (단일 prompt).
|
||||
batchClassifyDefault: () => ipcRenderer.invoke('inbox:batch-classify-default'),
|
||||
// v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
|
||||
listPromotionCandidates: () => ipcRenderer.invoke('inbox:list-promotion-candidates'),
|
||||
getPromotionDismissedTags: () => ipcRenderer.invoke('inbox:get-promotion-dismissed-tags'),
|
||||
addPromotionDismissedTag: (tag: string) => ipcRenderer.invoke('inbox:add-promotion-dismissed-tag', tag),
|
||||
getPromotionSnoozeUntil: () => ipcRenderer.invoke('inbox:get-promotion-snoozed-until'),
|
||||
setPromotionSnoozeUntil: (ms: number) => ipcRenderer.invoke('inbox:set-promotion-snoozed-until', ms),
|
||||
},
|
||||
// v0.4 — notebook CRUD IPC
|
||||
notebook: {
|
||||
list: () => ipcRenderer.invoke('notebook:list'),
|
||||
create: (input: { name: string; color?: string }) => ipcRenderer.invoke('notebook:create', input),
|
||||
rename: (id: string, name: string) => ipcRenderer.invoke('notebook:rename', id, name),
|
||||
setColor: (id: string, color: string | null) => ipcRenderer.invoke('notebook:set-color', id, color),
|
||||
delete: (id: string) => ipcRenderer.invoke('notebook:delete', id),
|
||||
moveNote: (noteId: string, notebookId: string) => ipcRenderer.invoke('notebook:move-note', noteId, notebookId),
|
||||
reorder: (id: string, direction: 'up' | 'down') => ipcRenderer.invoke('notebook:reorder', id, direction)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,8 +16,14 @@ import { SettingsPage } from './components/SettingsPage.js';
|
||||
import { OnboardingWizard } from './components/OnboardingWizard.js';
|
||||
import { SearchBox } from './components/SearchBox.js';
|
||||
import { ReviewView } from './components/ReviewView.js';
|
||||
import { Sidebar } from './components/Sidebar.js';
|
||||
import { PromotionBanner } from './components/PromotionBanner.js';
|
||||
import { BatchMoveModal } from './components/BatchMoveModal.js';
|
||||
import type { InboxView } from './store.js';
|
||||
|
||||
// QuickCapture 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl.
|
||||
const MOD_KEY = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
notes, trashNotes, trashCount, showTrash,
|
||||
@@ -32,6 +38,10 @@ export function App(): React.ReactElement {
|
||||
const counts = useInbox((s) => s.counts);
|
||||
const setView = useInbox((s) => s.setView);
|
||||
const searchResults = useInbox((s) => s.searchResults);
|
||||
const selectedNotebookId = useInbox((s) => s.selectedNotebookId);
|
||||
const notebooks = useInbox((s) => s.notebooks);
|
||||
const runBatchClassify = useInbox((s) => s.runBatchClassify);
|
||||
const batchClassifying = useInbox((s) => s.batchClassifying);
|
||||
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
||||
// v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시.
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
||||
@@ -48,6 +58,23 @@ export function App(): React.ReactElement {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void useInbox.getState().loadNotebooks();
|
||||
void useInbox.getState().loadPromotionCandidates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isMac = /Mac/i.test(navigator.platform);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'b' && (isMac ? e.metaKey : e.ctrlKey) && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
useInbox.getState().toggleSidebar();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitial();
|
||||
const unsubNote = inboxApi.onNoteUpdated((note) => {
|
||||
@@ -68,6 +95,9 @@ export function App(): React.ReactElement {
|
||||
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
|
||||
}, [loadInitial, refreshMeta, upsertNote]);
|
||||
|
||||
// v0.4 T5 — default notebook(첫 번째) 선택 시 "AI 정리하기" 버튼 노출 조건.
|
||||
const isDefaultNotebook = notebooks.length > 0 && selectedNotebookId === notebooks[0]?.id;
|
||||
|
||||
if (showOnboarding === null) return <></>;
|
||||
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;
|
||||
|
||||
@@ -92,15 +122,27 @@ export function App(): React.ReactElement {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', height: '100vh' }}>
|
||||
<Sidebar />
|
||||
<main style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div className="header">
|
||||
<button
|
||||
onClick={() => useInbox.getState().toggleSidebar()}
|
||||
aria-label="사이드바 토글"
|
||||
title="사이드바 토글 (Cmd/Ctrl+B)"
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 18, padding: '0 8px 0 0', color: '#444', lineHeight: 1
|
||||
}}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
|
||||
{(
|
||||
[
|
||||
{ key: 'inbox', label: 'Inbox', count: counts.active },
|
||||
{ key: 'completed', label: '완료', count: counts.completed },
|
||||
{ key: 'archived', label: '보관', count: counts.archived },
|
||||
{ key: 'trash', label: '휴지통', count: counts.trashed }
|
||||
] as const
|
||||
).map((t) => (
|
||||
@@ -116,6 +158,19 @@ export function App(): React.ReactElement {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === 'inbox' && isDefaultNotebook && notebooks.length > 1 && (
|
||||
<button
|
||||
onClick={() => { void runBatchClassify(); }}
|
||||
disabled={batchClassifying}
|
||||
style={{
|
||||
background: '#5a3a8c', color: '#fff', border: 'none', borderRadius: 4,
|
||||
padding: '4px 10px', fontSize: 11, cursor: batchClassifying ? 'not-allowed' : 'pointer',
|
||||
marginLeft: 8
|
||||
}}
|
||||
>
|
||||
{batchClassifying ? '🪄 분석 중…' : '🪄 AI 정리하기'}
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
aria-label="회고 기간"
|
||||
value={view.startsWith('review-') ? view.replace('review-', '') : ''}
|
||||
@@ -150,7 +205,7 @@ export function App(): React.ReactElement {
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
<main className="main">
|
||||
<div className="main">
|
||||
{!showTrash && (
|
||||
<>
|
||||
{/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출.
|
||||
@@ -166,6 +221,7 @@ export function App(): React.ReactElement {
|
||||
<FailedBanner />
|
||||
<ExpiryBanner />
|
||||
<RecallBanner />
|
||||
<PromotionBanner />
|
||||
</>
|
||||
)}
|
||||
{tagFilter !== null && (
|
||||
@@ -190,7 +246,7 @@ export function App(): React.ReactElement {
|
||||
) : searchResults !== null && displayed.length === 0 ? (
|
||||
<div className="empty">검색 결과가 없습니다.</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>{MOD_KEY}+Shift+J</code></div>
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||
) : (
|
||||
@@ -234,8 +290,10 @@ export function App(): React.ReactElement {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<TagUndoToast />
|
||||
</>
|
||||
</main>
|
||||
<BatchMoveModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import type { InboxApi } from '@shared/types';
|
||||
import type { InboxApi, NotebookApi } from '@shared/types';
|
||||
export const inboxApi: InboxApi = window.inkling.inbox;
|
||||
export const notebookApi: NotebookApi = window.inkling.notebook;
|
||||
|
||||
98
src/renderer/inbox/components/BatchMoveModal.tsx
Normal file
98
src/renderer/inbox/components/BatchMoveModal.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
|
||||
export function BatchMoveModal(): React.ReactElement | null {
|
||||
const result = useInbox((s) => s.batchClassifyResult);
|
||||
const clear = useInbox((s) => s.clearBatchClassify);
|
||||
const accept = useInbox((s) => s.acceptBatchAssignments);
|
||||
const notes = useInbox((s) => s.notes);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// result 변할 때 → 매칭된 (notebookId != null) noteId 모두 default 로 select
|
||||
useEffect(() => {
|
||||
if (result === null) return;
|
||||
const ids = new Set<string>();
|
||||
for (const a of result.assignments) {
|
||||
if (a.notebookId !== null) ids.add(a.noteId);
|
||||
}
|
||||
setSelectedIds(ids);
|
||||
}, [result]);
|
||||
|
||||
if (result === null) return null;
|
||||
|
||||
const actionable = result.assignments.filter((a) => a.notebookId !== null);
|
||||
|
||||
function toggle(noteId: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(noteId)) n.delete(noteId);
|
||||
else n.add(noteId);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
function findNote(id: string): string {
|
||||
const n = notes.find((nn) => nn.id === id);
|
||||
return n?.aiTitle ?? n?.rawText.slice(0, 60) ?? '(노트)';
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
const accepted = actionable
|
||||
.filter((a) => selectedIds.has(a.noteId) && a.notebookId !== null)
|
||||
.map((a) => ({ noteId: a.noteId, notebookId: a.notebookId! }));
|
||||
await accept(accepted);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={clear}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 130 }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ background: '#fff', padding: 20, borderRadius: 8, width: 560, maxHeight: '80vh', overflow: 'auto' }}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 12px 0', fontSize: 15 }}>AI 분류 제안</h3>
|
||||
{actionable.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: '#666' }}>
|
||||
현재 분류할 만한 노트를 찾지 못했어요. 노트북을 더 만들거나 노트가 누적된 뒤 다시 시도해보세요.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: 12, color: '#666', marginBottom: 10 }}>
|
||||
체크된 노트만 이동합니다. ({selectedIds.size}/{actionable.length})
|
||||
</p>
|
||||
<div style={{ maxHeight: '50vh', overflow: 'auto', border: '1px solid #eee', borderRadius: 4 }}>
|
||||
{actionable.map((a) => (
|
||||
<label
|
||||
key={a.noteId}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid #f0f0f0', cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(a.noteId)}
|
||||
onChange={() => toggle(a.noteId)}
|
||||
/>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{findNote(a.noteId)}
|
||||
</span>
|
||||
<span style={{ color: '#0a4b80', fontSize: 11 }}>→ {a.notebookName}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14 }}>
|
||||
<button onClick={clear} style={{ padding: '5px 12px', fontSize: 12 }}>취소</button>
|
||||
<button
|
||||
onClick={() => { void onConfirm(); }}
|
||||
disabled={selectedIds.size === 0}
|
||||
style={{ padding: '5px 12px', fontSize: 12, background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, cursor: selectedIds.size === 0 ? 'not-allowed' : 'pointer', opacity: selectedIds.size === 0 ? 0.5 : 1 }}
|
||||
>
|
||||
{selectedIds.size}건 이동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,35 @@ import React, { useEffect, useState } from 'react';
|
||||
import type { Note } from '@shared/types';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { DAY_MS, kstTodayIso } from '@shared/util/kstDate.js';
|
||||
|
||||
/**
|
||||
* due_date 대비 오늘 (KST) 의 상대 라벨. 오늘 = "오늘", 지난 = "N일 지남".
|
||||
* findExpiredCandidates 가 미래 due 는 반환하지 않으므로 음수 케이스 미고려.
|
||||
*/
|
||||
function dueRelativeLabel(due: string, todayKst: string): string {
|
||||
if (due === todayKst) return '오늘';
|
||||
const dueUtc = Date.UTC(
|
||||
Number(due.slice(0, 4)), Number(due.slice(5, 7)) - 1, Number(due.slice(8, 10))
|
||||
);
|
||||
const todayUtc = Date.UTC(
|
||||
Number(todayKst.slice(0, 4)), Number(todayKst.slice(5, 7)) - 1, Number(todayKst.slice(8, 10))
|
||||
);
|
||||
const days = Math.round((todayUtc - dueUtc) / DAY_MS);
|
||||
return `${days}일 지남`;
|
||||
}
|
||||
|
||||
function headingText(todayCount: number, overdueCount: number): string {
|
||||
if (todayCount > 0 && overdueCount > 0) return `오늘 마감 ${todayCount} · 지난 ${overdueCount}`;
|
||||
if (todayCount > 0) return `오늘 마감 ${todayCount}개`;
|
||||
return `지난 ${overdueCount}개`;
|
||||
}
|
||||
|
||||
/** RecallBanner 와 동일 패턴 — NoteCard 의 `note-{id}` element 로 smooth scroll. */
|
||||
function scrollToNote(id: string): void {
|
||||
const el = document.getElementById(`note-${id}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
export function ExpiryBanner(): React.ReactElement | null {
|
||||
const candidates = useInbox((s) => s.expiredCandidates);
|
||||
@@ -58,6 +87,10 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
const allSelected = candidates.length > 0 && candidates.every((c) => selected.has(c.id));
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
|
||||
const todayKst = kstTodayIso();
|
||||
const todayCount = candidates.filter((c) => c.dueDate === todayKst).length;
|
||||
const overdueCount = candidates.length - todayCount;
|
||||
|
||||
function toggleAll() {
|
||||
if (allSelected) setSelected(new Set());
|
||||
else setSelected(new Set(candidates.map((c) => c.id)));
|
||||
@@ -75,7 +108,7 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
return (
|
||||
<Banner severity="warning">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>⏰ <b>오늘 기준 만료 {candidates.length}개</b></span>
|
||||
<span>⏰ <b>{headingText(todayCount, overdueCount)}</b></span>
|
||||
<button
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#946100' }}
|
||||
@@ -107,33 +140,51 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
<span style={{ color: '#666' }}>전체 선택 ({selected.size}/{candidates.length})</span>
|
||||
</label>
|
||||
<div>
|
||||
{candidates.map((n) => (
|
||||
<label
|
||||
key={n.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '4px 0', cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(n.id)}
|
||||
onChange={() => toggle(n.id)}
|
||||
/>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{n.aiTitle ?? n.rawText.slice(0, 60)}
|
||||
</span>
|
||||
<span style={{ color: '#946100', fontSize: 12 }}>due {n.dueDate}</span>
|
||||
{n.tags[0] && (
|
||||
<span style={{
|
||||
background: '#fce8b2', color: '#946100', padding: '0 6px',
|
||||
borderRadius: 10, fontSize: 11
|
||||
}}>
|
||||
#{n.tags[0].name}
|
||||
{candidates.map((n) => {
|
||||
const title = n.aiTitle ?? n.rawText.slice(0, 60);
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '4px 0'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(n.id)}
|
||||
onChange={() => toggle(n.id)}
|
||||
aria-label={`${title} 선택`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToNote(n.id)}
|
||||
title="해당 메모로 이동"
|
||||
style={{
|
||||
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
background: 'none', border: 'none', padding: 0,
|
||||
cursor: 'pointer', color: 'inherit', font: 'inherit', textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
<span
|
||||
style={{ color: '#946100', fontSize: 12 }}
|
||||
title={`due ${n.dueDate}`}
|
||||
>
|
||||
{dueRelativeLabel(n.dueDate ?? todayKst, todayKst)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{n.tags[0] && (
|
||||
<span style={{
|
||||
background: '#fce8b2', color: '#946100', padding: '0 6px',
|
||||
borderRadius: 10, fontSize: 11
|
||||
}}>
|
||||
#{n.tags[0].name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onTrash(Array.from(selected))}
|
||||
|
||||
@@ -18,8 +18,9 @@ interface Props {
|
||||
* 사유 입력 + AI 자동 분류 + 수동 status 선택. 버튼은 currentStatus 를 제외한
|
||||
* 나머지 status 만 노출 (v0.3.6 까지는 완료/보관/휴지통 hardcode 라 완료/보관 노트가
|
||||
* inbox 로 못 돌아오던 버그를 v0.3.7 에서 정정).
|
||||
* v0.4 Task 16 — 'archived' 제거. active/completed/trashed 3개 옵션만 노출.
|
||||
*/
|
||||
const ALL_STATUSES: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
|
||||
const ALL_STATUSES: readonly NoteStatus[] = ['active', 'completed', 'trashed'];
|
||||
|
||||
export function MoveStatusModal({
|
||||
noteId,
|
||||
@@ -150,8 +151,6 @@ export function statusLabel(s: NoteStatus): string {
|
||||
return 'Inbox';
|
||||
case 'completed':
|
||||
return '완료';
|
||||
case 'archived':
|
||||
return '보관';
|
||||
case 'trashed':
|
||||
return '휴지통';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { Note, Notebook } from '@shared/types';
|
||||
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
|
||||
import { inboxApi } from '../api.js';
|
||||
import { useInbox } from '../store.js';
|
||||
@@ -109,6 +109,64 @@ function DueDateBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function NotebookChip({ current, notebooks, onMove }: {
|
||||
current: Notebook;
|
||||
notebooks: Notebook[];
|
||||
onMove: (id: string) => Promise<void>;
|
||||
}): React.ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const others = notebooks.filter((nb) => nb.id !== current.id);
|
||||
const hasOthers = others.length > 0;
|
||||
return (
|
||||
<span style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
onClick={() => hasOthers && setOpen(!open)}
|
||||
title={hasOthers ? '다른 노트북으로 이동' : '현재 노트북'}
|
||||
disabled={!hasOthers}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: '#eaf3ff', color: '#0a4b80',
|
||||
border: '1px solid #cfe0f5', borderRadius: 10,
|
||||
padding: '2px 8px', fontSize: 11, cursor: hasOthers ? 'pointer' : 'default'
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: current.color ?? '#bbb', display: 'inline-block' }} />
|
||||
📓 {current.name}
|
||||
{hasOthers && <span style={{ fontSize: 9, opacity: 0.6 }}>▾</span>}
|
||||
</button>
|
||||
{open && hasOthers && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', bottom: '100%', left: 0,
|
||||
background: '#fff', border: '1px solid #ccc', borderRadius: 4,
|
||||
zIndex: 50, minWidth: 140, boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
marginBottom: 2
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 10, color: '#888', padding: '4px 10px', borderBottom: '1px solid #eee' }}>
|
||||
이동할 노트북
|
||||
</div>
|
||||
{others.map((nb) => (
|
||||
<button
|
||||
key={nb.id}
|
||||
onClick={async () => { await onMove(nb.id); setOpen(false); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
width: '100%', textAlign: 'left',
|
||||
padding: '4px 10px', border: 'none', background: 'transparent',
|
||||
cursor: 'pointer', fontSize: 11
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: nb.color ?? '#bbb', display: 'inline-block' }} />
|
||||
{nb.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement {
|
||||
const isTrash = mode === 'trash';
|
||||
// v0.2.9 Cut B Task 13 — ai_status='disabled' 노트는 raw_text 가 1차 정보. 원문 펼침 default 켬.
|
||||
@@ -122,6 +180,10 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
const [draftRaw, setDraftRaw] = useState('');
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
|
||||
const notebooks = useInbox((s) => s.notebooks);
|
||||
const moveNoteToNotebook = useInbox((s) => s.moveNoteToNotebook);
|
||||
const currentNb = notebooks.find((nb) => nb.id === local.notebookId);
|
||||
|
||||
React.useEffect(() => { setLocal(note); }, [note]);
|
||||
|
||||
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
|
||||
@@ -154,7 +216,15 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
if (next.trim().length === 0) return;
|
||||
const r = await inboxApi.updateRawText(note.id, next);
|
||||
if (!r.ok) return;
|
||||
const updated = { ...local, rawText: next, updatedAt: new Date().toISOString() };
|
||||
// disabled 노트는 AI 재처리 안 됨 (서버에서 skip) — aiStatus 유지.
|
||||
// 그 외는 optimistic 으로 pending 표시 → AiWorker 완료 시 push 로 자동 sync.
|
||||
const willReprocess = local.aiStatus !== 'disabled';
|
||||
const updated = {
|
||||
...local,
|
||||
rawText: next,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(willReprocess ? { aiStatus: 'pending' as const, aiError: null } : {})
|
||||
};
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
setEditingRaw(false);
|
||||
@@ -236,23 +306,40 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
</div>
|
||||
)}
|
||||
{local.aiStatus === 'failed' && (
|
||||
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55' }}>
|
||||
정리 보류 — 원문은 안전합니다
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#a55' }}>
|
||||
정리 보류 — 원문은 안전합니다
|
||||
</div>
|
||||
{/* v0.3.9 — per-note 재시도 UI. FailedBanner 의 일괄 재시도와 별개. */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
await inboxApi.retryOneFailed(local.id);
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #a55', color: '#a55',
|
||||
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
|
||||
}}
|
||||
title="이 노트만 AI 처리 재시도"
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
{/* v0.3.9 — per-note 재시도 UI. FailedBanner 의 일괄 재시도와 별개. */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
await inboxApi.retryOneFailed(local.id);
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #a55', color: '#a55',
|
||||
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
|
||||
}}
|
||||
title="이 노트만 AI 처리 재시도"
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
{/* v0.3.14 — fail 원인 inline 표시. ai_error 의 raw message 가 그대로 사용자에게
|
||||
보여서 디버깅 + 모델/네트워크 이슈 진단 가능. 너무 길면 <details> 로 접힘. */}
|
||||
{local.aiError !== null && local.aiError.length > 0 && (
|
||||
<details style={{ marginTop: 4 }}>
|
||||
<summary style={{ fontSize: 12, color: '#a55', cursor: 'pointer' }}>
|
||||
원인 보기
|
||||
</summary>
|
||||
<pre style={{
|
||||
fontSize: 11, color: '#666', background: '#fff0f0', padding: 6,
|
||||
borderRadius: 4, marginTop: 4, whiteSpace: 'pre-wrap', wordBreak: 'break-word'
|
||||
}}>
|
||||
{local.aiError}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
|
||||
@@ -450,6 +537,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{/* notebook chip — tag(키워드)와 notebook(컨텍스트) 의미 분리. done + inbox 에서만 표시. */}
|
||||
{!isTrash && local.aiStatus === 'done' && currentNb && (
|
||||
<NotebookChip
|
||||
current={currentNb}
|
||||
notebooks={notebooks}
|
||||
onMove={async (newId) => {
|
||||
await moveNoteToNotebook(local.id, newId);
|
||||
setLocal({ ...local, notebookId: newId });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 이동 버튼 — 클릭 시 MoveStatusModal 진입.
|
||||
사유 입력 + AI 자동 분류 + 수동 status 선택 한 곳에서 처리. */}
|
||||
<button
|
||||
|
||||
64
src/renderer/inbox/components/NotebookCreateModal.tsx
Normal file
64
src/renderer/inbox/components/NotebookCreateModal.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
|
||||
const COLOR_PALETTE = ['#0a4b80', '#236b1a', '#946100', '#a55', '#5a3a8c', '#1a5b6e'];
|
||||
|
||||
export function NotebookCreateModal({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
const createNotebook = useInbox((s) => s.createNotebook);
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState<string>(COLOR_PALETTE[0]!);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit() {
|
||||
const r = await createNotebook(name.trim(), color);
|
||||
if (r.ok) onClose();
|
||||
else setErr(r.reason === 'duplicate_name' ? '같은 이름의 노트북이 이미 있어요.' : (r.reason ?? '저장 실패'));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 120 }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ background: '#fff', padding: 20, borderRadius: 8, width: 360 }}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 12px 0', fontSize: 15 }}>새 노트북</h3>
|
||||
<input
|
||||
aria-label="노트북 이름"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="예: 회사, 학습"
|
||||
autoFocus
|
||||
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
|
||||
{COLOR_PALETTE.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
aria-label={`색 ${c}`}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
background: c, border: c === color ? '2px solid #333' : '1px solid #ccc',
|
||||
cursor: 'pointer', padding: 0
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{err && <div style={{ color: '#c33', fontSize: 12, marginTop: 8 }}>{err}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14 }}>
|
||||
<button onClick={onClose} style={{ padding: '5px 12px', fontSize: 12 }}>취소</button>
|
||||
<button
|
||||
onClick={() => { void onSubmit(); }}
|
||||
disabled={name.trim().length === 0}
|
||||
style={{ padding: '5px 12px', fontSize: 12, background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, cursor: name.trim().length === 0 ? 'not-allowed' : 'pointer', opacity: name.trim().length === 0 ? 0.5 : 1 }}
|
||||
>
|
||||
만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/renderer/inbox/components/NotebookList.tsx
Normal file
79
src/renderer/inbox/components/NotebookList.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Notebook } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
notebooks: Notebook[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onReorder?: (id: string, direction: 'up' | 'down') => Promise<void>;
|
||||
}
|
||||
|
||||
export function NotebookList({ notebooks, selectedId, onSelect, onCreate, onReorder }: Props): React.ReactElement {
|
||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{notebooks.map((nb, idx) => {
|
||||
const active = nb.id === selectedId;
|
||||
const hover = nb.id === hoverId;
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === notebooks.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={nb.id}
|
||||
onMouseEnter={() => setHoverId(nb.id)}
|
||||
onMouseLeave={() => setHoverId(null)}
|
||||
style={{ position: 'relative', display: 'flex' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => onSelect(nb.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 12px', background: active ? '#eaf3ff' : 'transparent',
|
||||
border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||
color: active ? '#0a4b80' : '#333', fontSize: 13, flex: 1
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: nb.color ?? '#bbb', flexShrink: 0
|
||||
}} />
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{nb.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#888' }}>{nb.noteCount}</span>
|
||||
</button>
|
||||
{hover && onReorder && notebooks.length > 1 && (
|
||||
<div style={{ position: 'absolute', right: 4, top: 2, display: 'flex', gap: 2 }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (!isFirst) void onReorder(nb.id, 'up'); }}
|
||||
disabled={isFirst}
|
||||
aria-label={`${nb.name} 위로`}
|
||||
title="위로"
|
||||
style={{ background: 'rgba(255,255,255,0.9)', border: '1px solid #ccc', borderRadius: 3, fontSize: 10, padding: '0 4px', cursor: isFirst ? 'not-allowed' : 'pointer', opacity: isFirst ? 0.3 : 1 }}
|
||||
>▲</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (!isLast) void onReorder(nb.id, 'down'); }}
|
||||
disabled={isLast}
|
||||
aria-label={`${nb.name} 아래로`}
|
||||
title="아래로"
|
||||
style={{ background: 'rgba(255,255,255,0.9)', border: '1px solid #ccc', borderRadius: 3, fontSize: 10, padding: '0 4px', cursor: isLast ? 'not-allowed' : 'pointer', opacity: isLast ? 0.3 : 1 }}
|
||||
>▼</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={onCreate}
|
||||
style={{
|
||||
padding: '6px 12px', background: 'transparent', border: 'none',
|
||||
cursor: 'pointer', textAlign: 'left', color: '#888', fontSize: 12
|
||||
}}
|
||||
>
|
||||
+ 새 노트북
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/renderer/inbox/components/PromotionBanner.tsx
Normal file
78
src/renderer/inbox/components/PromotionBanner.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
const COLOR_PALETTE = ['#0a4b80', '#236b1a', '#946100', '#a55', '#5a3a8c'];
|
||||
|
||||
export function PromotionBanner(): React.ReactElement | null {
|
||||
const candidates = useInbox((s) => s.promotionCandidates);
|
||||
const accept = useInbox((s) => s.acceptPromotion);
|
||||
const snooze = useInbox((s) => s.snoozePromotion);
|
||||
const dismiss = useInbox((s) => s.dismissPromotion);
|
||||
const [editing, setEditing] = useState<{ tag: string; name: string; color: string } | null>(null);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
const c = candidates[0]!;
|
||||
|
||||
return (
|
||||
<Banner severity="info">
|
||||
{editing === null ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>💡 <code>{c.tag}</code> 관련 노트 {c.noteIds.length}개가 모였어요. 새 노트북 <b>{c.suggestedName}</b> 로 분리할까요?</span>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto' }}>
|
||||
<button
|
||||
onClick={() => setEditing({ tag: c.tag, name: c.suggestedName, color: COLOR_PALETTE[0]! })}
|
||||
style={{ background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
|
||||
>
|
||||
수락
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void snooze(); }}
|
||||
style={{ background: 'transparent', color: '#234', border: '1px solid #ccc', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
|
||||
>
|
||||
나중에
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void dismiss(c.tag); }}
|
||||
style={{ background: 'transparent', color: '#888', border: 'none', cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
숨기기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
aria-label="노트북 이름"
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
style={{ flex: 1, fontSize: 13, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4, minWidth: 120 }}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{COLOR_PALETTE.map((col) => (
|
||||
<button
|
||||
key={col}
|
||||
onClick={() => setEditing({ ...editing, color: col })}
|
||||
aria-label={`색 ${col}`}
|
||||
style={{
|
||||
width: 20, height: 20, borderRadius: '50%', background: col,
|
||||
border: col === editing.color ? '2px solid #333' : '1px solid #ccc',
|
||||
cursor: 'pointer', padding: 0
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { void accept(editing.tag, editing.name.trim(), editing.color); setEditing(null); }}
|
||||
disabled={editing.name.trim().length === 0}
|
||||
style={{ background: '#0a4b80', color: '#fff', border: 'none', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
|
||||
>
|
||||
만들기
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)} style={{ fontSize: 12, padding: '4px 10px' }}>취소</button>
|
||||
</div>
|
||||
)}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
@@ -3,32 +3,50 @@ import { useInbox } from '../store.js';
|
||||
|
||||
export function SearchBox(): React.ReactElement {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [scope, setScope] = useState<'current' | 'all'>('current');
|
||||
const selectedNotebookId = useInbox((s) => s.selectedNotebookId);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed.length === 0) useInbox.getState().clearSearch();
|
||||
else void useInbox.getState().searchNotes(trimmed);
|
||||
if (trimmed.length === 0) {
|
||||
useInbox.getState().clearSearch();
|
||||
} else {
|
||||
// v0.4 Task 18 — scope='current' 이면 현재 notebook ID 전달, 'all' 이면 미전달(전체 검색).
|
||||
const notebookId = scope === 'current' ? (selectedNotebookId ?? undefined) : undefined;
|
||||
void useInbox.getState().searchNotes(trimmed, { notebookId });
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [draft]);
|
||||
}, [draft, scope, selectedNotebookId]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="search"
|
||||
role="searchbox"
|
||||
placeholder="검색…"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="노트 검색"
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #bbb',
|
||||
borderRadius: 4,
|
||||
width: 200
|
||||
}}
|
||||
/>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
<input
|
||||
type="search"
|
||||
role="searchbox"
|
||||
placeholder="검색…"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="노트 검색"
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #bbb',
|
||||
borderRadius: 4,
|
||||
width: 200
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={scope}
|
||||
onChange={(e) => setScope(e.target.value as 'current' | 'all')}
|
||||
aria-label="검색 범위"
|
||||
style={{ fontSize: 11, padding: '2px 4px', marginLeft: 4 }}
|
||||
>
|
||||
<option value="current">이 노트북</option>
|
||||
<option value="all">모든 노트북</option>
|
||||
</select>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
33
src/renderer/inbox/components/Sidebar.tsx
Normal file
33
src/renderer/inbox/components/Sidebar.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { NotebookList } from './NotebookList.js';
|
||||
import { NotebookCreateModal } from './NotebookCreateModal.js';
|
||||
|
||||
export function Sidebar(): React.ReactElement | null {
|
||||
const visible = useInbox((s) => s.sidebarVisible);
|
||||
const width = useInbox((s) => s.sidebarWidth);
|
||||
const notebooks = useInbox((s) => s.notebooks);
|
||||
const selectedId = useInbox((s) => s.selectedNotebookId);
|
||||
const selectNotebook = useInbox((s) => s.selectNotebook);
|
||||
const reorderNotebook = useInbox((s) => s.reorderNotebook);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<aside style={{
|
||||
width, borderRight: '1px solid #e0e0e0',
|
||||
background: '#fafafa', overflowY: 'auto',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<NotebookList
|
||||
notebooks={notebooks}
|
||||
selectedId={selectedId}
|
||||
onSelect={selectNotebook}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onReorder={reorderNotebook}
|
||||
/>
|
||||
{createOpen && <NotebookCreateModal onClose={() => setCreateOpen(false)} />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -54,8 +54,8 @@ export function SyncHelpModal({ onClose, initialAnchor }: Props): React.ReactEle
|
||||
</div>
|
||||
|
||||
<section id="main-conflict" style={sectionStyle}>
|
||||
<h4 style={h4Style}>1. 충돌 해결 (메인 시나리오)</h4>
|
||||
<p style={pStyle}>같은 노트를 두 기기에서 동시에 수정하면 충돌이 발생한다. "충돌 해결…" 버튼이 활성화되면 ConflictModal 이 열려 path 별 결정 (내 것 사용 / 원격 사용) 을 받는다.</p>
|
||||
<h4 style={h4Style}>1. 충돌 해결 (직접 결정해야 하는 일)</h4>
|
||||
<p style={pStyle}>같은 노트를 두 기기에서 동시에 수정하면 어느 쪽을 남길지 Inkling 이 자동으로 결정할 수 없습니다. 이때 "충돌 해결…" 버튼이 활성화되고, 노트별로 "내 것 사용" 또는 "원격 사용" 을 골라주시면 됩니다.</p>
|
||||
|
||||
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>편집/편집 — 가장 흔한 경우</p>
|
||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||
@@ -79,21 +79,23 @@ export function SyncHelpModal({ onClose, initialAnchor }: Props): React.ReactEle
|
||||
</section>
|
||||
|
||||
<section id="auto" style={sectionStyle}>
|
||||
<h4 style={h4Style}>2. 자동 처리 (내가 안 해도 되는 일)</h4>
|
||||
<h4 style={h4Style}>2. 자동으로 처리되는 일</h4>
|
||||
<p style={pStyle}>아래 동작은 Inkling 이 알아서 처리합니다. 충돌이 없으면 사용자가 신경 쓸 일은 없습니다.</p>
|
||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||
<li style={liStyle}><b>fetch + rebase</b>: sync 시작 시 원격 변경을 가져와 내 변경 위에 다시 쌓음 (linear history). conflict 없으면 자동 진행</li>
|
||||
<li style={liStyle}><b>첫 sync 순서</b>: 빈 원격에는 어느 기기든 먼저 push 가능. 두 번째 기기는 fetch 후 자동 rebase</li>
|
||||
<li style={liStyle}><b>push 거부 (non-fast-forward)</b>: 다른 기기가 먼저 push 했어도 자동 fetch + rebase + 재시도. 사용자 개입은 rebase conflict 발생 시에만</li>
|
||||
<li style={liStyle}><b>자동 sync 주기</b>: 기본 30분 (설정에서 변경). 앱 종료 시 자동 1회 추가</li>
|
||||
<li style={liStyle}><b>원격 변경 먼저 받아오기</b>: 동기화를 시작하면 다른 기기가 올린 변경을 먼저 받아와 내 변경 위에 차곡차곡 올려놓습니다. 양쪽이 같은 줄을 건드리지 않으면 자동으로 진행됩니다.</li>
|
||||
<li style={liStyle}><b>첫 동기화 순서</b>: 비어있는 원격 저장소에는 어느 기기든 먼저 올릴 수 있습니다. 두 번째 기기는 받아온 뒤 자동으로 합쳐집니다.</li>
|
||||
<li style={liStyle}><b>업로드 거부 시 자동 재시도</b>: 다른 기기가 이미 변경을 올려둔 상태라 내 업로드가 막혀도, 받아오기 + 합치기 + 재시도가 자동으로 진행됩니다. 사용자가 개입할 일은 같은 위치를 양쪽이 동시에 수정한 경우에만 생깁니다.</li>
|
||||
<li style={liStyle}><b>자동 동기화 주기</b>: 기본 30분 (설정에서 변경). 앱 종료 시에도 한 번 추가로 실행됩니다.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="silent" style={sectionStyle}>
|
||||
<h4 style={h4Style}>3. 조용히 잘못될 수 있는 케이스 (silent risk)</h4>
|
||||
<h4 style={h4Style}>3. 모르고 넘어가기 쉬운 함정</h4>
|
||||
<p style={pStyle}>아래 상황은 에러처럼 보이지 않지만 결과가 잘못 나올 수 있으니 미리 알아두는 것이 좋습니다.</p>
|
||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||
<li style={liStyle}><b>시계 어긋남 (NTP)</b>: 양 기기 시계가 다르면 timestamp 기반 merge 가 잘못된 결과를 낼 수 있음. macOS / Windows 모두 기본 NTP 동기화 켜져 있음 — 수동으로 끄지 말 것</li>
|
||||
<li style={liStyle}><b>두 기기 동시 수정 회피</b>: 같은 노트를 동시에 수정하면 conflict 가 더 자주 발생. 한 기기에서 작업 중이면 다른 기기에서 같은 노트 수정 자제</li>
|
||||
<li style={liStyle}><b>자동 sync 실패 silent</b>: 주기적 sync 실패 시 토스트 안 뜸. 마지막 sync 시각 / 결과는 설정 페이지에서 확인 — 주 1회 점검 권장</li>
|
||||
<li style={liStyle}><b>두 기기의 시각 어긋남</b>: 시계가 어긋나면 변경 순서가 뒤바뀌어 한쪽 수정이 묻힐 수 있습니다. macOS / Windows 모두 기본적으로 시각이 자동 동기화되니, 일부러 끄지 마세요.</li>
|
||||
<li style={liStyle}><b>같은 노트를 두 기기에서 동시에 수정</b>: 충돌이 더 자주 발생합니다. 한 기기에서 작업 중이라면 다른 기기에서 같은 노트는 만지지 않는 편이 안전합니다.</li>
|
||||
<li style={liStyle}><b>자동 동기화 실패는 조용히 지나갑니다</b>: 주기적 동기화가 실패해도 알림 토스트는 뜨지 않습니다. 마지막 동기화 시각과 결과는 설정 페이지에서 확인할 수 있으니, 주 1회 정도 점검을 권장합니다.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { inboxApi } from '../../api.js';
|
||||
import { VisionSection } from './VisionSection.js';
|
||||
import { SectionIntro } from './SectionIntro.js';
|
||||
|
||||
const endpointSchema = z.string().url();
|
||||
|
||||
@@ -78,6 +79,10 @@ export function AiProviderSection(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionIntro>
|
||||
메모를 자동으로 정리하는 AI 제공자입니다. Inkling 은 기본적으로 로컬 Ollama 를 사용해
|
||||
데이터가 기기 밖으로 나가지 않게 합니다. 사용할 모델과 접속 주소를 여기서 지정합니다.
|
||||
</SectionIntro>
|
||||
{/* v0.2.9 Cut B Task 15 — AI 자동 처리 토글 (가장 위, 스위치 의미가 가장 큰 결정) */}
|
||||
{aiEnabled !== null && (
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: 13 }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { AutostartResponse } from '@shared/types';
|
||||
import { inboxApi } from '../../api.js';
|
||||
import { SectionIntro } from './SectionIntro.js';
|
||||
|
||||
export function AutostartSection(): React.ReactElement {
|
||||
const [data, setData] = useState<AutostartResponse | null>(null);
|
||||
@@ -31,14 +32,19 @@ export function AutostartSection(): React.ReactElement {
|
||||
}
|
||||
|
||||
const d = data.diagnostic;
|
||||
// v0.2.7 F12 deeper fix — withArgs vs noArgs 의 openAtLogin 불일치, 또는
|
||||
// executableWillLaunchAtLogin = false 면 mismatch 로 간주 (등록은 됐지만 실제론
|
||||
// 로그인 시 실행되지 않을 수 있는 상태).
|
||||
const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin
|
||||
|| (data.openAtLogin && !d.withArgs.executableWillLaunchAtLogin);
|
||||
// withArgs vs noArgs 의 openAtLogin 불일치는 양 플랫폼에서 진짜 mismatch 시그널.
|
||||
// executableWillLaunchAtLogin 은 Win 에서만 신뢰 — macOS 13+ SMAppService API 는
|
||||
// LoginItems 에 등록되어 있어도 unsigned/Electron 앱에 대해 false 를 자주 반환해
|
||||
// false positive 가 발생함. macOS 는 이 신호를 mismatch 판정에서 제외.
|
||||
const willLaunchSignal = d.platform === 'win32' && data.openAtLogin && !d.withArgs.executableWillLaunchAtLogin;
|
||||
const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin || willLaunchSignal;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionIntro>
|
||||
시스템에 로그인하면 Inkling 이 백그라운드로 함께 시작합니다. 메인 창은 뜨지 않고,
|
||||
Cmd+Shift+J (macOS) / Ctrl+Shift+J (Windows) 로 필요할 때 불러와 쓰시면 됩니다.
|
||||
</SectionIntro>
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13 }}>
|
||||
<input type="checkbox" checked={data.openAtLogin} onChange={onToggle} />
|
||||
앱 시작 시 자동으로 실행
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
import { SectionIntro } from './SectionIntro.js';
|
||||
|
||||
export function BackupSection(): React.ReactElement {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
@@ -14,6 +15,10 @@ export function BackupSection(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<SectionIntro>
|
||||
메모와 첨부 이미지를 안전하게 백업하거나 다른 기기로 옮길 때 사용합니다. 자동 백업은 매일
|
||||
앱 종료 시 1회 실행되고, 여기서는 필요할 때 수동으로 실행할 수 있습니다.
|
||||
</SectionIntro>
|
||||
<button onClick={() => run('지금 백업', () => inboxApi.runBackup())}>지금 백업</button>
|
||||
<button onClick={() => run('내보내기', () => inboxApi.runExport())}>내보내기...</button>
|
||||
<button onClick={() => run('백업에서 복원', () => inboxApi.runImport())}>백업에서 복원...</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
import { SectionIntro } from './SectionIntro.js';
|
||||
|
||||
interface AppInfo {
|
||||
version: string;
|
||||
@@ -20,6 +21,9 @@ export function InfoSection(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionIntro>
|
||||
문제 보고나 호환성 확인이 필요할 때 참고하실 정보입니다.
|
||||
</SectionIntro>
|
||||
<dl style={{ fontSize: 12, lineHeight: 1.6 }}>
|
||||
<dt style={{ fontWeight: 600 }}>버전</dt>
|
||||
<dd>{info.version}</dd>
|
||||
|
||||
12
src/renderer/inbox/components/settings/SectionIntro.tsx
Normal file
12
src/renderer/inbox/components/settings/SectionIntro.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props { children: React.ReactNode; }
|
||||
|
||||
/** Settings page 각 section 상단에 표시되는 간단한 설명 paragraph. */
|
||||
export function SectionIntro({ children }: Props): React.ReactElement {
|
||||
return (
|
||||
<p style={{ fontSize: 12, color: '#666', lineHeight: 1.6, margin: '0 0 12px 0' }}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { inboxApi } from '../../api.js';
|
||||
import type { SyncStatusSnapshot } from '@shared/types';
|
||||
import { ConflictModal } from '../ConflictModal.js';
|
||||
import { SyncHelpModal, type SyncHelpAnchor } from '../SyncHelpModal.js';
|
||||
import { SectionIntro } from './SectionIntro.js';
|
||||
|
||||
export function SyncSection(): React.ReactElement {
|
||||
const [url, setUrl] = useState('');
|
||||
@@ -64,6 +65,10 @@ export function SyncSection(): React.ReactElement {
|
||||
return (
|
||||
<section style={{ marginTop: 24 }}>
|
||||
<h3 style={{ fontSize: 14, marginBottom: 8 }}>동기화 저장소</h3>
|
||||
<SectionIntro>
|
||||
Git 저장소를 통해 여러 기기 간 메모를 동기화합니다. 단일 기기에서만 사용하시면 URL 을
|
||||
비워두셔도 됩니다. 자동 동기화 주기와 충돌 처리는 아래에서 설정합니다.
|
||||
</SectionIntro>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
<input
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
import { SectionIntro } from './SectionIntro.js';
|
||||
|
||||
export function VisionSection(): React.ReactElement {
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
@@ -44,6 +45,10 @@ export function VisionSection(): React.ReactElement {
|
||||
return (
|
||||
<section style={{ marginTop: 16 }}>
|
||||
<h4 style={{ fontSize: 13, marginBottom: 6 }}>이미지 분석 모델 (선택사항)</h4>
|
||||
<SectionIntro>
|
||||
첨부 이미지를 함께 분석할 vision 지원 모델입니다. 텍스트용 모델과 별도로 지정할 수 있고,
|
||||
미지정 시 이미지 첨부 메모는 텍스트만 정리됩니다.
|
||||
</SectionIntro>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||||
<select
|
||||
aria-label="이미지 분석 모델"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: file:" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: file: inkling-media:" />
|
||||
<title>Inkling</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, sans-serif; background: #f5f5f7; color: #111; }
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Note, ReviewAggregate, WeeklyContinuity } from '@shared/types';
|
||||
import { inboxApi } from './api.js';
|
||||
import type { Note, NoteStatus, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity, BatchClassifyResult } from '@shared/types';
|
||||
import { inboxApi, notebookApi } from './api.js';
|
||||
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
|
||||
|
||||
function toTitleCase(s: string): string {
|
||||
return s.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
}
|
||||
|
||||
export { selectFilteredNotes } from './selectFilteredNotes.js';
|
||||
|
||||
// v0.2.9 Cut B Task 4 — 4탭 view enum + settings.
|
||||
// 'inbox' = active, 'completed'/'archived' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage.
|
||||
// v0.2.9 Cut B Task 4 — 3탭 view enum + settings.
|
||||
// v0.4 Task 16 — 'archived' view 제거 (NoteStatus 에서 archived 삭제됨).
|
||||
// 'inbox' = active, 'completed' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage.
|
||||
export type InboxView =
|
||||
| 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'
|
||||
| 'inbox' | 'completed' | 'trash' | 'settings'
|
||||
| 'review-daily' | 'review-weekly' | 'review-monthly';
|
||||
|
||||
export interface InboxCounts {
|
||||
active: number;
|
||||
completed: number;
|
||||
archived: number;
|
||||
trashed: number;
|
||||
}
|
||||
|
||||
@@ -45,6 +49,16 @@ interface InboxState {
|
||||
searchQuery: string;
|
||||
searchResults: Note[] | null; // null = 검색 안 한 상태
|
||||
reviewData: ReviewAggregate | null;
|
||||
// v0.4 — Notebook sidebar state.
|
||||
notebooks: Notebook[];
|
||||
selectedNotebookId: string | null;
|
||||
sidebarVisible: boolean;
|
||||
sidebarWidth: number;
|
||||
// v0.4 Task 11 — promotion candidates (dismissed/snoozed 필터 적용 후 목록).
|
||||
promotionCandidates: PromotionCandidate[];
|
||||
// v0.4 T5 — AI batch classify state.
|
||||
batchClassifyResult: BatchClassifyResult | null;
|
||||
batchClassifying: boolean;
|
||||
loadInitial: () => Promise<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
@@ -52,7 +66,7 @@ interface InboxState {
|
||||
setTagFilter: (tag: string | null) => void;
|
||||
setShowSettings: (open: boolean) => void;
|
||||
setView: (view: InboxView) => void;
|
||||
loadByView: (view: 'inbox' | 'completed' | 'archived' | 'trash') => Promise<void>;
|
||||
loadByView: (view: 'inbox' | 'completed' | 'trash') => Promise<void>;
|
||||
toggleShowTrash: () => Promise<void>;
|
||||
loadTrash: () => Promise<void>;
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
@@ -68,9 +82,29 @@ interface InboxState {
|
||||
snoozeRecall: () => Promise<void>;
|
||||
// v0.2.11 Cut D — search + review actions.
|
||||
setSearchQuery: (q: string) => void;
|
||||
searchNotes: (q: string) => Promise<void>;
|
||||
// v0.4 Task 18 — scope 토글. notebookId 전달 시 해당 notebook 안 검색, undefined 시 전체 검색.
|
||||
searchNotes: (q: string, opts?: { notebookId?: string }) => Promise<void>;
|
||||
clearSearch: () => void;
|
||||
loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>;
|
||||
// v0.4 — Notebook actions.
|
||||
loadNotebooks: () => Promise<void>;
|
||||
selectNotebook: (id: string) => void;
|
||||
createNotebook: (name: string, color?: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
renameNotebook: (id: string, name: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
setNotebookColor: (id: string, color: string | null) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
|
||||
moveNoteToNotebook: (noteId: string, notebookId: string) => Promise<void>;
|
||||
reorderNotebook: (id: string, direction: 'up' | 'down') => Promise<void>;
|
||||
toggleSidebar: () => void;
|
||||
// v0.4 Task 11 — promotion candidate actions.
|
||||
loadPromotionCandidates: () => Promise<void>;
|
||||
acceptPromotion: (tag: string, customName: string, color: string | undefined) => Promise<void>;
|
||||
snoozePromotion: () => Promise<void>;
|
||||
dismissPromotion: (tag: string) => Promise<void>;
|
||||
// v0.4 T5 — AI batch classify actions.
|
||||
runBatchClassify: () => Promise<void>;
|
||||
clearBatchClassify: () => void;
|
||||
acceptBatchAssignments: (accepted: Array<{ noteId: string; notebookId: string }>) => Promise<void>;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -85,7 +119,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
showTrash: false,
|
||||
showSettings: false,
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
counts: { active: 0, completed: 0, trashed: 0 },
|
||||
continuity: emptyContinuity,
|
||||
pendingCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
@@ -101,14 +135,22 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
searchQuery: '',
|
||||
searchResults: null,
|
||||
reviewData: null,
|
||||
notebooks: [],
|
||||
selectedNotebookId: null,
|
||||
sidebarVisible: true,
|
||||
sidebarWidth: 240,
|
||||
promotionCandidates: [],
|
||||
batchClassifyResult: null,
|
||||
batchClassifying: false,
|
||||
async loadInitial() {
|
||||
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
|
||||
set({ loading: true });
|
||||
try {
|
||||
const notebookId = get().selectedNotebookId ?? undefined;
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
// inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
|
||||
// listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
|
||||
inboxApi.listByStatus('active', { limit: 50 }),
|
||||
inboxApi.listByStatus('active', { limit: 50, notebookId }),
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
@@ -117,10 +159,17 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate(),
|
||||
inboxApi.countsByStatus(),
|
||||
inboxApi.countsByStatus({ notebookId }),
|
||||
inboxApi.getSettings()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
|
||||
set({
|
||||
notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates,
|
||||
failedCount, recallCandidate, counts,
|
||||
ai_enabled: settings.ai_enabled ?? true,
|
||||
sidebarVisible: settings.sidebar_visible ?? true,
|
||||
sidebarWidth: settings.sidebar_width ?? 240,
|
||||
loading: false
|
||||
});
|
||||
} catch (e) {
|
||||
// 첫 launch 의 IPC 실패 (DB migration 실패 / main process 비정상) 시 무한 loading 회피.
|
||||
// 빈 데이터로 진입하면 사용자가 캡처 시도 → 실제 fail 이 표면화 → 재시도 가능.
|
||||
@@ -130,6 +179,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
},
|
||||
async refreshMeta() {
|
||||
try {
|
||||
const notebookId = get().selectedNotebookId ?? undefined;
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
@@ -139,7 +189,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate(),
|
||||
inboxApi.countsByStatus(),
|
||||
inboxApi.countsByStatus({ notebookId }),
|
||||
inboxApi.getSettings()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
|
||||
@@ -160,10 +210,9 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
const state = get();
|
||||
const view = state.view;
|
||||
const showTrash = state.showTrash;
|
||||
const viewStatus: 'active' | 'completed' | 'archived' | 'trashed' | null =
|
||||
const viewStatus: 'active' | 'completed' | 'trashed' | null =
|
||||
view === 'inbox' ? 'active' :
|
||||
view === 'completed' ? 'completed' :
|
||||
view === 'archived' ? 'archived' :
|
||||
view === 'trash' ? 'trashed' : null;
|
||||
|
||||
// trashNotes — note.status='trashed' 면 upsert, 아니면 제거.
|
||||
@@ -244,7 +293,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
});
|
||||
// status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
|
||||
// 이전 status 로 stale 한 상태이므로 재로드 필요.
|
||||
if (view === 'inbox' || view === 'completed' || view === 'archived' || view === 'trash') {
|
||||
if (view === 'inbox' || view === 'completed' || view === 'trash') {
|
||||
void get().loadByView(view);
|
||||
}
|
||||
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
|
||||
@@ -257,8 +306,9 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
// fail 시 빈 배열로 reset 해서 사용자에게 "비어있음" 으로 표시 (혼동 < stale).
|
||||
const status =
|
||||
view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view;
|
||||
const notebookId = get().selectedNotebookId ?? undefined;
|
||||
try {
|
||||
const notes = await inboxApi.listByStatus(status, { limit: 200 });
|
||||
const notes = await inboxApi.listByStatus(status, { limit: 200, notebookId });
|
||||
if (view === 'trash') {
|
||||
set({ trashNotes: notes, trashCount: notes.length });
|
||||
} else {
|
||||
@@ -354,18 +404,21 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
set({ searchQuery: q });
|
||||
if (q.trim().length === 0) set({ searchResults: null });
|
||||
},
|
||||
async searchNotes(q) {
|
||||
async searchNotes(q, opts) {
|
||||
if (q.trim().length === 0) {
|
||||
set({ searchResults: null });
|
||||
return;
|
||||
}
|
||||
const view = get().view;
|
||||
// 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색
|
||||
const status = view === 'completed' || view === 'archived' || view === 'trash'
|
||||
const status = view === 'completed' || view === 'trash'
|
||||
? (view === 'trash' ? 'trashed' : view)
|
||||
: view === 'inbox' ? 'active' : undefined;
|
||||
try {
|
||||
const r = await inboxApi.search(q, status ? { status } : {});
|
||||
// v0.4 Task 18 — opts.notebookId 전달 시 해당 notebook 안 검색, undefined 시 전체 검색.
|
||||
const searchOpts: { status?: NoteStatus; notebookId?: string } = status ? { status: status as NoteStatus } : {};
|
||||
if (opts?.notebookId !== undefined) searchOpts.notebookId = opts.notebookId;
|
||||
const r = await inboxApi.search(q, searchOpts);
|
||||
set({ searchResults: r });
|
||||
} catch (e) {
|
||||
// FTS5 query parse error (special char 미escape) / IPC fail → 빈 결과로.
|
||||
@@ -386,5 +439,140 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
console.error('[inbox] loadReview failed', period, e);
|
||||
set({ reviewData: { totalCount: 0, tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 }, recentNotes: [] } });
|
||||
}
|
||||
},
|
||||
// v0.4 — Notebook actions.
|
||||
async loadNotebooks() {
|
||||
const notebooks = await notebookApi.list();
|
||||
const current = get().selectedNotebookId;
|
||||
// selectedNotebookId 가 null 이면 첫 notebook (가장 오래된 = 기본) 으로 설정.
|
||||
const selectedNotebookId = current === null && notebooks.length > 0
|
||||
? notebooks[0]!.id
|
||||
: current;
|
||||
set({ notebooks, selectedNotebookId });
|
||||
},
|
||||
selectNotebook(id) {
|
||||
set({ selectedNotebookId: id });
|
||||
// v0.4 Task 19 — notebook 전환 시 list + counts 자동 갱신.
|
||||
const v = get().view;
|
||||
if (v === 'inbox' || v === 'completed' || v === 'trash') {
|
||||
void get().loadByView(v);
|
||||
}
|
||||
void get().refreshMeta();
|
||||
},
|
||||
async createNotebook(name, color) {
|
||||
const r = await notebookApi.create({ name, color });
|
||||
if (r.ok) {
|
||||
set({ notebooks: [...get().notebooks, r.notebook] });
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: r.reason };
|
||||
},
|
||||
async renameNotebook(id, name) {
|
||||
const r = await notebookApi.rename(id, name);
|
||||
if (r.ok) {
|
||||
set({ notebooks: get().notebooks.map((n) => n.id === id ? { ...n, name } : n) });
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: r.reason };
|
||||
},
|
||||
async setNotebookColor(id, color) {
|
||||
await notebookApi.setColor(id, color);
|
||||
set({ notebooks: get().notebooks.map((n) => n.id === id ? { ...n, color } : n) });
|
||||
},
|
||||
async deleteNotebook(id) {
|
||||
const r = await notebookApi.delete(id);
|
||||
if (r.ok) {
|
||||
const remaining = get().notebooks.filter((n) => n.id !== id);
|
||||
const wasSelected = get().selectedNotebookId === id;
|
||||
set({
|
||||
notebooks: remaining,
|
||||
selectedNotebookId: wasSelected ? (remaining[0]?.id ?? null) : get().selectedNotebookId
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: r.reason };
|
||||
},
|
||||
async moveNoteToNotebook(noteId, notebookId) {
|
||||
await notebookApi.moveNote(noteId, notebookId);
|
||||
await get().refreshMeta();
|
||||
},
|
||||
async reorderNotebook(id, direction) {
|
||||
const r = await notebookApi.reorder(id, direction);
|
||||
if (r.ok) {
|
||||
await get().loadNotebooks();
|
||||
}
|
||||
},
|
||||
toggleSidebar() {
|
||||
const next = !get().sidebarVisible;
|
||||
set({ sidebarVisible: next });
|
||||
void inboxApi.setSidebarVisible(next);
|
||||
},
|
||||
// v0.4 Task 11 — promotion candidate actions.
|
||||
async loadPromotionCandidates() {
|
||||
try {
|
||||
const [dismissed, snoozeUntil, raw] = await Promise.all([
|
||||
inboxApi.getPromotionDismissedTags(),
|
||||
inboxApi.getPromotionSnoozeUntil(),
|
||||
inboxApi.listPromotionCandidates()
|
||||
]);
|
||||
// snoozed_until > now → 빈 배열 (전체 스누즈).
|
||||
if (snoozeUntil > Date.now()) {
|
||||
set({ promotionCandidates: [] });
|
||||
return;
|
||||
}
|
||||
const dismissedSet = new Set(dismissed);
|
||||
const candidates: PromotionCandidate[] = raw
|
||||
.filter((c) => !dismissedSet.has(c.tag))
|
||||
.map((c) => ({ ...c, suggestedName: toTitleCase(c.tag) }));
|
||||
set({ promotionCandidates: candidates });
|
||||
} catch (e) {
|
||||
console.error('[inbox] loadPromotionCandidates failed', e);
|
||||
set({ promotionCandidates: [] });
|
||||
}
|
||||
},
|
||||
async acceptPromotion(tag, customName, color) {
|
||||
const candidate = get().promotionCandidates.find((c) => c.tag === tag);
|
||||
if (!candidate) return;
|
||||
const r = await notebookApi.create({ name: customName, color });
|
||||
if (!r.ok) return;
|
||||
const notebookId = r.notebook.id;
|
||||
await Promise.all(candidate.noteIds.map((noteId) => notebookApi.moveNote(noteId, notebookId)));
|
||||
// state: candidate 제거 + notebooks 갱신 + sidebar 열기 + 새 notebook 선택.
|
||||
set({
|
||||
promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag),
|
||||
notebooks: [...get().notebooks, r.notebook],
|
||||
sidebarVisible: true,
|
||||
selectedNotebookId: notebookId
|
||||
});
|
||||
await get().refreshMeta();
|
||||
},
|
||||
async snoozePromotion() {
|
||||
const snoozeUntil = Date.now() + 24 * 60 * 60 * 1000;
|
||||
await inboxApi.setPromotionSnoozeUntil(snoozeUntil);
|
||||
set({ promotionCandidates: [] });
|
||||
},
|
||||
async dismissPromotion(tag) {
|
||||
await inboxApi.addPromotionDismissedTag(tag);
|
||||
set({ promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag) });
|
||||
},
|
||||
// v0.4 T5 — AI batch classify actions.
|
||||
async runBatchClassify() {
|
||||
set({ batchClassifying: true });
|
||||
try {
|
||||
const r = await inboxApi.batchClassifyDefault();
|
||||
set({ batchClassifyResult: r, batchClassifying: false });
|
||||
} catch (e) {
|
||||
console.error('[inbox] batchClassify failed', e);
|
||||
set({ batchClassifying: false });
|
||||
}
|
||||
},
|
||||
clearBatchClassify() {
|
||||
set({ batchClassifyResult: null });
|
||||
},
|
||||
async acceptBatchAssignments(accepted) {
|
||||
await Promise.all(accepted.map((a) => notebookApi.moveNote(a.noteId, a.notebookId)));
|
||||
set({ batchClassifyResult: null });
|
||||
await get().refreshMeta();
|
||||
await get().loadByView(get().view as 'inbox' | 'completed' | 'trash');
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,9 @@ import { captureApi } from './api.js';
|
||||
|
||||
interface PastedImage { url: string; buffer: ArrayBuffer; }
|
||||
|
||||
// 저장 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl.
|
||||
const MOD_KEY = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const [text, setText] = useState('');
|
||||
const [images, setImages] = useState<PastedImage[]>([]);
|
||||
@@ -65,7 +68,7 @@ export function App(): React.ReactElement {
|
||||
{images.map((i, idx) => (<img key={idx} src={i.url} alt="" />))}
|
||||
</div>
|
||||
)}
|
||||
<div className="hint">Ctrl+Enter 저장 · Esc 취소 · 이미지 붙여넣기</div>
|
||||
<div className="hint">{MOD_KEY}+Enter 저장 · Esc 취소 · 이미지 붙여넣기</div>
|
||||
{err && <div className="err">{err}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:" />
|
||||
<title>Inkling Capture</title>
|
||||
<style>
|
||||
html, body, #root { margin: 0; height: 100%; background: transparent; font-family: system-ui, sans-serif; overflow: hidden; }
|
||||
|
||||
@@ -13,8 +13,9 @@ export interface NoteMedia {
|
||||
|
||||
export type AiStatus = 'pending' | 'done' | 'failed' | 'disabled';
|
||||
|
||||
// v0.2.9 Cut B — 노트 status 4분기 (사용자 액션). m004 마이그레이션 + setStatus.
|
||||
export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
|
||||
// v0.2.9 Cut B — 노트 status 3분기 (사용자 액션). m004 마이그레이션 + setStatus.
|
||||
// v0.4 Task 16 — 'archived' 제거. m008 마이그레이션이 DB 의 archived 를 completed 로 통합.
|
||||
export type NoteStatus = 'active' | 'completed' | 'trashed';
|
||||
|
||||
export interface NoteTag {
|
||||
name: string;
|
||||
@@ -92,6 +93,38 @@ export interface Note {
|
||||
updatedAt: string;
|
||||
tags: NoteTag[];
|
||||
media: NoteMedia[];
|
||||
// v0.4 — m008 마이그레이션 보장 (notebook_id NOT NULL, default notebook 자동 할당).
|
||||
notebookId: string;
|
||||
}
|
||||
|
||||
// v0.4 T4 — AI batch classify 결과.
|
||||
export interface BatchClassifyAssignment {
|
||||
noteId: string;
|
||||
notebookId: string | null;
|
||||
notebookName: string | null;
|
||||
}
|
||||
|
||||
export interface BatchClassifyResult {
|
||||
assignments: BatchClassifyAssignment[];
|
||||
skippedReason?: string;
|
||||
}
|
||||
|
||||
// v0.4 Task 11 — tag 기반 notebook 승격 제안 후보.
|
||||
// suggestedName 은 renderer 가 toTitleCase(tag) 로 채움 — IPC 응답에는 없음.
|
||||
export interface PromotionCandidate {
|
||||
tag: string;
|
||||
noteIds: string[];
|
||||
suggestedName: string;
|
||||
}
|
||||
|
||||
// v0.4 — Notebook: 노트 묶음 단위. noteCount = status='active' 노트 수.
|
||||
export interface Notebook {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
noteCount: number;
|
||||
}
|
||||
|
||||
export interface WeeklyContinuity {
|
||||
@@ -113,6 +146,8 @@ export interface AutostartDiagnostic {
|
||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
execPath: string;
|
||||
/** mismatch 판정 플랫폼 분기용 (macOS 의 SMAppService API 한계 우회). */
|
||||
platform: NodeJS.Platform;
|
||||
registryPath?: string;
|
||||
registryValue?: string | null;
|
||||
}
|
||||
@@ -123,7 +158,7 @@ export interface AutostartResponse {
|
||||
}
|
||||
|
||||
export interface InboxApi {
|
||||
listNotes(opts: { limit: number; cursor?: string }): Promise<Note[]>;
|
||||
listNotes(opts: { limit: number; cursor?: string; notebookId?: string }): Promise<Note[]>;
|
||||
updateAiFields(
|
||||
noteId: string,
|
||||
fields: { title?: string; summary?: string; tags?: string[] }
|
||||
@@ -183,8 +218,9 @@ export interface InboxApi {
|
||||
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
|
||||
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
// v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count.
|
||||
listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise<Note[]>;
|
||||
countsByStatus(): Promise<{ active: number; completed: number; archived: number; trashed: number }>;
|
||||
// v0.4 — notebookId 옵션 추가. archived 제거 (Task 16 — NoteStatus 에서 제거됨).
|
||||
listByStatus(status: NoteStatus, opts?: { limit?: number; notebookId?: string }): Promise<Note[]>;
|
||||
countsByStatus(opts?: { notebookId?: string }): Promise<{ active: number; completed: number; trashed: number }>;
|
||||
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
|
||||
setStatus(
|
||||
id: string,
|
||||
@@ -204,8 +240,13 @@ export interface InboxApi {
|
||||
vision_model?: string | null;
|
||||
vision_capable_cache?: string[];
|
||||
vision_cache_at?: string;
|
||||
// v0.4 — Sidebar UI state 영속화
|
||||
sidebar_visible?: boolean;
|
||||
sidebar_width?: number;
|
||||
}>;
|
||||
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
|
||||
setSidebarVisible(visible: boolean): Promise<{ ok: true }>;
|
||||
setSidebarWidth(width: number): Promise<{ ok: true }>;
|
||||
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
|
||||
// v0.2.9 Cut B Task 16 — ai_status='disabled' 메모 재투입 (사용자가 ai_enabled OFF→ON 전환 시).
|
||||
enqueueDisabled(): Promise<{ count: number }>;
|
||||
@@ -215,7 +256,8 @@ export interface InboxApi {
|
||||
listRevisions(noteId: string): Promise<NoteRevision[]>;
|
||||
restoreRevision(noteId: string, revId: number): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
// v0.2.11 Cut D — FTS5 search + 회고 aggregate.
|
||||
search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise<Note[]>;
|
||||
// v0.4 — notebookId 옵션 추가.
|
||||
search(query: string, opts?: { limit?: number; status?: NoteStatus; notebookId?: string }): Promise<Note[]>;
|
||||
reviewAggregate(period: ReviewPeriod): Promise<ReviewAggregate>;
|
||||
// v0.3.0 Cut E — 양방향 sync.
|
||||
configureSync(url: string | null): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
@@ -229,11 +271,30 @@ export interface InboxApi {
|
||||
getVisionModels(): Promise<{ models: string[]; at: string | null; selected: string | null }>;
|
||||
setVisionModel(value: string | null): Promise<{ ok: true }>;
|
||||
refreshVisionCache(): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }>;
|
||||
// v0.4 T4 — AI batch classify: default notebook 노트 일괄 fit 매칭 (단일 prompt).
|
||||
batchClassifyDefault(): Promise<BatchClassifyResult>;
|
||||
// v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
|
||||
listPromotionCandidates(): Promise<PromotionCandidate[]>;
|
||||
getPromotionDismissedTags(): Promise<string[]>;
|
||||
addPromotionDismissedTag(tag: string): Promise<void>;
|
||||
getPromotionSnoozeUntil(): Promise<number>;
|
||||
setPromotionSnoozeUntil(ms: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface NotebookApi {
|
||||
list(): Promise<Notebook[]>;
|
||||
create(input: { name: string; color?: string }): Promise<{ ok: true; notebook: Notebook } | { ok: false; reason: string }>;
|
||||
rename(id: string, name: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
setColor(id: string, color: string | null): Promise<{ ok: true }>;
|
||||
delete(id: string): Promise<{ ok: true } | { ok: false; reason: 'has_notes' | 'not_found' }>;
|
||||
moveNote(noteId: string, notebookId: string): Promise<{ ok: true }>;
|
||||
reorder(id: string, direction: 'up' | 'down'): Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
capture: CaptureApi;
|
||||
inbox: InboxApi;
|
||||
notebook: NotebookApi;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -14,7 +14,7 @@ function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProv
|
||||
return {
|
||||
name: 'mock',
|
||||
generate: vi.fn(async (): Promise<AiResponse> => ({
|
||||
title: '제목', summary: 'a\nb\nc', tags: ['tag'], dueDate: null
|
||||
title: '제목', summary: 'a\nb\nc', tags: ['tag'], dueDate: null, notebookMatch: null
|
||||
})),
|
||||
healthCheck: vi.fn(async () => ({ ok: true })),
|
||||
...overrides
|
||||
@@ -77,7 +77,7 @@ describe('AiWorker', () => {
|
||||
running++; max = Math.max(max, running);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
running--;
|
||||
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
||||
})
|
||||
});
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
|
||||
@@ -137,7 +137,8 @@ describe('AiWorker', () => {
|
||||
title: '아무 메모',
|
||||
summary: 'a\nb\nc',
|
||||
tags: [],
|
||||
dueDate: null
|
||||
dueDate: null,
|
||||
notebookMatch: null
|
||||
}),
|
||||
healthCheck: async () => ({ ok: true })
|
||||
} as any;
|
||||
@@ -159,7 +160,7 @@ describe('AiWorker', () => {
|
||||
generate: async (input: any) => {
|
||||
seen.todayKst = input.todayKst;
|
||||
seen.dueDateCandidates = input.dueDateCandidates;
|
||||
return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||
return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
||||
},
|
||||
healthCheck: async () => ({ ok: true })
|
||||
} as any;
|
||||
@@ -181,7 +182,7 @@ describe('AiWorker', () => {
|
||||
name: 'mock',
|
||||
generate: async (input: any) => {
|
||||
captured = input;
|
||||
return { title: '내일', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||
return { title: '내일', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
||||
},
|
||||
healthCheck: async () => ({ ok: true })
|
||||
} as any;
|
||||
@@ -409,7 +410,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
|
||||
generate: vi.fn(async (): Promise<AiResponse> => {
|
||||
callCount += 1;
|
||||
if (callCount <= 2) throw new Error('ECONNREFUSED');
|
||||
return { title: 't', summary: 's', tags: [], dueDate: null };
|
||||
return { title: 't', summary: 's', tags: [], dueDate: null, notebookMatch: null };
|
||||
})
|
||||
});
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
@@ -442,7 +443,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
const generateMock = vi.fn(async () => ({
|
||||
title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null
|
||||
title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null, notebookMatch: null
|
||||
}));
|
||||
const w = new AiWorker(repo, new ProviderHolder(makeProvider({ generate: generateMock })), {
|
||||
backoffsMs: [0, 0, 0]
|
||||
@@ -465,7 +466,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design', 'newtag'], // 1 hit + 1 miss
|
||||
dueDate: null
|
||||
dueDate: null,
|
||||
notebookMatch: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
@@ -495,7 +497,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design', 'meeting', 'qa'],
|
||||
dueDate: null
|
||||
dueDate: null,
|
||||
notebookMatch: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
@@ -520,7 +523,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design', 'meeting', 'qa'],
|
||||
dueDate: null
|
||||
dueDate: null,
|
||||
notebookMatch: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
@@ -544,7 +548,8 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design', 'design', 'meeting'], // 중복 'design' 의도적
|
||||
dueDate: null
|
||||
dueDate: null,
|
||||
notebookMatch: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
@@ -561,6 +566,104 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiWorker notebook matching', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('AI 응답의 notebookMatch 가 valid 이름이면 moveNote 호출', async () => {
|
||||
const moveNote = vi.fn();
|
||||
const notebookRepo = {
|
||||
list: () => [{ id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }],
|
||||
findByName: (n: string) => n === '회사' ? { id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 } : null,
|
||||
moveNote
|
||||
};
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async (): Promise<AiResponse> => ({
|
||||
title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: '회사'
|
||||
}))
|
||||
});
|
||||
const { id } = repo.create({ rawText: '회사 업무' });
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0],
|
||||
notebookRepo
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(moveNote).toHaveBeenCalledWith(id, 'nb-회사');
|
||||
});
|
||||
|
||||
it('AI 응답 notebookMatch null 시 moveNote 호출 X', async () => {
|
||||
const moveNote = vi.fn();
|
||||
const notebookRepo = {
|
||||
list: () => [{ id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }],
|
||||
findByName: (_n: string) => null,
|
||||
moveNote
|
||||
};
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async (): Promise<AiResponse> => ({
|
||||
title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null
|
||||
}))
|
||||
});
|
||||
const { id } = repo.create({ rawText: '일반 메모' });
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0],
|
||||
notebookRepo
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(moveNote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('notebooks 배열을 provider.generate 에 전달', async () => {
|
||||
let capturedNotebooks: string[] | undefined;
|
||||
const notebookRepo = {
|
||||
list: () => [
|
||||
{ id: 'nb-1', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 },
|
||||
{ id: 'nb-2', name: '개인', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }
|
||||
],
|
||||
findByName: (_n: string) => null,
|
||||
moveNote: vi.fn()
|
||||
};
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async (input: any): Promise<AiResponse> => {
|
||||
capturedNotebooks = input.notebooks;
|
||||
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
||||
})
|
||||
});
|
||||
const { id } = repo.create({ rawText: '테스트' });
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0],
|
||||
notebookRepo
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(capturedNotebooks).toEqual(['회사', '개인']);
|
||||
});
|
||||
|
||||
it('notebookRepo 미전달 시 notebooks 빈 배열 전달 + moveNote 호출 X', async () => {
|
||||
let capturedNotebooks: string[] | undefined;
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async (input: any): Promise<AiResponse> => {
|
||||
capturedNotebooks = input.notebooks;
|
||||
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: '회사' };
|
||||
})
|
||||
});
|
||||
const { id } = repo.create({ rawText: '테스트' });
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0] });
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(capturedNotebooks).toEqual([]);
|
||||
// notebookRepo 없으므로 moveNote 미호출 — note 는 done 상태
|
||||
expect(repo.findById(id)?.aiStatus).toBe('done');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vocab COLLATE NOCASE', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
@@ -581,7 +684,8 @@ describe('vocab COLLATE NOCASE', () => {
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['Design'], // AI returns capitalized — DB COLLATE NOCASE matches 'design'
|
||||
dueDate: null
|
||||
dueDate: null,
|
||||
notebookMatch: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
@@ -610,7 +714,8 @@ describe('vocab COLLATE NOCASE', () => {
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design'], // AI returns lowercase — DB COLLATE NOCASE matches 'Design'
|
||||
dueDate: null
|
||||
dueDate: null,
|
||||
notebookMatch: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
@@ -634,7 +739,8 @@ describe('vocab COLLATE NOCASE', () => {
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design'], // same lowercase — should still hit
|
||||
dueDate: null
|
||||
dueDate: null,
|
||||
notebookMatch: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||
): Promise<AiResponse> => {
|
||||
calls.push([input, opts]);
|
||||
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
||||
});
|
||||
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
|
||||
const worker = makeWorker(generate, getVisionModel);
|
||||
@@ -87,7 +87,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||
): Promise<AiResponse> => {
|
||||
calls.push([input, opts]);
|
||||
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
||||
});
|
||||
const getVisionModel = vi.fn(async (): Promise<string | null> => null);
|
||||
const worker = makeWorker(generate, getVisionModel);
|
||||
@@ -98,6 +98,53 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
||||
expect(calls[0]![0].images).toBeUndefined();
|
||||
});
|
||||
|
||||
it('v0.3.14 — 본문 빈 + 이미지만 첨부 → generate 호출 skip + 자동 placeholder', async () => {
|
||||
const { id } = repo.create({ rawText: '' }); // 빈 본문
|
||||
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]);
|
||||
|
||||
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
|
||||
const generate = vi.fn(async (
|
||||
input: Parameters<InferenceProvider['generate']>[0],
|
||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||
): Promise<AiResponse> => {
|
||||
calls.push([input, opts]);
|
||||
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
||||
});
|
||||
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma4:26b');
|
||||
const worker = makeWorker(generate, getVisionModel);
|
||||
await worker.enqueue(id);
|
||||
await worker.drain();
|
||||
|
||||
// vision 호출 자체 skip
|
||||
expect(calls.length).toBe(0);
|
||||
// 노트가 자동 placeholder 로 done
|
||||
const note = repo.findById(id);
|
||||
expect(note?.aiStatus).toBe('done');
|
||||
expect(note?.aiTitle).toContain('첨부 이미지');
|
||||
expect(note?.aiSummary).toContain('이미지');
|
||||
expect(note?.aiProvider).toBe('image-only-skip');
|
||||
});
|
||||
|
||||
it('v0.3.14 — 이미지 다수 첨부 시 placeholder 가 개수 포함', async () => {
|
||||
const { id } = repo.create({ rawText: '' });
|
||||
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89]));
|
||||
await writeFile(join(workDir, 'media', id, '2.png'), Buffer.from([0x89]));
|
||||
repo.insertMedia([
|
||||
{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 1 },
|
||||
{ noteId: id, kind: 'image', relPath: `media/${id}/2.png`, mime: 'image/png', bytes: 1 }
|
||||
]);
|
||||
const generate = vi.fn(async (): Promise<AiResponse> => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null }));
|
||||
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma4:26b');
|
||||
const worker = makeWorker(generate, getVisionModel);
|
||||
await worker.enqueue(id);
|
||||
await worker.drain();
|
||||
const note = repo.findById(id);
|
||||
expect(note?.aiTitle).toContain('2장');
|
||||
});
|
||||
|
||||
it('5MB 초과 이미지 → throw → AiWorker 의 fail 분기 (generate 미호출)', async () => {
|
||||
const { id } = repo.create({ rawText: 'big image' });
|
||||
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||
@@ -110,7 +157,7 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||
): Promise<AiResponse> => {
|
||||
calls.push([input, opts]);
|
||||
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
|
||||
});
|
||||
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
|
||||
const worker = makeWorker(generate, getVisionModel);
|
||||
|
||||
@@ -4,10 +4,13 @@ import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
notebookApi: {
|
||||
list: vi.fn(async () => [])
|
||||
},
|
||||
inboxApi: {
|
||||
listNotes: vi.fn(async () => []),
|
||||
listByStatus: vi.fn(async () => []),
|
||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, trashed: 0 })),
|
||||
getContinuity: vi.fn(async () => ({
|
||||
weekStart: '', weekCount: 0, weekTarget: 7,
|
||||
consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
|
||||
@@ -53,6 +56,8 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
// v0.2.9 Cut B Task 12 — onboarding wizard 분기. default 는 onboarding_completed=true 라 wizard 미표시.
|
||||
getSettings: vi.fn(async () => ({ onboarding_completed: true })),
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setSidebarVisible: vi.fn(async () => ({ ok: true as const })),
|
||||
setSidebarWidth: vi.fn(async () => ({ ok: true as const })),
|
||||
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
|
||||
// v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출.
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
@@ -66,7 +71,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
|
||||
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
|
||||
setVisionModel: vi.fn(async () => ({ ok: true as const })),
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] })),
|
||||
// v0.4 Task 15 — loadPromotionCandidates 초기화 stub.
|
||||
listPromotionCandidates: vi.fn(async () => []),
|
||||
getPromotionDismissedTags: vi.fn(async () => []),
|
||||
getPromotionSnoozeUntil: vi.fn(async () => 0)
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -79,9 +88,10 @@ describe('App — settings view', () => {
|
||||
cleanup();
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
counts: { active: 0, completed: 0, trashed: 0 },
|
||||
showSettings: false, showTrash: false,
|
||||
notes: [], trashNotes: [], trashCount: 0
|
||||
notes: [], trashNotes: [], trashCount: 0,
|
||||
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,48 +124,67 @@ describe('App — settings view', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('App header — 4 tabs', () => {
|
||||
describe('App header — 3 tabs (v0.4)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 5, completed: 3, archived: 2, trashed: 1 },
|
||||
counts: { active: 5, completed: 3, trashed: 1 },
|
||||
notes: [], trashNotes: [], trashCount: 0,
|
||||
showTrash: false, showSettings: false
|
||||
showTrash: false, showSettings: false,
|
||||
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
||||
});
|
||||
// loadInitial 이 비동기로 counts 를 덮어씀 — onboarding wizard async gate (Task 12) 도입
|
||||
// 후 render 가 await 후 발생하므로 mock 의 countsByStatus 가 테스트 기대값을 반환하도록 갱신.
|
||||
vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, archived: 2, trashed: 1 });
|
||||
// v0.4 Task 16 — countsByStatus 응답에서 archived 제거 (NoteStatus 에서 삭제됨).
|
||||
vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, trashed: 1 });
|
||||
});
|
||||
|
||||
it('renders 4 tabs with counts', async () => {
|
||||
it('renders 3 tabs (Inbox/완료/휴지통) with counts', async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByRole('tab', { name: /Inbox\(5\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /완료\(3\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /보관\(2\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /휴지통\(1\)/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('보관 탭이 헤더에 없음', async () => {
|
||||
render(<App />);
|
||||
await screen.findByRole('tab', { name: /Inbox/ });
|
||||
expect(screen.queryByRole('tab', { name: /보관/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('clicking 완료 tab sets view=completed', async () => {
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /완료/ }));
|
||||
expect(useInbox.getState().view).toBe('completed');
|
||||
});
|
||||
|
||||
it('aria-selected reflects current view', async () => {
|
||||
useInbox.setState({ view: 'archived' });
|
||||
render(<App />);
|
||||
const archivedBtn = await screen.findByRole('tab', { name: /보관/ });
|
||||
expect(archivedBtn.getAttribute('aria-selected')).toBe('true');
|
||||
const inboxBtn = screen.getByRole('tab', { name: /Inbox/ });
|
||||
expect(inboxBtn.getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
|
||||
it('inbox tab has aria-selected="true" when active', async () => {
|
||||
render(<App />);
|
||||
const inboxTab = await screen.findByRole('tab', { name: /Inbox/ });
|
||||
expect(inboxTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('Cmd+B 키 이벤트가 toggleSidebar 호출', async () => {
|
||||
// loadInitial 의 getSettings hydrate 후 state 가 정해진 시점 기준으로 토글 검증.
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true, sidebar_visible: false });
|
||||
render(<App />);
|
||||
await screen.findByRole('tab', { name: /Inbox/ });
|
||||
const initialVisible = useInbox.getState().sidebarVisible;
|
||||
// jsdom 에서 navigator.platform = '' → isMac=false → ctrlKey 로 판단.
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true }));
|
||||
// toggleSidebar 가 호출되면 sidebarVisible 이 반전됨.
|
||||
expect(useInbox.getState().sidebarVisible).toBe(!initialVisible);
|
||||
});
|
||||
|
||||
it('Sidebar 컴포넌트가 렌더 트리에 포함됨 (sidebarVisible=true)', async () => {
|
||||
// loadInitial 의 getSettings 가 sidebar_visible=true 반환 (Strict Mode 중복 호출 대비 mockResolvedValue).
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true, sidebar_visible: true });
|
||||
render(<App />);
|
||||
await screen.findByRole('tab', { name: /Inbox/ });
|
||||
// loadInitial 비동기 hydrate 가 완료될 때까지 기다림
|
||||
await waitFor(() => expect(document.querySelector('aside')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('App — onboarding wizard', () => {
|
||||
@@ -163,9 +192,10 @@ describe('App — onboarding wizard', () => {
|
||||
cleanup();
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
counts: { active: 0, completed: 0, trashed: 0 },
|
||||
showSettings: false, showTrash: false,
|
||||
notes: [], trashNotes: [], trashCount: 0
|
||||
notes: [], trashNotes: [], trashCount: 0,
|
||||
sidebarVisible: false, notebooks: [], promotionCandidates: []
|
||||
});
|
||||
// 각 테스트가 getSettings 의 default mock 을 직접 override.
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('AutostartDiagnostic — collectAutostartState', () => {
|
||||
expect(state.withArgs).toEqual({ openAtLogin: true, executableWillLaunchAtLogin: true });
|
||||
expect(state.noArgs).toEqual({ openAtLogin: false, executableWillLaunchAtLogin: true });
|
||||
expect(state.execPath).toBe(process.execPath);
|
||||
expect(state.platform).toBe('darwin');
|
||||
});
|
||||
|
||||
it('passes args=["--hidden"] for the first call, no args for the second', async () => {
|
||||
|
||||
@@ -3,15 +3,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||
|
||||
function makeDiag(open: boolean): {
|
||||
function makeDiag(open: boolean, platform: NodeJS.Platform = 'win32'): {
|
||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
execPath: string;
|
||||
platform: NodeJS.Platform;
|
||||
} {
|
||||
return {
|
||||
withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
|
||||
noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
|
||||
execPath: '/path/to/exe'
|
||||
execPath: '/path/to/exe',
|
||||
platform
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +53,8 @@ describe('AutostartSection', () => {
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true },
|
||||
execPath: '/path/to/Inkling.exe'
|
||||
execPath: '/path/to/Inkling.exe',
|
||||
platform: 'win32'
|
||||
}
|
||||
});
|
||||
render(<AutostartSection />);
|
||||
@@ -71,6 +74,7 @@ describe('AutostartSection', () => {
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
execPath: 'C:\\app.exe',
|
||||
platform: 'win32',
|
||||
registryPath: 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling',
|
||||
registryValue: '"C:\\app.exe" --hidden'
|
||||
}
|
||||
@@ -89,7 +93,8 @@ describe('AutostartSection', () => {
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
execPath: '/p'
|
||||
execPath: '/p',
|
||||
platform: 'win32'
|
||||
}
|
||||
});
|
||||
render(<AutostartSection />);
|
||||
@@ -97,6 +102,38 @@ describe('AutostartSection', () => {
|
||||
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('macOS: no false-positive mismatch when willLaunch=false (SMAppService 한계)', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
||||
openAtLogin: true,
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
|
||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
|
||||
execPath: '/Applications/Inkling.app',
|
||||
platform: 'darwin'
|
||||
}
|
||||
});
|
||||
render(<AutostartSection />);
|
||||
await screen.findByRole('checkbox');
|
||||
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Win: mismatch warning when openAtLogin=true but willLaunch=false', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
||||
openAtLogin: true,
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
|
||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
|
||||
execPath: 'C:\\app.exe',
|
||||
platform: 'win32'
|
||||
}
|
||||
});
|
||||
render(<AutostartSection />);
|
||||
await screen.findByRole('checkbox');
|
||||
expect(await screen.findByText(/⚠️/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"재등록" button calls setAutostart with current openAtLogin value', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
||||
@@ -104,7 +141,8 @@ describe('AutostartSection', () => {
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
execPath: '/p'
|
||||
execPath: '/p',
|
||||
platform: 'win32'
|
||||
}
|
||||
});
|
||||
render(<AutostartSection />);
|
||||
|
||||
143
tests/unit/BatchMoveModal.test.tsx
Normal file
143
tests/unit/BatchMoveModal.test.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockMoveNote, mockBatchClassifyDefault } = vi.hoisted(() => ({
|
||||
mockMoveNote: vi.fn(async () => ({ ok: true as const })),
|
||||
mockBatchClassifyDefault: vi.fn(async () => ({ assignments: [] }))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
batchClassifyDefault: mockBatchClassifyDefault,
|
||||
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, trashed: 0 })),
|
||||
getSettings: vi.fn(async () => ({ ai_enabled: true })),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
listExpired: vi.fn(async () => []),
|
||||
getFailedCount: vi.fn(async () => 0),
|
||||
listRecallCandidate: vi.fn(async () => null),
|
||||
listByStatus: vi.fn(async () => [])
|
||||
},
|
||||
notebookApi: {
|
||||
moveNote: mockMoveNote,
|
||||
list: vi.fn(async () => [])
|
||||
}
|
||||
}));
|
||||
|
||||
import { BatchMoveModal } from '../../src/renderer/inbox/components/BatchMoveModal';
|
||||
import { useInbox } from '../../src/renderer/inbox/store';
|
||||
|
||||
const baseNote = {
|
||||
id: 'n1',
|
||||
rawText: '테스트 노트 내용',
|
||||
aiTitle: 'AI 제목',
|
||||
aiSummary: null,
|
||||
aiStatus: 'done' as const,
|
||||
aiError: null,
|
||||
aiProvider: null,
|
||||
aiGeneratedAt: null,
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false,
|
||||
deletedAt: null,
|
||||
lastRecalledAt: null,
|
||||
recallDismissedAt: null,
|
||||
status: 'active' as const,
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
tags: [],
|
||||
media: [],
|
||||
notebookId: 'nb-default'
|
||||
};
|
||||
|
||||
describe('BatchMoveModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
useInbox.setState({
|
||||
batchClassifyResult: null,
|
||||
batchClassifying: false,
|
||||
notes: [],
|
||||
notebooks: []
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
});
|
||||
|
||||
it('batchClassifyResult null 시 null 반환', () => {
|
||||
useInbox.setState({ batchClassifyResult: null } as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<BatchMoveModal />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('actionable 0건 시 "찾지 못했어요" 메시지 표시', () => {
|
||||
useInbox.setState({
|
||||
batchClassifyResult: { assignments: [{ noteId: 'n1', notebookId: null, notebookName: null }] }
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
render(<BatchMoveModal />);
|
||||
expect(screen.getByText(/찾지 못했어요/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('actionable 노트 표시 + checkbox 기본 체크', () => {
|
||||
useInbox.setState({
|
||||
batchClassifyResult: {
|
||||
assignments: [
|
||||
{ noteId: 'n1', notebookId: 'nb1', notebookName: '업무' }
|
||||
]
|
||||
},
|
||||
notes: [{ ...baseNote, id: 'n1', aiTitle: 'AI 제목' }]
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
render(<BatchMoveModal />);
|
||||
expect(screen.getByText('AI 제목')).toBeInTheDocument();
|
||||
expect(screen.getByText(/→ 업무/)).toBeInTheDocument();
|
||||
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('checkbox toggle 시 selectedIds 변경 (체크 해제 후 확인 버튼 비활성)', () => {
|
||||
useInbox.setState({
|
||||
batchClassifyResult: {
|
||||
assignments: [
|
||||
{ noteId: 'n1', notebookId: 'nb1', notebookName: '업무' }
|
||||
]
|
||||
},
|
||||
notes: [{ ...baseNote, id: 'n1' }]
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
render(<BatchMoveModal />);
|
||||
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
const confirmBtn = screen.getByRole('button', { name: /건 이동/ });
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('확인 클릭 → acceptBatchAssignments 호출 후 modal 닫힘', async () => {
|
||||
const mockAccept = vi.fn(async () => {});
|
||||
useInbox.setState({
|
||||
batchClassifyResult: {
|
||||
assignments: [
|
||||
{ noteId: 'n1', notebookId: 'nb1', notebookName: '업무' }
|
||||
]
|
||||
},
|
||||
notes: [{ ...baseNote, id: 'n1' }],
|
||||
acceptBatchAssignments: mockAccept
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
render(<BatchMoveModal />);
|
||||
const confirmBtn = screen.getByRole('button', { name: /건 이동/ });
|
||||
fireEvent.click(confirmBtn);
|
||||
await waitFor(() => {
|
||||
expect(mockAccept).toHaveBeenCalledWith([{ noteId: 'n1', notebookId: 'nb1' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe('ImportService.applySyncFromDir', () => {
|
||||
expect(note?.rawText).toBe('new body');
|
||||
});
|
||||
|
||||
it('preserves status field from frontmatter', async () => {
|
||||
it('preserves status field from frontmatter (archived coerced to completed — v0.4 Task 16)', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
@@ -86,7 +86,8 @@ describe('ImportService.applySyncFromDir', () => {
|
||||
);
|
||||
await svc.applySyncFromDir(workDir);
|
||||
const note = repo.findById('00000000-0000-0000-0000-000000000002');
|
||||
expect(note?.status).toBe('archived');
|
||||
// archived → completed coerce (m008 와 동일 정책, NoteStatus 에서 archived 삭제됨).
|
||||
expect(note?.status).toBe('completed');
|
||||
expect(note?.statusChangedAt).toBe('2026-05-08T00:00:00Z');
|
||||
expect(note?.moveReason).toBe('done');
|
||||
});
|
||||
|
||||
@@ -45,13 +45,47 @@ describe('LocalOllamaProvider', () => {
|
||||
expect(parsed.prompt).toContain('Prefer reusing');
|
||||
});
|
||||
|
||||
it('generate throws on non-JSON', async () => {
|
||||
it('v0.3.14 — generate falls back to placeholder when JSON unparseable', async () => {
|
||||
// 이전엔 throw 했지만 schema graceful coerce 추가 후 placeholder 채워서 통과.
|
||||
// truncated / repetition-loop 응답에서 사용자 데이터 (raw_text) 무손실 보존.
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
||||
response: 'not json'
|
||||
});
|
||||
await expect(
|
||||
new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
|
||||
).rejects.toThrow(/json/i);
|
||||
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
|
||||
expect(r.title).toBe('(첨부 메모)');
|
||||
expect(r.summary.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('v0.3.14 — body 에 repeat_penalty 포함 (repetition loop 방지)', async () => {
|
||||
let capturedBody: string = '';
|
||||
mock.get('http://localhost:11434').intercept({
|
||||
path: '/api/generate', method: 'POST'
|
||||
}).reply((opts) => {
|
||||
capturedBody = opts.body as string;
|
||||
return { statusCode: 200, data: JSON.stringify({
|
||||
response: JSON.stringify({ title: '회의', summary: 'a\nb\nc', tags: [] })
|
||||
}) };
|
||||
});
|
||||
await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
|
||||
const parsed = JSON.parse(capturedBody) as { options: { repeat_penalty: number } };
|
||||
expect(parsed.options.repeat_penalty).toBe(1.15);
|
||||
});
|
||||
|
||||
it('v0.3.11 — generate extracts JSON from markdown fence', async () => {
|
||||
// vision model 이 ```json ... ``` 형태로 응답하는 경우 fallback 으로 추출.
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
||||
response: '```json\n{"title":"회의","summary":"a\\nb\\nc","tags":["meet"]}\n```'
|
||||
});
|
||||
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
|
||||
expect(r.title).toBe('회의');
|
||||
});
|
||||
|
||||
it('v0.3.11 — generate extracts JSON when prose 가 앞뒤로 섞임', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
||||
response: 'Here is the response:\n{"title":"회의","summary":"a\\nb\\nc","tags":[]}\nDone.'
|
||||
});
|
||||
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
|
||||
expect(r.title).toBe('회의');
|
||||
});
|
||||
|
||||
it('generate aborts on timeout', async () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('MoveStatusModal', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders reason textarea + 4 buttons + AI classify button', () => {
|
||||
it('renders reason textarea + 3 buttons + AI classify button (v0.4 — 보관 제거)', () => {
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
@@ -39,7 +39,7 @@ describe('MoveStatusModal', () => {
|
||||
);
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '보관' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe('MoveStatusModal', () => {
|
||||
await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
|
||||
});
|
||||
|
||||
it('currentStatus=completed → Inbox/보관/휴지통 노출, 완료 미노출', () => {
|
||||
it('currentStatus=completed → Inbox/휴지통 노출, 완료/보관 미노출 (v0.4 — 보관 제거)', () => {
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
@@ -96,31 +96,14 @@ describe('MoveStatusModal', () => {
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Inbox' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '보관' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '완료' })).toBeNull();
|
||||
});
|
||||
|
||||
it('currentStatus=archived → Inbox 버튼 클릭 시 setStatus("active") 호출', async () => {
|
||||
const onMoved = vi.fn();
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
rawText="t"
|
||||
summary=""
|
||||
currentStatus="archived"
|
||||
onClose={vi.fn()}
|
||||
onMoved={onMoved}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Inbox' }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'active', null);
|
||||
expect(onMoved).toHaveBeenCalledWith('active', null);
|
||||
});
|
||||
});
|
||||
// v0.4 Task 16 — currentStatus=archived 는 NoteStatus 에서 제거됨. 테스트 제거.
|
||||
|
||||
it('currentStatus=trashed → Inbox/완료/보관 노출, 휴지통 미노출', () => {
|
||||
it('currentStatus=trashed → Inbox/완료 노출, 휴지통/보관 미노출 (v0.4 — 보관 제거)', () => {
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
@@ -133,7 +116,7 @@ describe('MoveStatusModal', () => {
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Inbox' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '보관' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '휴지통' })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -185,7 +168,7 @@ describe('MoveStatusModal', () => {
|
||||
onMoved={onMoved}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '보관' }));
|
||||
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null));
|
||||
fireEvent.click(screen.getByRole('button', { name: '완료' }));
|
||||
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { Note, Notebook } from '@shared/types';
|
||||
|
||||
const { mockOpenMedia, mockSetStatus, mockClassify, mockUpdateRawText } = vi.hoisted(() => ({
|
||||
const { mockOpenMedia, mockSetStatus, mockClassify, mockUpdateRawText, mockMoveNoteToNotebook } = vi.hoisted(() => ({
|
||||
mockOpenMedia: vi.fn(async () => ({ ok: true })),
|
||||
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
|
||||
mockClassify: vi.fn(async () => ({
|
||||
recommended: 'archived' as const,
|
||||
rationale: 'stub'
|
||||
})),
|
||||
mockUpdateRawText: vi.fn(async () => ({ ok: true as const }))
|
||||
mockUpdateRawText: vi.fn(async () => ({ ok: true as const })),
|
||||
mockMoveNoteToNotebook: vi.fn(async () => {})
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
@@ -33,9 +34,24 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
}));
|
||||
|
||||
const mockRefreshMeta = vi.fn();
|
||||
|
||||
// Notebooks used across notebook-chip tests.
|
||||
const stubNotebooks: Notebook[] = [
|
||||
{ id: 'nb-1', name: '회사', color: '#4a90d9', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', noteCount: 1 },
|
||||
{ id: 'nb-2', name: '개인', color: '#e67e22', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', noteCount: 0 }
|
||||
];
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: Object.assign(
|
||||
() => ({}),
|
||||
// Selector-aware: if selector is a function, call it with the mock state.
|
||||
(selector?: (s: unknown) => unknown) => {
|
||||
const state = {
|
||||
notebooks: stubNotebooks,
|
||||
moveNoteToNotebook: mockMoveNoteToNotebook
|
||||
};
|
||||
if (typeof selector === 'function') return selector(state);
|
||||
return state;
|
||||
},
|
||||
{ getState: () => ({ setTagFilter: vi.fn(), refreshMeta: mockRefreshMeta }) }
|
||||
)
|
||||
}));
|
||||
@@ -69,7 +85,8 @@ const baseNote: Note = {
|
||||
media: [
|
||||
{ id: 'm1', kind: 'image', relPath: 'media/n1/img1.png', mime: 'image/png', bytes: 100 },
|
||||
{ id: 'm2', kind: 'image', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg', bytes: 200 }
|
||||
]
|
||||
],
|
||||
notebookId: 'nb-default'
|
||||
};
|
||||
|
||||
describe('NoteCard — image rendering', () => {
|
||||
@@ -189,3 +206,34 @@ describe('NoteCard — raw_text editing', () => {
|
||||
expect(last.rawText).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteCard — notebook chip (Task 17)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('현재 notebook 이름 chip 렌더링', () => {
|
||||
render(<NoteCard note={{ ...baseNote, notebookId: 'nb-1' }} onUpdated={vi.fn()} mode="inbox" />);
|
||||
expect(screen.getByTitle('다른 노트북으로 이동')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('다른 노트북으로 이동').textContent).toContain('회사');
|
||||
});
|
||||
|
||||
it('chip 클릭 → 다른 notebook 목록 dropdown', () => {
|
||||
render(<NoteCard note={{ ...baseNote, notebookId: 'nb-1' }} onUpdated={vi.fn()} mode="inbox" />);
|
||||
fireEvent.click(screen.getByTitle('다른 노트북으로 이동'));
|
||||
// 현재 nb-1('회사') 는 제외, nb-2('개인') 만 보임.
|
||||
expect(screen.getByText('개인')).toBeInTheDocument();
|
||||
// chip 자체 text 는 "📓 회사 ▾" 이라 정확 매칭 X → regex 로 chip 안에만 '회사' 존재 확인.
|
||||
expect(screen.queryAllByText(/회사/).length).toBe(1);
|
||||
});
|
||||
|
||||
it('dropdown 의 notebook 클릭 → store.moveNoteToNotebook 호출', async () => {
|
||||
render(<NoteCard note={{ ...baseNote, notebookId: 'nb-1' }} onUpdated={vi.fn()} mode="inbox" />);
|
||||
fireEvent.click(screen.getByTitle('다른 노트북으로 이동'));
|
||||
fireEvent.click(screen.getByText('개인'));
|
||||
await waitFor(() => {
|
||||
expect(mockMoveNoteToNotebook).toHaveBeenCalledWith('n1', 'nb-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -592,6 +592,7 @@ describe('NoteRepository.findExpiredCandidates', () => {
|
||||
edited?: boolean;
|
||||
deletedAt?: string | null;
|
||||
aiStatus?: 'pending' | 'done' | 'failed';
|
||||
status?: 'active' | 'completed' | 'archived' | 'trashed';
|
||||
}): string {
|
||||
const { id } = repo.create({ rawText: opts.rawText });
|
||||
db.prepare(
|
||||
@@ -599,19 +600,21 @@ describe('NoteRepository.findExpiredCandidates', () => {
|
||||
SET due_date = ?,
|
||||
due_date_edited_by_user = ?,
|
||||
ai_status = ?,
|
||||
deleted_at = ?
|
||||
deleted_at = ?,
|
||||
status = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
opts.dueDate,
|
||||
opts.edited ? 1 : 0,
|
||||
opts.aiStatus ?? 'done',
|
||||
opts.deletedAt ?? null,
|
||||
opts.status ?? 'active',
|
||||
id
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
it('returns notes with due_date < today (KST), ORDER BY created_at DESC', () => {
|
||||
it('returns notes with due_date <= today (KST), ORDER BY due_date DESC then created_at DESC', () => {
|
||||
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
||||
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a);
|
||||
const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' });
|
||||
@@ -620,6 +623,14 @@ describe('NoteRepository.findExpiredCandidates', () => {
|
||||
expect(r.map((n) => n.id)).toEqual([b, a]);
|
||||
});
|
||||
|
||||
it('includes notes with due_date == today (오늘 당일 우선 표시)', () => {
|
||||
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
|
||||
const todayNote = makeDone({ rawText: 'b', dueDate: '2026-05-01' });
|
||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||
// 오늘 당일이 먼저, 그 다음 지난 메모.
|
||||
expect(r.map((n) => n.id)).toEqual([todayNote, past]);
|
||||
});
|
||||
|
||||
it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => {
|
||||
const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false });
|
||||
const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true });
|
||||
@@ -629,7 +640,7 @@ describe('NoteRepository.findExpiredCandidates', () => {
|
||||
|
||||
it('excludes trashed notes (deleted_at IS NOT NULL)', () => {
|
||||
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
||||
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z' });
|
||||
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z', status: 'trashed' });
|
||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r.map((n) => n.id)).toEqual([a]);
|
||||
});
|
||||
@@ -649,11 +660,12 @@ describe('NoteRepository.findExpiredCandidates', () => {
|
||||
expect(r.map((n) => n.id)).toEqual([dated]);
|
||||
});
|
||||
|
||||
it('excludes notes with due_date == today (boundary, not expired)', () => {
|
||||
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
|
||||
makeDone({ rawText: 'b', dueDate: '2026-05-01' });
|
||||
it('excludes completed / archived notes (inbox 만 — 사용자 의도: 완료/보관은 알림 제외)', () => {
|
||||
const active = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
||||
makeDone({ rawText: 'b', dueDate: '2026-04-20', status: 'completed' });
|
||||
makeDone({ rawText: 'c', dueDate: '2026-04-20', status: 'archived' });
|
||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r.map((n) => n.id)).toEqual([past]);
|
||||
expect(r.map((n) => n.id)).toEqual([active]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -928,9 +940,9 @@ describe('NoteRepository — setStatus + listByStatus', () => {
|
||||
|
||||
it('setStatus accepts null reason', () => {
|
||||
const { id } = repo.create({ rawText: 'test' });
|
||||
repo.setStatus(id, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
repo.setStatus(id, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.status).toBe('archived');
|
||||
expect(note.status).toBe('completed');
|
||||
expect(note.moveReason).toBeNull();
|
||||
});
|
||||
|
||||
@@ -948,14 +960,14 @@ describe('NoteRepository — setStatus + listByStatus', () => {
|
||||
it('listByStatus filters correctly', () => {
|
||||
const idA = repo.create({ rawText: 'a' }).id;
|
||||
const idB = repo.create({ rawText: 'b' }).id;
|
||||
repo.setStatus(idB, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
repo.setStatus(idB, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
|
||||
const active = repo.listByStatus('active', { limit: 10 });
|
||||
const archived = repo.listByStatus('archived', { limit: 10 });
|
||||
const completed = repo.listByStatus('completed', { limit: 10 });
|
||||
expect(active.map((n) => n.id)).toContain(idA);
|
||||
expect(active.map((n) => n.id)).not.toContain(idB);
|
||||
expect(archived.map((n) => n.id)).toContain(idB);
|
||||
expect(archived.map((n) => n.id)).not.toContain(idA);
|
||||
expect(completed.map((n) => n.id)).toContain(idB);
|
||||
expect(completed.map((n) => n.id)).not.toContain(idA);
|
||||
});
|
||||
|
||||
it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => {
|
||||
@@ -972,10 +984,10 @@ describe('NoteRepository — setStatus + listByStatus', () => {
|
||||
it('listByStatus respects limit (cap 200)', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = repo.create({ rawText: `n${i}` }).id;
|
||||
repo.setStatus(id, 'archived', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`));
|
||||
repo.setStatus(id, 'completed', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`));
|
||||
}
|
||||
expect(repo.listByStatus('archived', { limit: 3 })).toHaveLength(3);
|
||||
expect(repo.listByStatus('archived', { limit: 100 })).toHaveLength(5);
|
||||
expect(repo.listByStatus('completed', { limit: 3 })).toHaveLength(3);
|
||||
expect(repo.listByStatus('completed', { limit: 100 })).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('listByStatus default limit 200', () => {
|
||||
@@ -1004,7 +1016,7 @@ describe('NoteRepository — setStatus + listByStatus', () => {
|
||||
expect(repo.findById(id)!.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('setStatus("completed"/"archived") also clears deleted_at', () => {
|
||||
it('setStatus("completed") also clears deleted_at', () => {
|
||||
const { id } = repo.create({ rawText: 'r' });
|
||||
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
|
||||
repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z'));
|
||||
@@ -1025,13 +1037,12 @@ describe('NoteRepository — setStatus + listByStatus', () => {
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.setStatus(c, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
const d = repo.create({ rawText: 'd' }).id;
|
||||
repo.setStatus(d, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
repo.setStatus(d, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
const e = repo.create({ rawText: 'e' }).id;
|
||||
repo.setStatus(e, 'trashed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
|
||||
expect(repo.countByStatus('active')).toBe(2);
|
||||
expect(repo.countByStatus('completed')).toBe(1);
|
||||
expect(repo.countByStatus('archived')).toBe(1);
|
||||
expect(repo.countByStatus('completed')).toBe(2);
|
||||
expect(repo.countByStatus('trashed')).toBe(1);
|
||||
// sanity — a 가 여전히 active.
|
||||
expect(repo.findById(a)!.status).toBe('active');
|
||||
@@ -1210,3 +1221,151 @@ describe('NoteRepository — notes_fts tags sync (v0.2.11 Cut D)', () => {
|
||||
expect(row.tags).toBe('결재');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.create with notebook', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let defaultId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id;
|
||||
});
|
||||
|
||||
it('notebook_id 미지정 시 default notebook 으로 들어감', () => {
|
||||
const { id } = repo.create({ rawText: 'hello' });
|
||||
const r = repo.findById(id);
|
||||
expect(r?.notebookId).toBe(defaultId);
|
||||
});
|
||||
|
||||
it('notebook_id 지정 시 그 값 보존', () => {
|
||||
db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb-other','회사','2026-05-14','2026-05-14')`).run();
|
||||
const { id } = repo.create({ rawText: 'hi', notebookId: 'nb-other' });
|
||||
expect(repo.findById(id)?.notebookId).toBe('nb-other');
|
||||
});
|
||||
|
||||
it('hydrate 가 notebookId 필드 반환', () => {
|
||||
const { id } = repo.create({ rawText: 'hi' });
|
||||
const r = repo.findById(id);
|
||||
expect(typeof r?.notebookId).toBe('string');
|
||||
expect(r?.notebookId).toBe(defaultId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.findPromotionCandidates', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let defaultId: string;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id;
|
||||
});
|
||||
|
||||
function insertWithTag(rawText: string, tagName: string, notebookId?: string): string {
|
||||
const { id } = repo.create({ rawText, notebookId });
|
||||
repo.updateAiResult(id, { title: rawText, summary: 'a\nb\nc', tags: [tagName], provider: 'test', dueDate: null });
|
||||
return id;
|
||||
}
|
||||
|
||||
it('threshold 미만: 빈 결과', () => {
|
||||
insertWithTag('n1', 'mlx-ops');
|
||||
insertWithTag('n2', 'mlx-ops');
|
||||
expect(repo.findPromotionCandidates(defaultId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('threshold 도달: tag 와 noteIds 반환', () => {
|
||||
const a = insertWithTag('n1', 'mlx-ops');
|
||||
const b = insertWithTag('n2', 'mlx-ops');
|
||||
const c = insertWithTag('n3', 'mlx-ops');
|
||||
const r = repo.findPromotionCandidates(defaultId);
|
||||
expect(r).toHaveLength(1);
|
||||
expect(r[0]!.tag).toBe('mlx-ops');
|
||||
expect(r[0]!.noteIds.sort()).toEqual([a, b, c].sort());
|
||||
});
|
||||
|
||||
it('default 가 아닌 notebook 의 노트는 제외', () => {
|
||||
db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb-x','회사','2099-01-01','2099-01-01')`).run();
|
||||
insertWithTag('n1', 'mlx-ops');
|
||||
insertWithTag('n2', 'mlx-ops');
|
||||
insertWithTag('n3', 'mlx-ops', 'nb-x');
|
||||
expect(repo.findPromotionCandidates(defaultId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('completed 제외 — active 만', () => {
|
||||
insertWithTag('n1', 'mlx-ops');
|
||||
insertWithTag('n2', 'mlx-ops');
|
||||
const c = insertWithTag('n3', 'mlx-ops');
|
||||
repo.setStatus(c, 'completed', null);
|
||||
expect(repo.findPromotionCandidates(defaultId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('threshold 인자로 cap 조절 가능', () => {
|
||||
insertWithTag('n1', 'mlx-ops');
|
||||
insertWithTag('n2', 'mlx-ops');
|
||||
expect(repo.findPromotionCandidates(defaultId, 2)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('여러 tag cluster 가 모두 반환', () => {
|
||||
insertWithTag('a1', 'mlx-ops');
|
||||
insertWithTag('a2', 'mlx-ops');
|
||||
insertWithTag('a3', 'mlx-ops');
|
||||
insertWithTag('b1', 'keycloak');
|
||||
insertWithTag('b2', 'keycloak');
|
||||
insertWithTag('b3', 'keycloak');
|
||||
const r = repo.findPromotionCandidates(defaultId);
|
||||
expect(r).toHaveLength(2);
|
||||
expect(r.map((c) => c.tag).sort()).toEqual(['keycloak', 'mlx-ops']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.list / countByStatus with notebookId', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let nbA: string, nbB: string;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
nbA = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id;
|
||||
nbB = 'nb-b';
|
||||
db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES(?,?,?,?)`).run(nbB,'회사','2099-01-01','2099-01-01');
|
||||
});
|
||||
|
||||
it('list 가 notebookId 필터로 노트 분리', () => {
|
||||
repo.create({ rawText: 'in-default' });
|
||||
repo.create({ rawText: 'in-B', notebookId: nbB });
|
||||
expect(repo.list({ limit: 10, notebookId: nbA }).map((n) => n.rawText)).toEqual(['in-default']);
|
||||
expect(repo.list({ limit: 10, notebookId: nbB }).map((n) => n.rawText)).toEqual(['in-B']);
|
||||
});
|
||||
|
||||
it('list 의 notebookId 미지정 시 모든 notebook 의 노트', () => {
|
||||
repo.create({ rawText: 'in-default' });
|
||||
repo.create({ rawText: 'in-B', notebookId: nbB });
|
||||
expect(repo.list({ limit: 10 })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('countByStatus(notebookId) — 각 notebook 의 active 갯수', () => {
|
||||
repo.create({ rawText: 'a1' });
|
||||
repo.create({ rawText: 'a2' });
|
||||
repo.create({ rawText: 'b1', notebookId: nbB });
|
||||
expect(repo.countByStatus('active', { notebookId: nbA })).toBe(2);
|
||||
expect(repo.countByStatus('active', { notebookId: nbB })).toBe(1);
|
||||
expect(repo.countByStatus('active')).toBe(3); // 옵션 미지정 시 전체
|
||||
});
|
||||
|
||||
it('listByStatus(notebookId) — 같은 status 라도 notebook 별 분리', () => {
|
||||
const { id: a1 } = repo.create({ rawText: 'a1' });
|
||||
const { id: b1 } = repo.create({ rawText: 'b1', notebookId: nbB });
|
||||
repo.setStatus(a1, 'completed', null);
|
||||
repo.setStatus(b1, 'completed', null);
|
||||
expect(repo.listByStatus('completed', { notebookId: nbA }).map((n) => n.id)).toEqual([a1]);
|
||||
expect(repo.listByStatus('completed', { notebookId: nbB }).map((n) => n.id)).toEqual([b1]);
|
||||
});
|
||||
});
|
||||
|
||||
64
tests/unit/NotebookCreateModal.test.tsx
Normal file
64
tests/unit/NotebookCreateModal.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
const createNotebook = vi.fn(async (): Promise<{ ok: boolean; reason?: string }> => ({ ok: true }));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: (selector?: (s: { createNotebook: typeof createNotebook }) => unknown) => {
|
||||
const state = { createNotebook };
|
||||
return selector ? selector(state) : state;
|
||||
}
|
||||
}));
|
||||
|
||||
import { NotebookCreateModal } from '../../src/renderer/inbox/components/NotebookCreateModal';
|
||||
|
||||
describe('NotebookCreateModal', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
createNotebook.mockClear();
|
||||
});
|
||||
|
||||
it('이름 빈 상태에서 "만들기" disabled', () => {
|
||||
render(<NotebookCreateModal onClose={() => {}} />);
|
||||
const btn = screen.getByRole('button', { name: '만들기' });
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('이름 입력 후 만들기 클릭 → createNotebook 호출 + onClose', async () => {
|
||||
const onClose = vi.fn();
|
||||
createNotebook.mockResolvedValueOnce({ ok: true });
|
||||
render(<NotebookCreateModal onClose={onClose} />);
|
||||
fireEvent.change(screen.getByLabelText('노트북 이름'), { target: { value: '회사' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '만들기' }));
|
||||
await waitFor(() => expect(createNotebook).toHaveBeenCalledWith('회사', expect.any(String)));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('duplicate_name reason 시 에러 표시 + onClose 안 됨', async () => {
|
||||
const onClose = vi.fn();
|
||||
createNotebook.mockResolvedValueOnce({ ok: false, reason: 'duplicate_name' });
|
||||
render(<NotebookCreateModal onClose={onClose} />);
|
||||
fireEvent.change(screen.getByLabelText('노트북 이름'), { target: { value: '기본' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '만들기' }));
|
||||
await waitFor(() => expect(screen.getByText(/이미 있어요/)).toBeInTheDocument());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overlay 클릭 → onClose', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(<NotebookCreateModal onClose={onClose} />);
|
||||
fireEvent.click(container.firstChild as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('color palette 클릭 시 선택 색 변경 (border 확인)', () => {
|
||||
render(<NotebookCreateModal onClose={() => {}} />);
|
||||
const colorBtns = screen.getAllByRole('button').filter((b) => b.getAttribute('aria-label')?.startsWith('색 '));
|
||||
expect(colorBtns).toHaveLength(6);
|
||||
fireEvent.click(colorBtns[2]!);
|
||||
// 선택 색의 border 가 '2px solid #333' 인지 확인
|
||||
expect(colorBtns[2]!.style.border).toContain('2px');
|
||||
});
|
||||
});
|
||||
72
tests/unit/NotebookList.test.tsx
Normal file
72
tests/unit/NotebookList.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { NotebookList } from '../../src/renderer/inbox/components/NotebookList';
|
||||
|
||||
const notebooks = [
|
||||
{ id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 3 },
|
||||
{ id: 'nb-2', name: '회사', color: '#0a4b80', createdAt: 't', updatedAt: 't', noteCount: 7 }
|
||||
];
|
||||
|
||||
describe('NotebookList', () => {
|
||||
beforeEach(cleanup);
|
||||
|
||||
it('노트북 이름 + count 렌더링', () => {
|
||||
render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={() => {}} />);
|
||||
expect(screen.getByText('기본')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('회사')).toBeInTheDocument();
|
||||
expect(screen.getByText('7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('클릭 시 onSelect 호출', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={onSelect} onCreate={() => {}} />);
|
||||
fireEvent.click(screen.getByText('회사'));
|
||||
expect(onSelect).toHaveBeenCalledWith('nb-2');
|
||||
});
|
||||
|
||||
it('+ 새 노트북 클릭 시 onCreate 호출', () => {
|
||||
const onCreate = vi.fn();
|
||||
render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={onCreate} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /새 노트북/ }));
|
||||
expect(onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selected notebook 의 background 가 다름', () => {
|
||||
render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={() => {}} />);
|
||||
const btn1 = screen.getByText('기본').closest('button')!;
|
||||
const btn2 = screen.getByText('회사').closest('button')!;
|
||||
expect(btn1.style.background).not.toBe('transparent');
|
||||
expect(btn2.style.background).toBe('transparent');
|
||||
});
|
||||
|
||||
it('hover 시 ↑↓ 버튼 노출', () => {
|
||||
const { container } = render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={() => {}} onReorder={async () => {}} />);
|
||||
// 기본 상태: ↑↓ 미노출
|
||||
expect(screen.queryByLabelText(/위로/)).not.toBeInTheDocument();
|
||||
// hover 후 보임 — position:relative 인 row div 선택
|
||||
const row = container.querySelector('div[style*="position: relative"]') as HTMLElement;
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(screen.getAllByLabelText(/위로/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('↑ 클릭 시 onReorder up 호출', () => {
|
||||
const onReorder = vi.fn();
|
||||
const { container } = render(<NotebookList notebooks={notebooks} selectedId="nb-2" onSelect={() => {}} onCreate={() => {}} onReorder={onReorder} />);
|
||||
const rows = container.querySelectorAll('div[style*="position: relative"]');
|
||||
// 두번째 row (nb-2) hover → 위로 클릭
|
||||
fireEvent.mouseEnter(rows[1] as Element);
|
||||
fireEvent.click(screen.getByLabelText('회사 위로'));
|
||||
expect(onReorder).toHaveBeenCalledWith('nb-2', 'up');
|
||||
});
|
||||
|
||||
it('첫 row 의 ↑ 는 disabled', () => {
|
||||
const { container } = render(<NotebookList notebooks={notebooks} selectedId="nb-1" onSelect={() => {}} onCreate={() => {}} onReorder={async () => {}} />);
|
||||
const rows = container.querySelectorAll('div[style*="position: relative"]');
|
||||
fireEvent.mouseEnter(rows[0] as Element);
|
||||
const upBtn = screen.getByLabelText('기본 위로');
|
||||
expect(upBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
160
tests/unit/NotebookRepository.test.ts
Normal file
160
tests/unit/NotebookRepository.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
||||
import { NotebookRepository } from '../../src/main/repository/NotebookRepository.js';
|
||||
|
||||
describe('NotebookRepository', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NotebookRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NotebookRepository(db);
|
||||
});
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
it('list: 기본 notebook 1개 + noteCount 0', () => {
|
||||
const all = repo.list();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]!.name).toBe('기본');
|
||||
expect(all[0]!.noteCount).toBe(0);
|
||||
});
|
||||
|
||||
it('create: 새 notebook 추가', () => {
|
||||
const nb = repo.create({ name: '회사', color: '#0a4b80' });
|
||||
expect(nb.name).toBe('회사');
|
||||
expect(nb.color).toBe('#0a4b80');
|
||||
expect(repo.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('create: 같은 이름 두 번이면 throw', () => {
|
||||
repo.create({ name: '회사' });
|
||||
expect(() => repo.create({ name: '회사' })).toThrow();
|
||||
});
|
||||
|
||||
it('rename: 이름 변경', () => {
|
||||
const nb = repo.create({ name: '회사' });
|
||||
repo.rename(nb.id, '워크');
|
||||
const after = repo.findById(nb.id);
|
||||
expect(after?.name).toBe('워크');
|
||||
});
|
||||
|
||||
it('delete: 메모 없으면 OK', () => {
|
||||
const nb = repo.create({ name: '회사' });
|
||||
const r = repo.delete(nb.id);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(repo.findById(nb.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('delete: 메모 있으면 RESTRICT — ok:false', () => {
|
||||
const nb = repo.create({ name: '회사' });
|
||||
const ts = '2026-05-14T00:00:00Z';
|
||||
db.prepare(
|
||||
`INSERT INTO notes(id, raw_text, ai_status, created_at, updated_at, status, notebook_id)
|
||||
VALUES('n1','t','pending',?,?,'active',?)`
|
||||
).run(ts, ts, nb.id);
|
||||
const r = repo.delete(nb.id);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe('has_notes');
|
||||
});
|
||||
|
||||
it('noteCount: status="active" 만 카운트 (completed/trashed 제외)', () => {
|
||||
const nb = repo.create({ name: '회사' });
|
||||
const ts = '2026-05-14T00:00:00Z';
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO notes(id, raw_text, ai_status, created_at, updated_at, status, notebook_id)
|
||||
VALUES(?,?,?,?,?,?,?)`
|
||||
);
|
||||
insert.run('n1','t','done',ts,ts,'active',nb.id);
|
||||
insert.run('n2','t','done',ts,ts,'completed',nb.id);
|
||||
insert.run('n3','t','done',ts,ts,'trashed',nb.id);
|
||||
const found = repo.findById(nb.id);
|
||||
expect(found?.noteCount).toBe(1);
|
||||
});
|
||||
|
||||
it('moveNote: notebook_id 갱신', () => {
|
||||
const nb = repo.create({ name: '회사' });
|
||||
const ts = '2026-05-14T00:00:00Z';
|
||||
const defaultId = repo.list().find((n) => n.name === '기본')!.id;
|
||||
db.prepare(
|
||||
`INSERT INTO notes(id, raw_text, ai_status, created_at, updated_at, status, notebook_id)
|
||||
VALUES('n1','t','pending',?,?,'active',?)`
|
||||
).run(ts, ts, defaultId);
|
||||
repo.moveNote('n1', nb.id);
|
||||
const r = db.prepare(`SELECT notebook_id FROM notes WHERE id='n1'`).get() as { notebook_id: string };
|
||||
expect(r.notebook_id).toBe(nb.id);
|
||||
});
|
||||
|
||||
it('setColor: 색 변경', () => {
|
||||
const nb = repo.create({ name: '회사', color: '#000' });
|
||||
repo.setColor(nb.id, '#fff');
|
||||
expect(repo.findById(nb.id)?.color).toBe('#fff');
|
||||
});
|
||||
|
||||
it('delete: 존재하지 않는 id → ok:false reason="not_found"', () => {
|
||||
const r = repo.delete('does-not-exist');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe('not_found');
|
||||
});
|
||||
|
||||
it('findByName: 이름으로 조회', () => {
|
||||
const nb = repo.create({ name: '회사' });
|
||||
const found = repo.findByName('회사');
|
||||
expect(found?.id).toBe(nb.id);
|
||||
});
|
||||
|
||||
it('findByName: case-insensitive', () => {
|
||||
repo.create({ name: 'Work' });
|
||||
expect(repo.findByName('work')?.name).toBe('Work');
|
||||
});
|
||||
|
||||
it('findByName: 없으면 null', () => {
|
||||
expect(repo.findByName('없음')).toBeNull();
|
||||
});
|
||||
|
||||
it('list: sort_order ASC 순서로 반환', () => {
|
||||
const a = repo.create({ name: 'A' }); // sort_order = 1 (기본=0)
|
||||
const b = repo.create({ name: 'B' }); // sort_order = 2
|
||||
const all = repo.list();
|
||||
expect(all[0]!.name).toBe('기본');
|
||||
expect(all[1]!.id).toBe(a.id);
|
||||
expect(all[2]!.id).toBe(b.id);
|
||||
});
|
||||
|
||||
it('reorder: B.up → B/기본/A 순서로 swap', () => {
|
||||
const a = repo.create({ name: 'A' }); // sort_order=1
|
||||
const b = repo.create({ name: 'B' }); // sort_order=2
|
||||
// 초기: 기본(0), A(1), B(2)
|
||||
const r = repo.reorder(b.id, 'up');
|
||||
expect(r.ok).toBe(true);
|
||||
const names = repo.list().map((n) => n.name);
|
||||
expect(names).toEqual(['기본', 'B', 'A']);
|
||||
});
|
||||
|
||||
it('reorder: 첫 번째 notebook up → ok:false', () => {
|
||||
const defaultId = repo.list()[0]!.id;
|
||||
const r = repo.reorder(defaultId, 'up');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('reorder: 마지막 notebook down → ok:false', () => {
|
||||
const c = repo.create({ name: 'C' }); // sort_order=1
|
||||
const r = repo.reorder(c.id, 'down');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('reorder: B.down → 기본/A/C/B 순서', () => {
|
||||
const a = repo.create({ name: 'A' }); // sort_order=1
|
||||
const b = repo.create({ name: 'B' }); // sort_order=2
|
||||
const c = repo.create({ name: 'C' }); // sort_order=3
|
||||
// 초기: 기본(0), A(1), B(2), C(3)
|
||||
const r = repo.reorder(b.id, 'down');
|
||||
expect(r.ok).toBe(true);
|
||||
const names = repo.list().map((n) => n.name);
|
||||
expect(names).toEqual(['기본', 'A', 'C', 'B']);
|
||||
// a, c 순서 안 변함 확인
|
||||
expect(names.indexOf('A')).toBeLessThan(names.indexOf('C'));
|
||||
void a; void c;
|
||||
});
|
||||
});
|
||||
81
tests/unit/PromotionBanner.test.tsx
Normal file
81
tests/unit/PromotionBanner.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
|
||||
const accept = vi.fn(async () => {});
|
||||
const snooze = vi.fn(async () => {});
|
||||
const dismiss = vi.fn(async () => {});
|
||||
|
||||
let mockState: {
|
||||
promotionCandidates: Array<{ tag: string; noteIds: string[]; suggestedName: string }>;
|
||||
acceptPromotion: typeof accept;
|
||||
snoozePromotion: typeof snooze;
|
||||
dismissPromotion: typeof dismiss;
|
||||
} = {
|
||||
promotionCandidates: [],
|
||||
acceptPromotion: accept,
|
||||
snoozePromotion: snooze,
|
||||
dismissPromotion: dismiss
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: (selector?: (s: typeof mockState) => unknown) => selector ? selector(mockState) : mockState
|
||||
}));
|
||||
|
||||
import { PromotionBanner } from '../../src/renderer/inbox/components/PromotionBanner';
|
||||
|
||||
describe('PromotionBanner', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
accept.mockClear(); snooze.mockClear(); dismiss.mockClear();
|
||||
mockState = {
|
||||
promotionCandidates: [],
|
||||
acceptPromotion: accept,
|
||||
snoozePromotion: snooze,
|
||||
dismissPromotion: dismiss
|
||||
};
|
||||
});
|
||||
|
||||
it('candidates 비어있으면 null', () => {
|
||||
const { container } = render(<PromotionBanner />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('첫 candidate 의 tag/suggestedName 표시', () => {
|
||||
mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }];
|
||||
render(<PromotionBanner />);
|
||||
expect(screen.getByText('mlx-ops')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mlx Ops')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('수락 클릭 → 편집 모드 → 만들기 → acceptPromotion 호출', () => {
|
||||
mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }];
|
||||
render(<PromotionBanner />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '수락' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '만들기' }));
|
||||
expect(accept).toHaveBeenCalledWith('mlx-ops', 'Mlx Ops', expect.any(String));
|
||||
});
|
||||
|
||||
it('나중에 클릭 → snoozePromotion 호출', () => {
|
||||
mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }];
|
||||
render(<PromotionBanner />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '나중에' }));
|
||||
expect(snooze).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('숨기기 클릭 → dismissPromotion(tag) 호출', () => {
|
||||
mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }];
|
||||
render(<PromotionBanner />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '숨기기' }));
|
||||
expect(dismiss).toHaveBeenCalledWith('mlx-ops');
|
||||
});
|
||||
|
||||
it('편집 모드: 이름 빈 시 만들기 disabled', () => {
|
||||
mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }];
|
||||
render(<PromotionBanner />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '수락' }));
|
||||
fireEvent.change(screen.getByLabelText('노트북 이름'), { target: { value: '' } });
|
||||
expect(screen.getByRole('button', { name: '만들기' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,13 @@ const { mockSearchNotes, mockClearSearch } = vi.hoisted(() => ({
|
||||
mockClearSearch: vi.fn()
|
||||
}));
|
||||
|
||||
// selectedNotebookId 를 테스트 간 변경할 수 있도록 ref 로 관리.
|
||||
let mockSelectedNotebookId: string | null = 'nb-1';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: Object.assign(
|
||||
(selector?: (s: { searchQuery: string }) => unknown) => {
|
||||
const state = { searchQuery: '' };
|
||||
(selector?: (s: { searchQuery: string; selectedNotebookId: string | null }) => unknown) => {
|
||||
const state = { searchQuery: '', selectedNotebookId: mockSelectedNotebookId };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ searchNotes: mockSearchNotes, clearSearch: mockClearSearch }) }
|
||||
@@ -26,6 +29,7 @@ describe('SearchBox', () => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
vi.useFakeTimers();
|
||||
mockSelectedNotebookId = 'nb-1';
|
||||
});
|
||||
|
||||
it('타이핑 → 200ms debounce 후 searchNotes 호출', () => {
|
||||
@@ -34,7 +38,7 @@ describe('SearchBox', () => {
|
||||
fireEvent.change(input, { target: { value: '회의' } });
|
||||
expect(mockSearchNotes).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('회의');
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('회의', { notebookId: 'nb-1' });
|
||||
});
|
||||
|
||||
it('빈 값 → clearSearch 호출', () => {
|
||||
@@ -44,4 +48,32 @@ describe('SearchBox', () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockClearSearch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('기본 scope=current — searchNotes 에 selectedNotebookId 전달', () => {
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: '노트' } });
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('노트', { notebookId: 'nb-1' });
|
||||
});
|
||||
|
||||
it('scope=all 변경 시 다음 검색에서 notebookId 미전달 (undefined)', () => {
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
const scopeSelect = screen.getByRole('combobox', { name: '검색 범위' });
|
||||
|
||||
fireEvent.change(scopeSelect, { target: { value: 'all' } });
|
||||
fireEvent.change(input, { target: { value: '리뷰' } });
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('리뷰', { notebookId: undefined });
|
||||
});
|
||||
|
||||
it('selectedNotebookId=null 이면 scope=current 에서 notebookId=undefined 전달', () => {
|
||||
mockSelectedNotebookId = null;
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: '테스트' } });
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('테스트', { notebookId: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
35
tests/unit/Sidebar.test.tsx
Normal file
35
tests/unit/Sidebar.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {} as never,
|
||||
notebookApi: {} as never
|
||||
}));
|
||||
|
||||
import { Sidebar } from '../../src/renderer/inbox/components/Sidebar';
|
||||
import { useInbox } from '../../src/renderer/inbox/store';
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({ sidebarVisible: false, sidebarWidth: 240, notebooks: [], selectedNotebookId: null } as never);
|
||||
});
|
||||
|
||||
it('sidebarVisible=false 면 렌더링 안 함', () => {
|
||||
const { container } = render(<Sidebar />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('sidebarVisible=true 면 NotebookList 렌더링', () => {
|
||||
useInbox.setState({
|
||||
sidebarVisible: true,
|
||||
sidebarWidth: 240,
|
||||
notebooks: [{ id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }],
|
||||
selectedNotebookId: 'nb-1'
|
||||
} as never);
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('기본')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,8 @@ describe('SyncHelpModal', () => {
|
||||
it('4 섹션 헤더 렌더링', () => {
|
||||
render(<SyncHelpModal onClose={() => {}} />);
|
||||
expect(screen.getByRole('heading', { name: /충돌 해결/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /자동 처리/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /조용히 잘못될 수 있는/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /자동으로 처리되는 일/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /모르고 넘어가기 쉬운 함정/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /Setup/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -13,10 +13,21 @@ describe('parseAiResponse', () => {
|
||||
expect(r.tags).toEqual(['api-timeout', 'meeting']);
|
||||
});
|
||||
|
||||
it('rejects title without Korean', () => {
|
||||
expect(() =>
|
||||
parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] })
|
||||
).toThrow(/korean/i);
|
||||
it('영어 title → (첨부 메모) placeholder fallback (vision graceful 처리)', () => {
|
||||
const r = parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] });
|
||||
expect(r.title).toBe('(첨부 메모)');
|
||||
});
|
||||
|
||||
it('null title/summary → placeholder coerce (vision 본문 빈 케이스)', () => {
|
||||
const r = parseAiResponse({ title: null, summary: null, tags: [], due_date: null });
|
||||
expect(r.title).toBe('(첨부 메모)');
|
||||
expect(r.summary.startsWith('내용을 자동으로 정리하지 못했습니다')).toBe(true);
|
||||
});
|
||||
|
||||
it('empty string title/summary → placeholder coerce', () => {
|
||||
const r = parseAiResponse({ title: '', summary: '', tags: [] });
|
||||
expect(r.title).toBe('(첨부 메모)');
|
||||
expect(r.summary.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('pads short summary to 3 lines', () => {
|
||||
@@ -82,10 +93,14 @@ describe('parseAiResponse', () => {
|
||||
expect(r.dueDate).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed due_date string', () => {
|
||||
expect(() =>
|
||||
parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: 'tomorrow' })
|
||||
).toThrow();
|
||||
it('malformed due_date string → null coerce (vision graceful 처리)', () => {
|
||||
const r = parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: 'tomorrow' });
|
||||
expect(r.dueDate).toBeNull();
|
||||
});
|
||||
|
||||
it('empty string due_date → null coerce', () => {
|
||||
const r = parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: '' });
|
||||
expect(r.dueDate).toBeNull();
|
||||
});
|
||||
|
||||
it('coerces invalid date that passes regex (e.g. 2026-13-99) to null', () => {
|
||||
@@ -97,4 +112,19 @@ describe('parseAiResponse', () => {
|
||||
});
|
||||
expect(r.dueDate).toBeNull();
|
||||
});
|
||||
|
||||
it('parseAiResponse — notebook_match valid string', () => {
|
||||
const r = parseAiResponse({ title: '제목입니다', summary: '한\n두\n셋', tags: [], due_date: null, notebook_match: '회사' });
|
||||
expect(r.notebookMatch).toBe('회사');
|
||||
});
|
||||
|
||||
it('parseAiResponse — notebook_match null', () => {
|
||||
const r = parseAiResponse({ title: '제목입니다', summary: '한\n두\n셋', tags: [], due_date: null, notebook_match: null });
|
||||
expect(r.notebookMatch).toBeNull();
|
||||
});
|
||||
|
||||
it('parseAiResponse — notebook_match 누락 시 null', () => {
|
||||
const r = parseAiResponse({ title: '제목입니다', summary: '한\n두\n셋', tags: [], due_date: null });
|
||||
expect(r.notebookMatch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
212
tests/unit/batchClassify.test.ts
Normal file
212
tests/unit/batchClassify.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { batchClassifyDefault } from '../../src/main/ai/batchClassify';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeNote { id: string; rawText: string; aiTitle: string | null; notebookId: string; status: string }
|
||||
interface FakeNotebook { id: string; name: string }
|
||||
|
||||
function makeNoteRepo(notes: FakeNote[], defaultNotebookId: string) {
|
||||
return {
|
||||
listByStatus: vi.fn((_status: string, opts: { notebookId?: string; limit?: number } = {}) => {
|
||||
if (opts.notebookId !== defaultNotebookId) return [];
|
||||
const limit = opts.limit ?? notes.length;
|
||||
return notes.slice(0, limit);
|
||||
}),
|
||||
getDefaultNotebookId: vi.fn(() => defaultNotebookId)
|
||||
};
|
||||
}
|
||||
|
||||
function makeNotebookRepo(notebooks: FakeNotebook[]) {
|
||||
return {
|
||||
getDefault: vi.fn(() => notebooks[0] ?? null),
|
||||
list: vi.fn(() => notebooks),
|
||||
findByName: vi.fn((name: string) => notebooks.find((n) => n.name.toLowerCase() === name.toLowerCase()) ?? null)
|
||||
};
|
||||
}
|
||||
|
||||
function makeProvider(json: string) {
|
||||
return {
|
||||
generateRaw: vi.fn(async (_p: string) => json)
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('batchClassifyDefault', () => {
|
||||
it('default notebook 의 노트들 + 다른 notebook 들 prompt 구성 및 결과 매핑', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
const notes: FakeNote[] = [
|
||||
{ id: 'n1', rawText: 'MLX 계정 생성 가이드', aiTitle: 'MLX 가이드', notebookId: 'nb-default', status: 'active' },
|
||||
{ id: 'n2', rawText: '미용실 예약', aiTitle: null, notebookId: 'nb-default', status: 'active' }
|
||||
];
|
||||
|
||||
const aiJson = JSON.stringify({
|
||||
assignments: [
|
||||
{ id: 'n1', notebook: '회사' },
|
||||
{ id: 'n2', notebook: null }
|
||||
]
|
||||
});
|
||||
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider: makeProvider(aiJson)
|
||||
});
|
||||
|
||||
expect(result.assignments).toHaveLength(2);
|
||||
|
||||
const n1 = result.assignments.find((a) => a.noteId === 'n1');
|
||||
expect(n1?.notebookId).toBe('nb-work');
|
||||
expect(n1?.notebookName).toBe('회사');
|
||||
|
||||
const n2 = result.assignments.find((a) => a.noteId === 'n2');
|
||||
expect(n2?.notebookId).toBeNull();
|
||||
expect(n2?.notebookName).toBeNull();
|
||||
});
|
||||
|
||||
it('다른 notebook 0개면 빈 결과', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const notes: FakeNote[] = [
|
||||
{ id: 'n1', rawText: '노트', aiTitle: null, notebookId: 'nb-default', status: 'active' }
|
||||
];
|
||||
|
||||
const provider = makeProvider('{}');
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb]) as never,
|
||||
provider
|
||||
});
|
||||
|
||||
expect(result.assignments).toHaveLength(0);
|
||||
expect(result.skippedReason).toBe('no_other_notebooks');
|
||||
// provider 가 호출되면 안 됨
|
||||
expect(provider.generateRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('default 의 노트 0건이면 빈 결과', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
|
||||
const provider = makeProvider('{}');
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo([], 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider
|
||||
});
|
||||
|
||||
expect(result.assignments).toHaveLength(0);
|
||||
expect(result.skippedReason).toBe('no_notes');
|
||||
expect(provider.generateRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('AI 가 hallucinate 한 새 notebook 이름은 skip (notebookId=null 매핑)', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
const notes: FakeNote[] = [
|
||||
{ id: 'n1', rawText: '테스트 메모', aiTitle: null, notebookId: 'nb-default', status: 'active' }
|
||||
];
|
||||
|
||||
// AI 가 존재하지 않는 notebook 이름 반환
|
||||
const aiJson = JSON.stringify({
|
||||
assignments: [{ id: 'n1', notebook: '없는노트북이름XYZ' }]
|
||||
});
|
||||
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider: makeProvider(aiJson)
|
||||
});
|
||||
|
||||
expect(result.assignments).toHaveLength(1);
|
||||
const a = result.assignments[0];
|
||||
expect(a?.noteId).toBe('n1');
|
||||
// 매칭 실패 → null
|
||||
expect(a?.notebookId).toBeNull();
|
||||
expect(a?.notebookName).toBeNull();
|
||||
});
|
||||
|
||||
it('AI 응답의 id 가 입력 list 에 없으면 skip', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
const notes: FakeNote[] = [
|
||||
{ id: 'n1', rawText: '메모', aiTitle: null, notebookId: 'nb-default', status: 'active' }
|
||||
];
|
||||
|
||||
// AI 가 unknown-id 반환
|
||||
const aiJson = JSON.stringify({
|
||||
assignments: [
|
||||
{ id: 'unknown-id', notebook: '회사' },
|
||||
{ id: 'n1', notebook: '회사' }
|
||||
]
|
||||
});
|
||||
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider: makeProvider(aiJson)
|
||||
});
|
||||
|
||||
const ids = result.assignments.map((a) => a.noteId);
|
||||
expect(ids).not.toContain('unknown-id');
|
||||
expect(ids).toContain('n1');
|
||||
});
|
||||
|
||||
it('provider 가 invalid JSON 반환 시 빈 결과 + skippedReason', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
const notes: FakeNote[] = [
|
||||
{ id: 'n1', rawText: '메모', aiTitle: null, notebookId: 'nb-default', status: 'active' }
|
||||
];
|
||||
|
||||
const result = await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider: makeProvider('not valid json at all!!!')
|
||||
});
|
||||
|
||||
expect(result.assignments).toHaveLength(0);
|
||||
expect(result.skippedReason).toMatch(/parse_error|ai_error/);
|
||||
});
|
||||
|
||||
it('top N cap (50) 이 적용되어 prompt 에 최대 50개 노트만 포함', async () => {
|
||||
const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
|
||||
const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
|
||||
|
||||
// 60개 노트 생성
|
||||
const notes: FakeNote[] = Array.from({ length: 60 }, (_, i) => ({
|
||||
id: `n${i}`,
|
||||
rawText: `메모 ${i}`,
|
||||
aiTitle: null,
|
||||
notebookId: 'nb-default',
|
||||
status: 'active'
|
||||
}));
|
||||
|
||||
// 50개만 assignments 반환
|
||||
const assignments = notes.slice(0, 50).map((n) => ({ id: n.id, notebook: '회사' }));
|
||||
const aiJson = JSON.stringify({ assignments });
|
||||
|
||||
const capturedPrompts: string[] = [];
|
||||
const provider = {
|
||||
generateRaw: vi.fn(async (p: string) => { capturedPrompts.push(p); return aiJson; })
|
||||
};
|
||||
|
||||
await batchClassifyDefault({
|
||||
noteRepo: makeNoteRepo(notes, 'nb-default') as never,
|
||||
notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
|
||||
provider
|
||||
});
|
||||
|
||||
// prompt 에 n50~n59 (cap 이후) 는 포함 안 됨
|
||||
const prompt = capturedPrompts[0] ?? '';
|
||||
// 처음 50개 (n0~n49) 중 일부는 있어야 함
|
||||
expect(prompt).toContain('n0');
|
||||
// n50 이후는 없어야 함
|
||||
expect(prompt).not.toContain('- n50:');
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ describe('classifyStatus', () => {
|
||||
expect(r.rationale).toBe('처리됨');
|
||||
});
|
||||
|
||||
it('falls back to archived on parse failure (invalid JSON)', async () => {
|
||||
it('falls back to completed on parse failure (invalid JSON)', async () => {
|
||||
const provider = makeProvider(vi.fn(async () => 'not json'));
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
@@ -36,11 +36,11 @@ describe('classifyStatus', () => {
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.recommended).toBe('completed');
|
||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||
});
|
||||
|
||||
it('falls back to archived on invalid status value', async () => {
|
||||
it('falls back to completed on invalid status value', async () => {
|
||||
const provider = makeProvider(
|
||||
vi.fn(async () => '{"recommended":"unknown","rationale":"x"}')
|
||||
);
|
||||
@@ -50,7 +50,7 @@ describe('classifyStatus', () => {
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.recommended).toBe('completed');
|
||||
});
|
||||
|
||||
it('handles provider throw', async () => {
|
||||
@@ -65,7 +65,7 @@ describe('classifyStatus', () => {
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.recommended).toBe('completed');
|
||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||
});
|
||||
|
||||
@@ -77,13 +77,13 @@ describe('classifyStatus', () => {
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.recommended).toBe('completed');
|
||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||
});
|
||||
|
||||
it('substitutes empty inputs with placeholder text in prompt', async () => {
|
||||
const generateRaw = vi.fn(
|
||||
async (_p: string) => '{"recommended":"archived","rationale":"ok"}'
|
||||
async (_p: string) => '{"recommended":"completed","rationale":"ok"}'
|
||||
);
|
||||
const provider = makeProvider(generateRaw);
|
||||
await classifyStatus({ provider, rawText: '', summary: '', reason: '' });
|
||||
|
||||
@@ -230,16 +230,22 @@ describe('composeIndexJsonl', () => {
|
||||
});
|
||||
|
||||
describe('composeManifest', () => {
|
||||
it('emits pretty JSON with required fields', () => {
|
||||
it('emits pretty JSON with required fields (timestamp-free)', () => {
|
||||
const m = composeManifest({
|
||||
exportedAt: '2026-04-26T00:00:00.000Z',
|
||||
noteCount: 42,
|
||||
mediaCount: 17
|
||||
});
|
||||
const obj = JSON.parse(m);
|
||||
expect(obj.inkling_export_version).toBe(1);
|
||||
expect(obj.exported_at).toBe('2026-04-26T00:00:00.000Z');
|
||||
expect(obj.note_count).toBe(42);
|
||||
expect(obj.media_count).toBe(17);
|
||||
// exported_at 필드 제거 — sync git history noise 방지.
|
||||
expect(obj.exported_at).toBeUndefined();
|
||||
});
|
||||
|
||||
it('두 번 호출 결과 stable (sync no-op invariant — 같은 input 이면 git diff 0)', () => {
|
||||
const a = composeManifest({ noteCount: 5, mediaCount: 2 });
|
||||
const b = composeManifest({ noteCount: 5, mediaCount: 2 });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeDeps(overrides: Partial<InboxIpcDeps> = {}): InboxIpcDeps {
|
||||
updateRawText: vi.fn(),
|
||||
listRevisions: vi.fn(() => []),
|
||||
restoreRevision: vi.fn(),
|
||||
markAiPendingForReprocess: vi.fn(() => ({ ok: false })),
|
||||
findById: vi.fn(),
|
||||
list: vi.fn(),
|
||||
listByStatus: vi.fn(),
|
||||
|
||||
@@ -69,9 +69,9 @@ describe('inbox:set-status IPC', () => {
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['inbox:set-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = await handler(null, 'n1', 'archived', null);
|
||||
const r = await handler(null, 'n1', 'trashed', null);
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null);
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'trashed', null);
|
||||
});
|
||||
|
||||
it('rejects invalid status without calling repo', async () => {
|
||||
@@ -130,7 +130,7 @@ describe('ai:classify-status IPC', () => {
|
||||
expect(prompt).toContain('결재');
|
||||
});
|
||||
|
||||
it('returns archived fallback when note not found', async () => {
|
||||
it('returns completed fallback when note not found (v0.4 — archived 제거)', async () => {
|
||||
mockFindById.mockReturnValue(null);
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['ai:classify-status'];
|
||||
@@ -139,12 +139,12 @@ describe('ai:classify-status IPC', () => {
|
||||
recommended: string;
|
||||
rationale: string;
|
||||
};
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.recommended).toBe('completed');
|
||||
expect(r.rationale.length).toBeGreaterThan(0);
|
||||
expect(mockGenerateRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns archived fallback when AI throws', async () => {
|
||||
it('returns completed fallback when AI throws (v0.4 — archived 제거)', async () => {
|
||||
mockFindById.mockReturnValue({
|
||||
id: 'n1',
|
||||
rawText: 't',
|
||||
@@ -158,6 +158,6 @@ describe('ai:classify-status IPC', () => {
|
||||
recommended: string;
|
||||
rationale: string;
|
||||
};
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.recommended).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
78
tests/unit/m008.test.ts
Normal file
78
tests/unit/m008.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
||||
import * as m001 from '../../src/main/db/migrations/m001_initial.js';
|
||||
import * as m002 from '../../src/main/db/migrations/m002_due_date.js';
|
||||
import * as m003 from '../../src/main/db/migrations/m003_soft_delete.js';
|
||||
import * as m004 from '../../src/main/db/migrations/m004_status.js';
|
||||
import * as m005 from '../../src/main/db/migrations/m005_ai_disabled.js';
|
||||
import * as m006 from '../../src/main/db/migrations/m006_revisions.js';
|
||||
import * as m007 from '../../src/main/db/migrations/m007_fts.js';
|
||||
|
||||
/** m001~m007 을 수동 적용하고 user_version=7 로 설정 (m008 만 미적용 상태) */
|
||||
function applyUpTo7(db: Database.Database): void {
|
||||
m001.up(db);
|
||||
m002.up(db);
|
||||
m003.up(db);
|
||||
m004.up(db);
|
||||
m005.up(db);
|
||||
m006.up(db);
|
||||
m007.up(db);
|
||||
db.pragma('user_version = 7');
|
||||
}
|
||||
|
||||
describe('m008 notebooks migration', () => {
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); });
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
it('fresh DB: notebooks 테이블 + default "기본" notebook 생성', () => {
|
||||
runMigrations(db);
|
||||
const row = db.prepare(`SELECT name FROM notebooks`).get() as { name: string };
|
||||
expect(row.name).toBe('기본');
|
||||
});
|
||||
|
||||
it('fresh insert 는 notebook_id NULL 유지 (migration UPDATE 는 migration 시점 행만 채움)', () => {
|
||||
runMigrations(db);
|
||||
db.prepare(`INSERT INTO notes(id,raw_text,ai_status,created_at,updated_at,status) VALUES('n1','t','pending','2026-05-14','2026-05-14','active')`).run();
|
||||
const r = db.prepare(`SELECT notebook_id FROM notes WHERE id='n1'`).get() as { notebook_id: string | null };
|
||||
expect(r.notebook_id).toBeNull(); // m008 의 UPDATE 는 migration 시점의 NULL 만 채움
|
||||
});
|
||||
|
||||
it('pre-migration 노트가 runMigrations 후 default notebook 으로 backfill 됨', () => {
|
||||
applyUpTo7(db);
|
||||
// m008 적용 전 상태에서 노트 삽입 (notebook_id 컬럼 없음)
|
||||
db.prepare(`INSERT INTO notes(id,raw_text,ai_status,created_at,updated_at,status) VALUES('pre1','hello','pending','2026-05-14','2026-05-14','active')`).run();
|
||||
// m008 만 적용
|
||||
runMigrations(db);
|
||||
const defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id;
|
||||
const r = db.prepare(`SELECT notebook_id FROM notes WHERE id='pre1'`).get() as { notebook_id: string | null };
|
||||
expect(r.notebook_id).toBe(defaultId);
|
||||
});
|
||||
|
||||
it('pre-migration archived 노트가 runMigrations 후 completed 로 변환됨', () => {
|
||||
applyUpTo7(db);
|
||||
// m008 적용 전 상태에서 archived 노트 삽입
|
||||
db.prepare(`INSERT INTO notes(id,raw_text,ai_status,created_at,updated_at,status) VALUES('arc1','bye','pending','2026-05-14','2026-05-14','archived')`).run();
|
||||
// m008 만 적용
|
||||
runMigrations(db);
|
||||
const r = db.prepare(`SELECT status FROM notes WHERE id='arc1'`).get() as { status: string };
|
||||
expect(r.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('UNIQUE index 가 같은 이름 중복 INSERT 거부', () => {
|
||||
runMigrations(db);
|
||||
expect(() =>
|
||||
db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('x','기본','t','t')`).run()
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('UNIQUE index 가 대소문자 다른 이름도 중복으로 거부 (COLLATE NOCASE)', () => {
|
||||
runMigrations(db);
|
||||
// 영문 notebook 추가 후 대소문자 변형 삽입 시도
|
||||
db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb1','Default','t','t')`).run();
|
||||
expect(() =>
|
||||
db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb2','default','t','t')`).run()
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
28
tests/unit/m009.test.ts
Normal file
28
tests/unit/m009.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
||||
|
||||
describe('m009 notebook sort_order migration', () => {
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); });
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
it('fresh DB: default notebook 의 sort_order = 0', () => {
|
||||
runMigrations(db);
|
||||
const r = db.prepare(`SELECT sort_order FROM notebooks`).get() as { sort_order: number };
|
||||
expect(r.sort_order).toBe(0);
|
||||
});
|
||||
|
||||
it('새 notebook insert 시 DEFAULT 0 (caller 가 max+1 책임)', () => {
|
||||
runMigrations(db);
|
||||
db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb-x','회사','t','t')`).run();
|
||||
const r = db.prepare(`SELECT sort_order FROM notebooks WHERE id='nb-x'`).get() as { sort_order: number };
|
||||
expect(r.sort_order).toBe(0);
|
||||
});
|
||||
|
||||
it('user_version reaches 9 after all migrations', () => {
|
||||
runMigrations(db);
|
||||
const r = db.prepare('PRAGMA user_version').get() as { user_version: number };
|
||||
expect(r.user_version).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('user_version reaches latest (7)', () => {
|
||||
it('user_version reaches latest (9)', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
|
||||
expect(row.user_version).toBe(7);
|
||||
expect(row.user_version).toBe(9);
|
||||
db.close();
|
||||
});
|
||||
|
||||
|
||||
119
tests/unit/notebookApi.test.ts
Normal file
119
tests/unit/notebookApi.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: { ipcMain: { handle: vi.fn(), on: vi.fn() } }
|
||||
}));
|
||||
|
||||
import electron from 'electron';
|
||||
import { registerNotebookApi } from '../../src/main/ipc/notebookApi.js';
|
||||
|
||||
function getHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const call = handle.mock.calls.find((c) => c[0] === channel);
|
||||
if (!call) throw new Error(`channel ${channel} not registered`);
|
||||
return call[1] as (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function makeRepo() {
|
||||
return {
|
||||
list: vi.fn(() => [] as never[]),
|
||||
create: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
setColor: vi.fn(),
|
||||
delete: vi.fn(() => ({ ok: true })),
|
||||
moveNote: vi.fn(),
|
||||
findById: vi.fn(() => null),
|
||||
reorder: vi.fn(() => ({ ok: true }))
|
||||
};
|
||||
}
|
||||
|
||||
describe('notebookApi IPC', () => {
|
||||
beforeEach(() => {
|
||||
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
|
||||
});
|
||||
|
||||
it('notebook:list — repo.list 결과 반환', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.list.mockReturnValue([{ id: '1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }] as never);
|
||||
registerNotebookApi({ repo: repo as never });
|
||||
const h = getHandler('notebook:list');
|
||||
const r = await h({}) as unknown[];
|
||||
expect(r).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('notebook:create — UNIQUE 위반 시 ok:false reason="duplicate_name"', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.create.mockImplementation(() => { throw new Error('UNIQUE constraint failed: notebooks.name'); });
|
||||
registerNotebookApi({ repo: repo as never });
|
||||
const h = getHandler('notebook:create');
|
||||
const r = await h({}, { name: '회사' }) as { ok: boolean; reason?: string };
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('duplicate_name');
|
||||
});
|
||||
|
||||
it('notebook:create — 빈 이름 시 ok:false reason="empty_name"', async () => {
|
||||
const repo = makeRepo();
|
||||
registerNotebookApi({ repo: repo as never });
|
||||
const h = getHandler('notebook:create');
|
||||
const r = await h({}, { name: ' ' }) as { ok: boolean; reason?: string };
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('empty_name');
|
||||
expect(repo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('notebook:rename — UNIQUE 위반 ok:false reason="duplicate_name"', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.rename.mockImplementation(() => { throw new Error('UNIQUE constraint failed: notebooks.name'); });
|
||||
registerNotebookApi({ repo: repo as never });
|
||||
const h = getHandler('notebook:rename');
|
||||
const r = await h({}, 'id1', '회사') as { ok: boolean; reason?: string };
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('duplicate_name');
|
||||
});
|
||||
|
||||
it('notebook:delete — has_notes 시 ok:false reason 전달', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.delete.mockReturnValue({ ok: false, reason: 'has_notes' } as never);
|
||||
registerNotebookApi({ repo: repo as never });
|
||||
const h = getHandler('notebook:delete');
|
||||
const r = await h({}, 'id1');
|
||||
expect(r).toEqual({ ok: false, reason: 'has_notes' });
|
||||
});
|
||||
|
||||
it('notebook:move-note — repo.moveNote 호출 + ok:true', async () => {
|
||||
const repo = makeRepo();
|
||||
registerNotebookApi({ repo: repo as never });
|
||||
const h = getHandler('notebook:move-note');
|
||||
const r = await h({}, 'n1', 'nb-2');
|
||||
expect(repo.moveNote).toHaveBeenCalledWith('n1', 'nb-2');
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('notebook:set-color — repo.setColor 호출 + ok:true', async () => {
|
||||
const repo = makeRepo();
|
||||
registerNotebookApi({ repo: repo as never });
|
||||
const h = getHandler('notebook:set-color');
|
||||
const r = await h({}, 'id1', '#fff');
|
||||
expect(repo.setColor).toHaveBeenCalledWith('id1', '#fff');
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('notebook:reorder — repo.reorder 호출 + ok:true 전달', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.reorder.mockReturnValue({ ok: true } as never);
|
||||
registerNotebookApi({ repo: repo as never });
|
||||
const h = getHandler('notebook:reorder');
|
||||
const r = await h({}, 'nb-1', 'up');
|
||||
expect(repo.reorder).toHaveBeenCalledWith('nb-1', 'up');
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('notebook:reorder — 첫 번째 항목 up 시 ok:false 전달', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.reorder.mockReturnValue({ ok: false } as never);
|
||||
registerNotebookApi({ repo: repo as never });
|
||||
const h = getHandler('notebook:reorder');
|
||||
const r = await h({}, 'nb-first', 'up');
|
||||
expect(r).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -29,3 +29,18 @@ describe('prompt', () => {
|
||||
expect(jsonRulesIdx).toBeGreaterThan(vocabIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPrompt with notebooks', () => {
|
||||
it('notebooks 목록이 prompt 에 포함됨', () => {
|
||||
const p = buildPrompt('hi', '2026-05-14', [], [], ['기본', '회사']);
|
||||
expect(p).toContain('기본');
|
||||
expect(p).toContain('회사');
|
||||
expect(p).toContain('notebook_match');
|
||||
});
|
||||
|
||||
it('notebooks 빈 배열 시 notebook 섹션 생략', () => {
|
||||
const p = buildPrompt('hi', '2026-05-14', [], [], []);
|
||||
expect(p).not.toContain('notebook_match');
|
||||
expect(p).not.toContain('사용 가능한 노트북');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ const noteStub = (id: string): Note => ({
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
|
||||
status: 'active', statusChangedAt: null, moveReason: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
tags: [], media: [], notebookId: 'nb-default'
|
||||
});
|
||||
|
||||
describe('useInbox — expired state (v0.2.3 #5)', () => {
|
||||
|
||||
129
tests/unit/store.notebook.test.ts
Normal file
129
tests/unit/store.notebook.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockListByStatus, mockCountsByStatus, mockReorder } = vi.hoisted(() => ({
|
||||
mockListByStatus: vi.fn(async () => []),
|
||||
mockCountsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
||||
mockReorder: vi.fn(async () => ({ ok: true as const }))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
listNotes: vi.fn(async () => []),
|
||||
listTrash: vi.fn(async () => []),
|
||||
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),
|
||||
listExpired: vi.fn(async () => []),
|
||||
getFailedCount: vi.fn(async () => 0),
|
||||
listRecallCandidate: vi.fn(async () => null),
|
||||
countsByStatus: mockCountsByStatus,
|
||||
getSettings: vi.fn(async () => ({ ai_enabled: true })),
|
||||
listByStatus: mockListByStatus,
|
||||
setSidebarVisible: vi.fn(async () => ({ ok: true as const })),
|
||||
setSidebarWidth: vi.fn(async () => ({ ok: true as const }))
|
||||
},
|
||||
notebookApi: {
|
||||
list: vi.fn(async () => [
|
||||
{ id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 3 },
|
||||
{ id: 'nb-2', name: '회사', color: '#0a4b80', createdAt: 't', updatedAt: 't', noteCount: 1 }
|
||||
]),
|
||||
create: vi.fn(async (i: { name: string; color?: string }) => ({ ok: true as const, notebook: { id: 'nb-new', name: i.name, color: i.color ?? null, createdAt: 't', updatedAt: 't', noteCount: 0 } })),
|
||||
delete: vi.fn(async () => ({ ok: true as const })),
|
||||
rename: vi.fn(async () => ({ ok: true as const })),
|
||||
setColor: vi.fn(async () => ({ ok: true as const })),
|
||||
moveNote: vi.fn(async () => ({ ok: true as const })),
|
||||
reorder: mockReorder
|
||||
}
|
||||
}));
|
||||
|
||||
import { useInbox } from '../../src/renderer/inbox/store.js';
|
||||
|
||||
describe('store notebooks', () => {
|
||||
beforeEach(() => {
|
||||
mockListByStatus.mockClear();
|
||||
mockCountsByStatus.mockClear();
|
||||
mockReorder.mockClear();
|
||||
useInbox.setState({ notebooks: [], selectedNotebookId: null, sidebarVisible: false, sidebarWidth: 240, view: 'inbox' } as never);
|
||||
});
|
||||
|
||||
it('loadNotebooks 가 notebookApi.list 결과 반영', async () => {
|
||||
await useInbox.getState().loadNotebooks();
|
||||
expect(useInbox.getState().notebooks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('loadNotebooks: selectedNotebookId null 이면 첫 notebook 으로 자동 설정', async () => {
|
||||
await useInbox.getState().loadNotebooks();
|
||||
expect(useInbox.getState().selectedNotebookId).toBe('nb-1');
|
||||
});
|
||||
|
||||
it('selectNotebook 가 selectedNotebookId 설정', () => {
|
||||
useInbox.getState().selectNotebook('nb-X');
|
||||
expect(useInbox.getState().selectedNotebookId).toBe('nb-X');
|
||||
});
|
||||
|
||||
it('createNotebook 성공 시 notebooks 에 추가', async () => {
|
||||
await useInbox.getState().createNotebook('학습', '#ccc');
|
||||
expect(useInbox.getState().notebooks.some((n) => n.name === '학습')).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleSidebar 가 sidebarVisible 반전', () => {
|
||||
expect(useInbox.getState().sidebarVisible).toBe(false);
|
||||
useInbox.getState().toggleSidebar();
|
||||
expect(useInbox.getState().sidebarVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('deleteNotebook 성공 시 notebooks 에서 제거 + selected 였으면 첫 notebook 으로', async () => {
|
||||
useInbox.setState({
|
||||
notebooks: [
|
||||
{ id: 'nb-1', name: '기본', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 },
|
||||
{ id: 'nb-2', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }
|
||||
],
|
||||
selectedNotebookId: 'nb-2'
|
||||
} as never);
|
||||
await useInbox.getState().deleteNotebook('nb-2');
|
||||
expect(useInbox.getState().notebooks.map((n) => n.id)).toEqual(['nb-1']);
|
||||
expect(useInbox.getState().selectedNotebookId).toBe('nb-1');
|
||||
});
|
||||
|
||||
it('selectNotebook 가 loadByView + refreshMeta 호출', async () => {
|
||||
// inbox view 에서 notebook 전환 → listByStatus + countsByStatus 호출됨.
|
||||
useInbox.setState({ view: 'inbox', selectedNotebookId: null } as never);
|
||||
useInbox.getState().selectNotebook('nb-X');
|
||||
expect(useInbox.getState().selectedNotebookId).toBe('nb-X');
|
||||
// 비동기 완료 대기
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mockListByStatus).toHaveBeenCalled();
|
||||
expect(mockCountsByStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loadByView 가 selectedNotebookId 를 inboxApi.listByStatus 에 전달', async () => {
|
||||
useInbox.setState({ selectedNotebookId: 'nb-X', view: 'inbox' } as never);
|
||||
await useInbox.getState().loadByView('inbox');
|
||||
expect(mockListByStatus).toHaveBeenCalledWith('active', expect.objectContaining({ notebookId: 'nb-X' }));
|
||||
});
|
||||
|
||||
it('refreshMeta 가 selectedNotebookId 를 inboxApi.countsByStatus 에 전달', async () => {
|
||||
useInbox.setState({ selectedNotebookId: 'nb-X' } as never);
|
||||
await useInbox.getState().refreshMeta();
|
||||
expect(mockCountsByStatus).toHaveBeenCalledWith(expect.objectContaining({ notebookId: 'nb-X' }));
|
||||
});
|
||||
|
||||
it('selectNotebook: review view 에선 loadByView 를 호출하지 않음', async () => {
|
||||
useInbox.setState({ view: 'review-weekly', selectedNotebookId: null } as never);
|
||||
useInbox.getState().selectNotebook('nb-X');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
// listByStatus 는 호출되지 않아야 함 (review view 는 list 대상 아님)
|
||||
expect(mockListByStatus).not.toHaveBeenCalled();
|
||||
// countsByStatus 는 refreshMeta 경로로 호출됨
|
||||
expect(mockCountsByStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reorderNotebook 성공 시 notebookApi.reorder 호출 + loadNotebooks 재로드', async () => {
|
||||
await useInbox.getState().reorderNotebook('nb-1', 'down');
|
||||
expect(mockReorder).toHaveBeenCalledWith('nb-1', 'down');
|
||||
// loadNotebooks 가 호출되어 notebooks 가 갱신됨
|
||||
expect(useInbox.getState().notebooks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
144
tests/unit/store.promotion.test.ts
Normal file
144
tests/unit/store.promotion.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
// promotion IPC
|
||||
listPromotionCandidates: vi.fn(async () => []),
|
||||
getPromotionDismissedTags: vi.fn(async () => []),
|
||||
addPromotionDismissedTag: vi.fn(async () => undefined),
|
||||
getPromotionSnoozeUntil: vi.fn(async () => 0),
|
||||
setPromotionSnoozeUntil: vi.fn(async () => undefined),
|
||||
// refreshMeta deps
|
||||
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),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
listExpired: vi.fn(async () => []),
|
||||
getFailedCount: vi.fn(async () => 0),
|
||||
listRecallCandidate: vi.fn(async () => null),
|
||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
||||
getSettings: vi.fn(async () => ({ ai_enabled: true }))
|
||||
},
|
||||
notebookApi: {
|
||||
list: vi.fn(async () => []),
|
||||
create: vi.fn(async (i: { name: string; color?: string }) => ({
|
||||
ok: true as const,
|
||||
notebook: { id: 'nb-promo', name: i.name, color: i.color ?? null, createdAt: 't', updatedAt: 't', noteCount: 0 }
|
||||
})),
|
||||
moveNote: vi.fn(async () => ({ ok: true as const })),
|
||||
rename: vi.fn(async () => ({ ok: true as const })),
|
||||
setColor: vi.fn(async () => ({ ok: true as const })),
|
||||
delete: vi.fn(async () => ({ ok: true as const }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { useInbox } from '../../src/renderer/inbox/store.js';
|
||||
import { inboxApi } from '../../src/renderer/inbox/api.js';
|
||||
import { notebookApi } from '../../src/renderer/inbox/api.js';
|
||||
|
||||
type MockInboxApi = {
|
||||
listPromotionCandidates: ReturnType<typeof vi.fn>;
|
||||
getPromotionDismissedTags: ReturnType<typeof vi.fn>;
|
||||
addPromotionDismissedTag: ReturnType<typeof vi.fn>;
|
||||
getPromotionSnoozeUntil: ReturnType<typeof vi.fn>;
|
||||
setPromotionSnoozeUntil: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type MockNotebookApi = {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
moveNote: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const mockInbox = inboxApi as unknown as MockInboxApi;
|
||||
const mockNotebook = notebookApi as unknown as MockNotebookApi;
|
||||
|
||||
describe('store promotion actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useInbox.setState({ promotionCandidates: [], notebooks: [], sidebarVisible: false, selectedNotebookId: null } as never);
|
||||
});
|
||||
|
||||
it('loadPromotionCandidates: 후보 목록 반환 + suggestedName toTitleCase 변환', async () => {
|
||||
mockInbox.getPromotionDismissedTags.mockResolvedValueOnce([]);
|
||||
mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(0);
|
||||
mockInbox.listPromotionCandidates.mockResolvedValueOnce([
|
||||
{ tag: 'machine-learning', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' }
|
||||
]);
|
||||
await useInbox.getState().loadPromotionCandidates();
|
||||
const candidates = useInbox.getState().promotionCandidates;
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates[0]!.suggestedName).toBe('Machine Learning');
|
||||
expect(candidates[0]!.noteIds).toEqual(['n1', 'n2', 'n3']);
|
||||
});
|
||||
|
||||
it('loadPromotionCandidates: snooze 유효 시 빈 배열', async () => {
|
||||
mockInbox.getPromotionDismissedTags.mockResolvedValueOnce([]);
|
||||
// 24h 후 만료되는 snooze
|
||||
mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(Date.now() + 24 * 60 * 60 * 1000);
|
||||
mockInbox.listPromotionCandidates.mockResolvedValueOnce([
|
||||
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' }
|
||||
]);
|
||||
await useInbox.getState().loadPromotionCandidates();
|
||||
expect(useInbox.getState().promotionCandidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('loadPromotionCandidates: dismissed tag 는 제외', async () => {
|
||||
mockInbox.getPromotionDismissedTags.mockResolvedValueOnce(['work']);
|
||||
mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(0);
|
||||
mockInbox.listPromotionCandidates.mockResolvedValueOnce([
|
||||
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' },
|
||||
{ tag: 'study', noteIds: ['n4', 'n5', 'n6'], suggestedName: '' }
|
||||
]);
|
||||
await useInbox.getState().loadPromotionCandidates();
|
||||
const candidates = useInbox.getState().promotionCandidates;
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates[0]!.tag).toBe('study');
|
||||
});
|
||||
|
||||
it('dismissPromotion: addPromotionDismissedTag 호출 + state 에서 그 tag 제거', async () => {
|
||||
useInbox.setState({
|
||||
promotionCandidates: [
|
||||
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Work' },
|
||||
{ tag: 'study', noteIds: ['n4', 'n5'], suggestedName: 'Study' }
|
||||
]
|
||||
} as never);
|
||||
await useInbox.getState().dismissPromotion('work');
|
||||
expect(mockInbox.addPromotionDismissedTag).toHaveBeenCalledWith('work');
|
||||
const candidates = useInbox.getState().promotionCandidates;
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates[0]!.tag).toBe('study');
|
||||
});
|
||||
|
||||
it('snoozePromotion: setPromotionSnoozeUntil 24h 후로 호출 + state 비우기', async () => {
|
||||
useInbox.setState({
|
||||
promotionCandidates: [
|
||||
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Work' }
|
||||
]
|
||||
} as never);
|
||||
const before = Date.now();
|
||||
await useInbox.getState().snoozePromotion();
|
||||
const [[ms]] = mockInbox.setPromotionSnoozeUntil.mock.calls as [[number]];
|
||||
expect(ms).toBeGreaterThan(before + 23 * 60 * 60 * 1000);
|
||||
expect(ms).toBeLessThan(before + 25 * 60 * 60 * 1000);
|
||||
expect(useInbox.getState().promotionCandidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('acceptPromotion: notebook 생성 + moveNote 호출 + sidebar 열림 + selectedNotebookId 설정', async () => {
|
||||
useInbox.setState({
|
||||
promotionCandidates: [
|
||||
{ tag: 'work', noteIds: ['n1', 'n2'], suggestedName: 'Work' }
|
||||
],
|
||||
notebooks: []
|
||||
} as never);
|
||||
await useInbox.getState().acceptPromotion('work', 'Work', '#0a4b80');
|
||||
expect(mockNotebook.create).toHaveBeenCalledWith({ name: 'Work', color: '#0a4b80' });
|
||||
expect(mockNotebook.moveNote).toHaveBeenCalledTimes(2);
|
||||
expect(mockNotebook.moveNote).toHaveBeenCalledWith('n1', 'nb-promo');
|
||||
expect(mockNotebook.moveNote).toHaveBeenCalledWith('n2', 'nb-promo');
|
||||
const state = useInbox.getState();
|
||||
expect(state.sidebarVisible).toBe(true);
|
||||
expect(state.selectedNotebookId).toBe('nb-promo');
|
||||
expect(state.promotionCandidates.find((c) => c.tag === 'work')).toBeUndefined();
|
||||
expect(state.notebooks.some((n) => n.id === 'nb-promo')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,8 @@ const note = (id: string): Note => ({
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
|
||||
status: 'active', statusChangedAt: null, moveReason: null
|
||||
status: 'active', statusChangedAt: null, moveReason: null,
|
||||
notebookId: 'nb-default'
|
||||
});
|
||||
|
||||
describe('store recall actions', () => {
|
||||
|
||||
@@ -27,7 +27,8 @@ function sample(id: string, tags: string[]): Note {
|
||||
createdAt: '2026-04-26T00:00:00Z',
|
||||
updatedAt: '2026-04-26T00:00:00Z',
|
||||
tags: tags.map((name) => ({ name, source: 'ai' as const })),
|
||||
media: []
|
||||
media: [],
|
||||
notebookId: 'nb-default'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const noteStub = (id: string, deletedAt: string | null = null): Note => ({
|
||||
deletedAt, lastRecalledAt: null, recallDismissedAt: null,
|
||||
status: deletedAt ? 'trashed' : 'active', statusChangedAt: deletedAt, moveReason: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
tags: [], media: [], notebookId: 'nb-default'
|
||||
});
|
||||
|
||||
describe('useInbox — trash state (v0.2.3 #4)', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ const mockApi = {
|
||||
listNotes: vi.fn(async () => [] as Note[]),
|
||||
listTrash: vi.fn(async () => [] as Note[]),
|
||||
listByStatus: vi.fn(async () => [] as Note[]),
|
||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, trashed: 0 })),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
getContinuity: vi.fn(async () => ({
|
||||
weekStart: '', weekCount: 0, weekTarget: 7,
|
||||
@@ -40,7 +40,7 @@ describe('inbox store — view enum', () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
counts: { active: 0, completed: 0, trashed: 0 },
|
||||
notes: [], trashNotes: [], trashCount: 0,
|
||||
showTrash: false, showSettings: false,
|
||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
||||
@@ -65,7 +65,7 @@ describe('inbox store — view enum', () => {
|
||||
|
||||
it('counts initialized to zero per status', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, archived: 0, trashed: 0 });
|
||||
expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, trashed: 0 });
|
||||
});
|
||||
|
||||
it('backward-compat: showTrash mirrors view==="trash"', async () => {
|
||||
|
||||
@@ -15,9 +15,18 @@ describe('buildVisionPrompt', () => {
|
||||
expect(result).toContain('work, meeting, project, todo');
|
||||
});
|
||||
|
||||
it('uses (이미지만 있음) placeholder when text is empty', () => {
|
||||
it('본문 빈 경우 이미지 묘사 + null 금지 명시 + one-shot 예시 (v0.3.14+)', () => {
|
||||
const result = buildVisionPrompt('', '2026-05-09', [], []);
|
||||
expect(result).toContain('(이미지만 있음)');
|
||||
expect(result).not.toContain('\n\n\n'); // no double-blank from empty text
|
||||
expect(result).toContain('본문이 없습니다');
|
||||
expect(result).toContain('null 반환 절대 금지');
|
||||
expect(result).toContain('예시'); // one-shot 예시 포함
|
||||
expect(result).toContain('잔디 위 강아지'); // 예시 1
|
||||
expect(result).not.toContain('메모 본문:\n');
|
||||
});
|
||||
|
||||
it('본문 있는 경우 본문 우선 + 이미지 함께 분석 명시', () => {
|
||||
const result = buildVisionPrompt('회의 메모', '2026-05-09', [], []);
|
||||
expect(result).toContain('메모 본문:\n회의 메모');
|
||||
expect(result).toContain('첨부 이미지도 함께 분석');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user