Compare commits
235 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 | ||
|
|
2e69f598bc | ||
|
|
014c06e1f0 | ||
|
|
4216d42d7c | ||
|
|
e2058cfdbe | ||
|
|
2c6bfebb5b | ||
|
|
e815289b2a | ||
|
|
b35b644fe8 | ||
| f2db82b6d6 | |||
|
|
9d6f5bfacc | ||
|
|
d686c661ba | ||
|
|
dca1def87c | ||
|
|
8cd6382902 | ||
|
|
a5e1c1de35 | ||
|
|
54ef394bb4 | ||
|
|
5e55cd3469 | ||
|
|
976d53ccfc | ||
|
|
e8c6b94d2e | ||
|
|
d5143ab1ad | ||
|
|
2221113329 | ||
| f37e17dd81 | |||
|
|
41310dbe6a | ||
|
|
bb909e44ff | ||
|
|
83cefccbdd | ||
|
|
4db7a0bce0 | ||
|
|
aa7eb9d99f | ||
|
|
9073e78169 | ||
|
|
302bbd4ce0 | ||
|
|
6985db3505 | ||
|
|
36eafa1ce9 | ||
|
|
4deb7775f3 | ||
|
|
d0d9461d75 | ||
| 0d2896e0cc | |||
|
|
2b3c3d727e | ||
|
|
81fae12a8c | ||
|
|
7b536409a8 | ||
|
|
7468217460 | ||
|
|
72e9b68923 | ||
|
|
d03098cfac | ||
|
|
2179cfbf39 | ||
|
|
5012b40c14 | ||
|
|
369d418c7e | ||
|
|
e2e8b9b921 | ||
|
|
3eb0ef1316 | ||
|
|
463be7cf26 | ||
|
|
7a56184ad2 | ||
| a54f134343 | |||
|
|
401414608b | ||
|
|
2ef4802050 | ||
|
|
e3f6c711a7 | ||
|
|
87c18a4c2d | ||
|
|
9e48624495 | ||
|
|
62e68dcfe7 | ||
|
|
8436846657 | ||
|
|
33588b09df | ||
|
|
9a1f0e269a | ||
|
|
bbfd0cccda | ||
|
|
dba64c546f | ||
|
|
662abdb508 | ||
| 2e9a82face | |||
|
|
735d5494f2 | ||
|
|
5801a98a00 | ||
|
|
9feb712c60 | ||
|
|
be125b8ace | ||
|
|
f5e43133be | ||
|
|
143684ce8a | ||
|
|
e60a2a23c8 | ||
|
|
726d155d04 | ||
|
|
19edeab7b1 | ||
|
|
1104a8c666 | ||
| c4e7536086 | |||
|
|
39b8d1e728 | ||
|
|
e32223d28c | ||
|
|
81fbacb21e | ||
|
|
ff1a015226 | ||
|
|
b4c2d85b26 | ||
|
|
7541d3c9e4 | ||
|
|
18deee5900 | ||
|
|
76c23457ee | ||
|
|
88ce78d860 | ||
|
|
07e61bc9e1 | ||
| d59e8388b6 | |||
|
|
3fab44b466 | ||
|
|
f42d03f70c | ||
|
|
ba08190722 | ||
|
|
6070562358 | ||
|
|
c21fca57dd | ||
|
|
49fbed050a | ||
|
|
bc67dea2c8 | ||
|
|
c65d6c810e | ||
|
|
d2c7bf1b39 | ||
|
|
d3150976d4 | ||
|
|
495c3d12a2 | ||
|
|
9eb7abc831 | ||
|
|
d4dce9bf34 | ||
|
|
92375edc31 | ||
|
|
606ac94976 | ||
|
|
fd839f6afe | ||
|
|
facbf54025 | ||
|
|
06a1caf2bd | ||
|
|
7d2b8c95ec | ||
| b20473a593 | |||
|
|
6db449f86d | ||
|
|
29259eef32 | ||
|
|
4d4dac5523 | ||
|
|
9cdea1531c | ||
|
|
f6bea623bf | ||
|
|
470384bf80 | ||
| e8cddc7889 | |||
|
|
e19f6a8de7 | ||
|
|
ccfdbce79b | ||
|
|
cffd1cec90 | ||
|
|
c5f2b8337a | ||
|
|
836828636c | ||
|
|
8a8652e87a | ||
|
|
ce6c5ea756 | ||
|
|
39bbf8f443 | ||
|
|
5f964aa2f5 | ||
|
|
3a8137f334 | ||
|
|
3b53cec663 | ||
|
|
9c8ba8ad09 | ||
|
|
f30fbddd38 | ||
|
|
77effb4526 | ||
|
|
feb7c62f19 | ||
|
|
95ed0fba93 | ||
|
|
6ab518410e | ||
|
|
5cd38f2537 | ||
|
|
fca28fb0c4 | ||
|
|
7301f4d73d | ||
|
|
91bf98f1a2 | ||
|
|
5b37529175 | ||
|
|
c9d374ade6 | ||
|
|
b1b7bfee26 | ||
|
|
66bae5e317 | ||
|
|
5a605ef98f | ||
|
|
c2be135031 | ||
|
|
9f47c13649 | ||
|
|
a51f241b94 | ||
| 8bc33da954 | |||
|
|
a991008689 | ||
|
|
54e2f5b10f | ||
|
|
8b2920fee4 | ||
|
|
0447b69b82 | ||
|
|
476a519fb5 | ||
|
|
9230ebff9d | ||
|
|
983306e004 | ||
|
|
05c45c1e10 | ||
|
|
a2c17a8b0d | ||
|
|
3cfa60bbba | ||
|
|
075f395b6d | ||
|
|
e485b77888 | ||
|
|
e2c53a28dc | ||
|
|
df27a9637e | ||
|
|
6fdb72101f | ||
|
|
341f55505d | ||
|
|
b3e16ff5bc | ||
| 8f2b9adb3a | |||
|
|
7187aea0a9 | ||
| 49c29f34c3 | |||
|
|
d213d45f92 | ||
|
|
298d1c6182 | ||
|
|
d3dfe1e4e2 | ||
|
|
c87c248e89 | ||
|
|
ef5d3daf4c | ||
|
|
4bde148cdc | ||
|
|
8ba43d939e | ||
| fee982a6e6 | |||
|
|
d974335ee4 | ||
|
|
6f95e89456 | ||
|
|
3a2ff1a35c | ||
|
|
0c0327ddb6 | ||
|
|
833a598368 | ||
|
|
4153284af1 | ||
|
|
cee39a90aa | ||
|
|
d1f36250e7 | ||
|
|
9fef2edb6e | ||
|
|
c77c30be83 | ||
|
|
de895b8fec | ||
|
|
71ec79ae19 | ||
|
|
97ca119b55 | ||
|
|
b259734aa0 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -7,3 +7,11 @@ dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# build/ 산출물 — icon.{ico,icns,png} 만 커밋, 중간 산출물은 무시
|
||||
build/icons/
|
||||
build/icon-source.png
|
||||
|
||||
# Claude Code 로컬 worktree + 사용자별 settings
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
367
CHANGELOG.md
367
CHANGELOG.md
@@ -3,6 +3,373 @@
|
||||
본 파일은 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 안전성.
|
||||
|
||||
### 수정
|
||||
|
||||
- **`ai_status='pending'` 노트 cancel UI 부재 (P1).** Ollama 끊김 / 무한 pending 상태에서 사용자가 빠져나오는 path 가 없었음. NoteCard 의 pending 표시 옆에 "건너뛰기" 버튼 추가 → `inboxApi.cancelPending(id)` → `repo.cancelPending`: `ai_status='disabled'` + `pending_jobs` DELETE. raw_text 는 보존. pushNoteUpdated emit 으로 renderer 자동 sync.
|
||||
- **`ai_status='failed'` 노트 per-note 재시도 UI 부재 (P2).** 이전엔 FailedBanner 의 일괄 재시도만 가능. NoteCard 의 failed 표시 옆에 "재시도" 버튼 추가 → `inboxApi.retryOneFailed(id)` → `repo.retryOneFailed`: failed → pending + `pending_jobs` INSERT + worker enqueue. pushNoteUpdated emit.
|
||||
- **FTS5 query escape 불완전 (P2).** `sanitizeFtsQuery` 의 special chars regex 가 `["*():]` 만 처리 → backtick/dash/caret 미escape 로 일부 입력이 FTS5 parser throw 야기. `["*():`^\-]` 로 확장. 한국어 사용자가 의도 없이 입력할 가능성 높은 punctuation 까지 안전 처리.
|
||||
|
||||
### 미수정 (의도)
|
||||
|
||||
- **동시 편집 race (P2).** EditableField 가 이미 `editing=true` 중 value prop 변경을 무시하는 guard 보유. 사용자 입력은 보존됨 (last-write-wins). 추가 코드 불필요.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 745 → **750 PASS** (+5: repo retryOneFailed 2 + cancelPending 2 + FTS sanitize 1)
|
||||
- typecheck 0 errors (src)
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.8 인스톨러 위에 v0.3.9 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [0.3.8] — 2026-05-11
|
||||
|
||||
전수 audit 후 발견된 사용자 상호작용 hole 8건 일괄 hotfix. 핵심은 (1) push-based status 동기화 root fix, (2) modal Escape affordance 통일, (3) IPC 실패 resilience.
|
||||
|
||||
### 수정
|
||||
|
||||
- **`inbox:set-status` IPC 가 `pushNoteUpdated` emit 안 함 (root fix).** 이전엔 setStatus 후 counts/list/search 가 모두 stale → 호출처마다 `refreshMeta()` 명시 호출이 필요했음 (v0.3.5 워크어라운드). 이제 IPC 핸들러가 `repo.findById()` 후 `pushNoteUpdated` 호출. renderer 의 `onNoteUpdated` 콜백 1개 path 로 모든 status 전이가 일관 갱신.
|
||||
- **`upsertNote` 가 current view 무시 → 잘못된 status 노트 잔류.** 이전엔 trashed 외 모든 status 를 `notes` 에 누적 → 사용자가 inbox 에서 완료로 옮긴 노트가 list 에 잔류. v0.3.8 부터 `viewStatus` (inbox→active, completed→completed, archived→archived) 와 매칭되는 status 만 유지. `searchResults` 도 동일 패턴.
|
||||
- **`upsertNote` 의 trash 판정을 `deletedAt` → `status='trashed'` 로 전환.** m004 이후 status 가 single source of truth, deletedAt 은 backward-compat mirror. sync conflict 후 두 컬럼 불일치 가능성 대비.
|
||||
- **`restoreNote` 가 status 도 'active' 로 갱신.** 이전엔 `deletedAt: null` 만 clear → upsertNote 가 status='trashed' 그대로 라 여전히 trashNotes 에 잔류.
|
||||
- **OnboardingWizard close path 부재.** IPC 실패 시 무한 wizard 잠금 → `try/catch + setBusy/error state + "지금 건너뛰기" 버튼 + Escape` 추가. 첫 launch 사용자 막힘 회피.
|
||||
- **Modal Escape key dismiss 통일.** `MoveStatusModal` / `RevisionHistoryModal` / `ConflictModal` / `SyncHelpModal` / `OnboardingWizard` 모두 `keydown` listener 추가. MoveStatusModal 은 overlay 클릭 close 도 추가 (다른 modal 들은 이미 외부 클릭 지원).
|
||||
- **`store.ts` async 함수 error-resilient.** `loadInitial` / `loadByView` / `searchNotes` / `loadReview` / `refreshMeta` 가 IPC throw 시 try/catch 로 감싸 무한 loading / stale data 회피. loadInitial 은 catch 시 `loading: false`, loadByView 는 빈 list, searchNotes 는 빈 결과, loadReview 는 빈 aggregate 로 graceful fallback.
|
||||
|
||||
### 갱신
|
||||
|
||||
- **NoteCard 의 명시적 `refreshMeta` 호출 보존** — onNoteUpdated path 가 이미 refreshMeta 호출하므로 redundant 지만 backup 으로 유지 (2번 fetch 만 발생, 무해).
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 739 → **745 PASS** (+6: view-aware upsertNote 3 + setStatus push emit 1 + Modal Escape 1 + Modal overlay 클릭 1)
|
||||
- typecheck 0 errors (src)
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.7 인스톨러 위에 v0.3.8 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
### 미수정 (낮은 우선순위 / 별도 작업)
|
||||
|
||||
- ai_status='pending' 노트 cancel UI (P1, 별도 spec 필요)
|
||||
- ai_status='failed' 노트 per-note 재시도 UI (P2)
|
||||
- FTS5 query escape (P2, 확인 필요)
|
||||
- 동시 편집 race condition (P2)
|
||||
|
||||
## [0.3.7] — 2026-05-11
|
||||
|
||||
`MoveStatusModal` 의 button hardcode 로 인해 완료/보관/휴지통 노트가 inbox 로 돌아올 수 없던 버그 fix. v0.2.9 Cut B 부터 존재한 잠재 결함 (dropdown 의 `possibleTargets` 필터가 modal 까지 흐르지 못함).
|
||||
|
||||
### 수정
|
||||
|
||||
- **완료/보관/휴지통 노트의 Inbox 복원 path 부재.** `MoveStatusModal` 이 `완료/보관/휴지통` 3 button hardcode 라 currentStatus 외 3 status 만 동적으로 노출해야 한다는 의도가 누락. `currentStatus: NoteStatus` prop 추가 + 4 status 중 current 제외 동적 render. NoteCard 가 `local.status` 전달.
|
||||
- **status label 일관성** — `statusLabel('active')` 가 '활성' 이었으나 헤더 탭 표기는 'Inbox'. modal button + AI 추천 텍스트 양쪽 모두 'Inbox' 로 통일.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 736 → **739 PASS** (+3: completed/archived/trashed currentStatus button list 검증)
|
||||
- typecheck 0 errors (src)
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.6 인스톨러 위에 v0.3.7 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [0.3.6] — 2026-05-11
|
||||
|
||||
v0.3.5 의 이동 dropdown 단순화가 사용자 의도와 어긋난 점 정정. 이동 modal (사유 + AI 자동 분류 + 수동 status 선택) 은 보존해야 하는 핵심 UX 였음.
|
||||
|
||||
### 수정
|
||||
|
||||
- **이동 dropdown → 단일 "이동" 버튼 + `MoveStatusModal` 복원.** v0.3.5 에서 dropdown 항목 클릭 = 즉시 setStatus 로 단순화한 path 를 되돌림. 사용자 의도는 dropdown 의 목적지 중복 (modal 도 목적지 묻기) 제거였지, modal 자체 제거가 아니었음. 단일 "이동" 버튼 → modal → 사유 입력 + AI 자동 분류 + 수동 status 선택 path 로 통일.
|
||||
- **`MoveStatusModal.tsx` + 테스트 6 case 복원** — v0.3.5 에서 dead code 로 판단해 삭제했으나 다시 mount 됨. statusLabel 헬퍼 위치는 modal 내부로 회귀 (orphan `statusLabel.ts` 제거).
|
||||
- **이동 후 `refreshMeta()` 호출 유지** — v0.3.5 D1 fix (setStatus IPC 가 pushNoteUpdated emit 안 함 → 헤더 탭 count stale) 는 modal `onMoved` callback path 에서도 동일하게 트리거.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 734 → **736 PASS** (NoteCard 이동 case 3 → 2 + MoveStatusModal 6 복원)
|
||||
- typecheck 0 errors (src)
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.5 인스톨러 위에 v0.3.6 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [0.3.5] — 2026-05-11
|
||||
|
||||
v0.3.4 까지 누적된 dogfood UX 결함 7건 hotfix. 사용자가 막혔던 inbox/회고/이동 3건 + 그 부류의 동반 갭 4건. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
|
||||
|
||||
### 수정
|
||||
|
||||
- **Inbox 탭 진입 실패: 다른 보관함에서 inbox 로 못 돌아옴.** `setView('inbox')` 가 reload 를 호출 안 해서 `notes` state 가 이전 view 의 status 로 stale. `loadByView` 시그니처에 `'inbox' → 'active'` 매핑 추가 + setView 의 reload 분기에 inbox 포함.
|
||||
- **회고 view 탈출 불가.** `ReviewView` 가 App 의 헤더를 우회 (early return) 해서 사용자가 뒤로 갈 길이 없던 문제. `← 돌아가기` 버튼 추가 — 클릭 시 `setView('inbox')`.
|
||||
- **이동 dropdown 의 modal 중복 질문.** dropdown 에서 "완료로 이동" 선택해도 `MoveStatusModal` 이 떠서 목적지를 재확인. dropdown 클릭 = `inboxApi.setStatus(id, target, null)` 즉시 호출로 단순화. modal path 자체 제거 (`MoveStatusModal.tsx` + 동반 테스트 삭제, `statusLabel` 헬퍼는 별도 `statusLabel.ts` 로 분리).
|
||||
- **이동 후 헤더 탭 count stale.** `setStatus` IPC 가 `pushNoteUpdated` emit 을 안 해서 `refreshMeta` 가 트리거되지 않던 잠재 버그. dropdown 이동 path 끝에 `refreshMeta()` 명시 호출 추가.
|
||||
- **View 전환 시 검색/태그 필터 잔류.** `setView` 가 `searchResults`/`searchQuery`/`tagFilter` 를 reset 안 해서 이전 view 의 필터가 완료/보관/휴지통/회고에 잘못 적용. 한 번에 reset.
|
||||
- **Inbox 첫 로드와 탭 reload 결과 불일치.** `loadInitial` 이 `listNotes()` (= `deleted_at IS NULL` = active+completed+archived 혼재) 사용 → 헤더 inbox count (active 만) 와 list 불일치. `listByStatus('active', limit:50)` 로 통일.
|
||||
- **AI 배너가 completed/archived 탭에서도 노출.** OllamaBanner/PendingBanner/FailedBanner/ExpiryBanner/RecallBanner/RecoveryToast 가 `!showTrash` 만 체크해서 active 무관 컨텍스트에서도 그림. `view === 'inbox'` 분기로 한정.
|
||||
|
||||
### 갱신
|
||||
|
||||
- **이동 dropdown UX** — 메뉴 열린 상태에서 외부 클릭 / Escape 로 닫힘 (mousedown + keydown listener, useEffect 로 menuOpen=true 일 때만 활성).
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 738 → **734 PASS** (MoveStatusModal 6 case 삭제 + NoteCard 메뉴 case 1 → 3 (직접 이동/외부 클릭/Escape) 로 재구성)
|
||||
- typecheck 0 errors (src)
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.4 인스톨러 위에 v0.3.5 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [0.3.4] — 2026-05-11
|
||||
|
||||
v0.3.0 Cut E (양방향 sync) dogfood 의 결과로, 사용자가 conflict 시나리오에 막힌 순간 도움받을 곳이 부재한 갭을 메운 cut. 3 표면 (in-app modal + ConflictModal inline + README) 통합 도움말. PR #33 머지.
|
||||
|
||||
### 신규
|
||||
|
||||
- **`SyncHelpModal` (4 anchor 섹션)** — 설정 → 동기화 저장소 → "도움말" 버튼 또는 ConflictModal 의 "자세히 보기 →" 링크에서 진입. `#main-conflict` (편집/편집·삭제/편집·AI 결과 충돌 결정 트리) / `#auto` (fetch+rebase·첫 sync·push 거부·자동 주기) / `#silent` (NTP·동시 수정·자동 sync 실패 silent) / `#setup` (URL SSH/HTTPS 형식·잘못된 `git@https://` 사례·인증 helper·연결 테스트 실패 troubleshoot·URL 재설정).
|
||||
|
||||
### 갱신
|
||||
|
||||
- **`ConflictModal` inline 설명** — 각 conflict row 의 "내 것 사용" / "원격 사용" 의미를 1-2 줄 인라인 안내 + (옵션) "자세히 보기 →" 링크 (onOpenHelp callback). 기존 caller backward-compatible (optional prop).
|
||||
- **`SyncSection` 도움말 버튼** — URL row 마지막에 추가. busy (저장/테스트/sync 진행) 중에도 도움말 reachable (read-only).
|
||||
- **`README` 동기화 섹션 통째 재작성** — stale "원격 백업 (F6-L2)" (v0.2.1 MVP, 트레이 "지금 동기화" + 수동 `git init` 안내) → "동기화 (Git, F21 Cut E)". 일회 설정 / 일상 사용 / 충돌 해결 (3 케이스) / Silent risk / Troubleshoot. in-app SyncHelpModal 과 동일 정보 산문체.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 727 → **738 PASS** (+11): SyncHelpModal 7 + ConflictModal 회귀 3 + SyncSection 회귀 1
|
||||
- typecheck 0 errors
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 후속 (deferred)
|
||||
|
||||
- ESC key handler (현재 SyncHelpModal / ConflictModal 모두 X + overlay 만, 프로젝트 패턴 정합. 도입 시 양쪽 동시).
|
||||
- 1주 dogfood soak 후 도움말 텍스트 정합성 1차 갱신 (실제 사용자 경험과 어긋난 부분 보강).
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.3 인스톨러 위에 v0.3.4 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
|
||||
|
||||
## [0.3.3] — 2026-05-10
|
||||
|
||||
v0.3.0 Cut E (양방향 sync) dogfood 첫 시도 중 발견된 sync 설정 ENOENT 버그 hotfix.
|
||||
|
||||
### 수정
|
||||
|
||||
- **Sync 설정 첫 저장 실패 (git init ENOENT)**: 설정 → 동기화 저장소에서 URL 입력 후 "저장" 클릭 시 `git init failed: fatal: cannot change to '<profileDir>/sync': No such file or directory` 로 실패하던 문제. `settings:configure-sync` IPC 핸들러가 `git -C <syncDir> init` 호출 전에 syncDir 디렉토리를 생성하지 않아 git 이 chdir 단계에서 죽음. `SyncService.runSync()` 의 동일 패턴 (`mkdir(syncDir, { recursive: true })`) 을 핸들러에도 추가. 결과적으로 "연결 테스트" 버튼이 영영 활성화되지 않던 연쇄 증상 (저장 성공 시에만 url state 채워지고 버튼 enable) 도 자동 해소.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 테스트: `tests/unit/sync-ipc.test.ts` 18 (mkdir 호출 순서 회귀 1 추가)
|
||||
- typecheck: 0 errors
|
||||
- 신규 npm dependency: 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.2 인스톨러 위에 v0.3.3 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
|
||||
|
||||
## [0.2.2] — 2026-04-26
|
||||
|
||||
v0.2.1 dogfood 중 발견된 F7 (Due Date 합성 표현) + Quick Capture 스크롤 버그를 묶은 패치.
|
||||
|
||||
59
README.md
59
README.md
@@ -190,37 +190,58 @@ inkling.md 원본 제품 브리프 v1.4
|
||||
|
||||
---
|
||||
|
||||
## 원격 백업 (선택, F6-L2)
|
||||
## 동기화 (Git, F21 Cut E)
|
||||
|
||||
Inkling 데이터를 사적 git 원격에 백업하려면 한 번만 설정하면 된다. 인코딩된 형식이 아니라 평문 마크다운(F5 export 형식)으로 저장되니, **반드시 비공개 repo** 를 사용한다.
|
||||
Inkling 데이터를 사적 git 원격으로 양방향 동기화 (Mac ↔ Windows 등). 평문 마크다운(F5 export 형식)으로 저장되니 **반드시 비공개 repo** 를 사용한다.
|
||||
|
||||
상세 도움말은 앱 내 설정 → 동기화 저장소 → "도움말" 버튼 (4 섹션 modal) 참조. 본 섹션은 setup + 주요 시나리오 요약.
|
||||
|
||||
### 일회 설정
|
||||
|
||||
```bash
|
||||
# 1. 빈 사적 repo 생성 (예: gitea, GitHub private)
|
||||
1. 빈 사적 repo 생성 (Gitea / GitHub private)
|
||||
2. 앱 → 설정 → 동기화 저장소 → URL 입력 → "저장"
|
||||
3. "연결 테스트" 클릭해 인증 / 네트워크 확인
|
||||
4. 자동 sync 사용 토글 + interval (기본 30분) 확인
|
||||
|
||||
# 2. 데이터 디렉터리에 git 초기화 + 원격 등록
|
||||
cd "%APPDATA%\Inkling\Inkling\profiles\default\sync" # Windows
|
||||
git init
|
||||
git remote add origin https://your-host/owner/inkling-data.git
|
||||
git fetch origin || true # 빈 repo 면 무시
|
||||
URL 형식 (둘 중 하나):
|
||||
|
||||
# 3. 자격증명 설정 (Windows Credential Manager 자동 / 또는 token 임베드 URL)
|
||||
- SSH: `git@host:user/repo.git`
|
||||
- HTTPS: `https://host/user/repo.git`
|
||||
|
||||
# 4. 첫 동기화: 트레이 → "지금 동기화"
|
||||
```
|
||||
`git@https://...` 같은 혼합 형식은 거부된다.
|
||||
|
||||
처음 sync 시 SyncService 가 `<profileDir>/sync/` 안에 F5 export 트리(notes/, media/, index.jsonl, manifest.json)를 덮어쓰고 `git add -A && git commit && git push -u origin <branch>` 를 자동 수행.
|
||||
### 일상 사용
|
||||
|
||||
### 사용
|
||||
- 자동 sync: 설정한 interval 마다 + 앱 종료 시 1회
|
||||
- 수동 sync: 트레이 → "지금 동기화"
|
||||
- 충돌 발생 시 트레이 토스트 + 설정 페이지의 "충돌 해결…" 버튼 → ConflictModal
|
||||
|
||||
- 트레이 → "지금 동기화" 로 수동 트리거
|
||||
- 앱 종료 시 자동 1회 (sync dir 이 설정된 경우만)
|
||||
- 변경 없으면 토스트 "변경 사항 없음", 변경 있으면 "동기화 완료"
|
||||
### 충돌 해결 (ConflictModal)
|
||||
|
||||
설정이 안 됐으면 트레이 토스트로 안내. 한 번 설정하면 이후 push 는 OS credential helper 가 자동 처리.
|
||||
같은 노트를 두 기기에서 동시에 수정하면 path 별 결정 (내 것 / 원격) 을 받는다.
|
||||
|
||||
데이터 라이프사이클 측면에서 F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F6-L2 (원격 git, 반자동) 3-layer 구조의 마지막 layer.
|
||||
- **내 것 사용**: 이 기기의 변경을 보존, 원격 변경을 폐기
|
||||
- **원격 사용**: 원격 변경을 가져오고, 이 기기의 변경을 폐기
|
||||
|
||||
3 케이스:
|
||||
|
||||
1. 편집/편집 — 양 텍스트 비교 후 더 새롭고 완전한 쪽 선택. 둘 다 보존하려면 한쪽 선택 + 사후 수동 병합 ('both' 미지원)
|
||||
2. 삭제/편집 — 삭제가 의도였으면 원격 사용 (trash 측), 수정이 더 중요하면 내 것 사용 (편집 측 = trash 취소)
|
||||
3. AI 결과 충돌 — 한쪽 선택 후 AI 재실행 권장
|
||||
|
||||
### Silent risk
|
||||
|
||||
- **시계 어긋남 (NTP)**: 양 기기 시계가 다르면 timestamp merge 가 잘못된 결과를 낼 수 있음. NTP 동기화 끄지 말 것
|
||||
- **두 기기 동시 수정 회피**: 같은 노트를 동시에 수정하면 conflict 가 잦아짐
|
||||
- **자동 sync 실패 silent**: 주기적 sync 실패 시 토스트 안 뜸. 마지막 sync 시각 / 결과는 설정 페이지에서 확인
|
||||
|
||||
### Troubleshoot
|
||||
|
||||
- **"연결 테스트" 실패** — 네트워크 (브라우저로 호스트 접속) / 인증 (SSH key 또는 token) / URL 오타 점검
|
||||
- **인증 실패 (push 안 됨)** — SSH 는 public key 등록 점검, HTTPS 는 OS credential helper (Windows Credential Manager / macOS Keychain) 의 저장 token 점검
|
||||
- **URL 변경** — 설정 페이지에서 새 URL 입력 → 저장 (`git remote set-url origin` 자동 처리)
|
||||
|
||||
데이터 라이프사이클: F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F21 Cut E (양방향 git sync) 3-layer 구조.
|
||||
|
||||
---
|
||||
|
||||
|
||||
24
assets/icon.svg
Normal file
24
assets/icon.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Inkling">
|
||||
<!-- 배경 -->
|
||||
<rect width="1024" height="1024" rx="192" fill="#1a6b6e"/>
|
||||
|
||||
<!-- 화살표 marker -->
|
||||
<defs>
|
||||
<marker id="head" markerWidth="14" markerHeight="14" refX="6" refY="7" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M 0 0 L 12 7 L 0 14 Z" fill="#5fdbc8"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- sync 호 1개 (270도, 시작점 + 끝 화살표) -->
|
||||
<path d="M 512 132 A 380 380 0 1 1 132 512"
|
||||
stroke="#5fdbc8" stroke-width="36" stroke-linecap="round" fill="none"
|
||||
marker-end="url(#head)"/>
|
||||
<circle cx="512" cy="132" r="28" fill="#5fdbc8"/>
|
||||
|
||||
<!-- 노트 1장 (단일 흰색 paper) -->
|
||||
<rect x="332" y="332" width="360" height="360" rx="32" fill="#ffffff"/>
|
||||
|
||||
<!-- 텍스트 라인 2개 -->
|
||||
<rect x="376" y="436" width="272" height="28" rx="14" fill="#1a6b6e"/>
|
||||
<rect x="376" y="510" width="200" height="28" rx="14" fill="#1a6b6e"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 988 B |
BIN
build/icon.icns
Normal file
BIN
build/icon.icns
Normal file
Binary file not shown.
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 353 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
1156
docs/superpowers/plans/2026-05-04-v0231-ollama-settings.md
Normal file
1156
docs/superpowers/plans/2026-05-04-v0231-ollama-settings.md
Normal file
File diff suppressed because it is too large
Load Diff
1133
docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md
Normal file
1133
docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md
Normal file
File diff suppressed because it is too large
Load Diff
2364
docs/superpowers/plans/2026-05-07-v027-cross-platform.md
Normal file
2364
docs/superpowers/plans/2026-05-07-v027-cross-platform.md
Normal file
File diff suppressed because it is too large
Load Diff
1314
docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md
Normal file
1314
docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md
Normal file
File diff suppressed because it is too large
Load Diff
705
docs/superpowers/plans/2026-05-09-v028-cut-a.md
Normal file
705
docs/superpowers/plans/2026-05-09-v028-cut-a.md
Normal file
@@ -0,0 +1,705 @@
|
||||
# v0.2.8 Cut A Implementation Plan — 이미지 렌더링 + 앱 아이콘
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** F22 (NoteCard 의 회색 placeholder → 실제 `<img>` + 클릭 시 OS viewer) + chore (앱 아이콘 SVG → ICO/ICNS/PNG 다중 size + electron-builder 통합).
|
||||
|
||||
**Architecture:** Electron renderer 보안 정책 우회를 위해 main process 에 `inkling-media://` custom protocol 등록 — `<profileDir>/media/<noteId>/<filename>` 을 fetch 가능하게 함. NoteCard 가 protocol URL 을 `<img src>` 로 사용. 클릭 시 IPC `inbox:open-media` → `shell.openPath` 로 OS default viewer 열기. 앱 아이콘은 `assets/icon.svg` (이미 작성) 를 `electron-icon-builder` 로 한 번 빌드 → `build/icon.ico/icns/png` 산출물 git 추적 + electron-builder config 매핑.
|
||||
|
||||
**Tech Stack:** Electron 41 protocol API + React 19 + better-sqlite3 + electron-icon-builder + sharp (SVG 변환 fallback)
|
||||
|
||||
**선행 spec:** [docs/superpowers/specs/2026-05-09-v028-cut-a-design.md](docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 경로 | 책임 |
|
||||
|---|---|
|
||||
| `src/main/protocol/inklingMedia.ts` | `inkling-media://` scheme 권한 + handler 등록 (path traversal 검사 + inferMime) |
|
||||
| `tests/unit/inklingMedia.test.ts` | protocol handler 단위 테스트 (path traversal 403 / 정상 200 / 404 / mime) |
|
||||
| `assets/icon.svg` | (이미 v0.2.7 turn 에서 생성 — Cut A 시작 전 commit 필요 시 재확인) |
|
||||
|
||||
### 수정 파일
|
||||
|
||||
| 경로 | 변경 |
|
||||
|---|---|
|
||||
| `src/main/index.ts` | top-level `protocol.registerSchemesAsPrivileged` + whenReady 안 `registerInklingMediaProtocol(paths.profileDir)` 호출 + `registerInboxApi` 가 신규 IPC 채널 등록 |
|
||||
| `src/main/ipc/inboxApi.ts` | 신규 IPC `inbox:open-media` 핸들러 (path traversal 검사 + `shell.openPath`) |
|
||||
| `src/preload/index.ts` | `inbox:open-media` 채널 화이트리스트 |
|
||||
| `src/shared/types.ts` | `InboxApi` 에 `openMedia(relPath: string): Promise<{ ok: boolean; reason?: string }>` 시그니처 추가 |
|
||||
| `src/renderer/inbox/api.ts` | inboxApi 객체에 `openMedia` wrapper |
|
||||
| `src/renderer/inbox/components/NoteCard.tsx` | 회색 `<div>` placeholder → `<img src="inkling-media://...">` + onClick |
|
||||
| `tests/unit/NoteCard.test.tsx` | 신규 또는 추가 — `<img>` 렌더 + 클릭 시 IPC 호출 |
|
||||
| `package.json` | devDep `electron-icon-builder` + script `build:icons` + `build.win/mac/linux.icon` 경로 |
|
||||
| `.gitignore` | `build/` 안 `icon.ico/icns/png` 만 추적 (whitelist) |
|
||||
| `build/icon.ico` / `build/icon.icns` / `build/icon.png` | 빌드 산출물 commit |
|
||||
| `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` | F22 진행 상태 🚀 promoted 마킹 |
|
||||
| `docs/superpowers/v024-backlog.md` | (해당 없음 — Cut A 의 작업이 backlog # 와 매핑 X) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 개요
|
||||
|
||||
```
|
||||
Phase 1: F22 inkling-media protocol (Task 1)
|
||||
Phase 2: F22 NoteCard <img> + 클릭 (Task 2)
|
||||
Phase 3: F22 IPC inbox:open-media (Task 3)
|
||||
Phase 4: chore 앱 아이콘 빌드 + config (Task 4)
|
||||
Phase 5: verification + version bump (Task 5)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: inkling-media protocol
|
||||
|
||||
### Task 1: protocol 등록 + handler + 단위 테스트
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/protocol/inklingMedia.ts`
|
||||
- Create: `tests/unit/inklingMedia.test.ts`
|
||||
- Modify: `src/main/index.ts`
|
||||
|
||||
- [ ] **Step 1: failing test 작성**
|
||||
|
||||
```ts
|
||||
// tests/unit/inklingMedia.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { join, sep } from 'node:path';
|
||||
|
||||
const mockReadFile = vi.fn();
|
||||
vi.mock('node:fs/promises', () => ({ readFile: mockReadFile }));
|
||||
|
||||
const mockHandle = vi.fn();
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
protocol: {
|
||||
registerSchemesAsPrivileged: vi.fn(),
|
||||
handle: mockHandle
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
import { registerInklingMediaProtocol, inferMime } from '../../src/main/protocol/inklingMedia';
|
||||
|
||||
describe('inferMime', () => {
|
||||
it('returns image/png for .png', () => { expect(inferMime('foo.png')).toBe('image/png'); });
|
||||
it('returns image/jpeg for .jpg and .jpeg', () => {
|
||||
expect(inferMime('foo.jpg')).toBe('image/jpeg');
|
||||
expect(inferMime('foo.jpeg')).toBe('image/jpeg');
|
||||
});
|
||||
it('returns image/gif for .gif', () => { expect(inferMime('foo.gif')).toBe('image/gif'); });
|
||||
it('returns image/webp for .webp', () => { expect(inferMime('foo.webp')).toBe('image/webp'); });
|
||||
it('returns application/octet-stream for unknown', () => { expect(inferMime('foo.xyz')).toBe('application/octet-stream'); });
|
||||
});
|
||||
|
||||
describe('inkling-media protocol handler', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
function getHandler(profileDir: string): (req: Request) => Promise<Response> {
|
||||
registerInklingMediaProtocol(profileDir);
|
||||
return mockHandle.mock.calls[0][1];
|
||||
}
|
||||
|
||||
it('serves valid file with correct mime', async () => {
|
||||
mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3]));
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(new Request('inkling-media://media/note1/img.png'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('image/png');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
|
||||
});
|
||||
|
||||
it('returns 403 on path traversal attempt', async () => {
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(new Request('inkling-media://media/../etc/passwd'));
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when file missing', async () => {
|
||||
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(new Request('inkling-media://media/note1/missing.png'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실행 → fail**
|
||||
|
||||
```bash
|
||||
npm run rebuild:node
|
||||
npx vitest run tests/unit/inklingMedia.test.ts
|
||||
```
|
||||
|
||||
Expected: `Cannot find module '../../src/main/protocol/inklingMedia'`.
|
||||
|
||||
- [ ] **Step 3: protocol handler 작성**
|
||||
|
||||
```ts
|
||||
// src/main/protocol/inklingMedia.ts
|
||||
import electron from 'electron';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join, normalize, sep, extname } from 'node:path';
|
||||
|
||||
const { protocol } = electron;
|
||||
|
||||
export function registerSchemesAsPrivileged(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
|
||||
]);
|
||||
}
|
||||
|
||||
export function inferMime(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.png': return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.gif': return 'image/gif';
|
||||
case '.webp': return 'image/webp';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerInklingMediaProtocol(profileDir: string): void {
|
||||
const mediaRoot = join(profileDir, 'media');
|
||||
protocol.handle('inkling-media', async (req) => {
|
||||
const url = new URL(req.url);
|
||||
// host + pathname 합쳐서 relPath 구성. inkling-media://media/<noteId>/<file> 형식.
|
||||
// URL parse 시 host = 'media', pathname = '/<noteId>/<file>'
|
||||
const relPath = decodeURIComponent((url.host + url.pathname).replace(/^media\//, 'media/'));
|
||||
const target = normalize(join(profileDir, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const data = await fs.readFile(target);
|
||||
return new Response(new Uint8Array(data), {
|
||||
headers: { 'content-type': inferMime(target) }
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/inklingMedia.test.ts
|
||||
```
|
||||
|
||||
Expected: 모든 test pass.
|
||||
|
||||
- [ ] **Step 5: src/main/index.ts 통합**
|
||||
|
||||
`src/main/index.ts` 의 import 추가:
|
||||
|
||||
```ts
|
||||
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
|
||||
```
|
||||
|
||||
top-level (whenReady 이전, app 생성 직후) 에서:
|
||||
|
||||
```ts
|
||||
registerSchemesAsPrivileged();
|
||||
```
|
||||
|
||||
`whenReady` 안에서 (paths 초기화 후):
|
||||
|
||||
```ts
|
||||
registerInklingMediaProtocol(paths.profileDir);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: typecheck + 전체 회귀**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Expected: 0 errors + 460 → 466 (+6 inferMime 5 + handler 3).
|
||||
|
||||
- [ ] **Step 7: commit**
|
||||
|
||||
```bash
|
||||
git add src/main/protocol/inklingMedia.ts tests/unit/inklingMedia.test.ts src/main/index.ts
|
||||
git commit -m "feat(v028): inkling-media:// custom protocol + path traversal 검사"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: NoteCard `<img>` + 클릭
|
||||
|
||||
### Task 2: NoteCard placeholder → `<img>` 교체
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/renderer/inbox/components/NoteCard.tsx`
|
||||
- Create: `tests/unit/NoteCard.test.tsx` (없으면 신규)
|
||||
|
||||
- [ ] **Step 1: failing test 작성**
|
||||
|
||||
```tsx
|
||||
// tests/unit/NoteCard.test.tsx
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const mockOpenMedia = vi.fn(async () => ({ ok: true }));
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
openMedia: mockOpenMedia,
|
||||
// 다른 inboxApi 메서드 stub (NoteCard 가 사용할 수 있음)
|
||||
permanentDeleteNote: vi.fn(),
|
||||
restoreNote: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { NoteCard } from '../../src/renderer/inbox/components/NoteCard';
|
||||
|
||||
const baseNote: Note = {
|
||||
id: 'n1',
|
||||
rawText: 'test',
|
||||
title: 'T',
|
||||
summary: '',
|
||||
tags: [],
|
||||
aiStatus: 'complete',
|
||||
createdAt: '2026-05-09T00:00:00Z',
|
||||
updatedAt: '2026-05-09T00:00:00Z',
|
||||
deletedAt: null,
|
||||
media: [
|
||||
{ id: 'm1', relPath: 'media/n1/img1.png', mime: 'image/png' },
|
||||
{ id: 'm2', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg' }
|
||||
]
|
||||
} as unknown as Note;
|
||||
|
||||
describe('NoteCard — image rendering', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); cleanup(); });
|
||||
|
||||
it('renders <img> for each media item', () => {
|
||||
render(<NoteCard note={baseNote} isTrash={false} />);
|
||||
const imgs = screen.getAllByRole('img');
|
||||
expect(imgs).toHaveLength(2);
|
||||
expect(imgs[0].getAttribute('src')).toBe('inkling-media://media/n1/img1.png');
|
||||
expect(imgs[1].getAttribute('src')).toBe('inkling-media://media/n1/img2.jpg');
|
||||
});
|
||||
|
||||
it('clicking <img> calls inboxApi.openMedia', () => {
|
||||
render(<NoteCard note={baseNote} isTrash={false} />);
|
||||
fireEvent.click(screen.getAllByRole('img')[0]);
|
||||
expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실행 → fail**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/NoteCard.test.tsx
|
||||
```
|
||||
|
||||
Expected: `getAllByRole('img')` 미존재 (현재 회색 div 만).
|
||||
|
||||
- [ ] **Step 3: NoteCard 갱신**
|
||||
|
||||
[src/renderer/inbox/components/NoteCard.tsx:334-340](src/renderer/inbox/components/NoteCard.tsx#L334-L340) 의 회색 placeholder div 부분을 다음으로 교체:
|
||||
|
||||
```tsx
|
||||
{local.media.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{local.media.map((m) => (
|
||||
<img
|
||||
key={m.id}
|
||||
src={`inkling-media://${m.relPath}`}
|
||||
alt=""
|
||||
title={m.relPath}
|
||||
onClick={() => { void inboxApi.openMedia(m.relPath); }}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
`inboxApi` import 가 이미 있는지 확인 — 있으면 그대로. 없으면 추가:
|
||||
|
||||
```tsx
|
||||
import { inboxApi } from '../api.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 + typecheck**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/NoteCard.test.tsx
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Expected: 2 test pass (`openMedia` 가 types.ts 에 아직 없으니 typecheck error 가능 — Task 3 에서 해결). 일단 임시로 NoteCard.test.tsx 의 mock 만 동작.
|
||||
|
||||
만약 typecheck error 발생: `inboxApi.openMedia` 가 InboxApi 인터페이스에 없음 → 일시 `(inboxApi as any).openMedia(m.relPath)` 로 cast. **Task 3 에서 정식 시그니처 추가 후 cast 제거.**
|
||||
|
||||
- [ ] **Step 5: commit (with TODO note for Task 3)**
|
||||
|
||||
```bash
|
||||
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
|
||||
git commit -m "feat(v028): NoteCard 이미지 <img> 렌더링 + onClick (openMedia 시그니처는 Task 3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: IPC `inbox:open-media`
|
||||
|
||||
### Task 3: main IPC 핸들러 + api.ts wrapper + types
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/ipc/inboxApi.ts`
|
||||
- Modify: `src/preload/index.ts`
|
||||
- Modify: `src/shared/types.ts`
|
||||
- Modify: `src/renderer/inbox/api.ts`
|
||||
- Modify: `src/renderer/inbox/components/NoteCard.tsx` (Task 2 cast 제거)
|
||||
|
||||
- [ ] **Step 1: failing test 작성 (IPC handler 단위)**
|
||||
|
||||
```ts
|
||||
// tests/unit/inboxApi-openMedia.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { join, sep } from 'node:path';
|
||||
|
||||
const handlers: Record<string, Function> = {};
|
||||
const mockOpenPath = vi.fn(async () => '');
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: { handle: (ch: string, fn: Function) => { handlers[ch] = fn; } },
|
||||
shell: { openPath: mockOpenPath }
|
||||
}
|
||||
}));
|
||||
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi';
|
||||
|
||||
describe('inbox:open-media IPC', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); for (const k of Object.keys(handlers)) delete handlers[k]; });
|
||||
|
||||
it('opens valid relPath with shell.openPath', async () => {
|
||||
// 기존 registerInboxApi 가 deps 받으므로 minimal stub 만 — paths 만 사용
|
||||
registerInboxApi({ paths: { profileDir: '/profile' } } as any);
|
||||
const r = await handlers['inbox:open-media'](null, 'media/note1/img.png');
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(mockOpenPath).toHaveBeenCalledWith(join('/profile', 'media/note1/img.png'));
|
||||
});
|
||||
|
||||
it('rejects path traversal', async () => {
|
||||
registerInboxApi({ paths: { profileDir: '/profile' } } as any);
|
||||
const r = await handlers['inbox:open-media'](null, '../etc/passwd');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid path');
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실행 → fail**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/inboxApi-openMedia.test.ts
|
||||
```
|
||||
|
||||
Expected: handler 미등록.
|
||||
|
||||
- [ ] **Step 3: src/main/ipc/inboxApi.ts 핸들러 추가**
|
||||
|
||||
기존 `registerInboxApi(deps)` 함수 안에 다음 추가 (다른 IPC 핸들러와 같은 패턴):
|
||||
|
||||
```ts
|
||||
import { join, normalize, sep } from 'node:path';
|
||||
import electron from 'electron';
|
||||
const { ipcMain, shell } = electron;
|
||||
|
||||
// registerInboxApi 안:
|
||||
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
|
||||
const mediaRoot = join(deps.paths.profileDir, 'media');
|
||||
const target = normalize(join(deps.paths.profileDir, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
|
||||
return { ok: false, reason: 'invalid path' };
|
||||
}
|
||||
await shell.openPath(target);
|
||||
return { ok: true };
|
||||
});
|
||||
```
|
||||
|
||||
(기존 deps 타입에 `paths.profileDir` 가 있는지 확인. 없으면 SettingsIpcDeps 와 비슷한 형태로 추가.)
|
||||
|
||||
- [ ] **Step 4: src/shared/types.ts InboxApi 갱신**
|
||||
|
||||
```ts
|
||||
// 기존 InboxApi 인터페이스 안:
|
||||
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: preload + api.ts wrapper**
|
||||
|
||||
`src/preload/index.ts` 의 invoke whitelist 또는 API expose 객체 안에 `'inbox:open-media'` 추가 (기존 `'inbox:*'` 채널 패턴 따름).
|
||||
|
||||
`src/renderer/inbox/api.ts` 의 inboxApi 객체에 추가:
|
||||
|
||||
```ts
|
||||
async openMedia(relPath: string) {
|
||||
return await window.inkling.invoke('inbox:open-media', relPath);
|
||||
}
|
||||
```
|
||||
|
||||
(또는 기존 wildcard re-export 사용 시 자동 노출 — `feb7c62` commit 의 `onNavigate` 처럼.)
|
||||
|
||||
- [ ] **Step 6: NoteCard.tsx cast 제거**
|
||||
|
||||
Task 2 에서 임시 `(inboxApi as any).openMedia` cast 했다면 → `inboxApi.openMedia` 정상 사용으로 변경. typecheck 통과 확인.
|
||||
|
||||
- [ ] **Step 7: 테스트 + typecheck + 회귀**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/inboxApi-openMedia.test.ts
|
||||
npm run typecheck
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Expected: IPC 2 test pass + 466 → 468 (+2) + 0 typecheck.
|
||||
|
||||
- [ ] **Step 8: commit**
|
||||
|
||||
```bash
|
||||
git add -A src/main/ipc/ src/preload/ src/shared/ src/renderer/ tests/unit/inboxApi-openMedia.test.ts
|
||||
git commit -m "feat(v028): IPC inbox:open-media + path traversal + NoteCard cast 정리"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 앱 아이콘 빌드 + config
|
||||
|
||||
### Task 4: electron-icon-builder + 산출물 + builder config
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json`
|
||||
- Modify: `.gitignore`
|
||||
- Create: `build/icon.ico`, `build/icon.icns`, `build/icon.png` (빌드 산출물)
|
||||
- (조건부) Create: `scripts/svg-to-png.mjs` (SVG → PNG fallback if needed)
|
||||
|
||||
- [ ] **Step 1: devDep 설치**
|
||||
|
||||
```bash
|
||||
npm install --save-dev electron-icon-builder
|
||||
```
|
||||
|
||||
확인: `package.json` 의 devDependencies 에 `"electron-icon-builder": "^2.x.x"` 추가됨.
|
||||
|
||||
- [ ] **Step 2: package.json scripts + builder config**
|
||||
|
||||
`package.json` 의 scripts 블록에 추가:
|
||||
|
||||
```json
|
||||
"build:icons": "electron-icon-builder --input=assets/icon.svg --output=build --flatten"
|
||||
```
|
||||
|
||||
(만약 SVG 직접 input 안 되면 — Step 3 에서 sharp fallback 으로 분기.)
|
||||
|
||||
`build` 블록 갱신 (기존 win/mac/linux 에 icon 키 추가):
|
||||
|
||||
```json
|
||||
"win": { "icon": "build/icon.ico", ... 기존 그대로 ... },
|
||||
"mac": { "icon": "build/icon.icns", ... 기존 그대로 ... },
|
||||
"linux": { "icon": "build/icon.png", ... 기존 그대로 ... }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 빌드 실행**
|
||||
|
||||
```bash
|
||||
npm run build:icons
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `build/icon.ico` (Win)
|
||||
- `build/icon.icns` (macOS)
|
||||
- `build/icon.png` (1024x1024, Linux)
|
||||
|
||||
만약 SVG 직접 안 되면 (electron-icon-builder 가 PNG 만 input 받음):
|
||||
|
||||
1. `scripts/svg-to-png.mjs` 작성:
|
||||
|
||||
```js
|
||||
import sharp from 'sharp';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const [,, input, output, size = '1024'] = process.argv;
|
||||
const svg = readFileSync(input);
|
||||
const png = await sharp(svg).resize(Number(size), Number(size)).png().toBuffer();
|
||||
writeFileSync(output, png);
|
||||
console.log(`OK: ${output} (${size}x${size})`);
|
||||
```
|
||||
|
||||
2. `package.json` scripts 갱신:
|
||||
|
||||
```json
|
||||
"build:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024",
|
||||
"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten"
|
||||
```
|
||||
|
||||
3. `npm install --save-dev sharp` (이미 있으면 skip).
|
||||
|
||||
4. 다시 `npm run build:icons` 실행.
|
||||
|
||||
- [ ] **Step 4: .gitignore 갱신 (build/ 안 산출물 whitelist)**
|
||||
|
||||
`.gitignore` 의 `build/` 또는 `dist/` 항목 확인. 만약 `build/` 가 ignore 되어 있다면:
|
||||
|
||||
```
|
||||
build/
|
||||
!build/icon.ico
|
||||
!build/icon.icns
|
||||
!build/icon.png
|
||||
```
|
||||
|
||||
(만약 `build/` 가 ignore 안 되어 있다면 — 모두 추적 가능 — Step 4 skip.)
|
||||
|
||||
- [ ] **Step 5: 산출물 확인 + commit**
|
||||
|
||||
```bash
|
||||
ls -la build/icon.ico build/icon.icns build/icon.png
|
||||
```
|
||||
|
||||
Expected: 3 파일 모두 size 양수 (수십 KB 이상).
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json .gitignore scripts/svg-to-png.mjs build/icon.ico build/icon.icns build/icon.png
|
||||
git commit -m "chore(v028): 앱 아이콘 (assets/icon.svg → ICO/ICNS/PNG) + electron-builder config"
|
||||
```
|
||||
|
||||
(scripts/svg-to-png.mjs 는 sharp fallback 사용 시만 추가.)
|
||||
|
||||
- [ ] **Step 6: typecheck + 전체 회귀**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Expected: 0 errors + 468 pass (이전 + Task 1-3 변경 포함). 아이콘 변경은 테스트 영향 X.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: verification + version bump
|
||||
|
||||
### Task 5: 회귀 + dogfood-feedback 마킹 + version bump
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F22 promoted 마킹)
|
||||
- Modify: `package.json` (version 0.2.7 → 0.2.8)
|
||||
|
||||
- [ ] **Step 1: 단위 + e2e + typecheck 일괄**
|
||||
|
||||
```bash
|
||||
npm run rebuild:node
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run rebuild:electron
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Expected: 모두 pass. 단위 460 → 약 468 (+8: inferMime 5 + protocol handler 3 + NoteCard img 2 + IPC 2 = 12, 일부 mock 충돌 가능 — 실제 카운트 ±). e2e 1/1.
|
||||
|
||||
- [ ] **Step 2: 수동 launch 검증 (Win + macOS 가능 시)**
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
체크리스트:
|
||||
|
||||
- inbox 의 capture-with-image 노트의 thumbnail = 실제 이미지 (회색 사각형 X)
|
||||
- thumbnail 클릭 → OS default viewer (예: Win Photos / macOS Preview) 열림
|
||||
- 새 아이콘이 트레이 / Windows taskbar / dock 정확 표시
|
||||
- 다중 이미지 노트의 grid layout (flex-wrap) 자연스러움
|
||||
|
||||
- [ ] **Step 3: dogfood-feedback.md F22 promoted 마킹**
|
||||
|
||||
`docs/superpowers/specs/2026-04-25-dogfood-feedback.md` 의 F22 entry 헤더 갱신:
|
||||
|
||||
```markdown
|
||||
## F22. NoteCard 이미지가 회색 placeholder 만 표시 (🚀 promoted → docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
|
||||
```
|
||||
|
||||
진행 상태 line 도 `🚀 promoted` 로 갱신.
|
||||
|
||||
- [ ] **Step 4: package.json version bump**
|
||||
|
||||
```json
|
||||
"version": "0.2.8"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json
|
||||
git commit -m "chore(release): v0.2.8 — Cut A (이미지 렌더링 + 앱 아이콘)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
| Spec 섹션 | task |
|
||||
|---|---|
|
||||
| §3-1 protocol 등록 | Task 1 |
|
||||
| §3-2 NoteCard `<img>` | Task 2 |
|
||||
| §3-3 IPC inbox:open-media | Task 3 |
|
||||
| §3-4 보안 (path traversal) | Task 1 + Task 3 |
|
||||
| §4-1 devDep + scripts | Task 4 |
|
||||
| §4-2 builder config | Task 4 |
|
||||
| §4-3 산출물 git 추적 | Task 4 |
|
||||
| §4-4 SVG → PNG fallback | Task 4 (조건부) |
|
||||
| §5 테스트 | 각 task 안 단위 + Task 5 회귀 |
|
||||
| §6 Risk | Task 4 fallback 분기 |
|
||||
|
||||
모든 spec 요구가 task 매핑됨.
|
||||
|
||||
**Placeholder scan**: "TBD" / "TODO" / "implement later" 없음. 각 step 의 코드/명령 실행 가능 형태.
|
||||
|
||||
**Type consistency**:
|
||||
|
||||
- `InboxApi.openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>` — Task 3 정의, Task 2 cast → Task 3 cast 제거 일관.
|
||||
- `inferMime(filename: string): string` — Task 1 정의, Task 1 안 사용.
|
||||
- `registerInklingMediaProtocol(profileDir: string): void` / `registerSchemesAsPrivileged(): void` — Task 1 export, src/main/index.ts import 일관.
|
||||
|
||||
이슈 없음.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan 작성 완료, `docs/superpowers/plans/2026-05-09-v028-cut-a.md` 저장.
|
||||
|
||||
두 가지 실행 옵션:
|
||||
|
||||
**1. Subagent-Driven (recommended)** — fresh subagent per task, two-stage review (spec compliance + code quality), 빠른 iteration
|
||||
|
||||
**2. Inline Execution** — 본 세션에서 task 일괄 실행 + checkpoint 마다 review
|
||||
|
||||
어느 쪽?
|
||||
1867
docs/superpowers/plans/2026-05-09-v029-cut-b.md
Normal file
1867
docs/superpowers/plans/2026-05-09-v029-cut-b.md
Normal file
File diff suppressed because it is too large
Load Diff
720
docs/superpowers/plans/2026-05-10-sync-help.md
Normal file
720
docs/superpowers/plans/2026-05-10-sync-help.md
Normal file
@@ -0,0 +1,720 @@
|
||||
# Sync 도움말 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** v0.3.0 Cut E 양방향 sync 의 사용자 도움말을 in-app modal + ConflictModal inline + README 3 표면에 도입. 다기기 dogfood 의 conflict 시나리오에 막힌 순간 결정 트리 / 자동 처리 동작 / silent risk / setup 인증 troubleshoot 4 카테고리를 즉시 찾을 수 있게.
|
||||
|
||||
**Architecture:** 신규 `SyncHelpModal` 컴포넌트 (4 anchor 섹션, ConflictModal 패턴 재사용) + ConflictModal 의 local/remote 옵션 inline 설명 + "자세히 보기" 링크 (`onOpenHelp` callback) + SyncSection 의 "도움말" 버튼이 modal mount/unmount 관리. README 의 stale "원격 백업 (F6-L2)" 섹션은 "동기화 (Git)" 로 통째 재작성.
|
||||
|
||||
**Tech Stack:** React 18 / TypeScript / vitest + @testing-library/react / electron-vite. 기존 ConflictModal 패턴 정합 (overlay + stopPropagation + 인라인 style object).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-10-sync-help-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**신규**:
|
||||
|
||||
- `src/renderer/inbox/components/SyncHelpModal.tsx` — 신규 modal, 4 anchor 섹션 (`#main-conflict`, `#auto`, `#silent`, `#setup`)
|
||||
- `tests/unit/SyncHelpModal.test.tsx` — 렌더링 + close 회귀
|
||||
|
||||
**수정**:
|
||||
|
||||
- `src/renderer/inbox/components/ConflictModal.tsx` — `onOpenHelp` prop 추가, 각 옵션 inline 설명 + "자세히 보기" 링크
|
||||
- `tests/unit/ConflictModal.test.tsx` — inline 설명 + 링크 회귀 추가
|
||||
- `src/renderer/inbox/components/settings/SyncSection.tsx` — "도움말" 버튼 + `showHelp` state + `SyncHelpModal` mount + `ConflictModal` 의 `onOpenHelp` wiring
|
||||
- `tests/unit/SyncSection.test.tsx` — 도움말 버튼 → modal open 회귀
|
||||
- `README.md` line 193-223 — "원격 백업 (F6-L2)" 섹션 통째 재작성
|
||||
|
||||
---
|
||||
|
||||
## Task 1: SyncHelpModal 컴포넌트 (4 anchor 섹션 + close 동작)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/renderer/inbox/components/SyncHelpModal.tsx`
|
||||
- Test: `tests/unit/SyncHelpModal.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
`tests/unit/SyncHelpModal.test.tsx` 신규 작성:
|
||||
|
||||
```tsx
|
||||
// @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 React from 'react';
|
||||
import { SyncHelpModal } from '../../src/renderer/inbox/components/SyncHelpModal';
|
||||
|
||||
describe('SyncHelpModal', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
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: /Setup/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('각 섹션이 anchor id 보유', () => {
|
||||
const { container } = render(<SyncHelpModal onClose={() => {}} />);
|
||||
expect(container.querySelector('#main-conflict')).not.toBeNull();
|
||||
expect(container.querySelector('#auto')).not.toBeNull();
|
||||
expect(container.querySelector('#silent')).not.toBeNull();
|
||||
expect(container.querySelector('#setup')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('초기 anchor prop 으로 해당 섹션 scrollIntoView 호출', () => {
|
||||
const scrollSpy = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrollSpy;
|
||||
render(<SyncHelpModal onClose={() => {}} initialAnchor="main-conflict" />);
|
||||
expect(scrollSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('X 버튼 클릭 → onClose 호출', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<SyncHelpModal onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /닫기/ }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overlay 클릭 → onClose 호출', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(<SyncHelpModal onClose={onClose} />);
|
||||
const overlay = container.firstChild as HTMLElement;
|
||||
fireEvent.click(overlay);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('modal body 클릭 → onClose 호출 X (stopPropagation)', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<SyncHelpModal onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('heading', { name: /충돌 해결/ }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('주요 시나리오 키워드 본문 포함 (회귀)', () => {
|
||||
render(<SyncHelpModal onClose={() => {}} />);
|
||||
// 메인 conflict 3 케이스
|
||||
expect(screen.getByText(/편집\/편집/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/삭제\/편집/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/AI 결과 충돌/)).toBeInTheDocument();
|
||||
// setup 의 잘못된 URL 형식 사례
|
||||
expect(screen.getByText(/git@https:\/\//)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
cd C:\Users\rlaxo\inkling
|
||||
npx vitest run tests/unit/SyncHelpModal.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL (module not found — `SyncHelpModal` 미존재)
|
||||
|
||||
- [ ] **Step 3: Implement SyncHelpModal**
|
||||
|
||||
`src/renderer/inbox/components/SyncHelpModal.tsx` 신규:
|
||||
|
||||
```tsx
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export type SyncHelpAnchor = 'main-conflict' | 'auto' | 'silent' | 'setup';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
initialAnchor?: SyncHelpAnchor;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 110
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff', borderRadius: 8, padding: 20, width: 640,
|
||||
maxHeight: '80vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
||||
};
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
marginTop: 18, paddingTop: 12, borderTop: '1px solid #eee'
|
||||
};
|
||||
|
||||
const h4Style: React.CSSProperties = { fontSize: 14, margin: '0 0 8px 0' };
|
||||
const pStyle: React.CSSProperties = { fontSize: 12, color: '#444', lineHeight: 1.6, margin: '4px 0' };
|
||||
const liStyle: React.CSSProperties = { fontSize: 12, color: '#444', lineHeight: 1.6, marginBottom: 4 };
|
||||
const codeStyle: React.CSSProperties = { background: '#f4f4f4', padding: '1px 4px', borderRadius: 3, fontSize: 11 };
|
||||
|
||||
export function SyncHelpModal({ onClose, initialAnchor }: Props): React.ReactElement {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialAnchor) return;
|
||||
const el = bodyRef.current?.querySelector(`#${initialAnchor}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
}, [initialAnchor]);
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div ref={bodyRef} style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>동기화 도움말</h3>
|
||||
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
|
||||
</div>
|
||||
|
||||
<section id="main-conflict" style={sectionStyle}>
|
||||
<h4 style={h4Style}>1. 충돌 해결 (메인 시나리오)</h4>
|
||||
<p style={pStyle}>같은 노트를 두 기기에서 동시에 수정하면 충돌이 발생한다. "충돌 해결…" 버튼이 활성화되면 ConflictModal 이 열려 path 별 결정 (내 것 사용 / 원격 사용) 을 받는다.</p>
|
||||
|
||||
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>편집/편집 — 가장 흔한 경우</p>
|
||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||
<li style={liStyle}>두 기기에서 같은 노트 본문 수정 → 양 텍스트가 ConflictModal 에 좌우로 나란히 표시</li>
|
||||
<li style={liStyle}>결정 트리: 어느 쪽 변경이 더 새롭고 완전한지 비교 → 더 나은 쪽 선택</li>
|
||||
<li style={liStyle}>둘 다 보존하려면? 현재 'both' 미지원 — 한쪽 선택 후 사후 수동 병합 (다른 쪽 텍스트 메모 → 모달 닫고 노트 편집)</li>
|
||||
</ul>
|
||||
|
||||
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>삭제/편집</p>
|
||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||
<li style={liStyle}>한쪽에서 trash 처리, 다른 쪽에서 같은 노트 본문 수정</li>
|
||||
<li style={liStyle}>"삭제가 의도였다" → 원격 사용 (trash 측 적용)</li>
|
||||
<li style={liStyle}>"수정이 더 중요" → 내 것 사용 (편집 측 적용 = trash 취소)</li>
|
||||
</ul>
|
||||
|
||||
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>AI 결과 충돌</p>
|
||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||
<li style={liStyle}>양 기기에서 AI 자동 처리 결과 (태그 / 주제 / 요약) 가 다름</li>
|
||||
<li style={liStyle}>대부분 어느 쪽이든 무관 → 한쪽 선택 후 AI 재실행 권장 (가장 최신 모델 결과로 통일)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="auto" style={sectionStyle}>
|
||||
<h4 style={h4Style}>2. 자동 처리 (내가 안 해도 되는 일)</h4>
|
||||
<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>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="silent" style={sectionStyle}>
|
||||
<h4 style={h4Style}>3. 조용히 잘못될 수 있는 케이스 (silent risk)</h4>
|
||||
<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>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="setup" style={sectionStyle}>
|
||||
<h4 style={h4Style}>4. Setup / 인증 (troubleshoot)</h4>
|
||||
<p style={{ ...pStyle, fontWeight: 600 }}>URL 형식 (둘 중 하나)</p>
|
||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||
<li style={liStyle}>SSH: <code style={codeStyle}>git@host:user/repo.git</code></li>
|
||||
<li style={liStyle}>HTTPS: <code style={codeStyle}>https://host/user/repo.git</code></li>
|
||||
<li style={liStyle}>잘못된 형식: <code style={codeStyle}>git@https://...</code> 같은 혼합 형식 ✗</li>
|
||||
</ul>
|
||||
|
||||
<p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}>인증</p>
|
||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||
<li style={liStyle}>SSH: 기기에 SSH key 등록 + 원격에 public key 추가</li>
|
||||
<li style={liStyle}>HTTPS: OS credential helper (Windows Credential Manager / macOS Keychain) 가 첫 push 시 token 입력받아 저장. 매 push 마다 재입력 X</li>
|
||||
</ul>
|
||||
|
||||
<p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}>"연결 테스트" 실패 시</p>
|
||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||
<li style={liStyle}>네트워크: 원격 host 에 브라우저로 접속해 응답 확인</li>
|
||||
<li style={liStyle}>인증: 위 인증 절차 점검</li>
|
||||
<li style={liStyle}>URL: 형식 (SSH/HTTPS) + 오타 점검</li>
|
||||
</ul>
|
||||
|
||||
<p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}>재설정</p>
|
||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||
<li style={liStyle}>URL 변경 시 설정 → 동기화 저장소에서 새 URL 입력 → 저장. 내부적으로 <code style={codeStyle}>git remote set-url origin</code> 자동 처리</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/SyncHelpModal.test.tsx
|
||||
```
|
||||
|
||||
Expected: 7/7 PASS
|
||||
|
||||
- [ ] **Step 5: typecheck**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/inbox/components/SyncHelpModal.tsx tests/unit/SyncHelpModal.test.tsx
|
||||
git commit -m "feat(sync-help): SyncHelpModal 4 anchor 섹션 (메인 conflict / 자동 / silent / setup)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: ConflictModal 갱신 — inline 설명 + "자세히 보기" 링크
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/renderer/inbox/components/ConflictModal.tsx`
|
||||
- Modify: `tests/unit/ConflictModal.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Update test**
|
||||
|
||||
`tests/unit/ConflictModal.test.tsx` 의 마지막에 두 케이스 추가 (기존 3 케이스 유지). 또한 mock 시그니처에 `onOpenHelp` 추가.
|
||||
|
||||
기존 코드:
|
||||
|
||||
```tsx
|
||||
import { ConflictModal } from '../../src/renderer/inbox/components/ConflictModal';
|
||||
|
||||
describe('ConflictModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockListConflicts.mockResolvedValue([
|
||||
{ path: 'notes/n1.md', localText: 'local A', remoteText: 'remote A' },
|
||||
{ path: 'notes/n2.md', localText: 'local B', remoteText: 'remote B' }
|
||||
]);
|
||||
mockResolveConflict.mockResolvedValue({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
기존 3 it 블록은 그대로 (onOpenHelp optional 이라 미수정 호출 type-clean).
|
||||
|
||||
신규 2 케이스 추가:
|
||||
|
||||
```tsx
|
||||
it('각 conflict row 에 local/remote inline 설명 표시', async () => {
|
||||
render(<ConflictModal onClose={() => {}} onResolved={() => {}} onOpenHelp={() => {}} />);
|
||||
await waitFor(() => screen.getByText(/local A/));
|
||||
// 두 conflict row → inline 설명 2 회씩
|
||||
expect(screen.getAllByText(/이 기기의 변경을 보존/).length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText(/원격의 변경을 가져오고/).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('"자세히 보기" 클릭 → onOpenHelp("main-conflict") 호출', async () => {
|
||||
const onOpenHelp = vi.fn();
|
||||
render(<ConflictModal onClose={() => {}} onResolved={() => {}} onOpenHelp={onOpenHelp} />);
|
||||
await waitFor(() => screen.getByText(/local A/));
|
||||
const links = screen.getAllByRole('button', { name: /자세히 보기/ });
|
||||
fireEvent.click(links[0]!);
|
||||
expect(onOpenHelp).toHaveBeenCalledWith('main-conflict');
|
||||
});
|
||||
|
||||
it('onOpenHelp 미제공 → "자세히 보기" 링크 미렌더', async () => {
|
||||
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
|
||||
await waitFor(() => screen.getByText(/local A/));
|
||||
expect(screen.queryByRole('button', { name: /자세히 보기/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/ConflictModal.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL — `onOpenHelp` prop 미존재 / inline 설명 미표시 / "자세히 보기" 버튼 없음
|
||||
|
||||
- [ ] **Step 3: Update ConflictModal**
|
||||
|
||||
`src/renderer/inbox/components/ConflictModal.tsx`:
|
||||
|
||||
Props interface 갱신 (`onOpenHelp` 는 **optional** — 없으면 "자세히 보기" 링크 미렌더. caller 가 wiring 하지 않은 환경에서 type-clean):
|
||||
|
||||
```tsx
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onResolved: () => void;
|
||||
onOpenHelp?: (anchor: 'main-conflict' | 'auto' | 'silent' | 'setup') => void;
|
||||
}
|
||||
```
|
||||
|
||||
함수 signature:
|
||||
|
||||
```tsx
|
||||
export function ConflictModal({ onClose, onResolved, onOpenHelp }: Props): React.ReactElement {
|
||||
```
|
||||
|
||||
각 conflict row 의 button row 위에 inline 설명 + (조건부) "자세히 보기" 링크 삽입. 기존 button row (`<div style={{ marginTop: 8, ... }}>`) 직전에 추가:
|
||||
|
||||
```tsx
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#666', lineHeight: 1.5 }}>
|
||||
<div><b>내 것 사용</b>: 이 기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.</div>
|
||||
<div><b>원격 사용</b>: 원격의 변경을 가져오고 내 변경을 폐기.</div>
|
||||
{onOpenHelp && (
|
||||
<button
|
||||
onClick={() => onOpenHelp('main-conflict')}
|
||||
style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: 0, marginTop: 2, textDecoration: 'underline' }}
|
||||
>
|
||||
자세히 보기 →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
전체 row 변경 후 모습:
|
||||
|
||||
```tsx
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.path} style={rowStyle}>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>{c.path}</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>내 기기</div>
|
||||
<pre style={preStyle()}>{c.localText || '(미리보기 없음)'}</pre>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>다른 기기</div>
|
||||
<pre style={preStyle()}>{c.remoteText || '(미리보기 없음)'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#666', lineHeight: 1.5 }}>
|
||||
<div><b>내 것 사용</b>: 이 기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.</div>
|
||||
<div><b>원격 사용</b>: 원격의 변경을 가져오고 내 변경을 폐기.</div>
|
||||
<button
|
||||
onClick={() => onOpenHelp('main-conflict')}
|
||||
style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: 0, marginTop: 2, textDecoration: 'underline' }}
|
||||
>
|
||||
자세히 보기 →
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { void onChoose(c.path, 'local'); }}
|
||||
disabled={busy === c.path}
|
||||
style={chooseBtnStyle('#0a4b80')}
|
||||
>
|
||||
{busy === c.path ? '처리 중…' : '내 것 사용'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void onChoose(c.path, 'remote'); }}
|
||||
disabled={busy === c.path}
|
||||
style={chooseBtnStyle('#236b1a')}
|
||||
>
|
||||
{busy === c.path ? '처리 중…' : '원격 사용'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/ConflictModal.test.tsx
|
||||
```
|
||||
|
||||
Expected: 6/6 PASS (기존 3 + 신규 3)
|
||||
|
||||
- [ ] **Step 5: typecheck**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: 0 errors (`onOpenHelp` 가 optional 이라 기존 SyncSection.tsx caller 그대로 type-clean. Task 3 에서 wiring).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/inbox/components/ConflictModal.tsx tests/unit/ConflictModal.test.tsx
|
||||
git commit -m "feat(sync-help): ConflictModal inline 설명 + 자세히 보기 링크 (onOpenHelp prop)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: SyncSection wiring — 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/renderer/inbox/components/settings/SyncSection.tsx`
|
||||
- Modify: `tests/unit/SyncSection.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Update test**
|
||||
|
||||
`tests/unit/SyncSection.test.tsx` 에 추가 (기존 케이스 유지):
|
||||
|
||||
```tsx
|
||||
it('도움말 버튼 클릭 → SyncHelpModal open', async () => {
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /저장/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^도움말$/ }));
|
||||
await waitFor(() => screen.getByRole('heading', { name: /동기화 도움말/ }));
|
||||
expect(screen.getByRole('heading', { name: /동기화 도움말/ })).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/SyncSection.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL — "도움말" 버튼 없음
|
||||
|
||||
- [ ] **Step 3: Update SyncSection**
|
||||
|
||||
`src/renderer/inbox/components/settings/SyncSection.tsx`:
|
||||
|
||||
상단 import 에 추가:
|
||||
|
||||
```tsx
|
||||
import { SyncHelpModal, type SyncHelpAnchor } from '../SyncHelpModal.js';
|
||||
```
|
||||
|
||||
state 추가 (`showConflict` 옆):
|
||||
|
||||
```tsx
|
||||
const [showHelp, setShowHelp] = useState<{ open: boolean; anchor?: SyncHelpAnchor }>({ open: false });
|
||||
```
|
||||
|
||||
URL row 의 버튼 영역에 "도움말" 버튼 추가 (연결 테스트 버튼 옆):
|
||||
|
||||
```tsx
|
||||
<button onClick={() => setShowHelp({ open: true })} disabled={busy !== null} style={btnStyle()}>
|
||||
도움말
|
||||
</button>
|
||||
```
|
||||
|
||||
`ConflictModal` 호출에 `onOpenHelp` 추가:
|
||||
|
||||
```tsx
|
||||
{showConflict && (
|
||||
<ConflictModal
|
||||
onClose={() => setShowConflict(false)}
|
||||
onResolved={async () => {
|
||||
setStatus(await inboxApi.getSyncStatus());
|
||||
}}
|
||||
onOpenHelp={(anchor) => setShowHelp({ open: true, anchor })}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
return 의 마지막 (section close 직전) 에 SyncHelpModal mount 추가:
|
||||
|
||||
```tsx
|
||||
{showHelp.open && (
|
||||
<SyncHelpModal
|
||||
onClose={() => setShowHelp({ open: false })}
|
||||
initialAnchor={showHelp.anchor}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/SyncSection.test.tsx tests/unit/ConflictModal.test.tsx tests/unit/SyncHelpModal.test.tsx
|
||||
```
|
||||
|
||||
Expected: 모두 PASS
|
||||
|
||||
- [ ] **Step 5: App.test.tsx / SettingsPage.test.tsx 에서 ConflictModal 깊이 호출 X 회귀 확인**
|
||||
|
||||
`tests/unit/App.test.tsx` 와 `tests/unit/SettingsPage.test.tsx` 는 SyncSection 을 mount 하지만 `showConflict=false` default 라 ConflictModal 직접 렌더 X. SyncHelpModal 도 default closed. mock 갱신 불필요.
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/App.test.tsx tests/unit/SettingsPage.test.tsx
|
||||
```
|
||||
|
||||
Expected: PASS (mock 갱신 없이도 통과)
|
||||
|
||||
- [ ] **Step 6: 전체 typecheck + 단위**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit && npx vitest run
|
||||
```
|
||||
|
||||
Expected: 0 errors, 모두 PASS (총 +11 케이스: SyncHelpModal 7 + ConflictModal 회귀 3 + SyncSection 회귀 1, App/SettingsPage 무영향)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/inbox/components/settings/SyncSection.tsx tests/unit/SyncSection.test.tsx
|
||||
git commit -m "feat(sync-help): SyncSection 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp wiring"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: README "원격 백업 (F6-L2)" 섹션 통째 재작성 → "동기화 (Git, Cut E)"
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `README.md` line 193-223
|
||||
|
||||
- [ ] **Step 1: 기존 섹션 확인**
|
||||
|
||||
```bash
|
||||
sed -n '193,223p' README.md
|
||||
```
|
||||
|
||||
기대 출력: 옛 v0.2.1 MVP 안내 (`cd %APPDATA%\Inkling\...\sync` + 수동 `git init` + 트레이 "지금 동기화"). 본 섹션을 통째 교체.
|
||||
|
||||
- [ ] **Step 2: 섹션 교체 (Edit tool 사용)**
|
||||
|
||||
기존 텍스트 (line 193-223 전부):
|
||||
|
||||
```markdown
|
||||
## 원격 백업 (선택, F6-L2)
|
||||
|
||||
Inkling 데이터를 사적 git 원격에 백업하려면 한 번만 설정하면 된다. 인코딩된 형식이 아니라 평문 마크다운(F5 export 형식)으로 저장되니, **반드시 비공개 repo** 를 사용한다.
|
||||
|
||||
### 일회 설정
|
||||
|
||||
```bash
|
||||
# 1. 빈 사적 repo 생성 (예: gitea, GitHub private)
|
||||
|
||||
# 2. 데이터 디렉터리에 git 초기화 + 원격 등록
|
||||
cd "%APPDATA%\Inkling\Inkling\profiles\default\sync" # Windows
|
||||
git init
|
||||
git remote add origin https://your-host/owner/inkling-data.git
|
||||
git fetch origin || true # 빈 repo 면 무시
|
||||
|
||||
# 3. 자격증명 설정 (Windows Credential Manager 자동 / 또는 token 임베드 URL)
|
||||
|
||||
# 4. 첫 동기화: 트레이 → "지금 동기화"
|
||||
```
|
||||
|
||||
처음 sync 시 SyncService 가 `<profileDir>/sync/` 안에 F5 export 트리(notes/, media/, index.jsonl, manifest.json)를 덮어쓰고 `git add -A && git commit && git push -u origin <branch>` 를 자동 수행.
|
||||
|
||||
### 사용
|
||||
|
||||
- 트레이 → "지금 동기화" 로 수동 트리거
|
||||
- 앱 종료 시 자동 1회 (sync dir 이 설정된 경우만)
|
||||
- 변경 없으면 토스트 "변경 사항 없음", 변경 있으면 "동기화 완료"
|
||||
|
||||
설정이 안 됐으면 트레이 토스트로 안내. 한 번 설정하면 이후 push 는 OS credential helper 가 자동 처리.
|
||||
|
||||
데이터 라이프사이클 측면에서 F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F6-L2 (원격 git, 반자동) 3-layer 구조의 마지막 layer.
|
||||
```
|
||||
|
||||
신규 텍스트 (전체):
|
||||
|
||||
```markdown
|
||||
## 동기화 (Git, F21 Cut E)
|
||||
|
||||
Inkling 데이터를 사적 git 원격으로 양방향 동기화 (Mac ↔ Windows 등). 평문 마크다운(F5 export 형식)으로 저장되니 **반드시 비공개 repo** 를 사용한다.
|
||||
|
||||
상세 도움말은 앱 내 설정 → 동기화 저장소 → "도움말" 버튼 (4 섹션 modal) 참조. 본 섹션은 setup + 주요 시나리오 요약.
|
||||
|
||||
### 일회 설정
|
||||
|
||||
1. 빈 사적 repo 생성 (Gitea / GitHub private)
|
||||
2. 앱 → 설정 → 동기화 저장소 → URL 입력 → "저장"
|
||||
3. "연결 테스트" 클릭해 인증 / 네트워크 확인
|
||||
4. 자동 sync 사용 토글 + interval (기본 30분) 확인
|
||||
|
||||
URL 형식 (둘 중 하나):
|
||||
|
||||
- SSH: `git@host:user/repo.git`
|
||||
- HTTPS: `https://host/user/repo.git`
|
||||
|
||||
`git@https://...` 같은 혼합 형식은 거부된다.
|
||||
|
||||
### 일상 사용
|
||||
|
||||
- 자동 sync: 설정한 interval 마다 + 앱 종료 시 1회
|
||||
- 수동 sync: 트레이 → "지금 동기화"
|
||||
- 충돌 발생 시 트레이 토스트 + 설정 페이지의 "충돌 해결…" 버튼 → ConflictModal
|
||||
|
||||
### 충돌 해결 (ConflictModal)
|
||||
|
||||
같은 노트를 두 기기에서 동시에 수정하면 path 별 결정 (내 것 / 원격) 을 받는다.
|
||||
|
||||
- **내 것 사용**: 이 기기의 변경을 보존, 원격 변경을 폐기
|
||||
- **원격 사용**: 원격 변경을 가져오고, 이 기기의 변경을 폐기
|
||||
|
||||
3 케이스:
|
||||
|
||||
1. 편집/편집 — 양 텍스트 비교 후 더 새롭고 완전한 쪽 선택. 둘 다 보존하려면 한쪽 선택 + 사후 수동 병합 ('both' 미지원)
|
||||
2. 삭제/편집 — 삭제가 의도였으면 원격 사용 (trash 측), 수정이 더 중요하면 내 것 사용 (편집 측 = trash 취소)
|
||||
3. AI 결과 충돌 — 한쪽 선택 후 AI 재실행 권장
|
||||
|
||||
### Silent risk
|
||||
|
||||
- **시계 어긋남 (NTP)**: 양 기기 시계가 다르면 timestamp merge 가 잘못된 결과를 낼 수 있음. NTP 동기화 끄지 말 것
|
||||
- **두 기기 동시 수정 회피**: 같은 노트를 동시에 수정하면 conflict 가 잦아짐
|
||||
- **자동 sync 실패 silent**: 주기적 sync 실패 시 토스트 안 뜸. 마지막 sync 시각 / 결과는 설정 페이지에서 확인
|
||||
|
||||
### Troubleshoot
|
||||
|
||||
- **"연결 테스트" 실패** — 네트워크 (브라우저로 호스트 접속) / 인증 (SSH key 또는 token) / URL 오타 점검
|
||||
- **인증 실패 (push 안 됨)** — SSH 는 public key 등록 점검, HTTPS 는 OS credential helper (Windows Credential Manager / macOS Keychain) 의 저장 token 점검
|
||||
- **URL 변경** — 설정 페이지에서 새 URL 입력 → 저장 (`git remote set-url origin` 자동 처리)
|
||||
|
||||
데이터 라이프사이클: F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F21 Cut E (양방향 git sync) 3-layer 구조.
|
||||
```
|
||||
|
||||
Edit tool 호출: `old_string` = 기존 텍스트 전체, `new_string` = 신규 텍스트 전체.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: README 동기화 섹션 Cut E 반영 — 양방향 sync + ConflictModal + Silent risk + Troubleshoot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] **Step 1: 전체 단위 + typecheck**
|
||||
|
||||
```bash
|
||||
cd C:\Users\rlaxo\inkling
|
||||
npx tsc --noEmit && npx vitest run
|
||||
```
|
||||
|
||||
Expected: 0 type errors, 모든 테스트 PASS (직전 base 대비 +11: SyncHelpModal 7 + ConflictModal 회귀 3 + SyncSection 회귀 1)
|
||||
|
||||
- [ ] **Step 2: 수동 smoke (electron dev)**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
확인:
|
||||
|
||||
- 설정 → 동기화 저장소 → "도움말" 버튼 클릭 → SyncHelpModal 4 섹션 표시 + ESC/X/overlay close
|
||||
- 충돌이 있는 상태에서 "충돌 해결…" → ConflictModal → "자세히 보기" 클릭 → SyncHelpModal 이 메인 conflict 섹션 (#main-conflict) 으로 scroll 된 채 open
|
||||
- README 변경 사항을 GitHub/Gitea 웹에서 렌더링 정상 (헤더 / 코드 펜스 / 리스트)
|
||||
|
||||
- [ ] **Step 3: 모든 commit history 확인**
|
||||
|
||||
```bash
|
||||
git log --oneline -6
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
xxxxxxx docs: README 동기화 섹션 Cut E 반영 — ...
|
||||
xxxxxxx feat(sync-help): SyncSection 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp wiring
|
||||
xxxxxxx feat(sync-help): ConflictModal inline 설명 + 자세히 보기 링크 (onOpenHelp prop)
|
||||
xxxxxxx feat(sync-help): SyncHelpModal 4 anchor 섹션 (메인 conflict / 자동 / silent / setup)
|
||||
xxxxxxx docs(spec): sync 도움말 v0.3.4 — SyncHelpModal + ConflictModal inline + README
|
||||
...
|
||||
```
|
||||
1443
docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md
Normal file
1443
docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md
Normal file
File diff suppressed because it is too large
Load Diff
1158
docs/superpowers/plans/2026-05-10-v030-cut-e-bidirectional-sync.md
Normal file
1158
docs/superpowers/plans/2026-05-10-v030-cut-e-bidirectional-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
841
docs/superpowers/plans/2026-05-10-v031-cut-f-vision.md
Normal file
841
docs/superpowers/plans/2026-05-10-v031-cut-f-vision.md
Normal file
@@ -0,0 +1,841 @@
|
||||
# v0.3.1 Cut F — 멀티모달 vision AI Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** F24 — Ollama vision 모델 (gemma3 family default) 활용. 이미지 + raw_text 결합 prompt → title/summary/tags 자동 생성. Capability detection (app launch + manual refresh) + InferenceProvider 확장 + AiWorker 통합 + Configure UI dropdown.
|
||||
|
||||
**Architecture:** `isVisionCapable(model)` pure 함수 가 family/families/name 기반으로 vision 가능 모델 판정. `refreshVisionCache(deps)` 가 `/api/tags` 호출 후 settings 에 cache. AiWorker 가 `note.media.length > 0 && visionModel` 둘 다 충족 시 vision path (5MB cap + base64 변환). Configure UI 가 cache 기반 dropdown + manual refresh.
|
||||
|
||||
**Tech Stack:** undici/fetch (Ollama API), Node fs/promises (이미지 base64 변환), Electron IPC, React 19 + zustand 5, vitest 4 + RTL.
|
||||
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-05-09-v031-cut-f-design.md` — source spec (Cut F 정정 반영: 단위 679, 실제 SettingsService API, 'skipped' enum 미도입, fallback 미구현)
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F24
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` — Cut F 위치
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
|
||||
- `src/main/services/VisionDetect.ts` — `isVisionCapable(model)` pure + `refreshVisionCache(deps)` async (Ollama /api/tags)
|
||||
- `src/main/ai/visionPrompt.ts` — `buildVisionPrompt(text, todayKst, dueCandidates, vocab)` pure
|
||||
- `src/renderer/inbox/components/settings/VisionSection.tsx` — AI 제공자 섹션 안 또는 별도 sub-section. dropdown + 다시 감지 버튼
|
||||
- `tests/unit/VisionDetect.test.ts` — isVisionCapable 5 + refreshVisionCache 4
|
||||
- `tests/unit/visionPrompt.test.ts` — buildVisionPrompt 2 (text only / image-only fallback)
|
||||
- `tests/unit/AiWorker.vision.test.ts` — vision path 3 (text-only / vision body / 5MB cap)
|
||||
- `tests/unit/VisionSection.test.tsx` — UI 1 (dropdown + 다시 감지)
|
||||
|
||||
**Modify:**
|
||||
|
||||
- `src/main/services/SettingsService.ts` — zod schema vision_model / vision_capable_cache / vision_cache_at + 4 메서드
|
||||
- `src/main/ai/InferenceProvider.ts` — `GenerateInput.images?: Array<{ base64: string; mime: string }>` + `generate(input, opts?: { visionModel?: string | null })`
|
||||
- `src/main/ai/LocalOllamaProvider.ts` — `generate` body 에 `images` 필드 (vision path) + 모델 분기
|
||||
- `src/main/ai/AiWorker.ts` — `note.media + visionModel` vision path + 5MB cap + base64 변환. 생성자에 `settings: SettingsService` 의존성 추가
|
||||
- `src/main/ipc/settingsApi.ts` — 3 IPC: `settings:get-vision-models` / `settings:set-vision-model` / `settings:refresh-vision-cache`
|
||||
- `src/preload/index.ts` — 3 bridge
|
||||
- `src/shared/types.ts` — `getSettings()` 반환에 vision_* 3 필드 + InboxApi 3 메서드
|
||||
- `src/main/index.ts` — `void refreshVisionCache(...)` whenReady 안 + AiWorker 생성자에 settings 주입
|
||||
- `src/renderer/inbox/components/settings/AiProviderSection.tsx` 또는 SettingsPage — VisionSection 마운트
|
||||
- `tests/unit/SettingsService.test.ts` — vision 4 메서드 round-trip
|
||||
- `tests/unit/LocalOllamaProvider.test.ts` — vision body 분기 회귀
|
||||
- `tests/unit/AiWorker.test.ts` — 기존 mock 에 settings stub 추가 (생성자 변경)
|
||||
- `package.json` — version 0.3.0 → 0.3.1
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F24 promoted
|
||||
|
||||
---
|
||||
|
||||
## 단위 목표
|
||||
|
||||
679 (v0.3.0) → 약 701 (+22), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: VisionDetect — `isVisionCapable` + `refreshVisionCache`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/services/VisionDetect.ts`
|
||||
- Create: `tests/unit/VisionDetect.test.ts`
|
||||
|
||||
`isVisionCapable(model)` pure 함수 — family/families/name hints 기반 판정. `refreshVisionCache(deps)` async — `/api/tags` 호출 후 capable 추출 + settings cache 저장. fetch 주입 가능 (테스트).
|
||||
|
||||
- [ ] **Step 1: failing test** — `tests/unit/VisionDetect.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { isVisionCapable, refreshVisionCache } from '../../src/main/services/VisionDetect.js';
|
||||
|
||||
describe('isVisionCapable', () => {
|
||||
it('family=gemma3 → true', () => {
|
||||
expect(isVisionCapable({ name: 'gemma3:12b', details: { family: 'gemma3' } })).toBe(true);
|
||||
});
|
||||
it('families=[llava] → true', () => {
|
||||
expect(isVisionCapable({ name: 'llava-13b', details: { families: ['llava'] } })).toBe(true);
|
||||
});
|
||||
it('name hint "vision" → true', () => {
|
||||
expect(isVisionCapable({ name: 'custom-vision-7b' })).toBe(true);
|
||||
});
|
||||
it('text-only family=gemma → false', () => {
|
||||
expect(isVisionCapable({ name: 'gemma4:e4b', details: { family: 'gemma' } })).toBe(false);
|
||||
});
|
||||
it('no hints + unknown family → false', () => {
|
||||
expect(isVisionCapable({ name: 'mistral:7b', details: { family: 'mistral' } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshVisionCache', () => {
|
||||
it('happy path — capable 추출 + settings cache 저장', async () => {
|
||||
const settings = {
|
||||
isAiEnabled: vi.fn(async () => true),
|
||||
setVisionCapableCache: vi.fn(async () => {})
|
||||
};
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ name: 'gemma4:e4b', details: { family: 'gemma' } },
|
||||
{ name: 'gemma3:12b-vision', details: { family: 'gemma3' } },
|
||||
{ name: 'llava:13b', details: { families: ['llava'] } }
|
||||
]
|
||||
})
|
||||
})) as unknown as typeof fetch;
|
||||
const r = await refreshVisionCache({
|
||||
settings: settings as never,
|
||||
endpoint: 'http://localhost:11434',
|
||||
fetchImpl
|
||||
});
|
||||
expect(r).toEqual({ ok: true, models: ['gemma3:12b-vision', 'llava:13b'] });
|
||||
expect(settings.setVisionCapableCache).toHaveBeenCalledWith(['gemma3:12b-vision', 'llava:13b'], expect.any(Date));
|
||||
});
|
||||
|
||||
it('ai_disabled → 스킵', async () => {
|
||||
const settings = {
|
||||
isAiEnabled: vi.fn(async () => false),
|
||||
setVisionCapableCache: vi.fn(async () => {})
|
||||
};
|
||||
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x' });
|
||||
expect(r).toEqual({ ok: false, reason: 'ai_disabled' });
|
||||
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('http error → ok:false', async () => {
|
||||
const settings = {
|
||||
isAiEnabled: vi.fn(async () => true),
|
||||
setVisionCapableCache: vi.fn(async () => {})
|
||||
};
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({})
|
||||
})) as unknown as typeof fetch;
|
||||
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x', fetchImpl });
|
||||
expect(r).toMatchObject({ ok: false });
|
||||
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unreachable → ok:false', async () => {
|
||||
const settings = {
|
||||
isAiEnabled: vi.fn(async () => true),
|
||||
setVisionCapableCache: vi.fn(async () => {})
|
||||
};
|
||||
const fetchImpl = vi.fn(async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof fetch;
|
||||
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x', fetchImpl });
|
||||
expect(r).toMatchObject({ ok: false });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: implementation** — `src/main/services/VisionDetect.ts`:
|
||||
|
||||
```ts
|
||||
import type { SettingsService } from './SettingsService.js';
|
||||
|
||||
const VISION_FAMILIES = new Set(['gemma3', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
|
||||
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3'];
|
||||
|
||||
export interface OllamaModel {
|
||||
name: string;
|
||||
details?: { family?: string; families?: string[] };
|
||||
}
|
||||
|
||||
export function isVisionCapable(model: OllamaModel): boolean {
|
||||
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
|
||||
if (model.details?.families?.some((f) => VISION_FAMILIES.has(f))) return true;
|
||||
const lower = model.name.toLowerCase();
|
||||
return VISION_NAME_HINTS.some((h) => lower.includes(h));
|
||||
}
|
||||
|
||||
export interface RefreshDeps {
|
||||
settings: SettingsService;
|
||||
endpoint: string;
|
||||
now?: () => Date;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function refreshVisionCache(
|
||||
deps: RefreshDeps
|
||||
): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
|
||||
if (!(await deps.settings.isAiEnabled())) {
|
||||
return { ok: false, reason: 'ai_disabled' };
|
||||
}
|
||||
const fetchFn = deps.fetchImpl ?? fetch;
|
||||
let body: { models?: OllamaModel[] };
|
||||
try {
|
||||
const r = await fetchFn(`${deps.endpoint}/api/tags`);
|
||||
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
|
||||
body = (await r.json()) as { models?: OllamaModel[] };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
|
||||
}
|
||||
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
|
||||
const now = deps.now ? deps.now() : new Date();
|
||||
await deps.settings.setVisionCapableCache(capable, now);
|
||||
return { ok: true, models: capable };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run tests/unit/VisionDetect.test.ts
|
||||
git add src/main/services/VisionDetect.ts tests/unit/VisionDetect.test.ts
|
||||
git commit -m "feat(v031): VisionDetect — isVisionCapable + refreshVisionCache (fetch 주입)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: SettingsService — vision_model / vision_capable_cache + 4 메서드
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/services/SettingsService.ts`
|
||||
- Modify: `tests/unit/SettingsService.test.ts`
|
||||
|
||||
zod schema 확장 + 4 메서드 추가 (Cut E sync_* 패턴).
|
||||
|
||||
- [ ] **Step 1: zod schema 확장** — `src/main/services/SettingsService.ts`:
|
||||
|
||||
```ts
|
||||
const SettingsSchema = z.object({
|
||||
ollama: OllamaSettingsSchema.optional(),
|
||||
ai_enabled: z.boolean().optional(),
|
||||
onboarding_completed: z.boolean().optional(),
|
||||
sync_repo_url: z.string().nullable().optional(),
|
||||
sync_auto_enabled: z.boolean().optional(),
|
||||
sync_interval_min: z.number().int().min(5).optional(),
|
||||
// v0.3.1 Cut F — vision 모델 (이미지 분석). null/없음 = 비활성.
|
||||
vision_model: z.string().nullable().optional(),
|
||||
vision_capable_cache: z.array(z.string()).optional(),
|
||||
vision_cache_at: z.string().optional()
|
||||
}).strict();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 4 메서드 추가** (`setSyncIntervalMin` 다음):
|
||||
|
||||
```ts
|
||||
async getVisionModel(): Promise<string | null> {
|
||||
const s = await this.load();
|
||||
return s.vision_model ?? null;
|
||||
}
|
||||
|
||||
async setVisionModel(value: string | null): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, vision_model: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
|
||||
const s = await this.load();
|
||||
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
|
||||
}
|
||||
|
||||
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
|
||||
await this.persist(next);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: failing test** — `tests/unit/SettingsService.test.ts` 의 마지막 describe (Cut E sync) 다음에 추가:
|
||||
|
||||
```ts
|
||||
describe('v0.3.1 Cut F — vision settings', () => {
|
||||
it('getVisionModel() 기본 null', async () => {
|
||||
expect(await svc.getVisionModel()).toBeNull();
|
||||
});
|
||||
|
||||
it('setVisionModel / getVisionModel round-trip + null clear', async () => {
|
||||
await svc.setVisionModel('gemma3:12b-vision');
|
||||
expect(await svc.getVisionModel()).toBe('gemma3:12b-vision');
|
||||
await svc.setVisionModel(null);
|
||||
expect(await svc.getVisionModel()).toBeNull();
|
||||
});
|
||||
|
||||
it('getVisionCapableCache() 기본 빈 배열 + null at', async () => {
|
||||
expect(await svc.getVisionCapableCache()).toEqual({ models: [], at: null });
|
||||
});
|
||||
|
||||
it('setVisionCapableCache 저장 + at ISO', async () => {
|
||||
const at = new Date('2026-05-10T05:00:00Z');
|
||||
await svc.setVisionCapableCache(['gemma3:12b', 'llava:13b'], at);
|
||||
const r = await svc.getVisionCapableCache();
|
||||
expect(r.models).toEqual(['gemma3:12b', 'llava:13b']);
|
||||
expect(r.at).toBe('2026-05-10T05:00:00.000Z');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run tests/unit/SettingsService.test.ts
|
||||
git add src/main/services/SettingsService.ts tests/unit/SettingsService.test.ts
|
||||
git commit -m "feat(v031): SettingsService.{getVisionModel,setVisionModel,getVisionCapableCache,setVisionCapableCache}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: visionPrompt + InferenceProvider 인터페이스 확장
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/ai/visionPrompt.ts`
|
||||
- Modify: `src/main/ai/InferenceProvider.ts`
|
||||
- Create: `tests/unit/visionPrompt.test.ts`
|
||||
|
||||
`buildVisionPrompt(text, todayKst, dueCandidates, vocab)` pure — 이미지 + raw_text 결합 시나리오. 빈 text 도 처리 ("(이미지만 있음)" placeholder).
|
||||
|
||||
- [ ] **Step 1: failing test** — `tests/unit/visionPrompt.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildVisionPrompt } from '../../src/main/ai/visionPrompt.js';
|
||||
|
||||
describe('buildVisionPrompt', () => {
|
||||
it('text + 이미지 시 메모 본문 포함', () => {
|
||||
const r = buildVisionPrompt('회의 메모', '2026-05-10', ['2026-05-10'], ['회의']);
|
||||
expect(r).toContain('회의 메모');
|
||||
expect(r).toContain('2026-05-10');
|
||||
expect(r).toContain('회의');
|
||||
});
|
||||
|
||||
it('빈 text → "(이미지만 있음)" placeholder', () => {
|
||||
const r = buildVisionPrompt('', '2026-05-10', [], []);
|
||||
expect(r).toContain('(이미지만 있음)');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: implementation** — `src/main/ai/visionPrompt.ts`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* v0.3.1 Cut F — 멀티모달 vision prompt. 이미지 + raw_text 결합 분석 후
|
||||
* title/summary/tags/due_date JSON 응답 요청. 빈 raw_text 도 처리.
|
||||
*/
|
||||
export function buildVisionPrompt(
|
||||
text: string,
|
||||
todayKst: string,
|
||||
dueCandidates: string[],
|
||||
vocab: string[]
|
||||
): string {
|
||||
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
|
||||
|
||||
메모 본문 (비어 있을 수 있음):
|
||||
${text || '(이미지만 있음)'}
|
||||
|
||||
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
|
||||
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
|
||||
오늘: ${todayKst}
|
||||
가능한 due 후보: ${dueCandidates.join(', ')}
|
||||
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: InferenceProvider 인터페이스 확장** — `src/main/ai/InferenceProvider.ts`:
|
||||
|
||||
```ts
|
||||
export interface GenerateInput {
|
||||
text: string;
|
||||
todayKst: string;
|
||||
dueDateCandidates: string[];
|
||||
vocab?: string[];
|
||||
// v0.3.1 Cut F — 멀티모달 vision (옵션). LocalOllamaProvider 가 visionModel 과 함께 처리.
|
||||
images?: Array<{ base64: string; mime: string }>;
|
||||
}
|
||||
|
||||
export interface GenerateOptions {
|
||||
visionModel?: string | null;
|
||||
}
|
||||
|
||||
export interface InferenceProvider {
|
||||
generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse>;
|
||||
// ... 기존 abort / generateRaw
|
||||
}
|
||||
```
|
||||
|
||||
(기존 호출자는 `opts` 미전달이라 호환 — vision path off.)
|
||||
|
||||
- [ ] **Step 4: PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run tests/unit/visionPrompt.test.ts
|
||||
git add src/main/ai/visionPrompt.ts src/main/ai/InferenceProvider.ts tests/unit/visionPrompt.test.ts
|
||||
git commit -m "feat(v031): buildVisionPrompt + GenerateInput.images + GenerateOptions.visionModel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: LocalOllamaProvider — vision path
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/ai/LocalOllamaProvider.ts`
|
||||
- Modify: `tests/unit/LocalOllamaProvider.test.ts`
|
||||
|
||||
`generate(input, opts)` 가 `opts.visionModel + input.images` 둘 다 있으면 vision body 생성 (model = visionModel, prompt = buildVisionPrompt, body.images = base64 array). 그 외는 기존 text-only path.
|
||||
|
||||
- [ ] **Step 1: failing test** — 기존 `LocalOllamaProvider.test.ts` 의 적절한 describe 안:
|
||||
|
||||
```ts
|
||||
describe('vision path (v0.3.1 Cut F)', () => {
|
||||
it('opts.visionModel + input.images 둘 다 있으면 vision body', async () => {
|
||||
let captured: { model?: string; prompt?: string; images?: string[] } = {};
|
||||
const undici = await import('undici');
|
||||
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
|
||||
captured = JSON.parse(init?.body as string);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
|
||||
} as never;
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
|
||||
await provider.generate(
|
||||
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [], images: [{ base64: 'AAAA', mime: 'image/png' }] },
|
||||
{ visionModel: 'gemma3:12b-vision' }
|
||||
);
|
||||
expect(captured.model).toBe('gemma3:12b-vision');
|
||||
expect(captured.prompt).toContain('이미지');
|
||||
expect(captured.images).toEqual(['AAAA']);
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('visionModel 있어도 images 없으면 text-only path', async () => {
|
||||
let captured: { model?: string; images?: unknown } = {};
|
||||
const undici = await import('undici');
|
||||
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
|
||||
captured = JSON.parse(init?.body as string);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
|
||||
} as never;
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
|
||||
await provider.generate(
|
||||
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] },
|
||||
{ visionModel: 'gemma3:12b-vision' }
|
||||
);
|
||||
expect(captured.model).toBe('gemma4:e4b');
|
||||
expect(captured.images).toBeUndefined();
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('opts 미전달 → 기존 text-only (회귀)', async () => {
|
||||
let captured: { model?: string; images?: unknown } = {};
|
||||
const undici = await import('undici');
|
||||
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
|
||||
captured = JSON.parse(init?.body as string);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
|
||||
} as never;
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
|
||||
await provider.generate({ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] });
|
||||
expect(captured.model).toBe('gemma4:e4b');
|
||||
expect(captured.images).toBeUndefined();
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(기존 LocalOllamaProvider.test.ts 의 mock 패턴 따름. test file 의 imports + vi.mock 은 그대로 사용.)
|
||||
|
||||
- [ ] **Step 2: implementation** — `LocalOllamaProvider.generate` body 분기:
|
||||
|
||||
```ts
|
||||
import { buildVisionPrompt } from './visionPrompt.js';
|
||||
// ...
|
||||
|
||||
async generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse> {
|
||||
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
|
||||
const model = useVision ? opts!.visionModel! : this.model;
|
||||
const prompt = useVision
|
||||
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [])
|
||||
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);
|
||||
|
||||
this.abortController = new AbortController();
|
||||
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
prompt,
|
||||
format: 'json',
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
};
|
||||
if (useVision) {
|
||||
body.images = input.images!.map((i) => i.base64);
|
||||
}
|
||||
const res = await request(`${this.endpoint}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
// ... 기존 parse
|
||||
} finally {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run tests/unit/LocalOllamaProvider.test.ts
|
||||
git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts
|
||||
git commit -m "feat(v031): LocalOllamaProvider vision path (visionModel + images → body.images base64)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: AiWorker — vision integration + 5MB cap + settings 의존성
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/ai/AiWorker.ts`
|
||||
- Modify: `tests/unit/AiWorker.test.ts`
|
||||
- Create: `tests/unit/AiWorker.vision.test.ts`
|
||||
|
||||
AiWorker 가 `note.media + visionModel` 조건에서 base64 변환 (5MB cap) + provider.generate 에 images + visionModel 전달. 생성자에 `settings: SettingsService` 의존성 추가.
|
||||
|
||||
- [ ] **Step 1: AiWorker 생성자 변경** — settings 파라미터 추가. `src/main/index.ts` 의 인스턴스 생성도 갱신.
|
||||
|
||||
- [ ] **Step 2: AiWorker.processJob 갱신**:
|
||||
|
||||
```ts
|
||||
import { readFile } from 'node:fs/promises';
|
||||
// 클래스 안 generate 호출 직전:
|
||||
const visionModel = await this.settings.getVisionModel();
|
||||
let images: Array<{ base64: string; mime: string }> | undefined;
|
||||
if (visionModel && note.media.length > 0) {
|
||||
images = await Promise.all(
|
||||
note.media.map(async (m) => {
|
||||
const buf = await readFile(this.mediaStore.absolutePath(m.relPath));
|
||||
if (buf.byteLength > 5 * 1024 * 1024) {
|
||||
throw new Error(`image ${m.relPath} exceeds 5MB cap`);
|
||||
}
|
||||
return { base64: buf.toString('base64'), mime: m.mime };
|
||||
})
|
||||
);
|
||||
}
|
||||
const res = await this.holder.get().generate(
|
||||
{ text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab },
|
||||
{ visionModel: visionModel ?? undefined }
|
||||
);
|
||||
```
|
||||
|
||||
`mediaStore: MediaStore` 도 AiWorker 생성자에 신규 파라미터 (현재 없으면 추가; main 에서 주입).
|
||||
|
||||
- [ ] **Step 3: failing test** — `tests/unit/AiWorker.vision.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
||||
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
|
||||
import { AiWorker } from '../../src/main/ai/AiWorker.js';
|
||||
import { MediaStore } from '../../src/main/services/MediaStore.js';
|
||||
|
||||
describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let workDir: string;
|
||||
let mediaStore: MediaStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
workDir = await mkdtemp(join(tmpdir(), 'inkling-vision-'));
|
||||
mediaStore = new MediaStore(workDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('visionModel + media 있음 → provider.generate 가 images + opts 받음', async () => {
|
||||
const { id } = repo.create({ rawText: '이미지 메모' });
|
||||
const mediaPath = join(workDir, 'media', id, '1.png');
|
||||
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||
await writeFile(mediaPath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); // 4 bytes PNG-ish
|
||||
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]);
|
||||
|
||||
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
|
||||
const provider = { name: 'fake', generate, abort: () => {} };
|
||||
const settings = {
|
||||
getVisionModel: vi.fn(async () => 'gemma3:12b-vision'),
|
||||
isAiEnabled: vi.fn(async () => true)
|
||||
} as unknown as never;
|
||||
const worker = new AiWorker(/* ...deps with settings + mediaStore + repo + holder = { get: () => provider } */);
|
||||
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
|
||||
|
||||
expect(generate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ images: expect.any(Array) }),
|
||||
expect.objectContaining({ visionModel: 'gemma3:12b-vision' })
|
||||
);
|
||||
const callArg = generate.mock.calls[0]![0] as { images: Array<{ base64: string; mime: string }> };
|
||||
expect(callArg.images).toHaveLength(1);
|
||||
expect(callArg.images[0]!.mime).toBe('image/png');
|
||||
});
|
||||
|
||||
it('visionModel 없으면 text-only (회귀)', async () => {
|
||||
const { id } = repo.create({ rawText: 'just text' });
|
||||
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
|
||||
const provider = { name: 'fake', generate, abort: () => {} };
|
||||
const settings = {
|
||||
getVisionModel: vi.fn(async () => null),
|
||||
isAiEnabled: vi.fn(async () => true)
|
||||
} as unknown as never;
|
||||
const worker = new AiWorker(/* ... */);
|
||||
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
|
||||
expect(generate).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ images: expect.anything() }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('5MB 초과 이미지 → throw → ai_status=failed', async () => {
|
||||
const { id } = repo.create({ rawText: 'big image' });
|
||||
const mediaPath = join(workDir, 'media', id, '1.png');
|
||||
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||
await writeFile(mediaPath, Buffer.alloc(6 * 1024 * 1024)); // 6 MB
|
||||
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 6 * 1024 * 1024 }]);
|
||||
|
||||
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
|
||||
const settings = {
|
||||
getVisionModel: vi.fn(async () => 'gemma3:12b-vision'),
|
||||
isAiEnabled: vi.fn(async () => true)
|
||||
} as unknown as never;
|
||||
const worker = new AiWorker(/* ... */);
|
||||
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
|
||||
// 5MB cap 초과 throw → AiWorker 의 attempts 증가 분기 → ai_status='failed'
|
||||
const note = repo.findById(id);
|
||||
expect(['failed', 'pending']).toContain(note!.aiStatus); // attempts 모두 소진 시 'failed'; 첫 시도 throw 시 'pending' 유지 가능 — 구현 의존
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(NOTE: 정확한 AiWorker 생성자 인자 — 기존 test 의 setup 패턴 따라 deps 전체 stub 구성. 위 코드는 outline; 실수행자가 기존 `AiWorker.test.ts` setup 참고하여 정확한 deps 구조 채움.)
|
||||
|
||||
- [ ] **Step 4: 기존 AiWorker.test.ts mock 갱신** — 생성자에 `settings` / `mediaStore` 파라미터 추가됨. 모든 기존 test 의 worker 생성 site 에 stub 추가.
|
||||
|
||||
- [ ] **Step 5: PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run tests/unit/AiWorker.test.ts tests/unit/AiWorker.vision.test.ts
|
||||
git add src/main/ai/AiWorker.ts \
|
||||
src/main/index.ts \
|
||||
tests/unit/AiWorker.test.ts \
|
||||
tests/unit/AiWorker.vision.test.ts
|
||||
git commit -m "feat(v031): AiWorker vision integration — note.media + visionModel + 5MB cap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: types + IPC + preload
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/shared/types.ts` — `getSettings()` 반환에 vision_model / vision_capable_cache / vision_cache_at + InboxApi 3 메서드
|
||||
- Modify: `src/main/ipc/settingsApi.ts` — 3 IPC handler
|
||||
- Modify: `src/preload/index.ts` — 3 bridge
|
||||
- Create: `tests/unit/vision-ipc.test.ts`
|
||||
|
||||
3 채널:
|
||||
- `settings:get-vision-models` → `{ models: string[]; at: string | null; selected: string | null }` (cache 결과 + 현재 선택)
|
||||
- `settings:set-vision-model` (value: string | null) → `{ ok: true }`
|
||||
- `settings:refresh-vision-cache` → `{ ok: true; models: string[] } | { ok: false; reason: string }` (refreshVisionCache 호출)
|
||||
|
||||
상세 패턴은 Cut E sync IPC 와 동일.
|
||||
|
||||
- [ ] **Step 1: types** + **Step 2: failing test** + **Step 3: handlers** + **Step 4: preload bridges** — Cut E sync-ipc 패턴 그대로
|
||||
|
||||
- [ ] **Step 5: PASS + commit**
|
||||
|
||||
```bash
|
||||
git add src/shared/types.ts src/main/ipc/settingsApi.ts src/preload/index.ts tests/unit/vision-ipc.test.ts
|
||||
git commit -m "feat(v031): vision IPC + preload (get-vision-models / set / refresh)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: VisionSection UI + AI 제공자 섹션 통합
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/renderer/inbox/components/settings/VisionSection.tsx`
|
||||
- Modify: `src/renderer/inbox/components/settings/AiProviderSection.tsx` 또는 SettingsPage — 마운트
|
||||
- Create: `tests/unit/VisionSection.test.tsx`
|
||||
|
||||
dropdown (cache 기반) + 다시 감지 버튼 + 마지막 감지 시각 표시. dropdown 변경 시 `setVisionModel` 호출. 다시 감지 → `refreshVisionCache` IPC + dropdown 갱신.
|
||||
|
||||
```tsx
|
||||
// 핵심 구조 (Cut E SyncSection 패턴)
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [at, setAt] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<'select' | 'refresh' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const r = await inboxApi.getVisionModels();
|
||||
setModels(r.models);
|
||||
setAt(r.at);
|
||||
setSelected(r.selected);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function onSelect(value: string) {
|
||||
setBusy('select');
|
||||
await inboxApi.setVisionModel(value === '' ? null : value);
|
||||
setSelected(value === '' ? null : value);
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
setBusy('refresh');
|
||||
const r = await inboxApi.refreshVisionCache();
|
||||
setBusy(null);
|
||||
if (r.ok) {
|
||||
const cache = await inboxApi.getVisionModels();
|
||||
setModels(cache.models);
|
||||
setAt(cache.at);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
UI:
|
||||
|
||||
```tsx
|
||||
<select value={selected ?? ''} onChange={(e) => void onSelect(e.target.value)} aria-label="이미지 분석 모델">
|
||||
<option value="">(비활성)</option>
|
||||
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<button onClick={() => void onRefresh()} disabled={busy === 'refresh'}>
|
||||
{busy === 'refresh' ? '감지 중…' : '다시 감지'}
|
||||
</button>
|
||||
{at !== null && <span>마지막 감지: {new Date(at).toLocaleString('ko-KR')}</span>}
|
||||
```
|
||||
|
||||
- [ ] **Step 1-5: 컴포넌트 + test + 마운트 + commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/inbox/components/settings/VisionSection.tsx \
|
||||
src/renderer/inbox/components/settings/AiProviderSection.tsx \
|
||||
tests/unit/VisionSection.test.tsx
|
||||
git commit -m "feat(v031): VisionSection — dropdown + 다시 감지 + 마지막 감지 시각"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: main process — refreshVisionCache 자동 호출 + AiWorker settings 주입
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/index.ts`
|
||||
|
||||
`whenReady` 안 (Ollama provider 준비 후) `void refreshVisionCache(...)` fire-and-forget 호출. AiWorker 생성자에 settings + mediaStore 주입.
|
||||
|
||||
- [ ] **Step 1: imports + 호출** — `src/main/index.ts`:
|
||||
|
||||
```ts
|
||||
import { refreshVisionCache } from './services/VisionDetect.js';
|
||||
|
||||
// whenReady 안, AiWorker.start() 직후 또는 직전
|
||||
const ollama = providerHolder.get();
|
||||
void refreshVisionCache({
|
||||
settings: settingsSvc,
|
||||
endpoint: (ollama as LocalOllamaProvider).endpoint, // 또는 SettingsService 의 ollama 설정에서 가져옴
|
||||
}).catch(() => {});
|
||||
```
|
||||
|
||||
(LocalOllamaProvider 의 endpoint 가 private 이면 settings 에서 가져옴 또는 provider 에 getter 추가.)
|
||||
|
||||
- [ ] **Step 2: AiWorker 생성자 인자 갱신**
|
||||
|
||||
- [ ] **Step 3: typecheck + PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run
|
||||
git add src/main/index.ts
|
||||
git commit -m "feat(v031): main — refreshVisionCache whenReady + AiWorker settings/mediaStore 주입"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: dogfood promoted + version bump + release commit
|
||||
|
||||
- [ ] F24 promoted 마킹 (`docs/superpowers/specs/2026-04-25-dogfood-feedback.md`):
|
||||
|
||||
```markdown
|
||||
## F24. 멀티모달 vision (✅ promoted v0.3.1 Cut F)
|
||||
|
||||
**상태:** ✅ promoted v0.3.1 Cut F — Ollama vision 모델 (gemma3 family default) 활용. capability detection (app launch + manual refresh) + Configure UI dropdown + AiWorker vision integration (5MB cap + base64 변환). 자동 fallback (caption → text) deferred v0.3.2+.
|
||||
```
|
||||
|
||||
- [ ] package.json: 0.3.0 → 0.3.1 + package-lock.json
|
||||
- [ ] full unit + typecheck
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json package-lock.json
|
||||
git commit -m "chore(release): v0.3.1 — Cut F (멀티모달 vision AI)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist (수행자: 모든 task 완료 후 1회 점검)
|
||||
|
||||
- [ ] **Spec coverage**: §3 Capability Detection (Task 1) / §3-2 SettingsService (Task 2) / §3-3 main wiring (Task 8) / §3-4 UI (Task 7) / §4 Provider (Tasks 3-4) / §5 AiWorker (Task 5) / §6 image-only fallback ('skipped' enum 미도입 → 기존 'failed' 분기 활용)
|
||||
- [ ] **Single write path 강제 (Cut C/D/E 정책)**: 본 cut 은 새 데이터 path 추가 없음 — `notes_fts` / `note_revisions` / `note_tags` mutation 없음 (vision 결과는 기존 `updateAiResult` path 활용 → 이미 검증됨). 회귀 검사 4-path invariant 유지.
|
||||
- [ ] **Type 일관성**: `GenerateInput.images` ↔ `GenerateOptions.visionModel` ↔ AiWorker 호출 ↔ LocalOllamaProvider body 모두 동일 shape
|
||||
- [ ] **단위 카운트**: VisionDetect 9 (5+4) + SettingsService 4 + visionPrompt 2 + LocalOllamaProvider 3 + AiWorker 3 + IPC 3-5 + UI 1 = 약 25-27 신규. 목표 22 달성
|
||||
|
||||
---
|
||||
|
||||
## Risk
|
||||
|
||||
- **vision 모델 한국어 정확도**: gemma3 family 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책). dogfood 검증 필요
|
||||
- **Ollama 가 vision images 무시 (모델 misclassify)**: capability detection false-positive — 사용자가 dropdown 에서 다른 모델 선택해 우회. 자동 fallback 미구현 (YAGNI)
|
||||
- **base64 메모리 폭주**: 5MB cap 적용. 다중 이미지 시 N×5MB = 메모리 누적 — vision 호출 후 image array 즉시 GC. 본 cut 의 dogfood 규모 (메모당 < 3 이미지) 무시
|
||||
- **capability detection 실패 silent**: 첫 launch 시 network 실패 → cache 빈 채로 진행. 사용자가 설정 페이지에서 "다시 감지" 클릭 → 직접 trigger 가능
|
||||
- **AiWorker 생성자 변경**: 기존 test 모두 mock 갱신 필요 (typecheck 가 catch). 누락 시 typecheck red
|
||||
- **F23 OFF (ai_enabled=false) 시 자동 OFF**: refreshVisionCache 가 ai_enabled 체크 → ai_disabled 분기. AiWorker 의 vision path 진입 자체가 ai_enabled=true 가정 — F23 OFF 시 vision path 미도달 (자명)
|
||||
- **e2e**: Cut C/D/E 와 동일 — 본 cut 미수행, main 머지 후 검증
|
||||
1039
docs/superpowers/plans/2026-05-10-v032-cleanup.md
Normal file
1039
docs/superpowers/plans/2026-05-10-v032-cleanup.md
Normal file
File diff suppressed because it is too large
Load Diff
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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
# v0.2.3.1 Ollama 설정 In-App UI — Design Spec
|
||||
|
||||
> 작성: 2026-05-04 · v0.2.3 dogfood unblock 용 patch cut. 환경변수 의존 제거, 사용자 친화 endpoint/model 변경 path.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Inkling 사용자가 트레이 메뉴 / OllamaBanner 에서 Ollama endpoint + model 을 직접 변경 가능하도록. 현재는 `INKLING_OLLAMA_ENDPOINT` env var 만 지원 — Windows 의 dynamic port 점유 (Hyper-V/WSL2 NAT) 같은 환경 이슈에 즉시 대응 못함. patch cut 으로 dogfood unblock 후 1주 soak 진입.
|
||||
|
||||
## 2. Decisions (mini-brainstorm 합의)
|
||||
|
||||
| # | 질문 | 선택 | 이유 |
|
||||
|---|---|---|---|
|
||||
| Q1 | Model 설정 포함 여부 | **B** Endpoint + Model 둘 다 | endpoint 만으로는 커버 불충분 (LAN 서버 fallback 시 model 도 다를 수 있음) |
|
||||
| Q2 | Model input 형태 | **A** Freetext | "급한" patch cut, healthCheck 가 검증, dropdown 은 v0.2.4 영역 |
|
||||
| Q3 | Settings 영속화 | **B** JSON file (`<profileDir>/settings.json`) | migration v4 회피, 항목 2개 라 transaction 불필요, user 직접 편집 가능 |
|
||||
|
||||
자명 결정 (질문 없이 패턴 따름):
|
||||
- env var precedence: **settings > env > default**
|
||||
- in-flight job 처리: **AbortController abort + provider re-create**
|
||||
- UI placement: **트레이 메뉴 "Ollama 설정..." + OllamaBanner 의 "설정" 링크**
|
||||
- validation: **save 전 healthCheck**
|
||||
|
||||
## 3. Architecture & data flow
|
||||
|
||||
```
|
||||
사용자 액션:
|
||||
1. 트레이 → "Ollama 설정..." 클릭 (또는 OllamaBanner 의 "설정" 링크)
|
||||
2. Settings modal 열림 — endpoint + model 입력란 + "저장" 버튼
|
||||
3. 사용자 endpoint/model 입력 → "저장" 클릭
|
||||
|
||||
저장 흐름:
|
||||
├─ Renderer: inboxApi.saveOllamaSettings({ endpoint, model })
|
||||
├─ Main IPC: 임시 LocalOllamaProvider 생성 → healthCheck()
|
||||
│ ├─ ok=true → JSON 영속화 + provider/health 교체
|
||||
│ └─ ok=false → 저장 거부 + reason 반환 (modal 안에 inline 에러)
|
||||
├─ 기존 in-flight AI job 처리:
|
||||
│ └─ AbortController abort → 현재 generate 중단 → unreachable 분류 →
|
||||
│ AiWorker 의 무한 retry 가 새 endpoint 로 재시도 (자동 회복)
|
||||
└─ HealthChecker.recheck() → OllamaBanner 즉시 갱신
|
||||
|
||||
부팅 흐름 (precedence: settings > env > default):
|
||||
index.ts:
|
||||
const settings = await settingsSvc.load() // JSON 또는 빈 객체
|
||||
const endpoint = settings.ollama?.endpoint
|
||||
?? process.env.INKLING_OLLAMA_ENDPOINT
|
||||
?? 'http://localhost:11434'
|
||||
const model = settings.ollama?.model ?? 'gemma4:e4b'
|
||||
```
|
||||
|
||||
### 3.1 핵심 invariants
|
||||
|
||||
1. **저장 = 검증 통과 전제** — healthCheck ok=false 면 JSON 안 씀. 사용자 잘못된 값 영속화 방지
|
||||
2. **Provider mutability via re-create** — `setEndpoint()` 메서드 추가 X. `ProviderHolder` 가 새 인스턴스 보유, listeners 알림. `AbortController` 가 in-flight 중단
|
||||
3. **Settings precedence**: settings.json > env var > hardcoded default. UI 가 source of truth
|
||||
4. **단일 settings file** — `<profileDir>/settings.json`. atomic write (`writeFile` temp → `rename`). 손상 시 빈 객체 fallback (no app crash)
|
||||
5. **HealthChecker rebind** — `ProviderHolder.onReplace` 통해 새 provider 받아 polling endpoint 즉시 갱신
|
||||
6. **Backward compat** — settings.json 없는 첫 부팅: env var → default 순. 기존 사용자 영향 0
|
||||
7. **Cross-platform 자동** — `app.getPath('userData')` + `node:path.join` + `node:fs/promises` 가 OS 별 경로/separator/UTF-8 자동. 별도 분기 0
|
||||
|
||||
## 4. Components
|
||||
|
||||
### 4.1 `SettingsService` (신규)
|
||||
|
||||
**파일**: `src/main/services/SettingsService.ts`
|
||||
|
||||
```typescript
|
||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const OllamaSettingsSchema = z.object({
|
||||
endpoint: z.string().url(),
|
||||
model: z.string().min(1)
|
||||
}).strict();
|
||||
|
||||
const SettingsSchema = z.object({
|
||||
ollama: OllamaSettingsSchema.optional()
|
||||
}).strict();
|
||||
|
||||
export type Settings = z.infer<typeof SettingsSchema>;
|
||||
export type OllamaSettings = z.infer<typeof OllamaSettingsSchema>;
|
||||
|
||||
export class SettingsService {
|
||||
private filePath: string;
|
||||
private cache: Settings | null = null;
|
||||
|
||||
constructor(profileDir: string) {
|
||||
this.filePath = join(profileDir, 'settings.json');
|
||||
}
|
||||
|
||||
async load(): Promise<Settings> {
|
||||
if (this.cache !== null) return this.cache;
|
||||
try {
|
||||
const raw = await readFile(this.filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
this.cache = SettingsSchema.parse(parsed);
|
||||
} catch {
|
||||
this.cache = {}; // 파일 없음 또는 손상 → 빈 객체 fallback
|
||||
}
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
async setOllama(value: OllamaSettings): Promise<void> {
|
||||
const validated = OllamaSettingsSchema.parse(value);
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, ollama: validated };
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
const tmpPath = this.filePath + '.tmp';
|
||||
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
|
||||
await rename(tmpPath, this.filePath);
|
||||
this.cache = next;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `ProviderHolder` (신규)
|
||||
|
||||
**파일**: `src/main/ai/ProviderHolder.ts`
|
||||
|
||||
```typescript
|
||||
import type { LocalOllamaProvider } from './LocalOllamaProvider.js';
|
||||
|
||||
export class ProviderHolder {
|
||||
private current: LocalOllamaProvider;
|
||||
private listeners: Array<(p: LocalOllamaProvider) => void> = [];
|
||||
|
||||
constructor(initial: LocalOllamaProvider) {
|
||||
this.current = initial;
|
||||
}
|
||||
|
||||
get(): LocalOllamaProvider {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
replace(next: LocalOllamaProvider): void {
|
||||
this.current = next;
|
||||
for (const fn of this.listeners) fn(next);
|
||||
}
|
||||
|
||||
onReplace(fn: (p: LocalOllamaProvider) => void): void {
|
||||
this.listeners.push(fn);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `LocalOllamaProvider` AbortController + 사용처 변경
|
||||
|
||||
**파일**: `src/main/ai/LocalOllamaProvider.ts` (수정)
|
||||
|
||||
```typescript
|
||||
export class LocalOllamaProvider implements InferenceProvider {
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
async generate(input: GenerateInput): Promise<AiResponse> {
|
||||
this.abortController = new AbortController();
|
||||
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
|
||||
try {
|
||||
const res = await request(`${this.endpoint}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
|
||||
format: 'json',
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
}),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
// ... 기존 응답 처리 ...
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
기존 `signal: controller.signal` 부분에서 `controller` 가 method-local 이었음 → `this.abortController` 로 이동 (외부 abort 가능).
|
||||
|
||||
### 4.4 `AiWorker` + `HealthChecker` 가 `ProviderHolder` 사용
|
||||
|
||||
`AiWorker` constructor: `provider: InferenceProvider` → `private holder: ProviderHolder`
|
||||
- `processJob` 내 `this.provider.generate(...)` → `this.holder.get().generate(...)`
|
||||
- `provider: this.provider.name` → `provider: this.holder.get().name`
|
||||
|
||||
`HealthChecker` 도 동일 패턴 + `onReplace` listener 등록 → 새 provider 즉시 polling.
|
||||
|
||||
### 4.5 IPC + Preload + InboxApi types
|
||||
|
||||
```typescript
|
||||
// src/main/ipc/inboxApi.ts
|
||||
ipcMain.handle('inbox:loadOllamaSettings', async () => {
|
||||
const s = await deps.settings.load();
|
||||
return s.ollama ?? null;
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
|
||||
// 검증: 새 인스턴스로 healthCheck
|
||||
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
|
||||
const r = await trial.healthCheck();
|
||||
if (!r.ok) return { ok: false, reason: r.reason };
|
||||
await deps.settings.setOllama(value);
|
||||
// in-flight 중단 후 holder 교체
|
||||
deps.providerHolder.get().abort();
|
||||
deps.providerHolder.replace(trial);
|
||||
await deps.health.recheck();
|
||||
return { ok: true };
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/preload/index.ts
|
||||
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
|
||||
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/shared/types.ts InboxApi
|
||||
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
|
||||
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
```
|
||||
|
||||
### 4.6 `OllamaSettingsModal` 컴포넌트
|
||||
|
||||
**파일**: `src/renderer/inbox/components/OllamaSettingsModal.tsx`
|
||||
|
||||
- Props: `open: boolean`, `onClose: () => void`
|
||||
- 입력란 2개 (endpoint, model) + "저장" / "취소"
|
||||
- 마운트 시 `inboxApi.loadOllamaSettings()` → 초기값 prefill
|
||||
- 저장 시 `saveOllamaSettings(...)` → 성공 닫기 + 토스트, 실패 inline 에러
|
||||
- React `<dialog>` 또는 portal — 별도 BrowserWindow X (단순함)
|
||||
|
||||
### 4.7 OllamaBanner "설정" 링크
|
||||
|
||||
기존 `OllamaBanner.tsx` 에 endpoint 변경 링크 추가:
|
||||
```typescript
|
||||
<button onClick={() => setSettingsOpen(true)}>설정</button>
|
||||
```
|
||||
modal state 는 App.tsx 가 보유 + OllamaBanner 와 OllamaSettingsModal 둘 다에 넘김.
|
||||
|
||||
### 4.8 트레이 메뉴 + IPC 채널
|
||||
|
||||
`tray.ts` 의 `createTray` 가 10번째 positional callback 받음 → backlog #4/#26 (TrayCallbacks object refactor) 와 합산 가능. 본 cut 에선 일관성 우선 positional 추가:
|
||||
|
||||
```typescript
|
||||
{ label: 'Ollama 설정...', click: () => runOpenOllamaSettings() }
|
||||
```
|
||||
|
||||
`index.ts` 가 `runOpenOllamaSettings = () => mainWindow.webContents.send('inbox:openOllamaSettings')` 푸시. Renderer App.tsx 가 이 channel 구독해 modal 열기.
|
||||
|
||||
### 4.9 `index.ts` 부팅 흐름 변경
|
||||
|
||||
```typescript
|
||||
const settingsSvc = new SettingsService(paths.profileDir);
|
||||
const settings = await settingsSvc.load();
|
||||
|
||||
const resolvedEndpoint = settings.ollama?.endpoint
|
||||
?? process.env.INKLING_OLLAMA_ENDPOINT
|
||||
?? 'http://localhost:11434';
|
||||
const resolvedModel = settings.ollama?.model ?? 'gemma4:e4b';
|
||||
|
||||
logger.info('ai.endpoint', {
|
||||
endpoint: resolvedEndpoint,
|
||||
source: settings.ollama?.endpoint
|
||||
? 'settings'
|
||||
: (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default')
|
||||
});
|
||||
|
||||
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
|
||||
const holder = new ProviderHolder(provider);
|
||||
|
||||
const health = new HealthChecker(holder, { ... });
|
||||
const aiWorker = new AiWorker(repo, holder, { ... });
|
||||
```
|
||||
|
||||
## 5. Privacy invariant
|
||||
|
||||
- `settings.json` 은 local only, telemetry emit X
|
||||
- 잠재적 `ollama_settings_changed` event 추가 시 endpoint URL 노출 → privacy 위반 → **emit 안 함** (본 cut)
|
||||
- 향후 v0.2.4 dogfood telemetry 에서 변경 빈도 측정 필요 시 `{ count: number }` payload (URL 자체 X) 형태로 추가
|
||||
|
||||
## 6. Tests (≥10개)
|
||||
|
||||
### SettingsService.test.ts (신규, 6)
|
||||
1. `load()` 파일 없음 → 빈 객체
|
||||
2. `load()` 손상 JSON (parse 실패) → 빈 객체 fallback (no throw)
|
||||
3. `load()` 캐시 동작 — 두 번째 호출 시 file read 안 함
|
||||
4. `setOllama()` zod 검증 실패 (non-URL endpoint) → throw
|
||||
5. `setOllama()` 정상 저장 → 디스크 file 존재 + 내용 일치
|
||||
6. `setOllama()` atomic write — temp file 남지 않음 (rename 후 cleanup)
|
||||
|
||||
### ProviderHolder.test.ts (신규, 2)
|
||||
7. `replace()` 시 listener 발화 + `get()` 가 새 인스턴스 반환
|
||||
8. listener 여러 개 등록 시 모두 발화
|
||||
|
||||
### LocalOllamaProvider.test.ts (확장, 2)
|
||||
9. `abort()` 호출 시 in-flight `generate()` rejects (AbortError)
|
||||
10. constructor `model` 파라미터 적용 (default `gemma4:e4b` 외 임의 model)
|
||||
|
||||
총 신규 단위 **10개**. 기존 403 + 10 = **413**.
|
||||
|
||||
(Renderer modal 컴포넌트 단위 테스트 X — Inkling 패턴 따라 store-only. IPC handler 자체도 service-level test 가 logic 보유.)
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
- Multi-provider abstraction (OpenAI, Anthropic, etc) — strategy.md local-first 정책 충돌, v0.2.4+
|
||||
- Settings UI 안에서 다른 기능 (telemetry retention, vocab top-N 등) — 별 cut
|
||||
- Cross-machine settings sync — 단일 머신 dogfood 패턴
|
||||
- Model dropdown / 자동 list refresh — Q2=A 결정 (freetext)
|
||||
- `ollama pull` 자동 안내 — over-scope
|
||||
- Settings export/import / version migration — over-scope
|
||||
- Settings 변경 history / undo — over-scope
|
||||
- Settings UI 안에 model healthCheck 결과 시각화 (loading spinner 등) — minimal toast 만
|
||||
- `ollama_settings_changed` telemetry — privacy invariant 보호 (v0.2.4 검토 시 count-only)
|
||||
- Settings 변경 로그 파일 — env-debug 영역, v0.2.4 검토
|
||||
|
||||
## 8. Gates (roadmap §3.1)
|
||||
|
||||
- typecheck 0
|
||||
- 단위 403 → 413 (+10)
|
||||
- e2e 1/1 (smoke 회귀 X)
|
||||
- backward compat: settings.json 없는 부팅 → env var → default 폴백 정상
|
||||
- cross-platform: SettingsService.test 가 `app.getPath` mock 으로 Win/macOS/Linux 시뮬레이션 (별도 case 또는 path matrix)
|
||||
|
||||
## 9. Roadmap relation
|
||||
|
||||
- v0.2.3 dogfood unblock 패치. 정식 v0.2.3 cut (#1~#7) 와 별개 patch
|
||||
- 머지 후 v0.2.3.1 binary 재빌드 + Gitea release (existing `v0.2.3` tag → `v0.2.3.1` 신규 tag, release 별도)
|
||||
- ≥1주 dogfood soak 후 telemetry export + 신규 피드백 + v0.2.4 backlog 38건 일괄 triage → v0.2.4 brainstorm
|
||||
@@ -0,0 +1,54 @@
|
||||
# v0.2.4 Patch Cleanup — Design Spec (Brief)
|
||||
|
||||
> 작성: 2026-05-05 · 0.2.3.1 semver 위반 (`X.Y.Z.W` 4-part) → 0.2.4 minor bump 이용해 backlog 의 simple cleanup 5건 + 사용자 가치 1건 합쳐서 묶음 cut. v0.2.4 정식 brainstorm 은 v0.2.5 로 이동.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
PR #21 머지 후 0.2.3.1 binary 빌드 시도가 electron-builder 의 semver validation 으로 실패. 0.2.4 minor bump 으로 우회. 이번 cut 에는 dogfood unblock 외 backlog 의 risk 낮은 cleanup + 사용자 가치 항목 동봉.
|
||||
|
||||
## 2. Scope (5 backlog 항목 + version bump)
|
||||
|
||||
| backlog # | 항목 | 가치 | 작업량 |
|
||||
|---|---|---|---|
|
||||
| #1 | `TelemetryService.emit` 의 `now()` 2번 호출 → 1번 추출 | cosmetic (KST midnight straddle 이론) | 1줄 |
|
||||
| #2 | `DAY_MS = 24*60*60*1000` magic number → 모듈 상단 상수 | cosmetic | 1줄 |
|
||||
| #6 | `media.gc.run()` `.catch` 누락 → backup pattern 통일 | consistency | 1줄 |
|
||||
| #13 | NoteCard `mode='trash'` 의 `onDeleted` dead-code prop 제거 | API 청소 | 작음 |
|
||||
| #44 | 트레이 메뉴 + Inbox footer 에 "Inkling 0.2.4" 버전 정보 | **사용자 dogfood 가치** | 1 task |
|
||||
| - | version bump 0.2.3.1 → 0.2.4 | semver 표준 | trivial |
|
||||
|
||||
## 3. Out of scope
|
||||
|
||||
- **#45 (자동실행 버그)**: Windows registry 디버깅 필요, simple X. 별도 cut.
|
||||
- **#3/#4/#26 (KST 통합 / TrayCallbacks refactor)**: multi-file, 크다. 별도.
|
||||
- **#5/#22 (Union 통합 / hydrate cleanup)**: repo-wide.
|
||||
- **#39~#43 (PR #21 deferred)**: telemetry masking 등 의미 있는 결정 필요. v0.2.5 brainstorm 영역.
|
||||
- 기타 backlog 39건.
|
||||
|
||||
## 4. Architecture changes
|
||||
|
||||
본 cut 은 의미 있는 architecture 변경 없음. 기존 pattern 강화만:
|
||||
- `TelemetryService.emit` 의 atomic timestamp 보장 (now() 1회)
|
||||
- 모듈 상단 magic number 상수화 패턴 (다른 파일은 이미 그 패턴, TelemetryService 만 예외)
|
||||
- `.catch` consistency (backup.runDaily / telemetry.cleanupOldFiles 와 동일 wrapper)
|
||||
- React props 청소 (현재 호출되지 않는 prop 제거)
|
||||
- 신규 surface: 트레이 메뉴 "Inkling 정보..." → modal 또는 dialog
|
||||
|
||||
## 5. Tests
|
||||
|
||||
테스트 추가 없음 (모두 cosmetic / refactor). 기존 단위 413/413 회귀 X 확인만.
|
||||
|
||||
#44 의 modal 은 컴포넌트 단위 테스트 X (Inkling 패턴 — store-only).
|
||||
|
||||
## 6. Gates
|
||||
|
||||
- typecheck 0
|
||||
- 단위 413/413 (회귀 X)
|
||||
- e2e 1/1
|
||||
- backward compat: 기존 사용자 영향 0 (cosmetic + 새 surface)
|
||||
|
||||
## 7. Roadmap relation
|
||||
|
||||
- 0.2.3 cut 7/7 (PR #13~#19) + 0.2.3.1 patch (PR #21) 누적 후 binary 빌드를 위한 v0.2.4 minor bump
|
||||
- v0.2.5 brainstorm 트리거: dogfood ≥1주 soak + telemetry export + backlog 39건 (=45-5-1) + 신규 피드백 일괄 triage
|
||||
- backlog 명명 `v024-backlog.md` → 본 cut 후 `v025-backlog.md` 로 rename 검토 (또는 v024-backlog.md 유지하고 내용만 갱신)
|
||||
133
docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md
Normal file
133
docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# v0.2.6 Bugs + Cleanup — Design Spec
|
||||
|
||||
> 작성: 2026-05-05 · 정식 v0.2.6 cut. backlog 16건 (bug 4 + cleanup 12, 13 task 로 cluster) 통합 처리. dogfood telemetry 미수집 영역 (#7/#16/#18/#25/#33/#35/#36/#39/#40 등 14건) 은 v0.2.7 brainstorm 영역으로 별도.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
dogfood UX 마찰 (autostart 풀림, trashCount 부정확, restore 시 AI 미재처리) 즉시 해소 + 코드베이스 cleanup (KST helper 통합, TrayCallbacks 객체화, AiFailedReason union 통합 등) 으로 v0.2.7 brainstorm 시 신규 feature 작업 friction 제거.
|
||||
|
||||
## 2. Scope (16 backlog 항목 → 13 task)
|
||||
|
||||
### Bug fixes (B1~B4)
|
||||
|
||||
| Task | 항목 | 작업 요약 |
|
||||
|---|---|---|
|
||||
| **B1** | #10 | `NoteRepository.restoreNote(id)` 가 `ai_status='failed'` 인 노트 복구 시 `ai_status='pending'` reset + `pending_jobs INSERT` |
|
||||
| **B2** | #12 | `NoteRepository.countTrashed()` 추가 + IPC `inbox:trashCount` 가 SQL 정확 N 반환 (UI 200 cap 제거) |
|
||||
| **B3** | #45 | autostart 풀림: `app.getLoginItemSettings({ args: ['--hidden'] })` (args 비교 정확도) + path canonicalization 검토. fallback: 진단 로그만 추가 시 backlog 유지 |
|
||||
| **B4** | #46 | `app.requestSingleInstanceLock(additionalData)` + `second-instance(event, argv, cwd, additionalData)` 에서 hidden flag 체크 → 두 번째 hidden 이면 inbox 창 안 띄움 |
|
||||
|
||||
### Cleanup refactor (C1~C9)
|
||||
|
||||
| Task | 항목 (cluster) | 작업 요약 |
|
||||
|---|---|---|
|
||||
| **C1** | #3 + #19 + #34 | KST helper 통합 → `src/shared/util/kstDate.ts`. 4 callsite migrate (`TelemetryService.todayKstIso`, `telemetryStats.kstDate`, `AiWorker.todayKstAsDate/Iso`, store `snoozeExpired/snoozeRecall`) |
|
||||
| **C2** | #4 + #23 + #26 | `interface TrayCallbacks` + `createTray(callbacks: TrayCallbacks)` 1-arg refactor. positional 10개 → object |
|
||||
| **C3** | #27 | `refreshTrayFailedCount` module-scoped state 제거 → TrayCallbacks 객체 안 reactive 함수 또는 store-driven 패턴 |
|
||||
| **C4** | #5 | `export type AiFailedReason = 'unreachable' \| 'schema' \| 'timeout' \| 'other'` 단일 export + zod `z.enum` 의 `z.infer` 로 type 파생. 3 callsite migrate |
|
||||
| **C5** | #21 | `hasNoteId(ev: TelemetryEvent): ev is TelemetryEventWithNoteId` type predicate helper → `tests/unit/TelemetryService.test.ts` 의 4-line narrowing 체인 단축 |
|
||||
| **C6** | #22 | NoteRepository hydrate 의 `as any[]` → `Record<string, unknown>[]` (또는 explicit row interface) 일괄 cleanup |
|
||||
| **C7** | #24 + #41 | `<Banner severity="warning"\|"error"\|"info">` shared component → ExpiryBanner / OllamaBanner / FailedBanner / RecallBanner / OllamaSettingsModal 5 callsite migrate |
|
||||
| **C8** | #8 | `telemetryStats.aggregateStats` if/else if 끝에 `else { const _: never = ev; }` exhaustiveness check |
|
||||
| **C9** | #15 + #29 + #42 + #9 | microfixes 묶음: `inbox:delete`→`inbox:trash` rename / `getTopUsedTags(20)` → `VOCAB_TOP_N` const / `OllamaSettingsModal` zod URL pre-check / 휴지통 회수율 ratio 코멘트 1줄 |
|
||||
|
||||
## 3. Out of scope
|
||||
|
||||
- Telemetry 데이터 필요 (14건): #7 reason 분포 / #16 permanent_delete 빈도 / #18 loadExpired consumer / #20 telemetry .catch silent / #25 HealthChecker dedup / #28 unreachableBackoffStep / #29 top-N 튜닝값 (extract 만 본 cut, 튜닝은 v0.2.7) / #30 LIMIT-then-filter 정책 / #31 vocabSet COLLATE / #32 per-tag emit 병렬화 / #33 promptVersion payload / #35 recall_shown lifetime / #36 IPC handle vs on / #39 ollama reason PII / #40 Settings race flicker
|
||||
- 별도 brainstorm 영역 (3건): #11 restoreNote precondition / #14 ARIA 패턴 / #17 dialog 버튼 순서 / #37 NoteCard id ref-forwarding
|
||||
|
||||
## 4. Architecture changes
|
||||
|
||||
대부분 cosmetic refactor 또는 isolated bug fix. 주목할 architecture-level 변경:
|
||||
|
||||
### 4.1 KST helper 통합 (C1)
|
||||
- 신규 `src/shared/util/kstDate.ts` (main + renderer 양쪽 import 가능)
|
||||
- 기존 4 callsite 의 inline KST 계산 제거
|
||||
- API: `kstTodayIso(now?: Date): string`, `nextKstMidnightMs(now?: Date): number`
|
||||
- KST_OFFSET_MS 상수 단일
|
||||
|
||||
### 4.2 TrayCallbacks 객체화 (C2 + C3)
|
||||
- `interface TrayCallbacks` — 10+ 개 callback + state getter
|
||||
- `createTray(callbacks: TrayCallbacks): void` — 1-arg signature
|
||||
- module state (_failedCount, _todayCount, _ollamaOk) 는 TrayCallbacks 의 reactive getter / setter 패턴 또는 explicit refresh 함수 (`refreshTray(state: { todayCount, failedCount, ollamaOk })`)
|
||||
|
||||
### 4.3 Banner shared component (C7)
|
||||
- `<Banner severity="warning"|"error"|"info" icon? title? children>` — wrapping/styling 일원화
|
||||
- 5 callsite 가 themed inline style 제거 → severity prop
|
||||
- CSS variables 또는 hardcoded theme map (single source)
|
||||
|
||||
### 4.4 NoteRepository.restoreNote behavior change (B1)
|
||||
- 기존: `UPDATE notes SET deleted_at = NULL WHERE id = ?`
|
||||
- 변경: 추가로 `ai_status='failed'` 였을 경우 → `ai_status='pending'` reset + `INSERT OR IGNORE INTO pending_jobs`
|
||||
- atomic transaction
|
||||
- AiWorker 가 자동으로 다음 loop iteration 에서 처리
|
||||
|
||||
## 5. Tests
|
||||
|
||||
추정 +17 cases (413 → 430):
|
||||
|
||||
| Task | 신규 단위 |
|
||||
|---|---|
|
||||
| B1 | +3 (restore failed note re-enqueues, restore done note 영향 X, restore cancelled note 영향 X) |
|
||||
| B2 | +2 (countTrashed 정확, dialog message 정확 N) |
|
||||
| B3 | +1-2 (autostart args 비교, 가능하다면 mock electron app) |
|
||||
| B4 | +1 (additionalData hidden flag 가 second-instance 에 전달, mock test) |
|
||||
| C1 | +2 (kstTodayIso, nextKstMidnightMs) — 기존 4 callsite test 가 자동 검증 |
|
||||
| C2 | refactor only, 기존 tray 테스트 유지 |
|
||||
| C3 | refactor only |
|
||||
| C4 | refactor only |
|
||||
| C5 | +2 (hasNoteId predicate) |
|
||||
| C6 | refactor only |
|
||||
| C7 | refactor only (UI 컴포넌트 unit test X 패턴) |
|
||||
| C8 | +1 (exhaustive guard 컴파일 단계) |
|
||||
| C9 | +1 (Modal URL pre-check), 나머지 refactor only |
|
||||
|
||||
총 신규: ~13-15 (보수적). 단위 413 → **~426-428** 예상.
|
||||
|
||||
## 6. Privacy invariant
|
||||
|
||||
- B1/B2: telemetry 영향 없음
|
||||
- B3/B4: telemetry emit 없음 (autostart event 미수집)
|
||||
- C 시리즈: 모두 cosmetic refactor — invariant 영향 0
|
||||
- 본 cut 에서 신규 telemetry kind 추가 0
|
||||
|
||||
## 7. Gates (roadmap §3.1)
|
||||
|
||||
- typecheck 0
|
||||
- 단위 413 → ~427 (+13~15)
|
||||
- e2e 1/1
|
||||
- backward compat: 기존 사용자 데이터 + UI 동작 영향 0 (단 B1 은 의도적 동작 추가, B2 는 UI N 표시 정확화)
|
||||
|
||||
## 8. Risk + Fallback
|
||||
|
||||
### B3 (autostart 풀림) 진단 불확실
|
||||
가장 risky. Windows registry 디버깅 결과 깨끗한 fix 안 나올 수 있음. **Fallback 정책**:
|
||||
- 진단 절차 적용해도 fix 안 되면 → 진단 로그만 추가 (`logger.info('autostart.state', { stored, current, mismatch })`) → backlog #45 유지 → 본 cut 에서 task drop
|
||||
- 다른 task 영향 없음 (각 task 독립적)
|
||||
|
||||
### C1 KST helper 의 alias 경계
|
||||
`src/shared/util/kstDate.ts` 가 main + renderer 양쪽에서 import 되어야. 기존 `@main/util/kstDate.ts` 는 renderer 에서 import 불가 (alias 분리). `src/shared/` 가 양쪽 가능 패턴. 검증 필요.
|
||||
|
||||
### C2 TrayCallbacks 객체화 의 backward compat
|
||||
기존 createTray 호출자 (index.ts 1곳) 한 군데만 변경 → 안전. tray 테스트 영향 최소.
|
||||
|
||||
## 9. 작업 순서
|
||||
|
||||
순서대로 subagent dispatch. 의존성:
|
||||
- B1, B2: 독립
|
||||
- B3: 독립 (Windows-specific, mock 어려움)
|
||||
- B4: 독립
|
||||
- C1 → 다른 task 영향 X (shared util 추가)
|
||||
- C2 → C3 (TrayCallbacks 객체에 refreshTrayFailedCount 흡수)
|
||||
- C4, C5, C6, C7, C8, C9: 독립
|
||||
|
||||
권장 순서: **B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2 → C3 → C7 → C9**.
|
||||
|
||||
이유: B3 (위험) 을 cleanup 시작 직전에 두어 fail 시 빠르게 회피. C2/C3 cluster 는 묶어서. C7 (Banner shared) 는 isolated UI cleanup, 마지막 그룹.
|
||||
|
||||
## 10. Roadmap relation
|
||||
|
||||
- v0.2.6 정식 cut (이전 v0.2.4/v0.2.5 는 patch / hotfix)
|
||||
- 머지 후 binary 빌드 v0.2.6 (Windows + Mac) + Gitea release
|
||||
- v0.2.7 brainstorm 트리거: dogfood ≥1주 soak + telemetry export 모인 후, 잔여 backlog 14건 (data-dependent) + 신규 피드백 일괄 triage
|
||||
- backlog file 본 cut 후 prune (16 건 처리 완료 표기) + rename 검토 (`v027-backlog.md` 또는 `feature-backlog.md`)
|
||||
407
docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md
Normal file
407
docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# v0.2.7 — Cross-Platform 입구 정상화 (Design)
|
||||
|
||||
**작성일:** 2026-05-06
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F12, F14, F15, F16)
|
||||
- `docs/superpowers/v024-backlog.md` (잔여 24건)
|
||||
- `docs/superpowers/strategy/dogfood-strategy.md` (운영안)
|
||||
|
||||
**Cut 라벨:** v0.2.7 (semver 엄밀히는 MINOR — 새 플랫폼 + 새 surface — 이지만 본 프로젝트 관습상 v0.2.x 를 feature lane 으로 사용 중이므로 v0.2.7 라벨 유지)
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
**"Cross-platform 입구 정상화" cut.** F12 / F14 / F15 / F16 4개 항목을 한 묶음으로 처리. 핵심 동기:
|
||||
|
||||
> Windows 트레이 의존을 끊고 macOS / Linux 사용자에게 동등한 입구를 제공한다.
|
||||
|
||||
현재 13개 트레이 메뉴 항목이 macOS / Linux (특히 모던 GNOME) 에서 발견 / 접근성이 떨어져 핵심 설정 (Ollama endpoint, 자동 실행 등) 진입이 막히는 구조적 문제. 트레이를 deemphasis 하고 inbox 윈도우 안에 통합 설정 페이지를 둔다. 동시에 macOS dock 동작 정상화 (F14) + Linux 앱 빌드 추가 (F15 축소판) + 자동 실행 진단 노출 (F12 deeper fix) 까지 함께 처리한다.
|
||||
|
||||
**의도적으로 빠진 것:**
|
||||
|
||||
- ~~CLI (`inkling capture` 등)~~ — DB / Ollama 동시접근 race + monorepo 재구성 부담 대비 본인 dogfood metric 직접 기여 적음. v0.2.7 에서 제외. 외부 demand 누적 시 v0.3+ 재거론.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 출처 | 작업 |
|
||||
|---|---|---|
|
||||
| **F15 (축소판)** | dogfood F15 | Linux 앱 빌드 (AppImage + deb x64) + better-sqlite3 prebuild linux-x64 매트릭스 |
|
||||
| **F16** | dogfood F16 | 트레이 슬림 (13 → 4) + inbox 안 설정 페이지 (4 섹션) |
|
||||
| **F14** | dogfood F14 | macOS dock 클릭 시 hidden 창 show/focus (activate 핸들러 5줄 수정) |
|
||||
| **F12 deeper fix** | dogfood F12 (v0.2.6 진단 fallback 후속) | 설정 페이지 "자동 실행" 섹션 안에 진단 패널 노출 (withArgs vs noArgs / executableWillLaunchAtLogin / registry path) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture 변화
|
||||
|
||||
| 영역 | 현재 (v0.2.6) | v0.2.7 |
|
||||
|---|---|---|
|
||||
| 설정 진입 | 트레이 메뉴 13개 항목 | 트레이 4개 + 설정 페이지 (inbox 내부 라우트) |
|
||||
| Ollama 설정 | OllamaSettingsModal (트레이에서만 진입) | 설정 페이지 안 "AI 제공자" 섹션 (modal 흡수) |
|
||||
| 자동 실행 | 트레이 checkbox + args 명시 | 설정 페이지 안 섹션 + 진단 패널 |
|
||||
| macOS dock 클릭 | activate 핸들러 no-op (length===0 분기 못 탐) | `getInboxWindow().show() + focus()` 분기 추가 |
|
||||
| Linux 배포 | 없음 | AppImage + deb 산출물 |
|
||||
| 빌드 매트릭스 | win-x64 + mac-arm64 | + linux-x64 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 구현 순서 (Approach 2: Risk-reduction first)
|
||||
|
||||
```
|
||||
1. Linux 빌드 (가장 unknown — better-sqlite3 prebuild linux-x64 검증)
|
||||
↓ AppImage + deb 산출 + Linux VM/WSL2 smoke test
|
||||
2. 설정 페이지 (inbox 내부 라우트 + 4 섹션)
|
||||
↓ OllamaSettingsModal 흡수
|
||||
3. 트레이 슬림 (13 → 4)
|
||||
↓ 제거된 click 핸들러 → 설정 페이지 버튼으로 이동
|
||||
4. F14 macOS dock 클릭 fix
|
||||
↓ activate 핸들러 5줄
|
||||
5. F12 deeper fix (자동 실행 진단 노출)
|
||||
↓ IPC settings:autostart-state + 진단 panel UI
|
||||
```
|
||||
|
||||
Linux 빌드를 먼저 두는 이유: native ABI 트랩 (메모 `project_inkling_status.md`) 이 linux-x64 에서 재발할 수 있음. 만약 prebuild 가 깔끔히 떨어지지 않으면 v0.2.7 scope 조정 (예: AppImage 만, deb 는 v0.2.8) 여유. 설정 페이지 / 트레이 슬림 / F14 / F12 는 모두 코드 작성 risk 가 낮은 영역이라 후순위로 안전.
|
||||
|
||||
---
|
||||
|
||||
## 5. Linux 빌드 디테일
|
||||
|
||||
### 5-1. electron-builder config 추가
|
||||
|
||||
```json
|
||||
"linux": {
|
||||
"target": [
|
||||
{ "target": "AppImage", "arch": ["x64"] },
|
||||
{ "target": "deb", "arch": ["x64"] }
|
||||
],
|
||||
"category": "Utility",
|
||||
"synopsis": "로컬 메모 캡처 + AI 태그",
|
||||
"description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구."
|
||||
}
|
||||
```
|
||||
|
||||
### 5-2. npm scripts 추가
|
||||
|
||||
```json
|
||||
"predist:linux": "npm run rebuild:electron && npm run build",
|
||||
"dist:linux": "electron-builder --linux --x64"
|
||||
```
|
||||
|
||||
`rebuild:electron` 의 `--target=41.3.0` 그대로. `prebuild-install` 이 linux-x64 prebuild 를 npm 레지스트리에서 받아오는지 검증. 없으면 `node-gyp` fallback 으로 로컬 컴파일.
|
||||
|
||||
### 5-3. 빌드 호스트 전략
|
||||
|
||||
**1차: macOS 호스트** (이미 DMG 빌드 호스트). brew 로 도구 설치:
|
||||
|
||||
```bash
|
||||
brew install dpkg fakeroot
|
||||
```
|
||||
|
||||
electron-builder 가 cross-build 지원. AppImage 는 Mac 에서 직접 빌드 가능 (Linux 유저랜드 도구만 필요한 부분은 electron-builder 내장 + AppImageKit 자동 다운로드). deb 는 dpkg-deb 필요.
|
||||
|
||||
**Fallback (1차 실패 시): Docker on Mac/Windows.** `electronuserland/builder` 이미지로 Linux 빌드 환경 격리. v0.2.7 scope 안에서 결정.
|
||||
|
||||
### 5-4. Smoke test
|
||||
|
||||
`dist/` 산출물:
|
||||
|
||||
- `Inkling-0.2.7.AppImage` (x64) — Linux VM 또는 WSL2 에서 chmod +x → 실행 → 마이그레이션 통과 확인 → capture / recall 한 사이클.
|
||||
- `inkling_0.2.7_amd64.deb` — Ubuntu/Debian VM 또는 WSL2 에서 `sudo dpkg -i` → `inkling` 실행 → 동일 검증.
|
||||
|
||||
검증 항목:
|
||||
|
||||
1. better-sqlite3 native module 로드 성공 (마이그레이션 0 → m003 통과)
|
||||
2. Ollama 연결 시도 (settings.json 의 endpoint 또는 `INKLING_OLLAMA_ENDPOINT` env) — 본인 LAN 서버 `http://192.168.0.47:11434` 사용
|
||||
3. capture 한 줄 → AI 처리 → tag 표시
|
||||
4. 트레이 (KDE/Cinnamon DE 가정) 4 항목 표시
|
||||
5. 트레이 없는 DE (모던 GNOME) — launcher 에서 앱 실행 → inbox 윈도우 → 톱니바퀴 → 설정 페이지 진입
|
||||
|
||||
---
|
||||
|
||||
## 6. 설정 페이지 디테일
|
||||
|
||||
### 6-1. 라우팅 방식
|
||||
|
||||
React Router 도입 안 함 (의존성 + 학습 비용). zustand store 의 `view: 'inbox' | 'trash' | 'settings'` state + 조건부 렌더 — 기존 trash view 와 동일 패턴. 새 의존성 0.
|
||||
|
||||
### 6-2. 진입점
|
||||
|
||||
| 진입 | 동작 |
|
||||
|---|---|
|
||||
| 트레이 "설정..." 클릭 | main → IPC `inbox:navigate` 'settings' → renderer store action `setView('settings')` + inbox 윈도우 show/focus |
|
||||
| inbox 헤더 톱니바퀴 아이콘 | renderer store action `setView('settings')` |
|
||||
| 설정 페이지 안 "← 돌아가기" 버튼 | `setView('inbox')` |
|
||||
|
||||
### 6-3. 섹션 4개
|
||||
|
||||
#### 6-3-1. AI 제공자
|
||||
|
||||
흡수 대상: OllamaSettingsModal 전체 + 트레이 "Ollama 재확인".
|
||||
|
||||
UI 요소:
|
||||
|
||||
- Endpoint URL 입력 (zod 검증 — 기존 modal 의 `safeParse` 재활용)
|
||||
- Model 입력 (빈 값 guard)
|
||||
- "지금 재확인" 버튼 → ProviderHolder 의 health check trigger
|
||||
- 마지막 ping 결과 표시 (성공 시각 또는 실패 사유)
|
||||
- "기본값으로 되돌리기" 버튼
|
||||
|
||||
저장: 기존 SettingsService (atomic temp+rename + zod) 그대로.
|
||||
|
||||
#### 6-3-2. 자동 실행
|
||||
|
||||
흡수 대상: 트레이 "윈도우 시작 시 자동 실행" checkbox.
|
||||
|
||||
UI 요소:
|
||||
|
||||
- 토글 ("앱 시작 시 자동으로 실행")
|
||||
- 진단 패널 (펼치기 가능 — 평소엔 접혀 있음)
|
||||
- "재등록" 버튼 (setLoginItemSettings 강제 재호출)
|
||||
|
||||
진단 패널 디테일은 §9 (F12 deeper fix) 참조.
|
||||
|
||||
#### 6-3-3. 백업 / 복원 / 내보내기
|
||||
|
||||
흡수 대상: 트레이의 5개 항목 — "지금 백업" / "내보내기..." / "백업에서 복원..." / "지금 동기화" / "사용 로그 내보내기...".
|
||||
|
||||
UI 요소: 5개 버튼 + 각 작업 마지막 실행 시각 (가능하면) + 결과 toast.
|
||||
|
||||
IPC 핸들러는 기존 그대로 — 트레이 click 핸들러였던 함수를 IPC 핸들러로 등록 + renderer 에서 invoke.
|
||||
|
||||
#### 6-3-4. 정보
|
||||
|
||||
흡수 대상: 트레이 "Inkling 정보..." dialog.
|
||||
|
||||
UI 요소: 버전 / Electron / Node / OS / 데이터 위치 텍스트 + "데이터 위치 열기" 버튼 + "정보 복사" 버튼.
|
||||
|
||||
기존 `showAboutDialog` 의 detail 문자열 그대로 활용 — clipboard.writeText / shell.openPath 호출도 동일.
|
||||
|
||||
### 6-4. 제외 항목
|
||||
|
||||
- "지금 AI 처리 (실패 N건)" — 이미 inbox FailedBanner 가 surface. 트레이 / 설정 둘 다 제거.
|
||||
- "Ollama 재확인" 트레이 메뉴 단독 — OllamaBanner (끊김 시) + 설정 페이지 AI 섹션 "지금 재확인" 버튼이 surface. 트레이 단독 메뉴 제거.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트레이 슬림 디테일
|
||||
|
||||
### 7-1. 잔류 4개 (Win / Mac / Linux 동일)
|
||||
|
||||
```ts
|
||||
items.push({ label: '한 줄 적기', click: cb.showCapture });
|
||||
items.push({ label: '보관한 메모 보기', click: cb.showInbox });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: '설정...', click: cb.showSettings });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
|
||||
```
|
||||
|
||||
todayCount tooltip (`Inkling — 오늘 N`) 잔류. F4-C 의 "오늘 N번 잡아둠" 비활성 라벨도 잔류 (정체성 신호).
|
||||
|
||||
### 7-2. TrayCallbacks / TrayState 갱신
|
||||
|
||||
```ts
|
||||
export interface TrayCallbacks {
|
||||
showInbox: () => void;
|
||||
showCapture: () => void;
|
||||
showSettings: () => void; // NEW — IPC 'inbox:navigate' 'settings' 송출
|
||||
}
|
||||
|
||||
// 메뉴 영향 state 슬림
|
||||
export interface TrayState {
|
||||
todayCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
**제거 대상:**
|
||||
|
||||
- `runBackup`, `runExport`, `runImport`, `runSync`, `runExportTelemetry` callback (5개) → 설정 페이지 버튼으로 이동
|
||||
- `runOllamaRecheck`, `runRetryAllFailed`, `runOpenOllamaSettings` callback (3개) → 설정 페이지 또는 banner 로 이동
|
||||
- `ollamaOk`, `failedCount` state field (2개) → 트레이 메뉴 영향 사라짐 (banner 가 surface)
|
||||
- `refreshTray({ ollamaOk })`, `refreshTray({ failedCount })` 호출부 (HealthChecker, AiWorker) → 제거. todayCount 만 남음.
|
||||
|
||||
v0.2.6 의 Partial<TrayState> 패턴 그대로 활용 — 인터페이스 좁아질 뿐.
|
||||
|
||||
### 7-3. 자동 실행 토글 트레이 잔류 X
|
||||
|
||||
기존 트레이 안 checkbox (`type: 'checkbox'`) 는 제거. 설정 페이지 "자동 실행" 섹션 토글이 단일 진입점.
|
||||
|
||||
이유: 자동 실행 토글은 빈도 낮은 액션 + F12 진단이 같은 자리에 있어야 의미. 트레이 잔류 시 두 surface mismatch 위험.
|
||||
|
||||
---
|
||||
|
||||
## 8. F14 macOS dock 클릭 fix
|
||||
|
||||
[src/main/index.ts:411-413](src/main/index.ts#L411-L413) 수정:
|
||||
|
||||
```ts
|
||||
app.on('activate', () => {
|
||||
const win = getInboxWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (!win.isVisible()) win.show();
|
||||
win.focus();
|
||||
} else {
|
||||
createInboxWindow();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
second-instance 핸들러 (B4 #46) 와 패턴 일치 — 양쪽 모두 "살아있으면 show, 죽었으면 create".
|
||||
|
||||
테스트: BrowserWindow + activate 이벤트 mocking 비용 ↑ → manual dogfood 검증으로 충분 (macOS 빨간 신호등 → dock 클릭 → 즉시 창 등장).
|
||||
|
||||
---
|
||||
|
||||
## 9. F12 deeper fix — 자동 실행 진단 노출
|
||||
|
||||
### 9-1. 정보 모델
|
||||
|
||||
```ts
|
||||
// 신규 IPC: settings:autostart-state
|
||||
interface AutostartState {
|
||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
execPath: string; // process.execPath
|
||||
registryPath?: string; // Windows only
|
||||
registryValue?: string; // Windows only — null 또는 string
|
||||
}
|
||||
```
|
||||
|
||||
### 9-2. main process 핸들러
|
||||
|
||||
```ts
|
||||
ipcMain.handle('settings:autostart-state', async () => {
|
||||
const withArgs = app.getLoginItemSettings({ args: ['--hidden'] });
|
||||
const noArgs = app.getLoginItemSettings();
|
||||
const state: AutostartState = {
|
||||
withArgs: {
|
||||
openAtLogin: withArgs.openAtLogin,
|
||||
executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin
|
||||
},
|
||||
noArgs: {
|
||||
openAtLogin: noArgs.openAtLogin,
|
||||
executableWillLaunchAtLogin: noArgs.executableWillLaunchAtLogin
|
||||
},
|
||||
execPath: process.execPath
|
||||
};
|
||||
if (process.platform === 'win32') {
|
||||
state.registryPath = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling';
|
||||
state.registryValue = await readRegistryValueSilent(state.registryPath);
|
||||
}
|
||||
return state;
|
||||
});
|
||||
```
|
||||
|
||||
`readRegistryValueSilent`: `child_process.execFile('reg', ['query', path, '/v', 'Inkling'])` 1회. 실패 시 null 반환 (silent fallback — 사용자에 에러 노출 X).
|
||||
|
||||
새 dependency 추가 X (`winreg` 등 X) — built-in `child_process` + Windows `reg.exe` 만 활용.
|
||||
|
||||
### 9-3. UI
|
||||
|
||||
설정 페이지 "자동 실행" 섹션:
|
||||
|
||||
```
|
||||
[ ] 앱 시작 시 자동으로 실행
|
||||
상태: ✅ 등록됨 / ⚠️ 등록 안 됨 / ⚠️ args 미스매치
|
||||
▾ 진단 정보 (펼치기)
|
||||
- 표준 조회 (args 명시): openAtLogin=true, willLaunch=true
|
||||
- 비교 조회 (args 없이): openAtLogin=false, willLaunch=true ← mismatch ⚠️
|
||||
- 실행 파일 경로: /Applications/Inkling.app/Contents/MacOS/Inkling
|
||||
- registry 경로 (Windows): HKCU\...\Run\Inkling
|
||||
- registry 값: "C:\Users\...\Inkling.exe" --hidden
|
||||
[ 재등록 ] 버튼
|
||||
```
|
||||
|
||||
### 9-4. dogfood 시나리오
|
||||
|
||||
1. 토글 ON → 재시작 → 풀려있으면 진단 패널 펼침.
|
||||
2. withArgs vs noArgs mismatch 보임 → args canonicalization 문제 확인.
|
||||
3. registry 값 vs execPath 비교 — 다르면 path canonicalization 문제 (NSIS 재설치 시 path 바뀜).
|
||||
4. "재등록" 버튼 → setLoginItemSettings 재호출 → 다시 재시작 → 효과 측정.
|
||||
|
||||
수집된 데이터로 v0.2.8 root cause fix 작성.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 전략
|
||||
|
||||
| 영역 | 단위 | e2e | Manual dogfood |
|
||||
|---|---|---|---|
|
||||
| Linux 빌드 (F15) | - | - | AppImage + deb 산출 + Linux VM 실행 + 마이그레이션/캡처/recall 한 사이클 |
|
||||
| 설정 페이지 라우팅 | zustand store action `setView('settings')` 단위 | (선택) 트레이 "설정..." → IPC → view 전환 e2e | 실제 클릭 흐름 |
|
||||
| Ollama 섹션 흡수 | 기존 OllamaSettingsModal 단위 + 흡수 후 회귀 | - | 1회 |
|
||||
| 자동 실행 진단 IPC | autostart-state 핸들러 단위 (mock electron API + child_process) | - | Win 토글 → 재시작 → 진단 패널 mismatch 검출 |
|
||||
| 트레이 슬림 | tray.ts buildMenu 단위 (4 항목 검증 + 제거된 항목 부재) | - | - |
|
||||
| F14 dock fix | (mock 비용 ↑) — manual 만 | - | macOS dock 클릭 |
|
||||
| F12 진단 UI | mismatch 시 ⚠️ 렌더 단위 | - | F12 시나리오 재현 |
|
||||
|
||||
**목표**: 단위 426 → 약 450 (+24), e2e 1 유지 또는 +1.
|
||||
|
||||
---
|
||||
|
||||
## 11. Risk / Known unknowns
|
||||
|
||||
| Risk | 발생 시 대응 |
|
||||
|---|---|
|
||||
| linux-x64 prebuild 부재 → node-gyp 빌드 실패 | Docker `electronuserland/builder` fallback. 그래도 실패 시 v0.2.7 scope 조정: AppImage 만, deb 는 v0.2.8. [2026-05-07 검증: ✅ prebuild 존재 — electron-v145 (v41.3.0 ABI) 다운로드 성공, better_sqlite3.node 파일 생성] |
|
||||
| ELECTRON_RUN_AS_NODE 함정 (메모) 가 Linux 환경에서 재현 | smoke test launch env 에서 strip — 기존 e2e 의 strip 패턴 그대로 |
|
||||
| AppImage 가 모던 GNOME 에서 트레이 표시 안 됨 | 의도적 — 그래서 dock/launcher → inbox → 설정 페이지 흐름이 안전망. F14 fix 가 이 흐름의 핵심. |
|
||||
| 설정 페이지 라우팅이 inbox 의 keyboard shortcut / hotkey 와 충돌 | view='settings' 시 inbox-only shortcut 비활성. zustand state 분기. |
|
||||
| 자동 실행 진단 패널이 Mac/Linux 에선 의미 없는 정보 노출 | 플랫폼 분기 — Mac/Linux 는 registry 행 숨김 + executableWillLaunchAtLogin 만 표시 |
|
||||
| 트레이 callback 8개 제거 시 import 그래프에서 dead code 잔존 | 제거 후 typecheck + grep 으로 검증 |
|
||||
|
||||
---
|
||||
|
||||
### v0.2.7 Linux 빌드 1차 시도 결과 (2026-05-07, Windows 호스트)
|
||||
|
||||
`npm run dist:linux` 실행 — Windows 11 호스트.
|
||||
|
||||
**진행 단계:**
|
||||
|
||||
- ✅ predist:linux (electron rebuild + electron-vite build) — 성공
|
||||
- ✅ electron-builder linux x64 패키징 prep — 성공
|
||||
- ✅ electron-v41.3.0-linux-x64.zip 다운로드 (117 MB)
|
||||
- ✅ `dist/linux-unpacked/` 스테이징 생성 — 322 MB (electron + native modules + app)
|
||||
- ✅ appimage-12.0.1.7z 다운로드 (mksquashfs 등 Linux 유저랜드 도구 캐시)
|
||||
- ⚠️ **AppImage 패키징 실패** — `mksquashfs` 실행 불가
|
||||
- ⏭️ **deb 패키징은 시도조차 못함** (AppImage 실패로 빌드 중단)
|
||||
|
||||
**핵심 에러:**
|
||||
|
||||
```text
|
||||
⨯ cannot execute cause=exec: "C:\Users\...\electron-builder\Cache\appimage\appimage-12.0.1\linux-x64\mksquashfs": file does not exist
|
||||
⨯ failed to build AppImage error=...app-builder.exe process failed ERR_ELECTRON_BUILDER_CANNOT_EXECUTE
|
||||
Exit code: 2
|
||||
```
|
||||
|
||||
**진단:** `mksquashfs` 파일은 캐시에 존재하나 (270 KB Linux ELF 바이너리), Windows 가 ELF 를 실행 불가 → electron-builder 가 "file does not exist" 로 보고. AppImage cross-build from Windows 는 **근본적으로 불가능** (WSL/Docker/Linux/Mac 호스트 필요).
|
||||
|
||||
**결론:**
|
||||
|
||||
- AppImage: ⚠️ 실패 (Windows 호스트는 mksquashfs ELF 실행 불가 — 환경 제약)
|
||||
- deb: ⚠️ 미시도 (`dpkg-deb` + `fakeroot` 부재 추정. AppImage 실패로 도달 못함)
|
||||
|
||||
**Fallback 결정:** Mac (사용자 업무 호스트) 또는 Linux/WSL/Docker 핸드오프 필수. Windows 단독으로는 v0.2.7 Linux 산출물 생성 불가. plan Task 3 은 "시도 + 결과 기록" 이 핵심이었고, **macOS 후속 시도가 본 빌드** — Windows 시도는 환경 한계 확인용.
|
||||
|
||||
**권장 후속:**
|
||||
|
||||
1. 사용자 macOS 업무 호스트에서 동일 명령 (`npm run dist:linux`) 재시도. brew 로 `dpkg` + `fakeroot` 사전 설치 (`brew install dpkg`). AppImage 는 macOS 에서 정상 cross-build 가능 (mksquashfs Mach-O 바이너리 caching).
|
||||
2. macOS 에서도 deb 가 실패할 경우 — v0.2.7 scope 를 **AppImage only** 로 축소, deb 는 v0.2.8 또는 Docker `electronuserland/builder` 환경으로 이동. package.json `linux.target` 에서 deb 제거하거나 별도 task 로 분리.
|
||||
3. 향후 자동화: GitHub Actions / Gitea Actions 에서 ubuntu-latest runner 로 Linux build 자동화 (현 수동 cross-build 환경 의존성 제거).
|
||||
|
||||
---
|
||||
|
||||
## 12. v0.2.7 후
|
||||
|
||||
**잔여 backlog (24건)** — `docs/superpowers/v024-backlog.md`:
|
||||
|
||||
- v0.2.6 final reviewer minor cleanup 6건 — kstDate 의미 정정 / NoteRepository.test.ts as any / store.ts trashCount race 등
|
||||
- telemetry data-dependent 14건 — VOCAB_TOP_N 튜닝, recallBanner 임계값 등 — v0.2.8 후보 (v0.2.7 dogfood soak ≥1주 후 telemetry export 누적)
|
||||
- v0.2.7 본 cut 안 신규 발견 — F12 root cause 가 진단 데이터 누적 후 결정될 가능성
|
||||
|
||||
**v0.2.8 트리거**: v0.2.7 release 후 dogfood ≥1주 soak + telemetry export + F12 진단 데이터 → v0.2.8 brainstorm.
|
||||
206
docs/superpowers/specs/2026-05-09-v0210-cut-c-design.md
Normal file
206
docs/superpowers/specs/2026-05-09-v0210-cut-c-design.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# v0.2.10 — Cut C Design (raw_text 수정 + revision history)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F20)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut C
|
||||
|
||||
**Cut 라벨:** v0.2.10 — load-bearing invariant 변경 (raw_text 불변 폐기 + revision history). semver 엄밀히 minor 이지만 v0.2.x 관습.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
메모리 정책 `raw_text 불변` invariant 폐기 + 변경 이력 (revision) 보존. 사용자가 raw_text 자유 수정 + 옛 버전 회수 가능.
|
||||
|
||||
**load-bearing 정책 변경**:
|
||||
|
||||
- 옛: `raw_text 불변` (capture 시점 원본 영구 보존)
|
||||
- 새: `raw_text 가변` + `note_revisions 테이블` (옛 버전 모두 보존, rollback 가능)
|
||||
|
||||
이는 F1 / F4 / F17 / F19 의 raw_text 가정에 영향 — 모두 current latest raw_text 기준으로 동작 (시간 경과 시 정정된 값 사용).
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F20** | C 옵션 — raw_text 수정 허용 + `note_revisions` 테이블 + 옛 버전 회수 UI. AI 재실행 input = current latest raw_text (B 옵션). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema 마이그레이션 (m006)
|
||||
|
||||
> 메모: 본 스펙 작성 시점에는 m005 로 예상했으나 Cut B (v0.2.9) 에서 m005 (ai_disabled CHECK relax) 가 선점됨 → 실제 번호는 **m006**.
|
||||
|
||||
```sql
|
||||
CREATE TABLE note_revisions (
|
||||
rev_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL,
|
||||
edited_at TEXT NOT NULL,
|
||||
edited_by TEXT NOT NULL DEFAULT 'user', -- 'user' or 'capture' (최초 캡처)
|
||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC);
|
||||
|
||||
-- 기존 notes 의 모든 raw_text 를 첫 revision 으로 backfill
|
||||
INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
SELECT id, raw_text, created_at, 'capture' FROM notes;
|
||||
```
|
||||
|
||||
`note_revisions.rev_id = AUTOINCREMENT` — chronological 순서 보장. `edited_by` = 'user' (사용자 정정) 또는 'capture' (최초).
|
||||
|
||||
`notes.raw_text` 컬럼 그대로 — current latest 값. 검색 인덱스 (F19 FTS5) 가 이걸 source 로 사용 → revision 검색 X (latest only). YAGNI.
|
||||
|
||||
---
|
||||
|
||||
## 4. NoteRepository 메서드
|
||||
|
||||
```ts
|
||||
class NoteRepository {
|
||||
// 기존
|
||||
insert(input: ...): Note; // 내부에서 note_revisions INSERT (edited_by='capture')
|
||||
|
||||
// 신규
|
||||
updateRawText(id: string, newText: string, now: Date): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db.prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`).run(newText, now.toISOString(), id);
|
||||
this.db.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) VALUES (?, ?, ?, 'user')`).run(id, newText, now.toISOString());
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
listRevisions(id: string): NoteRevision[] {
|
||||
return this.db.prepare(`SELECT * FROM note_revisions WHERE note_id=? ORDER BY edited_at DESC`).all(id) as NoteRevision[];
|
||||
}
|
||||
|
||||
restoreRevision(id: string, revId: number, now: Date): void {
|
||||
const rev = this.db.prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`).get(revId, id) as { raw_text: string } | undefined;
|
||||
if (!rev) throw new Error(`revision ${revId} not found`);
|
||||
this.updateRawText(id, rev.raw_text, now); // 새 revision 으로 복원 (linear history 유지)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`restoreRevision` 은 옛 raw_text 를 **새 revision** 으로 INSERT — chain 끊지 않고 latest = restored. timestamp/순서 명확.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI — NoteCard 수정 흐름
|
||||
|
||||
### 5-1. raw_text 편집 UI
|
||||
|
||||
기존 NoteCard 의 "원문 보기" 펼침 → 추가 "편집" 버튼:
|
||||
|
||||
```tsx
|
||||
{rawOpen && (
|
||||
<div>
|
||||
{editingRaw ? (
|
||||
<>
|
||||
<textarea value={draftRaw} onChange={e => setDraftRaw(e.target.value)} />
|
||||
<button onClick={onSaveRaw}>저장</button>
|
||||
<button onClick={() => setEditingRaw(false)}>취소</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<pre>{local.rawText}</pre>
|
||||
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }}>편집</button>
|
||||
<button onClick={() => setShowRevisions(true)}>이력</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 5-2. Revision 회수 UI
|
||||
|
||||
"이력" 클릭 → modal 또는 확장 panel:
|
||||
|
||||
```
|
||||
이력 (3 buah)
|
||||
[2026-05-12 14:30 사용자] 본문... [회수]
|
||||
[2026-05-10 09:15 사용자] 옛 본문... [회수]
|
||||
[2026-05-09 11:00 캡처] 최초 캡처 본문... [회수]
|
||||
```
|
||||
|
||||
회수 클릭 → confirm dialog ("이 버전으로 되돌릴까요? 현재 본문도 이력에 보존됩니다.") → `restoreRevision()` 호출.
|
||||
|
||||
---
|
||||
|
||||
## 6. AI 재실행 정책
|
||||
|
||||
**입력 = current notes.raw_text (latest)**. 옛 revision 은 AI 재실행 input X. 정책 일관 (사용자 정정 의도 반영).
|
||||
|
||||
`AiWorker` 의 input 추출 코드는 변경 없음 — `notes.raw_text` 그대로 사용.
|
||||
|
||||
---
|
||||
|
||||
## 7. F1 (Due Date) / F4 (Aha Moment) / F17 / F19 영향
|
||||
|
||||
| 영역 | 영향 |
|
||||
|---|---|
|
||||
| F1 Due Date 파서 | input = current raw_text. 사용자 정정 후 due 갱신 가능 — 정책 충실 (수정 시 의도 반영) |
|
||||
| F4 Aha Moment | capture 카운트 = notes 갯수. revision 갯수 무관 |
|
||||
| F17 status | 영향 X (raw_text 수정과 status 분기 독립) |
|
||||
| F19 search FTS5 | 인덱스 source = notes.raw_text (latest). revision 검색 미지원. 향후 cut 에서 옵션 |
|
||||
|
||||
---
|
||||
|
||||
## 8. IPC + types
|
||||
|
||||
```ts
|
||||
// 신규
|
||||
'inbox:update-raw-text': (id: string, newText: string) => Promise<{ ok: true }>
|
||||
'inbox:list-revisions': (id: string) => Promise<NoteRevision[]>
|
||||
'inbox:restore-revision': (id: string, revId: number) => Promise<{ ok: true }>
|
||||
|
||||
interface NoteRevision {
|
||||
revId: number;
|
||||
noteId: string;
|
||||
rawText: string;
|
||||
editedAt: string;
|
||||
editedBy: 'user' | 'capture';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| m006 마이그레이션 | 기존 notes → revision backfill (edited_by='capture') |
|
||||
| `updateRawText` | notes.raw_text 갱신 + 새 revision INSERT atomic |
|
||||
| `listRevisions` | DESC 순 + edited_by 정확 |
|
||||
| `restoreRevision` | 옛 raw_text 가 새 revision 으로 INSERT + notes.raw_text 갱신 |
|
||||
| 편집 UI | textarea 입력 + 저장 → IPC 호출 + store 갱신 |
|
||||
| 이력 modal | revision 목록 표시 + 회수 클릭 → confirm + IPC |
|
||||
| AiWorker input | current notes.raw_text 사용 (revision X) 회귀 |
|
||||
|
||||
**목표**: 단위 548 → 약 567 (+19, m006 5 + create rev 1 + updateRawText 2 + listRevisions 1 + restoreRevision 2 + IPC 4 + NoteCard 편집 1 + RevisionHistoryModal 2 + findById 회귀 1), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| revision 무한 누적 (메모 1개당 100+ revision 시 DB bloat) | 향후 cut 에서 N개 cap 정책 (예: 최근 50개만 보존). 본 cut 은 unlimited |
|
||||
| 사용자가 실수로 옛 revision 회수 | confirm dialog 강제 |
|
||||
| F1 Due Date 가 raw_text 변경 시 재추출 안 함 | 별도 cut. 본 cut 은 raw_text 갱신 + 기존 due 잔류 (사용자 의도 보존) |
|
||||
| 메모리 정책 갱신 필수 | `project_inkling_status.md` 의 load-bearing invariant 갱신 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 메모리 정책 갱신 (Cut C 머지 후 필수)
|
||||
|
||||
`raw_text 불변` → `raw_text 가변 + revision 보존`. 메모 갱신:
|
||||
|
||||
```
|
||||
- ~~raw_text 불변~~ → raw_text 가변 (사용자 편집 가능, note_revisions 테이블에 변경 이력 보존)
|
||||
- AI 재실행 input = current latest raw_text (옛 revision X)
|
||||
```
|
||||
297
docs/superpowers/specs/2026-05-09-v0211-cut-d-design.md
Normal file
297
docs/superpowers/specs/2026-05-09-v0211-cut-d-design.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# v0.2.11 — Cut D Design (FTS5 search + 회고 view)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F19)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut D
|
||||
|
||||
**Cut 라벨:** v0.2.11
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
recall 핵심 가치 도달 — search + 회고 view. F19 의 6 옵션 중 **A (FTS5 search) + D (회고 view)** 2개. B/C/E/F 는 v0.3+ deferred.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F19-A** | SQLite FTS5 인덱스 + inbox 헤더 search box |
|
||||
| **F19-D** | 일/주/월 회고 라우트 — aggregate query + N건 list + tag distribution + due 진행 |
|
||||
|
||||
---
|
||||
|
||||
## 3. F19-A 디테일 (FTS5)
|
||||
|
||||
### 3-1. Schema 마이그레이션 (m007)
|
||||
|
||||
> 메모: 본 스펙 작성 시점에는 m006 로 예상했으나 Cut C (v0.2.10) 에서 m006 (note_revisions) 가 선점됨 → 실제 번호는 **m007**.
|
||||
|
||||
실제 schema 정정:
|
||||
- `notes.title`/`notes.summary` 컬럼 없음 → 실제 `notes.ai_title`/`notes.ai_summary` 사용
|
||||
- `notes.tags_csv` 컬럼 없음 → tags 는 `note_tags` join (note_tags.note_id ↔ tags.id)
|
||||
- `notes.status` (Cut B m004 도입) 사용 가능 — `status != 'trashed'` 필터
|
||||
|
||||
```sql
|
||||
CREATE VIRTUAL TABLE notes_fts USING fts5(
|
||||
note_id UNINDEXED,
|
||||
raw_text,
|
||||
ai_title,
|
||||
ai_summary,
|
||||
tags,
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
-- 기존 notes (active/completed/archived 만 — trashed 제외) 모두 인덱스.
|
||||
-- tags 는 note_tags+tags JOIN 후 GROUP_CONCAT 으로 csv 구성.
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
SELECT
|
||||
n.id,
|
||||
n.raw_text,
|
||||
COALESCE(n.ai_title, ''),
|
||||
COALESCE(n.ai_summary, ''),
|
||||
COALESCE((SELECT GROUP_CONCAT(t.name, ' ')
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = n.id), '')
|
||||
FROM notes n
|
||||
WHERE n.status != 'trashed';
|
||||
```
|
||||
|
||||
`tokenize='unicode61'` — 한국어 partial tokenize 가능 (단어 boundary). 향후 `tokenize='porter unicode61'` 또는 한국어 전용 tokenizer (예: `mecab-ko-fts5`) 검토 가능 — Cut D 는 unicode61 default.
|
||||
|
||||
`tags` 컬럼 = note_tags JOIN 결과 csv (예: `"기획 회의 결재"`). `note_tags` 변경 시 NoteRepository 에서 명시적 헬퍼 (`rebuildFtsTagsForNote(noteId)`) 호출 — trigger 로 sync 어려움 (`note_tags` INSERT/DELETE 가 다른 노트 row 재계산 트리거하기 부담). 단일 write path 패턴 (Cut C 확립) 으로 강제.
|
||||
|
||||
### 3-2. Trigger — auto-sync (notes 컬럼 한정)
|
||||
|
||||
`notes` INSERT/UPDATE/DELETE 시 `notes_fts` 자동 sync (raw_text/ai_title/ai_summary 만; tags 는 별도 헬퍼):
|
||||
|
||||
```sql
|
||||
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), '');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
|
||||
DELETE FROM notes_fts WHERE note_id = OLD.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
|
||||
UPDATE notes_fts
|
||||
SET raw_text = NEW.raw_text,
|
||||
ai_title = COALESCE(NEW.ai_title, ''),
|
||||
ai_summary = COALESCE(NEW.ai_summary, '')
|
||||
WHERE note_id = NEW.id;
|
||||
END;
|
||||
```
|
||||
|
||||
Cut C 의 `updateRawText` 가 `notes.raw_text` UPDATE → trigger 자동 발동 → FTS5 갱신.
|
||||
|
||||
`tags` 갱신 path:
|
||||
- `NoteRepository.updateAiResult` (AI tags) / `updateUserAiFields` (사용자 tags) 모두 `note_tags` 변경 후 동일 transaction 안에서 `rebuildFtsTagsForNote(noteId)` 호출.
|
||||
|
||||
trashed 노트 처리 — `setStatus(id, 'trashed', ...)` 시 trigger AFTER UPDATE 발동되어 FTS row 가 그대로 유지됨. 검색 시 query 단계에서 `n.status != 'trashed'` 필터로 제외 (별도 FTS row cleanup 안 함 — YAGNI).
|
||||
|
||||
### 3-3. NoteRepository.search
|
||||
|
||||
```ts
|
||||
search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] {
|
||||
if (query.trim().length === 0) return [];
|
||||
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
|
||||
const ftsQuery = sanitizeFtsQuery(query); // FTS5 special char escape
|
||||
const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`;
|
||||
const sql = `
|
||||
SELECT n.* FROM notes n
|
||||
JOIN notes_fts f ON n.id = f.note_id
|
||||
WHERE notes_fts MATCH ? ${statusClause}
|
||||
ORDER BY rank LIMIT ?
|
||||
`;
|
||||
const args = opts.status ? [ftsQuery, opts.status, limit] : [ftsQuery, limit];
|
||||
const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
```
|
||||
|
||||
`hydrate` — 기존 패턴 (tags + media join). `sanitizeFtsQuery` — FTS5 special chars (`"`, `*`, `(`, `)`, `:`) 이스케이프 및 multi-word AND 결합 (예: `기획 회의` → `"기획" AND "회의"` 또는 `기획 회의` 그대로 수용). YAGNI: 다중 토큰을 그대로 FTS5 implicit AND 로 보냄 + 따옴표 제거.
|
||||
|
||||
`status` 미지정 시 default = trashed 제외.
|
||||
|
||||
`MATCH` 쿼리 syntax — FTS5 standard (`"기획 회의"`, `회의 OR 결재`, `기획*` 등).
|
||||
|
||||
### 3-4. UI — inbox 헤더 search box
|
||||
|
||||
기존 헤더 (Inbox/완료/보관/휴지통 탭) 옆에 search input:
|
||||
|
||||
```tsx
|
||||
<input
|
||||
type="search"
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{ ... }}
|
||||
/>
|
||||
```
|
||||
|
||||
debounce 200ms → store action `searchNotes(query)` → `inboxApi.search(query, { status: currentView })` → result list 갱신.
|
||||
|
||||
빈 query → 기본 inbox list 복귀.
|
||||
|
||||
### 3-5. IPC
|
||||
|
||||
```ts
|
||||
'inbox:search': (query: string, opts: { status?: NoteStatus; limit?: number }) => Promise<Note[]>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. F19-D 디테일 (회고 view)
|
||||
|
||||
### 4-1. 라우트 추가
|
||||
|
||||
`useInbox.view` enum 에 `'review-daily' | 'review-weekly' | 'review-monthly'` 추가. 진입점:
|
||||
|
||||
- 헤더 메뉴: "📅 회고" 버튼 → 드롭다운 (일/주/월)
|
||||
- 또는 별도 라우트 (Settings 옆)
|
||||
|
||||
### 4-2. 회고 view 컴포넌트
|
||||
|
||||
```tsx
|
||||
// src/renderer/inbox/components/ReviewView.tsx
|
||||
export function ReviewView({ period }: { period: 'daily' | 'weekly' | 'monthly' }): ReactElement {
|
||||
const data = useReviewData(period); // store action — aggregate query 결과
|
||||
return (
|
||||
<div>
|
||||
<h2>{periodLabel(period)} 회고</h2>
|
||||
<div>총 N건 • 오늘 N건 • 평균 일 N건</div>
|
||||
<TagDistributionChart tags={data.tagCounts} />
|
||||
<DueProgressChart due={data.dueProgress} />
|
||||
<NoteList notes={data.recentNotes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4-3. Aggregate query
|
||||
|
||||
NoteRepository:
|
||||
|
||||
```ts
|
||||
reviewAggregate(period: 'daily' | 'weekly' | 'monthly', now: Date = new Date()): {
|
||||
totalCount: number;
|
||||
recentNotes: Note[];
|
||||
tagCounts: Array<{ tag: string; count: number }>;
|
||||
dueProgress: { total: number; passed: number; pending: number };
|
||||
} {
|
||||
const cutoff = computeCutoff(period, now); // ISO string — KST 자정 / 7일전 / 30일전
|
||||
const todayIso = kstTodayIso(now); // YYYY-MM-DD
|
||||
const totalCount = (this.db
|
||||
.prepare(`SELECT COUNT(*) as c FROM notes WHERE created_at >= ? AND status != 'trashed'`)
|
||||
.get(cutoff) as { c: number }).c;
|
||||
const recentRows = this.db
|
||||
.prepare(`SELECT * FROM notes WHERE created_at >= ? AND status != 'trashed'
|
||||
ORDER BY created_at DESC, id DESC LIMIT 50`)
|
||||
.all(cutoff) as Record<string, unknown>[];
|
||||
const recentNotes = recentRows.map((r) => this.hydrate(r));
|
||||
// tag counts via note_tags JOIN — period 안 노트의 태그만 집계
|
||||
const tagCounts = this.db
|
||||
.prepare(`SELECT t.name AS tag, COUNT(*) AS count
|
||||
FROM note_tags nt
|
||||
JOIN notes n ON n.id = nt.note_id
|
||||
JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE n.created_at >= ? AND n.status != 'trashed'
|
||||
GROUP BY t.id
|
||||
ORDER BY count DESC, t.name ASC`)
|
||||
.all(cutoff) as Array<{ tag: string; count: number }>;
|
||||
// due progress — period 안 created 노트 중 due_date 가 있는 것
|
||||
const dueRow = this.db
|
||||
.prepare(`SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed,
|
||||
SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending
|
||||
FROM notes
|
||||
WHERE created_at >= ?
|
||||
AND status != 'trashed'
|
||||
AND due_date IS NOT NULL`)
|
||||
.get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null };
|
||||
const dueProgress = {
|
||||
total: dueRow.total,
|
||||
passed: dueRow.passed ?? 0,
|
||||
pending: dueRow.pending ?? 0
|
||||
};
|
||||
return { totalCount, recentNotes, tagCounts, dueProgress };
|
||||
}
|
||||
```
|
||||
|
||||
`computeCutoff('daily', now)` = KST 자정 (오늘 시작) ISO. `'weekly'` = 7일 전 KST 자정 ISO. `'monthly'` = 30일 전 KST 자정 ISO. `kstTodayIso` 는 `src/shared/util/kstDate.ts` 에 이미 존재 (Cut B 활용).
|
||||
|
||||
period 별 query 는 동일 transaction 으로 wrap 해도 되나, read-only + 단일 호출이라 단순 sequential 호출로 충분 (better-sqlite3 동기 API).
|
||||
|
||||
### 4-4. Tag distribution chart
|
||||
|
||||
간단한 bar list (CSS — chart 라이브러리 X):
|
||||
|
||||
```tsx
|
||||
{data.tagCounts.slice(0, 10).map(t => (
|
||||
<div key={t.tag}>
|
||||
<span>{t.tag}</span>
|
||||
<div style={{ width: `${(t.count / max) * 100}%`, background: '#4ec5b8', height: 8 }} />
|
||||
<span>{t.count}</span>
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
### 4-5. Due progress
|
||||
|
||||
```
|
||||
완료 (passed): 12 / 25
|
||||
대기 (pending): 13
|
||||
이번 주 due: 3건
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| m007 마이그레이션 | FTS5 virtual table + trigger 3개 + 기존 notes backfill (status != 'trashed' + tags JOIN) |
|
||||
| Trigger sync | INSERT/UPDATE/DELETE → notes_fts 자동 sync (raw_text/ai_title/ai_summary) |
|
||||
| `rebuildFtsTagsForNote` 헬퍼 | note_tags 변경 후 FTS tags 컬럼 재구성 |
|
||||
| `updateAiResult` / `updateUserAiFields` | tags 변경 path 가 헬퍼 호출하여 FTS sync (회귀) |
|
||||
| `updateRawText` (Cut C) FTS sync 회귀 | trigger 자동 발동 검증 |
|
||||
| `search` | 한국어 token 매칭 + status filter + trashed 기본 제외 + 빈 query → [] |
|
||||
| `sanitizeFtsQuery` | FTS5 special char 이스케이프 + multi-word 통과 |
|
||||
| inbox header search box | debounce + 빈 값 → 기본 list 복귀 |
|
||||
| ReviewView 단위 | aggregate query 결과 렌더 + period 라벨 |
|
||||
| `reviewAggregate` | period 별 cutoff 정확 + tag count + due progress (passed/pending KST 비교) |
|
||||
| `computeCutoff` | daily/weekly/monthly KST 자정 ISO |
|
||||
|
||||
**목표**: 단위 569 → 약 595 (+26), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| FTS5 한국어 token 정확도 (unicode61 가 word boundary 부정확) | dogfood 검증. 부족 시 v0.3+ 에서 mecab-ko 또는 trigram tokenize 검토 |
|
||||
| FTS5 인덱스 size (notes 수만건 시 DB 크기 ↑) | 수만건 도달 전엔 무시. v0.3+ 에서 prune 또는 partial 인덱스 |
|
||||
| 회고 aggregate query latency | LIMIT 50 + index 활용 (`created_at DESC`). 수만건도 sub-second 예상 |
|
||||
| Cut C revision 추가 시 FTS 영향 | revision 은 인덱스 X (latest only). `notes` AFTER UPDATE trigger 가 raw_text 변경 자동 반영 |
|
||||
| `note_tags` 변경 누락 시 FTS tags stale | NoteRepository 의 tags 변경 path 모두에서 `rebuildFtsTagsForNote` 명시 호출 — single write path 패턴 강제 |
|
||||
| FTS5 special char crash | `sanitizeFtsQuery` 에서 `"`/`*`/`(`/`)`/`:` 이스케이프 또는 제거 |
|
||||
|
||||
---
|
||||
|
||||
## 7. v0.2.11 후
|
||||
|
||||
**Cut E** (v0.3.0) — F21 양방향 sync.
|
||||
|
||||
**dogfood verify**:
|
||||
|
||||
1. search 일 사용 빈도 (가설: ≥ 일 1회면 가치 있음)
|
||||
2. 회고 view 사용 빈도 (월요일 자동 prompt 추가 검토 — v0.3+)
|
||||
3. FTS5 한국어 token 정확도 (사용자 query 결과 만족도)
|
||||
226
docs/superpowers/specs/2026-05-09-v028-cut-a-design.md
Normal file
226
docs/superpowers/specs/2026-05-09-v028-cut-a-design.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# v0.2.8 — Cut A Design (이미지 렌더링 + 앱 아이콘)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F22)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` (Cut A 분할 + 우선순위)
|
||||
|
||||
**Cut 라벨:** v0.2.8 (semver patch — bug fix + asset 추가)
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
**"이미지 렌더링 + 앱 아이콘 polish" cut.** 두 작은 항목 묶음:
|
||||
|
||||
- **F22 (이미지 렌더링)**: NoteCard 의 회색 placeholder div 를 실제 `<img>` 로 교체. Electron renderer 가 raw `file://` 직접 접근 어려운 보안 정책 우회 — `inkling-media://` custom protocol 등록.
|
||||
- **chore (앱 아이콘)**: 사용자 첨부 SVG (이미 `assets/icon.svg` 작성·검토 완료) → ICO/ICNS/PNG 다중 size 자동 생성 + electron-builder config 통합.
|
||||
|
||||
명확/작은 작업, 의사결정 거의 없음. 빠른 release polish.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 출처 | 작업 |
|
||||
|---|---|---|
|
||||
| **F22** | dogfood F22 | `inkling-media://` protocol + NoteCard `<img>` + 클릭 시 OS viewer (`shell.openPath`) |
|
||||
| **chore** | roadmap | `electron-icon-builder` devDep + `npm run build:icons` + electron-builder config (`build.win.icon` / `build.mac.icon` / `build.linux.icon`) |
|
||||
|
||||
---
|
||||
|
||||
## 3. F22 디테일
|
||||
|
||||
### 3-1. Custom protocol 등록
|
||||
|
||||
`src/main/index.ts` 의 `whenReady` **이전** (top-level) 에 scheme 권한 등록:
|
||||
|
||||
```ts
|
||||
import { protocol } from 'electron';
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
|
||||
]);
|
||||
```
|
||||
|
||||
`whenReady` 안에서 handler 등록:
|
||||
|
||||
```ts
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join, normalize, sep } from 'node:path';
|
||||
|
||||
protocol.handle('inkling-media', async (req) => {
|
||||
const url = new URL(req.url);
|
||||
const relPath = decodeURIComponent(url.pathname).replace(/^\//, '');
|
||||
const mediaRoot = join(paths.profileDir, 'media');
|
||||
const target = normalize(join(mediaRoot, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep)) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const data = await fs.readFile(target);
|
||||
return new Response(data, { headers: { 'content-type': inferMime(target) } });
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
`inferMime()` — 파일 확장자 → MIME (png/jpg/jpeg/gif/webp). 작은 함수 (별도 util 또는 inline).
|
||||
|
||||
### 3-2. NoteCard 갱신
|
||||
|
||||
[src/renderer/inbox/components/NoteCard.tsx:336-338](src/renderer/inbox/components/NoteCard.tsx#L336-L338) 의 회색 div 를 `<img>` 로 교체:
|
||||
|
||||
```tsx
|
||||
{local.media.map((m) => (
|
||||
<img
|
||||
key={m.id}
|
||||
src={`inkling-media://${m.relPath}`}
|
||||
alt=""
|
||||
title={m.relPath}
|
||||
onClick={() => inboxApi.openMedia(m.relPath)}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
`m.relPath` 형식 = `media/<noteId>/<filename>`. URL 형식: `inkling-media://media/<noteId>/<filename>`. handler 가 prefix 제거 후 `<profileDir>/media/<noteId>/<filename>` 으로 resolve.
|
||||
|
||||
### 3-3. IPC `inbox:open-media`
|
||||
|
||||
`src/main/ipc/inboxApi.ts` 에 신규 핸들러:
|
||||
|
||||
```ts
|
||||
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
|
||||
const mediaRoot = join(paths.profileDir, 'media');
|
||||
const target = normalize(join(mediaRoot, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep)) return { ok: false, reason: 'invalid path' };
|
||||
await shell.openPath(target);
|
||||
return { ok: true };
|
||||
});
|
||||
```
|
||||
|
||||
preload 화이트리스트 + `src/shared/types.ts` `InboxApi.openMedia(relPath: string)` 시그니처 + `src/renderer/inbox/api.ts` wrapper.
|
||||
|
||||
### 3-4. 보안 검토
|
||||
|
||||
- **Path traversal**: protocol handler + IPC 핸들러 모두 `target.startsWith(mediaRoot + sep)` 검사. 통과 못 하면 403/실패.
|
||||
- **Schemes privileges**: `secure: true` 로 https 동등 권한 — webContents 가 페이지 안에서 `<img src="inkling-media://...">` 정상 로드.
|
||||
- **CORS**: same-origin 정책 영향 X (custom protocol 이라 별도). webContents 안 동일 origin 으로 인식.
|
||||
- **인증**: 단일 사용자 desktop app — 추가 인증 X.
|
||||
|
||||
---
|
||||
|
||||
## 4. chore 디테일
|
||||
|
||||
### 4-1. 의존성 + scripts
|
||||
|
||||
`package.json`:
|
||||
|
||||
```json
|
||||
"devDependencies": {
|
||||
"electron-icon-builder": "^2.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build:icons": "electron-icon-builder --input=assets/icon.svg --output=build --flatten"
|
||||
}
|
||||
```
|
||||
|
||||
`--flatten` 옵션 = output 을 `build/icon.ico`, `build/icon.icns`, `build/icon.png` (1024x1024) 평면 배치. nested `build/icons/png/<size>.png` 도 함께.
|
||||
|
||||
### 4-2. electron-builder config
|
||||
|
||||
`package.json` 의 `build` 블록 갱신:
|
||||
|
||||
```json
|
||||
"win": { "icon": "build/icon.ico", ... },
|
||||
"mac": { "icon": "build/icon.icns", ... },
|
||||
"linux": { "icon": "build/icon.png", ..., "target": [ ... ] }
|
||||
```
|
||||
|
||||
기존 win/mac/linux 블록에 `"icon"` 키만 추가 (다른 설정 그대로).
|
||||
|
||||
### 4-3. 산출물 git 추적
|
||||
|
||||
`build/` 가 `.gitignore` 에 있다면 — 두 옵션:
|
||||
|
||||
- (a) **`build/icon.*` 만 ignore 풀고 commit** (size 약 200KB-1MB 작음 — 바이너리 commit 일반적). SVG 갱신 시 `npm run build:icons` 후 commit.
|
||||
- (b) **모두 ignore 유지** + `prebuild` script 등으로 빌드 시 매번 재생성. dist 빌드 시 자동 — 그러나 dev 환경 (npm start) 에서 아이콘 미생성 시 fallback 필요.
|
||||
|
||||
추천: **(a)** — 단순, 빌드 시간 ↓, dev 환경 문제 X.
|
||||
|
||||
`.gitignore` 갱신 예:
|
||||
|
||||
```
|
||||
build/
|
||||
!build/icon.ico
|
||||
!build/icon.icns
|
||||
!build/icon.png
|
||||
```
|
||||
|
||||
### 4-4. SVG 가 input 으로 바로 가능?
|
||||
|
||||
`electron-icon-builder` v2.0.1 docs 검토 — PNG 1024x1024 입력 권장, SVG 는 `librsvg` 등 의존. SVG 직접 안 되면 `sharp` 로 SVG → PNG 1024 변환 후 input.
|
||||
|
||||
대안 (SVG 직접 안 될 시):
|
||||
|
||||
```json
|
||||
"build:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024",
|
||||
"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten"
|
||||
```
|
||||
|
||||
`scripts/svg-to-png.mjs` — `sharp` 활용 ~10줄 스크립트.
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 전략
|
||||
|
||||
| 영역 | 단위 | 수동 |
|
||||
|---|---|---|
|
||||
| protocol handler — path traversal | mock fs + URL 입력 (`../etc/passwd` 형태) → 403 | - |
|
||||
| protocol handler — 정상 200 | mock fs.readFile → bytes + content-type 검증 | - |
|
||||
| protocol handler — 404 | fs.readFile reject → 404 | - |
|
||||
| `inferMime` | 확장자별 정확 mapping | - |
|
||||
| NoteCard `<img>` 렌더 | media 배열 길이 N → `<img>` N 개 (jsdom mock) | - |
|
||||
| `<img>` 클릭 → IPC | onClick stub → `inboxApi.openMedia` 호출 | - |
|
||||
| IPC `inbox:open-media` | path traversal mock → 'invalid path' 반환 | - |
|
||||
| 아이콘 빌드 | - | `npm run build:icons` → `build/icon.ico` `build/icon.icns` `build/icon.png` 존재 확인 |
|
||||
| Win exe 아이콘 | - | `npm run dist:win` → `Inkling Setup 0.2.8.exe` 우클릭 → properties → 아이콘 = 새 디자인 |
|
||||
| dogfood image flow | - | inbox 의 thumbnail 클릭 → OS viewer 열림 (Win + macOS) |
|
||||
|
||||
**목표**: 단위 460 → 약 467 (+7), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risk + Known unknowns
|
||||
|
||||
| Risk | 발생 시 대응 |
|
||||
|---|---|
|
||||
| `electron-icon-builder` SVG 직접 미지원 | `sharp` 로 SVG → PNG 1024 변환 (4-4 대안 적용) |
|
||||
| `protocol.handle` 가 Electron 41 미지원 (deprecated `protocol.registerFileProtocol` 만 있는 경우) | Electron 41 docs 확인 후 deprecated API 사용 또는 newer API |
|
||||
| `<img>` 가 inkling-media:// 로드 실패 (CSP 차단 등) | webContents 의 contentSecurityPolicy 검토. v0.2.5/6 의 single-instance lock + B4 #46 hidden flag 와 무관 |
|
||||
| Win/Mac dogfood 시 OS viewer 가 default 미설정 | 사용자 OS settings — Inkling 책임 외 (그러나 안내 메시지 가능) |
|
||||
|
||||
---
|
||||
|
||||
## 7. v0.2.8 후
|
||||
|
||||
**다음**: Cut B (v0.2.9) — F17 status 분기 + F18 사유 + F23 Ollama-less. 데이터 모델 정비 cut.
|
||||
|
||||
**Cut A 머지 후 dogfood verify 항목**:
|
||||
|
||||
1. inbox 의 capture-with-image 흐름 — 캡처 → 이미지 thumbnail 표시 → 클릭 → OS viewer
|
||||
2. 새 아이콘이 트레이 / Windows taskbar / dock 모두 정확 표시
|
||||
3. 다중 이미지 (capture 가 N개 첨부) 의 grid layout — flex-wrap 적용 시 N row 자연스러운지
|
||||
|
||||
이슈 발견 시 dogfood-feedback.md F26 부터 누적.
|
||||
269
docs/superpowers/specs/2026-05-09-v029-cut-b-design.md
Normal file
269
docs/superpowers/specs/2026-05-09-v029-cut-b-design.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# v0.2.9 — Cut B Design (status 4분기 + 사유 + Ollama-less)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F17, F18, F23)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut B
|
||||
|
||||
**Cut 라벨:** v0.2.9 — semver 엄밀히 minor (새 status enum + onboarding wizard) 이지만 v0.2.x feature lane 관습 유지.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
데이터 모델 정비 cut. 메모의 의미 분기 (active / completed / archived / trashed) + 이동 시 사유 입력 + Ollama-less 모드 onboarding. 세 항목이 같은 schema 영역 (notes 테이블 + ai_status enum) 영향이라 한 cut.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F17** | status 4분기 (`active`/`completed`/`archived`/`trashed`) + AI 자동 분류 버튼 (사유 입력 후 클릭 → AI 가 reason+raw_text 분석 → status 추천 → 사용자 confirm/dismiss) |
|
||||
| **F18** | 자유 텍스트 사유 (preset X — friction 최소). notes.move_reason 컬럼 또는 별도 trash_log 테이블 |
|
||||
| **F23** | 첫 launch wizard (Y/N) + Ollama 최적화 안내 + 설치 가이드 페이지 링크. ai_status='disabled' 신규 enum + capture skip + raw-only NoteCard fallback |
|
||||
|
||||
---
|
||||
|
||||
## 3. F17 디테일
|
||||
|
||||
### 3-1. Schema 마이그레이션 (m004)
|
||||
|
||||
```sql
|
||||
ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'completed', 'archived', 'trashed'));
|
||||
ALTER TABLE notes ADD COLUMN status_changed_at TEXT;
|
||||
ALTER TABLE notes ADD COLUMN move_reason TEXT;
|
||||
|
||||
-- 기존 deleted_at != NULL 노트 → status='trashed' migrate
|
||||
UPDATE notes SET status='trashed', status_changed_at=deleted_at
|
||||
WHERE deleted_at IS NOT NULL;
|
||||
```
|
||||
|
||||
`deleted_at` 컬럼 — backward compat 위해 잔류 (status='trashed' 와 동기화). 향후 cut 에서 제거 가능.
|
||||
|
||||
### 3-2. 인터페이스
|
||||
|
||||
```ts
|
||||
type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
|
||||
|
||||
interface Note {
|
||||
// ... 기존 필드
|
||||
status: NoteStatus;
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
NoteRepository 메서드:
|
||||
|
||||
- `setStatus(id: string, status: NoteStatus, reason: string | null): void`
|
||||
- `listByStatus(status: NoteStatus, limit?: number): Note[]`
|
||||
- 기존 `restoreNote()` → `setStatus(id, 'active', null)` 으로 재구현
|
||||
|
||||
### 3-3. UI — inbox 헤더 탭 4개
|
||||
|
||||
기존 Inbox / 휴지통 2탭 → **Inbox / 완료 / 보관 / 휴지통** 4탭. 헤더 폭 좁아질 수 있어 short label + count badge.
|
||||
|
||||
```tsx
|
||||
<button>Inbox(N)</button> <button>완료(N)</button> <button>보관(N)</button> <button>휴지통(N)</button>
|
||||
```
|
||||
|
||||
`useInbox` store 의 `view: 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'` enum 확장 (기존 `showTrash` boolean + `showSettings` boolean → enum 통합 권장 — 또는 boolean 3개 유지). enum 통합이 깔끔.
|
||||
|
||||
### 3-4. NoteCard 액션 메뉴
|
||||
|
||||
기존 휴지통 버튼 1개 → 메뉴 (설정 페이지 내부의 dropdown 또는 inline 버튼 group):
|
||||
|
||||
- "완료로 이동"
|
||||
- "보관함으로 이동"
|
||||
- "휴지통으로 이동"
|
||||
|
||||
각 클릭 → 사유 입력 modal (한 줄 textarea, 빈 값 허용) → 확인 → `setStatus(id, target, reason)`.
|
||||
|
||||
### 3-5. AI 자동 분류 버튼
|
||||
|
||||
사유 입력 modal 안 옵션:
|
||||
|
||||
```
|
||||
사유: [____________________________]
|
||||
[ AI 자동 분류 ] [ 완료 ] [ 보관 ] [ 휴지통 ] [ 취소 ]
|
||||
```
|
||||
|
||||
"AI 자동 분류" 클릭 → main 의 `ai:classify-status` IPC → AiWorker 가 prompt:
|
||||
|
||||
```
|
||||
다음 메모와 사용자 사유를 보고 어디로 이동해야 할지 판단:
|
||||
- 메모 본문: <raw_text>
|
||||
- 메모 요약: <summary>
|
||||
- 사용자 사유: <reason>
|
||||
- 가능한 status: completed (작업 끝), archived (장기 보관, 회수 가능), trashed (불필요)
|
||||
JSON 출력: { "recommended": "completed|archived|trashed", "rationale": "..." }
|
||||
```
|
||||
|
||||
응답 → 사용자에게 추천 + rationale 표시:
|
||||
|
||||
```
|
||||
AI 추천: 완료
|
||||
이유: "처리됨" 표현 + 사용자 사유 "결재 끝" 일치
|
||||
[ 확정 ] [ 다른 status 선택 ]
|
||||
```
|
||||
|
||||
확정 → setStatus 적용. 다른 선택 → preset 버튼 노출.
|
||||
|
||||
### 3-6. IPC
|
||||
|
||||
```ts
|
||||
// 신규
|
||||
'inbox:set-status': (id: string, status: NoteStatus, reason: string | null) => Promise<{ ok: true }>
|
||||
'ai:classify-status': (id: string, reason: string) => Promise<{ recommended: NoteStatus; rationale: string }>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. F18 디테일
|
||||
|
||||
자유 텍스트 사유 입력 — F17 의 modal 안에 그대로 포함. 별도 컬럼 `notes.move_reason TEXT` (가장 마지막 사유 보존). 변경 이력 보존 시 별도 테이블 (`note_status_log`) 가능 but YAGNI — 마지막 사유만으로 충분.
|
||||
|
||||
빈 값 허용 (preset X 정책 따라). 검색/필터 — 향후 cut (F19 search) 에서 검색 인덱스 포함 가능.
|
||||
|
||||
---
|
||||
|
||||
## 5. F23 디테일
|
||||
|
||||
### 5-1. Schema
|
||||
|
||||
ai_status enum 확장: `pending | processing | complete | failed | disabled`. 마이그레이션 m004 동일 commit:
|
||||
|
||||
```sql
|
||||
-- ai_status 가 enum text 라 그대로 새 값 INSERT 가능. CHECK constraint 갱신:
|
||||
-- (SQLite 는 CHECK ALTER 직접 안 됨 → table 재생성 또는 trigger 추가)
|
||||
```
|
||||
|
||||
SQLite CHECK 갱신 어려움 — 옵션:
|
||||
|
||||
- (a) 기존 CHECK 제거 + 새 CHECK 추가 (table 재생성)
|
||||
- (b) CHECK 부재 + application-level 검증
|
||||
|
||||
추천: (b) — application 검증 (zod schema). 마이그레이션 비용 ↓.
|
||||
|
||||
### 5-2. Onboarding wizard
|
||||
|
||||
첫 launch (settings.json 의 `onboarding_completed` flag 부재) 시 모달:
|
||||
|
||||
```
|
||||
Inkling 사용 시작
|
||||
|
||||
Inkling 은 로컬 LLM (Ollama) 으로 메모를 자동 정리합니다.
|
||||
Ollama 가 설치되어 있고 한국어 지원 모델 (gemma3, gemma2 등) 이 pull 되어 있어야 최적의 경험이 가능합니다.
|
||||
|
||||
설치 가이드: https://ollama.com/download
|
||||
|
||||
[ AI 자동 처리 사용 (Ollama 필요) ]
|
||||
[ 원문만 저장 (AI 처리 안 함) ]
|
||||
[ 나중에 설정 ]
|
||||
```
|
||||
|
||||
3 옵션:
|
||||
|
||||
- (1) AI 사용 → settings.ai_enabled=true + onboarding_completed=true
|
||||
- (2) 원문만 → ai_enabled=false + onboarding_completed=true
|
||||
- (3) 나중에 → onboarding_completed=false (다음 launch 다시 prompt — 하지만 capture 가능, ai_enabled=null=undefined → default true)
|
||||
|
||||
추천: 3 옵션 모두 onboarding_completed=true 로 설정 + 사용자가 설정 페이지에서 언제든 변경. (3) 만 다시 prompt 면 friction.
|
||||
|
||||
수정: 3 옵션 모두 close 시 onboarding_completed=true. (3) 은 default ai_enabled=true (LAN Ollama 가정 본인 dogfood).
|
||||
|
||||
### 5-3. AI off 시 capture path
|
||||
|
||||
CaptureService.create():
|
||||
|
||||
```ts
|
||||
const aiEnabled = await settingsService.get('ai_enabled', true);
|
||||
if (!aiEnabled) {
|
||||
// notes INSERT with ai_status='disabled' + skip pending_jobs enqueue
|
||||
this.repo.insert({ ...input, ai_status: 'disabled' });
|
||||
return { id, ... };
|
||||
}
|
||||
// 기존 path
|
||||
```
|
||||
|
||||
### 5-4. NoteCard fallback
|
||||
|
||||
ai_status='disabled' 노트 → title fallback = raw_text 첫 60자 (또는 첫 줄).
|
||||
|
||||
```tsx
|
||||
const displayTitle = note.title?.trim() || note.rawText.split('\n')[0].slice(0, 60) || '(빈 메모)';
|
||||
```
|
||||
|
||||
summary/tags hide. raw_text 그대로.
|
||||
|
||||
### 5-5. Banner 비활성
|
||||
|
||||
ai_enabled=false → OllamaBanner / FailedBanner 자동 비활성 (state 가 false 면 render skip). HealthChecker 도 ai_enabled=false 시 polling 중단.
|
||||
|
||||
### 5-6. 설정 페이지 토글
|
||||
|
||||
AI 제공자 섹션 상단 추가:
|
||||
|
||||
```
|
||||
[ ] AI 자동 처리 사용
|
||||
|
||||
↳ AI 처리를 사용하면 메모의 제목/요약/태그가 자동 생성됩니다.
|
||||
Ollama 로컬 LLM 이 필요합니다. 설치 가이드: ollama.com/download
|
||||
```
|
||||
|
||||
토글 OFF → ON 전환 시: onboarding wizard 와 동일 prompt 재노출 (간소화 — 그냥 endpoint 검증 후 결과 표시).
|
||||
|
||||
### 5-7. 옛 노트 처리 (ON ↔ OFF 전환)
|
||||
|
||||
**B1 정책 채택** (roadmap):
|
||||
|
||||
- ON → OFF: 기존 pending 잔재 그대로 (드레인 후 enqueue stop)
|
||||
- OFF → ON: 기존 disabled 잔류 (사용자 명시 trigger 만 처리)
|
||||
|
||||
설정 페이지 안 "기존 disabled 메모 N건 — 지금 모두 처리" 버튼 (ON 전환 후 disabled count > 0 시 노출).
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 전략
|
||||
|
||||
| 영역 | 단위 | 수동 |
|
||||
|---|---|---|
|
||||
| m004 마이그레이션 | mock db → status 컬럼 + 기존 deleted_at != NULL → trashed | - |
|
||||
| `setStatus` repo | 4 status 전환 + reason 저장 + statusChangedAt | - |
|
||||
| `listByStatus` | 각 status filter | - |
|
||||
| 4탭 UI 렌더 | view enum 4값 분기 + count badge | - |
|
||||
| 사유 입력 modal | 자유 텍스트 입력 + 빈 값 허용 + 4 status 버튼 | - |
|
||||
| `ai:classify-status` IPC | mock provider 응답 → recommended + rationale 반환 | - |
|
||||
| AI 자동 분류 UI | recommended 표시 + 확정 클릭 시 setStatus 호출 | - |
|
||||
| ai_status='disabled' enum | application zod 검증 + capture path skip pending_jobs | - |
|
||||
| Onboarding wizard | 첫 launch 시 표시 (settings 부재) + 3 옵션 결과 | 첫 launch 시 표시 확인 |
|
||||
| AI off 시 NoteCard | title fallback (raw 첫 줄), summary/tags hide | - |
|
||||
| AI off 시 Banner | render skip 회귀 | - |
|
||||
|
||||
**목표**: 단위 467 → 약 490 (+23), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| AI 자동 분류 정확도 낮음 | 추천만 표시, 사용자 confirm 강제. fallback = preset 4 status 버튼 |
|
||||
| status 4분기 + tag + reason layer 가 사용자 정신 부담 | dogfood 1주 측정. 사용 빈도 낮은 status 는 v0.2.10+ 에서 hide 옵션 |
|
||||
| Onboarding wizard 가 첫 launch 흐름 차단 | "나중에 설정" 옵션 제공 + close 가능 |
|
||||
| ai_enabled false 시 회귀 (기존 pending → 영원히 잔류) | 설정 페이지의 "기존 disabled 메모 N건 처리" 버튼 |
|
||||
|
||||
---
|
||||
|
||||
## 8. v0.2.9 후
|
||||
|
||||
**Cut C** (v0.2.10) — F20 raw_text revision history. AI 재실행 input = current raw_text (latest revision).
|
||||
|
||||
**dogfood verify**:
|
||||
|
||||
1. 4탭 사용 빈도 (active/completed/archived/trashed) — 사용 안 되는 status 발견 시 cut B+1 에서 hide
|
||||
2. AI 자동 분류 정확도 (사용자 confirm 비율)
|
||||
3. Onboarding wizard 의 3 옵션 비율
|
||||
240
docs/superpowers/specs/2026-05-09-v030-cut-e-design.md
Normal file
240
docs/superpowers/specs/2026-05-09-v030-cut-e-design.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# v0.3.0 — Cut E Design (다기기 git-based 양방향 sync)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F21)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut E
|
||||
|
||||
**Cut 라벨:** v0.3.0 — semver MINOR (새 인프라 — 양방향 sync + Configure UI). Major 영역 진입.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
기존 push-only `SyncService` → 양방향 (pull + import + conflict resolution + Configure UI). 다기기 (Mac 업무 + Windows 개인) dogfood 가능.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F21 옵션 A** | `git fetch && rebase` 후 markdown → SQLite re-import. 자동 rebase default (충돌 시 fail + 사용자 prompt) |
|
||||
| **F21 옵션 B** | 설정 페이지 안 "동기화 저장소" sub-section — URL 입력 + 인증 안내 + 마지막 sync 결과 |
|
||||
| **F21 옵션 C** | conflict UI — 자동 rebase 실패 시 양쪽 비교 + 사용자 선택 |
|
||||
| **pull 시점** | 양쪽 — manual ("지금 동기화") + 자동 주기 (사용자 설정 가능 interval, default 30분) |
|
||||
| **revision 결합 (Cut C)** | note_revisions 가 sync 대상 — 양 기기 rev 가 다른 chain 에 있으면 timestamp linear merge (옛 rev 가 sync source 로 inserted) |
|
||||
|
||||
---
|
||||
|
||||
## 3. SyncService 양방향화
|
||||
|
||||
### 3-1. 갱신된 sync() 흐름
|
||||
|
||||
```ts
|
||||
async sync(opts: { interval?: boolean } = {}): Promise<SyncStatus> {
|
||||
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
|
||||
|
||||
const git = new GitClient(this.syncDir);
|
||||
|
||||
// 1. local export — 현재 SQLite 상태를 syncDir 에 markdown 으로 출력
|
||||
await this.exportSvc.export(this.syncDir, { includeMedia: true });
|
||||
await git.addAll();
|
||||
const localChanged = await git.hasUncommittedChanges();
|
||||
|
||||
// 2. local commit (변경 있으면)
|
||||
let localSha: string | null = null;
|
||||
if (localChanged) {
|
||||
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
|
||||
localSha = c.sha;
|
||||
}
|
||||
|
||||
// 3. fetch
|
||||
const fetchR = await git.fetch();
|
||||
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
|
||||
|
||||
// 4. rebase onto origin/main
|
||||
const rebaseR = await git.rebaseOnto('origin/main');
|
||||
if (rebaseR.exitCode !== 0) {
|
||||
// conflict — abort + conflict 목록 반환 (UI 가 활성)
|
||||
await git.rebaseAbort();
|
||||
return { ok: false, reason: 'conflict', conflicts: await this.listConflictsFromMarkdown() };
|
||||
}
|
||||
|
||||
// 5. re-import (rebase 후 markdown 변경 → SQLite upsertFromSync)
|
||||
const imported = await this.importSvc.applySyncFromDir(this.syncDir);
|
||||
|
||||
// 6. push
|
||||
try { await git.push(); } catch (e) { return { ok: false, reason: `push failed: ${(e as Error).message}` }; }
|
||||
|
||||
return { ok: true, changed: localChanged || imported.changedCount > 0, localSha, importedCount: imported.changedCount, pushed: true };
|
||||
}
|
||||
```
|
||||
|
||||
**6 단계 흐름 — local export 가 fetch 보다 먼저 (Cut E 정정)**: spec 초안은 fetch 우선이었으나, local export → commit 후 fetch + rebase 가 git workflow 표준 (rebase 가 local commit 위에 origin commit 적용). local export 안 한 상태로 fetch + rebase → 혼란 발생.
|
||||
|
||||
`SyncStatus` 인터페이스 확장:
|
||||
|
||||
```ts
|
||||
export interface SyncStatus {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
changed?: boolean;
|
||||
localSha?: string | null;
|
||||
pushed?: boolean;
|
||||
importedCount?: number;
|
||||
conflicts?: Array<{ noteId: string; localText: string; remoteText: string }>; // reason='conflict' 시
|
||||
}
|
||||
```
|
||||
|
||||
### 3-2. ImportService 활용 (실제 코드 정정)
|
||||
|
||||
기존 ImportService 는 `run(sourceDir)` 메서드 (백업 복원 흐름) — `parsedToInput` → `repo.importNote()` 호출. spec 작성 시 가정한 `importAll(dir)` 시그니처는 실재 코드와 다름.
|
||||
|
||||
`repo.importNote()` 의 기존 conflict 정책 (export tree 복원용):
|
||||
|
||||
- id 없음 → INSERT (`status: 'inserted'`)
|
||||
- id 있음 + raw_text 동일 → no-op (`status: 'skipped'`)
|
||||
- id 있음 + raw_text 다름 → fork-on-id-collision (fresh uuidv7) (`status: 'forked'`)
|
||||
|
||||
**Cut E sync 정책 — fork 미적합, in-place update + revision 보존**:
|
||||
|
||||
sync 에서 양 기기 raw_text 가 다를 때 fork 하면 노트 갯수 무한 증가 → 부적합. 신설 메서드 `repo.upsertFromSync(input)`:
|
||||
|
||||
- id 없음 → INSERT (m006 trigger 가 capture revision 자동 생성)
|
||||
- id 있음 + raw_text 동일 → metadata 갱신 path
|
||||
- source.updatedAt > local.updatedAt 인 경우만 ai_title/ai_summary/tags/status/dueDate 갱신
|
||||
- tags 변경 시 `rebuildFtsTagsForNote` 호출 (Cut D single write path)
|
||||
- 동등/older 면 skip
|
||||
- id 있음 + raw_text 다름 → 옵션 분기:
|
||||
- source.updatedAt > local.updatedAt → `updateRawText(id, sourceRawText, sourceUpdatedAt)` (Cut C single write path) → 새 user revision INSERT, latest = source
|
||||
- local.updatedAt > source.updatedAt → skip (다음 push 가 source 갱신할 것)
|
||||
- 동일 timestamp + 다른 raw_text → SyncService 가 conflict 마킹 (rebase 단계 git markdown conflict 가 먼저 잡힘 — 본 분기는 rare)
|
||||
|
||||
**revision edited_by**: 'sync' enum 추가 안 함 — `updateRawText` 의 default 'user' 그대로 활용 (sync = user-edited 변경 전파 = 의미상 user). YAGNI: m008 회피.
|
||||
|
||||
### 3-3. GitClient 확장
|
||||
|
||||
```ts
|
||||
class GitClient {
|
||||
// 기존: run, isRepo, hasRemote, addAll, commit, push
|
||||
|
||||
// 신규
|
||||
async fetch(): Promise<GitExecResult>;
|
||||
async rebaseOnto(ref: string): Promise<GitExecResult>;
|
||||
async rebaseAbort(): Promise<GitExecResult>;
|
||||
async hasUncommittedChanges(): Promise<boolean>;
|
||||
async listConflicts(): Promise<string[]>; // git diff --name-only --diff-filter=U
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Configure UI (옵션 B)
|
||||
|
||||
설정 페이지 → 신규 sub-section "동기화 저장소":
|
||||
|
||||
```
|
||||
[동기화 저장소]
|
||||
저장소 URL: [git@gitea.example.com:user/inkling-notes.git]
|
||||
[ 저장 ] [ 연결 테스트 ]
|
||||
|
||||
마지막 sync: 2026-05-09 14:32 (성공, 3건 가져옴, 2건 보냄)
|
||||
다음 자동 sync: 2026-05-09 15:02
|
||||
|
||||
[ 자동 sync 사용 ]
|
||||
interval: [30] 분
|
||||
|
||||
[ 지금 동기화 ] [ 충돌 해결... ]
|
||||
```
|
||||
|
||||
저장소 URL 변경 → main 의 `settings:configure-sync` IPC 호출 → SyncService 가 `<profileDir>/sync/` 에 git init + remote add origin (없으면). 인증 (SSH key / token) 은 사용자 OS 설정 (`~/.ssh/` 또는 git credential helper) — Inkling 자체 인증 X, 안내 메시지만.
|
||||
|
||||
---
|
||||
|
||||
## 5. Conflict UI (옵션 C)
|
||||
|
||||
자동 rebase 실패 시 SyncService 가 `{ ok: false, reason: 'conflict', conflicts: [...] }` 반환. 설정 페이지 의 "충돌 해결..." 버튼 활성화.
|
||||
|
||||
클릭 → modal:
|
||||
|
||||
```
|
||||
충돌 N건
|
||||
|
||||
[note-id-1.md]
|
||||
< 내 기기 > | < 다른 기기 >
|
||||
본문 A | 본문 B
|
||||
|
|
||||
[ 내 것 사용 ] [ 원격 사용 ] [ 양쪽 보존 (옛 revision 으로) ]
|
||||
```
|
||||
|
||||
선택:
|
||||
|
||||
- **내 것 사용**: local 채택 (origin 변경 폐기)
|
||||
- **원격 사용**: origin 채택 (local 변경 → note_revisions 에 보존)
|
||||
- **양쪽 보존**: local + origin 모두 note_revisions 에 INSERT, latest = 사용자 선택 (또는 timestamp 더 최신)
|
||||
|
||||
확정 → SyncService.resolveConflict(noteId, choice) → git rebase --continue → push.
|
||||
|
||||
---
|
||||
|
||||
## 6. 자동 주기 sync
|
||||
|
||||
main process 가 settings.sync_interval_min (default 30) 마다 `SyncService.sync({ interval: true })` 호출. interval=true 시 conflict 발생해도 silent (notification 만, 사용자가 다음 manual 또는 conflict UI 진입 시 처리).
|
||||
|
||||
settings: `sync_auto_enabled: boolean` (default true 단, configured 일 때만), `sync_interval_min: number` (default 30, min 5).
|
||||
|
||||
---
|
||||
|
||||
## 7. IPC
|
||||
|
||||
```ts
|
||||
// 신규
|
||||
'settings:configure-sync': (url: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
||||
'settings:test-sync-connection': () => Promise<{ ok: true } | { ok: false; reason: string }> // git ls-remote
|
||||
'sync:list-conflicts': () => Promise<Array<{ noteId: string; localText: string; remoteText: string }>>
|
||||
'sync:resolve-conflict': (noteId: string, choice: 'local' | 'remote' | 'both') => Promise<{ ok: true }>
|
||||
'sync:get-status': () => Promise<{ lastAt: string | null; lastResult: SyncStatus | null; nextAt: string | null }>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| `GitClient.fetch / rebaseOnto / rebaseAbort` | mock execFile + 결과 검증 |
|
||||
| `SyncService.sync` 양방향 | mock GitClient + ImportService → 6 단계 흐름 |
|
||||
| 자동 rebase 성공 | conflict 없는 시나리오 |
|
||||
| 자동 rebase 실패 → abort | conflict 시 rebaseAbort + reason 반환 |
|
||||
| ImportService.importAll | markdown → notes UPSERT + revision INSERT |
|
||||
| revision merge | 양 chain → timestamp 순 linear |
|
||||
| Configure UI | URL 입력 → IPC → git init/remote add |
|
||||
| Conflict UI | 3 choice 별 sync 동작 |
|
||||
| 자동 주기 sync | timer + interval=true mode |
|
||||
|
||||
**목표**: 단위 608 → 약 635 (+27), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 9. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| 인증 설정 실패 (사용자 SSH key 부재) | Configure UI 의 "연결 테스트" 버튼 — git ls-remote 결과 사용자에게 표시 |
|
||||
| revision linear merge 정확도 | timestamp 단조 증가 가정 (양 기기 시계 동기화). NTP 부재 시 충돌 risk → 사용자 prompt |
|
||||
| 자동 주기 sync 의 silent 충돌 누적 | interval mode 충돌 시 notification + 충돌 UI 자동 popup option |
|
||||
| Cut C revision history 와 sync 결합 시 chain 분기 | 본 cut 의 정책: timestamp linear, branch 분기 미지원 (사용자 manual 결정으로 처리) |
|
||||
|
||||
---
|
||||
|
||||
## 10. v0.3.0 후
|
||||
|
||||
**Cut F** (v0.3.1) — F24 멀티모달 vision.
|
||||
|
||||
**dogfood verify**:
|
||||
|
||||
1. Mac + Windows 양 기기 sync 1주 — 충돌 빈도 측정
|
||||
2. 자동 주기 sync 의 timing — battery / network 영향
|
||||
3. revision merge 정확도 (사용자 confirm 비율)
|
||||
278
docs/superpowers/specs/2026-05-09-v031-cut-f-design.md
Normal file
278
docs/superpowers/specs/2026-05-09-v031-cut-f-design.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# v0.3.1 — Cut F Design (멀티모달 vision AI)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F24)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut F
|
||||
|
||||
**Cut 라벨:** v0.3.1 — patch (vision 추가, 기존 기능 영향 X)
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
Ollama vision 모델 (gemma family — gemma3 / gemma4 default capable) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F24 default 모델** | gemma family — gemma3 / gemma4 둘 다 vision-capable hint (한국어 + 이미지 둘 다 강함, 본인 메모 `gemma4:e4b` 텍스트 모델과 같은 가족) |
|
||||
| **prompt 모드** | 단일 vision 모델 호출 (vision 모델이 텍스트도 처리). 모델 capability 부족 시 2단계 fallback (자동) |
|
||||
| **capability detection** | app launch 시 1회 + 설정 페이지 manual refresh 버튼 |
|
||||
| **F23 OFF 시 자동 OFF** | `ai_enabled=false` → vision 도 자동 OFF (자명) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Capability Detection
|
||||
|
||||
### 3-1. Ollama API 활용
|
||||
|
||||
`GET /api/tags` → 사용자 Ollama instance 의 모델 목록. response:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{ "name": "gemma4:e4b", "details": { "family": "gemma" } },
|
||||
{ "name": "gemma3:12b-vision", "details": { "family": "gemma3", "families": ["gemma3"] } },
|
||||
{ "name": "llava:13b", "details": { "family": "llava" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
vision capable 판정 — 모델 이름 또는 family 기반:
|
||||
|
||||
```ts
|
||||
const VISION_FAMILIES = new Set(['gemma3', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
|
||||
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3'];
|
||||
|
||||
function isVisionCapable(model: { name: string; details?: { family?: string; families?: string[] } }): boolean {
|
||||
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
|
||||
if (model.details?.families?.some(f => VISION_FAMILIES.has(f))) return true;
|
||||
return VISION_NAME_HINTS.some(h => model.name.toLowerCase().includes(h));
|
||||
}
|
||||
```
|
||||
|
||||
### 3-2. Settings storage (실제 SettingsService API)
|
||||
|
||||
zod schema 확장 (기존 ai_enabled / sync_* 와 동일 strict 패턴):
|
||||
|
||||
```ts
|
||||
const SettingsSchema = z.object({
|
||||
// ... 기존 ollama / ai_enabled / onboarding_completed / sync_*
|
||||
vision_model: z.string().nullable().optional(),
|
||||
vision_capable_cache: z.array(z.string()).optional(),
|
||||
vision_cache_at: z.string().optional()
|
||||
}).strict();
|
||||
```
|
||||
|
||||
신규 SettingsService 메서드 (개별 setter/getter — `get/set` 일반화 X):
|
||||
|
||||
```ts
|
||||
async getVisionModel(): Promise<string | null>;
|
||||
async setVisionModel(value: string | null): Promise<void>;
|
||||
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }>;
|
||||
async setVisionCapableCache(models: string[], now: Date): Promise<void>;
|
||||
```
|
||||
|
||||
### 3-3. AppLaunchDetect
|
||||
|
||||
`src/main/services/VisionDetect.ts` 신규 — pure 함수 + 외부 fetch 주입 (테스트 가능):
|
||||
|
||||
```ts
|
||||
export async function refreshVisionCache(deps: {
|
||||
settings: SettingsService;
|
||||
endpoint: string;
|
||||
now?: () => Date;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
|
||||
if (!(await deps.settings.isAiEnabled())) {
|
||||
return { ok: false, reason: 'ai_disabled' };
|
||||
}
|
||||
const fetchFn = deps.fetchImpl ?? fetch;
|
||||
let body: { models?: Array<{ name: string; details?: { family?: string; families?: string[] } }> };
|
||||
try {
|
||||
const r = await fetchFn(`${deps.endpoint}/api/tags`);
|
||||
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
|
||||
body = await r.json();
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
|
||||
}
|
||||
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
|
||||
await deps.settings.setVisionCapableCache(capable, deps.now ? deps.now() : new Date());
|
||||
return { ok: true, models: capable };
|
||||
}
|
||||
```
|
||||
|
||||
main process `whenReady` 안에서 fire-and-forget 호출. 실패 silent (cache 유지). settings:refresh-vision-cache IPC 가 동일 함수 호출 (manual "다시 감지" 버튼).
|
||||
|
||||
### 3-4. 설정 페이지 UI (AI 제공자 섹션 확장)
|
||||
|
||||
```
|
||||
[AI 제공자]
|
||||
Endpoint: [http://localhost:11434]
|
||||
모델: [gemma4:e4b]
|
||||
|
||||
[이미지 분석 모델 (선택사항)]
|
||||
[gemma3:12b-vision ▾] ← dropdown, 비어 있으면 비활성
|
||||
가능한 모델: gemma3:12b-vision, llava:13b, ...
|
||||
[ 다시 감지 ] 마지막 감지: 2026-05-09 14:30
|
||||
```
|
||||
|
||||
dropdown — `vision_capable_cache` 결과 + 빈 옵션. "다시 감지" → `refreshVisionCache()` + UI 갱신.
|
||||
|
||||
---
|
||||
|
||||
## 4. InferenceProvider 확장
|
||||
|
||||
### 4-1. 인터페이스
|
||||
|
||||
```ts
|
||||
// src/main/ai/InferenceProvider.ts
|
||||
interface GenerateInput {
|
||||
text: string;
|
||||
images?: Array<{ base64: string; mime: string }>; // NEW
|
||||
todayKst: string;
|
||||
dueDateCandidates: string[];
|
||||
vocab?: string[];
|
||||
}
|
||||
|
||||
interface InferenceProvider {
|
||||
generate(input: GenerateInput, opts?: { visionModel?: string }): Promise<AiResponse>;
|
||||
abort?(): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 4-2. LocalOllamaProvider 갱신
|
||||
|
||||
```ts
|
||||
async generate(input: GenerateInput, opts?: { visionModel?: string }): Promise<AiResponse> {
|
||||
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
|
||||
const model = useVision ? opts.visionModel : this.textModel;
|
||||
|
||||
const body: any = {
|
||||
model,
|
||||
prompt: useVision
|
||||
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [])
|
||||
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
|
||||
stream: false,
|
||||
format: 'json'
|
||||
};
|
||||
if (useVision) {
|
||||
body.images = input.images!.map(i => i.base64);
|
||||
}
|
||||
|
||||
const res = await request(`${this.endpoint}/api/generate`, body);
|
||||
// ... 기존 parse
|
||||
}
|
||||
```
|
||||
|
||||
### 4-3. buildVisionPrompt
|
||||
|
||||
```ts
|
||||
function buildVisionPrompt(text: string, todayKst: string, dueCandidates: string[], vocab: string[]): string {
|
||||
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
|
||||
|
||||
메모 본문 (비어 있을 수 있음):
|
||||
${text || '(이미지만 있음)'}
|
||||
|
||||
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
|
||||
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
|
||||
오늘: ${todayKst}
|
||||
가능한 due 후보: ${dueCandidates.join(', ')}
|
||||
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AiWorker 통합 (실제 API 정정)
|
||||
|
||||
기존 `AiWorker.processJob` 이 `repo.findById(noteId)` 로 hydrate 된 `Note` 받음 — `note.media` 가 이미 join 결과로 채워져 있어 별도 `listMediaByNote` 호출 불필요. `MediaStore.absolutePath(relPath)` 로 디스크 path 추출.
|
||||
|
||||
```ts
|
||||
// src/main/ai/AiWorker.ts processJob 흐름
|
||||
const note = this.repo.findById(job.noteId);
|
||||
if (!note || ...) return;
|
||||
const visionModel = await this.settings.getVisionModel();
|
||||
|
||||
let images: Array<{ base64: string; mime: string }> | undefined;
|
||||
if (visionModel && note.media.length > 0) {
|
||||
images = await Promise.all(
|
||||
note.media.map(async (m) => {
|
||||
const buf = await readFile(this.mediaStore.absolutePath(m.relPath));
|
||||
// 이미지당 5MB cap (base64 메모리 폭주 방지)
|
||||
if (buf.byteLength > 5 * 1024 * 1024) {
|
||||
throw new Error(`image ${m.relPath} exceeds 5MB cap`);
|
||||
}
|
||||
return { base64: buf.toString('base64'), mime: m.mime };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const res = await this.holder.get().generate({
|
||||
text: note.rawText,
|
||||
images,
|
||||
todayKst,
|
||||
dueDateCandidates: candidates,
|
||||
vocab
|
||||
}, { visionModel });
|
||||
```
|
||||
|
||||
`visionModel && note.media.length > 0` 둘 다 true 일 때만 vision path. 그 외는 기존 text-only path 유지 (호환 보존). image 5MB cap 초과 시 throw → 기존 AiWorker 의 attempts 카운트 + ai_status='failed' 분기 활용.
|
||||
|
||||
AiWorker 의 `settings: SettingsService` 의존성 추가 — 기존 생성자에 신규 파라미터.
|
||||
|
||||
---
|
||||
|
||||
## 6. 이미지만 있는 capture (정정 — 신규 enum 도입 X)
|
||||
|
||||
`raw_text` 빈 값 + media 첨부만 케이스:
|
||||
|
||||
- **vision enabled** (`visionModel` 설정 + media 있음): AiWorker 의 vision path → 의미 있는 title/summary/tags 응답
|
||||
- **vision disabled** (`visionModel` null): 기존 text-only 흐름 그대로 — 빈 prompt → AI 응답이 무의미하면 ai_status='failed' 분기 (재시도 가능). dogfood 시 빈도 측정 후 'skipped' enum 도입 여부 재평가.
|
||||
|
||||
**'skipped' 신규 enum 미도입 (YAGNI)**: m008 마이그레이션 (CHECK relax via table recreate) 부담 + 이미지-only capture 가 본 cut 의 main use case 가 아님. 사용자가 vision 활성 후 retry 하거나 raw_text 추가 후 reprocess 하는 우회로 충분. 정책 검토는 dogfood 후 별도 cut.
|
||||
|
||||
---
|
||||
|
||||
## 7. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| `isVisionCapable` | family / families / name hint 별 판정 |
|
||||
| `refreshVisionCache` | mock /api/tags → capable 추출 + settings 저장 |
|
||||
| 설정 페이지 dropdown | cache 기반 옵션 + "다시 감지" 클릭 → IPC |
|
||||
| `LocalOllamaProvider.generate` vision path | images 비어있음 → text-only / images 있음 + visionModel → vision body |
|
||||
| `buildVisionPrompt` | 빈 text + images 만 케이스 정확 prompt |
|
||||
| `AiWorker.processJob` vision integration | media + visionModel 있을 때만 base64 변환 |
|
||||
| 이미지 only capture | raw_text='' + media → vision 결과 정상 또는 'skipped' 분기 |
|
||||
|
||||
**목표**: 단위 679 → 약 701 (+22, isVisionCapable 5 + refreshVisionCache 4 + SettingsService vision 4 + LocalOllamaProvider vision path 3 + buildVisionPrompt 2 + AiWorker vision integration 3 + UI dropdown 1), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| vision 모델 추론 latency 큼 (수 초~분) | AiWorker backend 처리 — 사용자 대기 X. NoteCard 가 ai_status='processing' 표시 |
|
||||
| 이미지 base64 메모리 부담 | media 1개당 평균 < 1MB. 다중 이미지 시 N×base64 = 메모리 N배. cap (이미지당 max size 5MB) 적용 |
|
||||
| capability detection 실패 시 fallback | cache 부재 → vision dropdown 비어있음 표시 + "다시 감지" 안내 |
|
||||
| vision 모델 한국어 정확도 | dogfood 검증. gemma3 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책 갱신) |
|
||||
| Ollama 가 vision images 필드 무시 (모델이 multimodal 미지원) | **본 cut 미구현 (YAGNI)** — 자동 2단계 fallback (caption 추출 → 텍스트 모델 종합) 은 v0.3.2+ 검토. dogfood 시 capability detection 정확도 우선 |
|
||||
|
||||
---
|
||||
|
||||
## 9. v0.3.1 후
|
||||
|
||||
**Cut G** (v0.3.2) — F25 사이드바 + notebook_id.
|
||||
|
||||
**dogfood verify**:
|
||||
|
||||
1. 이미지 capture 빈도 (가설: 일 ≥ 1건 = vision 가치)
|
||||
2. vision 결과 사용자 수정 비율 (정확도 측정)
|
||||
3. capability detection 정확도 (false-positive / false-negative)
|
||||
229
docs/superpowers/specs/2026-05-09-v032-cut-g-design.md
Normal file
229
docs/superpowers/specs/2026-05-09-v032-cut-g-design.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# 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
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F25)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut G
|
||||
|
||||
**Cut 라벨:** v0.3.2
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
inbox layout 재구성 — 사이드바 + 메모 카테고리 (notebook). single-pane → two-pane. 단일 DB 안 `notebook_id` 컬럼 (옵션 B — 1주 scope, 다중 profile 옵션 A 는 v0.4+ 후보).
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F25 저장소 정의** | B — 카테고리/폴더 (notebook_id, 단일 DB 안 그룹화) |
|
||||
| **사이드바 가시성** | 사용자 토글 + last state 보존 (settings) |
|
||||
| **사이드바 내용** | 상단 notebook 목록 + 하단 메모 list (compact view) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema 마이그레이션 (m007)
|
||||
|
||||
```sql
|
||||
CREATE TABLE notebooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT, -- accent color for UI (옵션)
|
||||
created_at TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO notebooks (id, name, created_at, position)
|
||||
VALUES ('default', '기본', '2026-05-09T00:00:00Z', 0);
|
||||
|
||||
ALTER TABLE notes ADD COLUMN notebook_id TEXT NOT NULL DEFAULT 'default'
|
||||
REFERENCES notebooks(id) ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX idx_notes_notebook_status ON notes(notebook_id, status, created_at DESC);
|
||||
```
|
||||
|
||||
기존 모든 notes → notebook_id='default'. 사용자가 새 notebook 생성 후 메모 이동 가능.
|
||||
|
||||
`ON DELETE RESTRICT` — notebook 삭제 시 노트 잔류해야 함. notebook 삭제 흐름은 사용자가 명시 (메모 이동 후 삭제).
|
||||
|
||||
---
|
||||
|
||||
## 4. NotebookRepository
|
||||
|
||||
```ts
|
||||
class NotebookRepository {
|
||||
list(): Notebook[];
|
||||
get(id: string): Notebook | undefined;
|
||||
create(name: string, color?: string): Notebook;
|
||||
rename(id: string, name: string): void;
|
||||
delete(id: string): void; // notebook 안 메모 0건일 때만 (RESTRICT 위반 시 throw)
|
||||
reorder(ids: string[]): void; // position 갱신
|
||||
countNotes(id: string, opts?: { status?: NoteStatus }): number;
|
||||
}
|
||||
```
|
||||
|
||||
NoteRepository 의 모든 query 에 `notebook_id` filter 추가:
|
||||
|
||||
```ts
|
||||
listByStatus(status: NoteStatus, opts: { notebookId?: string; limit?: number }): Note[];
|
||||
moveToNotebook(noteId: string, notebookId: string): void;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI — 사이드바
|
||||
|
||||
### 5-1. layout
|
||||
|
||||
```
|
||||
┌──────────────┬───────────────────────────────────┐
|
||||
│ [≡] Inkling │ [Inbox(N) 완료(N) 보관(N) 휴지통(N)] [🔍 search] [⚙] │
|
||||
├──────────────┼───────────────────────────────────┤
|
||||
│ 노트북 │ │
|
||||
│ • 기본 (12) │ NoteCard list (current view) │
|
||||
│ • 회사 (5) │ │
|
||||
│ • 학습 (3) │ │
|
||||
│ + 새 노트북 │ │
|
||||
├──────────────┤ │
|
||||
│ 메모 빠른 list│ │
|
||||
│ - title 1 │ │
|
||||
│ - title 2 │ │
|
||||
│ - title 3 │ │
|
||||
│ ... │ │
|
||||
└──────────────┴───────────────────────────────────┘
|
||||
```
|
||||
|
||||
폭: 240px (settings 의 `sidebar_width` 사용자 조정 가능, default 240, min 180, max 400).
|
||||
|
||||
### 5-2. 토글
|
||||
|
||||
헤더 좌측 햄버거 (`≡`) 버튼 → `useInbox.sidebarVisible` toggle. last state 저장 (`settings.sidebar_visible`).
|
||||
|
||||
키보드 shortcut: `Ctrl+B` (또는 `Cmd+B` macOS) — 빠른 토글.
|
||||
|
||||
### 5-3. Notebook 목록
|
||||
|
||||
상단 panel — `NotebookRepository.list()` + 각 notebook 의 active 메모 count.
|
||||
|
||||
- 클릭 → `useInbox.selectedNotebookId` 갱신 → main pane 의 NoteCard list 가 해당 notebook 만 표시.
|
||||
- 우클릭 → context menu: 이름 변경 / 색 변경 / 삭제 (메모 0건일 때만).
|
||||
- "+ 새 노트북" 버튼 → modal: name 입력 + color picker (선택사항) → create.
|
||||
|
||||
### 5-4. 메모 빠른 list
|
||||
|
||||
하단 panel — selected notebook + selected status (Inbox/완료/보관/휴지통 탭) 의 NoteCard 들의 compact view.
|
||||
|
||||
- title + tag chip 1-2 개 + 시간 (relative — "2시간 전")
|
||||
- 클릭 → main pane 가 해당 NoteCard 위치로 scroll (또는 강조)
|
||||
|
||||
main pane 의 NoteCard grid 와 사이드 빠른 list 는 동일 데이터 — 단지 view 다름. 사이드는 navigation, main 은 detail.
|
||||
|
||||
### 5-5. NoteCard 갱신 — notebook 이동
|
||||
|
||||
NoteCard 액션 메뉴 (Cut B 의 status 메뉴 옆):
|
||||
|
||||
- "다른 노트북으로 이동" → notebook 목록 dropdown → 선택 → `moveToNotebook` IPC
|
||||
|
||||
---
|
||||
|
||||
## 6. store 갱신
|
||||
|
||||
```ts
|
||||
interface InboxState {
|
||||
// 기존
|
||||
view: 'inbox' | 'completed' | 'archived' | 'trash' | 'review-daily' | 'review-weekly' | 'review-monthly' | 'settings';
|
||||
|
||||
// 신규 (Cut G)
|
||||
notebooks: Notebook[];
|
||||
selectedNotebookId: string;
|
||||
sidebarVisible: boolean;
|
||||
loadNotebooks: () => Promise<void>;
|
||||
selectNotebook: (id: string) => void;
|
||||
createNotebook: (name: string, color?: string) => Promise<void>;
|
||||
renameNotebook: (id: string, name: string) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<void>;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
`refreshMeta` / `loadInitial` 가 notebooks 도 함께 fetch.
|
||||
|
||||
---
|
||||
|
||||
## 7. IPC
|
||||
|
||||
```ts
|
||||
'inbox:list-notebooks': () => Promise<Notebook[]>
|
||||
'inbox:create-notebook': (name: string, color?: string) => Promise<Notebook>
|
||||
'inbox:rename-notebook': (id: string, name: string) => Promise<{ ok: true }>
|
||||
'inbox:delete-notebook': (id: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
||||
'inbox:move-to-notebook': (noteId: string, notebookId: string) => Promise<{ ok: true }>
|
||||
'inbox:reorder-notebooks': (ids: string[]) => Promise<{ ok: true }>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. F19 search 와 결합 (Cut D 후)
|
||||
|
||||
search box — 사이드바 도입 후 위치 검토:
|
||||
|
||||
- (a) **inbox 헤더 잔류** (Cut D 결정) — 단순. 사이드바 토글 무관.
|
||||
- (b) **사이드바 안 상단** — 사이드바 visible 일 때만 search. hidden 시 inbox 헤더 fallback.
|
||||
|
||||
추천: (a) — Cut D 결정 보존, 사이드바 토글 무관. UX 일관.
|
||||
|
||||
search 결과 — current selectedNotebookId 안만 또는 모든 notebook? settings 토글 또는 search options dropdown. 추천: 기본 current notebook 안 검색 + "모든 노트북에서 검색" 옵션.
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| m007 마이그레이션 | notebooks 테이블 + 'default' INSERT + notes.notebook_id backfill |
|
||||
| `NotebookRepository.list/create/rename/delete/reorder` | 각 메서드 |
|
||||
| `delete` RESTRICT | 메모 잔류 시 throw |
|
||||
| `moveToNotebook` | notebook_id 갱신 + 카운트 영향 |
|
||||
| 사이드바 토글 | store action + settings 저장 |
|
||||
| Notebook 목록 렌더 | count badge + 클릭 → selectedNotebookId 갱신 |
|
||||
| 메모 빠른 list | selectedNotebook + selectedView 필터 |
|
||||
| Notebook 생성 modal | name 입력 + color picker → create |
|
||||
| Notebook 삭제 | 메모 잔류 시 error 표시 |
|
||||
| search + notebook scope | 'current notebook' / 'all' 옵션별 필터 |
|
||||
|
||||
**목표**: 단위 575 → 약 600 (+25), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| 사이드바 폭이 좁은 화면 (1280×720) 에서 너무 큼 | default hidden 옵션? settings 의 width 조정 + 좁은 화면 시 자동 hide |
|
||||
| Notebook 삭제 시 RESTRICT error UX | error message + "메모 N건 이동 후 다시 시도" 안내 |
|
||||
| 다중 notebook 시 search default scope 혼란 | search box 옆 'current/all' 토글 + 기본 current |
|
||||
| F21 sync (Cut E) 와 결합 시 notebook 정합성 | sync markdown export 가 notebook_id 도 frontmatter 에 포함 — Cut E ImportService 갱신 (미리 spec 잔류 — Cut G 머지 시 ImportService 갱신 commit 포함) |
|
||||
| 다중 profile 옵션 A 로 진화 시 notebook → profile 마이그레이션 | v0.4+ 영역. 본 cut 은 단일 profile + notebook 다 |
|
||||
|
||||
---
|
||||
|
||||
## 11. v0.3.2 후
|
||||
|
||||
**v0.4 후보** (사용자 dogfood metric 충족 후 외부 확장):
|
||||
|
||||
- F25 옵션 A (다중 profile 분리 DB) — 외부 user 확장 시
|
||||
- F19 옵션 B (context-based recall — 시간/태그/요일)
|
||||
- F19 옵션 E (spaced repetition)
|
||||
- F25 옵션 C (다중 sync remote)
|
||||
|
||||
**dogfood verify**:
|
||||
|
||||
1. 사이드바 사용 빈도 (열린 채로 유지 / 토글 자주)
|
||||
2. notebook 갯수 (본인 dogfood — 1개 vs N개)
|
||||
3. notebook 간 메모 이동 빈도 (분류 욕구 측정)
|
||||
138
docs/superpowers/specs/2026-05-10-sync-help-design.md
Normal file
138
docs/superpowers/specs/2026-05-10-sync-help-design.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Sync 도움말 — Design
|
||||
|
||||
날짜: 2026-05-10
|
||||
대상 버전: v0.3.4 (또는 v0.4.0 통합 시 Cut G 안에 포함)
|
||||
선행 의존: v0.3.0 Cut E (양방향 sync), v0.3.3 (configure-sync ENOENT hotfix)
|
||||
|
||||
## 배경
|
||||
|
||||
v0.3.0 Cut E 가 양방향 sync (configure UI + ConflictModal + auto-sync timer) 를 도입했지만, 사용자에게 노출되는 도움말은 다음 세 곳 모두 부족 또는 부재:
|
||||
|
||||
- **SettingsPage > 동기화 저장소**: URL 입력 + 저장/연결 테스트 + 자동 sync 토글만 있음. 무엇이 어떻게 동작하는지 안내 0.
|
||||
- **ConflictModal**: "내 것 사용 (local)" / "원격 사용 (remote)" 버튼만 노출, 각 옵션의 의미·결과 미설명. 사용자는 추측에 의존.
|
||||
- **README "원격 백업 (F6-L2)" 섹션**: v0.2.1 MVP 시점 기준 (트레이 "지금 동기화" + 수동 `git init`). Cut E 의 Configure UI / ConflictModal / auto-sync timer 미반영 — 사용자가 따라하면 어긋남.
|
||||
|
||||
다기기 (Mac + Windows) sync dogfood 는 본인 + 사내 베타 10인의 핵심 가치 검증인데, conflict 시나리오에 막혔을 때 도움말이 없어 사용자가 직접 git 내부 동작을 추측해야 하는 상태.
|
||||
|
||||
## 목표
|
||||
|
||||
git 기반 sync 의 정상 동작·이상 시나리오·복구 절차를 사용자가 막힌 순간에 바로 찾을 수 있게 만든다.
|
||||
|
||||
비목표:
|
||||
|
||||
- 'both' choice (v0.3.1+ deferred) 도움말
|
||||
- 다국어 (앱 한국어 only)
|
||||
- 스크린샷·GIF (텍스트만으로 충분)
|
||||
- README 외 docs/sync-guide.md 별도 파일 (in-app 이 메인, README 가 보조 — 별도 파일 발견성 ↓)
|
||||
|
||||
## 표면 (3개)
|
||||
|
||||
### 1. SyncHelpModal — 신규 컴포넌트
|
||||
|
||||
**위치**: `src/renderer/inbox/components/SyncHelpModal.tsx`
|
||||
|
||||
**진입점**:
|
||||
|
||||
- `SyncSection.tsx`: URL 입력 row 옆에 "도움말" 버튼 추가 → 클릭 시 modal open
|
||||
- `ConflictModal.tsx`: 각 옵션 설명 옆 "자세히 보기 →" 링크 → SyncHelpModal open + "메인 conflict" 섹션으로 스크롤
|
||||
|
||||
**구조**: `ConflictModal` 패턴 재사용 (overlay + 닫기 버튼 + scrollable body). 4 섹션 (단순 anchor jump — 좌측 nav 미도입, modal 무게 ↓):
|
||||
|
||||
1. **메인 conflict** — 편집/편집, 삭제/편집, AI 결과 충돌 3 케이스 + 각 결정 트리
|
||||
2. **자동 처리** — fetch+rebase, 첫 sync 순서, push 순서 ("내가 안 해도 되는 일")
|
||||
3. **Silent risk** — 시계 어긋남(NTP), 결합 실패 silent, dogfood 주의
|
||||
4. **Setup/인증** — URL 형식 (SSH vs HTTPS), 인증 helper, 연결 실패 troubleshoot
|
||||
|
||||
**Close**: ESC + 우상단 X + overlay 클릭 (ConflictModal 패턴 일치).
|
||||
|
||||
### 2. ConflictModal 갱신
|
||||
|
||||
**현재**: 각 conflict path 에 대해 "내 것 사용 (local)" / "원격 사용 (remote)" 버튼만.
|
||||
|
||||
**변경**: 각 옵션 라벨 아래 1-2 줄 inline 설명 + "자세히 보기 →" 링크.
|
||||
|
||||
```text
|
||||
내 것 사용 (local)
|
||||
이 기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.
|
||||
자세히 보기 →
|
||||
|
||||
원격 사용 (remote)
|
||||
원격 (다른 기기 또는 백업) 의 변경을 가져오고 내 변경을 폐기.
|
||||
자세히 보기 →
|
||||
```
|
||||
|
||||
"자세히 보기" 클릭 → SyncHelpModal open (메인 conflict 섹션 anchor).
|
||||
|
||||
### 3. README "원격 백업 (F6-L2)" 섹션 통째 재작성
|
||||
|
||||
**현재 (line 193-223)**: v0.2.1 MVP 기준 stale.
|
||||
|
||||
**신규 헤더**: "## 동기화 (Git, F21 Cut E)"
|
||||
|
||||
**하위 절**:
|
||||
|
||||
- 일회 설정 — Settings > 동기화 저장소 UI 안내 (트레이 "지금 동기화" 안내 제거 — 현재 UI 와 다름)
|
||||
- URL 형식 명확화: `git@host:user/repo.git` (SSH) 또는 `https://host/owner/repo.git` (HTTPS). v0.3.3 dogfood 에서 발견된 `git@https://` 혼합 오류 사례 명시
|
||||
- 일상 사용 — auto-sync 주기 / 수동 트리거 / 충돌 시 ConflictModal 안내
|
||||
- 충돌 해결 — local/remote 결정 트리 (in-app SyncHelpModal 과 같은 내용)
|
||||
- Silent risk — 시계 어긋남, 동시 수정 회피
|
||||
- Troubleshoot — push 실패 / 인증 실패 / 첫 sync 순서
|
||||
|
||||
## 콘텐츠 분배
|
||||
|
||||
| 시나리오 | SyncHelpModal | ConflictModal inline | README |
|
||||
|---|---|---|---|
|
||||
| 편집/편집 conflict | 결정 트리 (어떤 변경이 더 최신인지 / 둘 다 보존하려면 사후 수동 병합) | 1줄 + "자세히" 링크 | 상세 + 예시 |
|
||||
| 삭제/편집 | 케이스 설명 (삭제 측이 'remote' 면 trash 로 이동, 편집 측이 'local' 이면 trash 취소) | (해당 없음 — path 가 같음) | 케이스 설명 |
|
||||
| AI 결과 충돌 | "재처리 권장" — local/remote 한쪽 선택 후 AI 재실행 권장 | (해당 없음) | 케이스 설명 |
|
||||
| fetch+rebase 자동 | "내가 안 해도 되는 일" 단원 | — | 동일 |
|
||||
| 첫 sync 순서 | "Mac 먼저 push → Windows pull 후 push" | — | 동일 |
|
||||
| 시계 어긋남 (NTP) | "두 기기 동시 수정 회피", `chrony` / Windows time sync 점검 안내 | — | 동일 |
|
||||
| Setup/URL 형식 | SSH/HTTPS 예시, `git@https://` 같은 혼합 형식 거부 사례 | — | 동일 + 인증 helper 안내 |
|
||||
| 인증 실패 | "OS credential helper 점검", token URL embed 우회 옵션 | — | 동일 |
|
||||
|
||||
## 변경 파일
|
||||
|
||||
**신규**:
|
||||
|
||||
- `src/renderer/inbox/components/SyncHelpModal.tsx`
|
||||
- `tests/unit/SyncHelpModal.test.tsx`
|
||||
|
||||
**수정**:
|
||||
|
||||
- `src/renderer/inbox/components/settings/SyncSection.tsx` — "도움말" 버튼 추가 (URL row 옆)
|
||||
- `src/renderer/inbox/components/ConflictModal.tsx` — 각 옵션 inline 설명 + "자세히" 링크
|
||||
- `tests/unit/ConflictModal.test.tsx` — inline 설명 / 링크 클릭 시 SyncHelpModal open 회귀
|
||||
- `tests/unit/SyncSection.test.tsx` — 도움말 버튼 클릭 → SyncHelpModal open 회귀
|
||||
- `README.md` — "원격 백업 (F6-L2)" 섹션 line 193-223 통째 재작성
|
||||
|
||||
## 게이트
|
||||
|
||||
- `SyncHelpModal.test.tsx` 신규 — 4 섹션 렌더링, close (ESC/X/overlay), anchor jump
|
||||
- `ConflictModal.test.tsx` 회귀 — inline 설명 표시, "자세히" 링크 → SyncHelpModal open
|
||||
- `SyncSection.test.tsx` 회귀 — 도움말 버튼 → SyncHelpModal open
|
||||
- typecheck 0
|
||||
- 단위 +6~8 (SyncHelpModal 4 + ConflictModal 회귀 1 + SyncSection 회귀 1)
|
||||
- e2e 미수행 (UI-only, 기존 capture/onboarding flow 무관)
|
||||
|
||||
## Risk
|
||||
|
||||
- **콘텐츠 정확성**: AI 결과 충돌 / 시계 어긋남 같은 시나리오는 dogfood 미경험 (v0.3.3 까지 1 dogfood 발견). 도움말이 실제 사용자 경험과 어긋날 risk → 1주 dogfood soak 후 도움말 텍스트 1차 갱신 필수
|
||||
- **README 와 in-app 의 중복 maintain**: 두 곳에 같은 내용. 정합성 깨질 risk → 우선순위는 in-app (사용자가 보는 위치). README 는 보조
|
||||
- **'both' choice 부재 안내**: v0.3.1+ deferred 인데 사용자가 "왜 둘 다 보존이 없냐" 질문 가능 → 도움말에 "현재 미지원, 사후 수동 병합" 명시
|
||||
- **콘텐츠 길이**: SyncHelpModal 4 섹션이 길어지면 modal 자체가 무거워짐 → 각 섹션 200 자 이내 + README 가 상세. modal 은 "막힌 순간 결정 트리" 우선
|
||||
|
||||
## 비포함 / Deferred
|
||||
|
||||
- 'both' choice 도움말 (Cut E 정책 deferred)
|
||||
- 다국어 (앱 한국어 only)
|
||||
- 스크린샷 / GIF
|
||||
- 별도 docs/sync-guide.md (README + in-app 으로 충분)
|
||||
- ConflictModal 의 diff 시각 개선 (별개 task, 본 도움말 cut 의 scope 외)
|
||||
- 도움말 검색 기능 (4 섹션, 짧음)
|
||||
|
||||
## How to apply
|
||||
|
||||
- v0.3.4 patch 또는 Cut G 안에 통합. 단독 cut 으로 갈 경우 v0.3.4 — 데이터/마이그레이션 변경 0
|
||||
- dogfood 1주 soak 후 도움말 텍스트 정합성 1차 갱신 (실제 사용자 경험과 어긋난 부분 보강)
|
||||
- ConflictModal 의 inline 설명은 "자세히 보기" 링크 한 번이 SyncHelpModal 메인 conflict 섹션 anchor 로 점프 — anchor id 명명: `#main-conflict`, `#auto`, `#silent`, `#setup`
|
||||
320
docs/superpowers/specs/2026-05-10-v032-cleanup-design.md
Normal file
320
docs/superpowers/specs/2026-05-10-v032-cleanup-design.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# v0.3.2 — Cleanup Cut Design
|
||||
|
||||
**작성일:** 2026-05-10
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/v024-backlog.md` (잔여 backlog audit)
|
||||
- `~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` (stale memory — 본 cut 에서 폐기)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md`
|
||||
|
||||
**Cut 라벨:** v0.3.2 — patch (기능 추가 X, 잠재 bug fix + cosmetic + 기록 정리)
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
기능 추가 X. backlog 잔여 23건 중 **잠재 bug 4건 + cosmetic 6건 + 기록 정리 2건 = 12건** 일괄 처리. data-dependent 항목 (telemetry 분포 의존) 과 cross-cutting refactor (TrayController class / Banner CSS variables) 는 dogfood 후 재평가. Cut F (v0.3.1) + Cut E (v0.3.0) 종합 dogfood ≥1주 soak 진입을 위한 baseline 정리.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **포함 카테고리** | 잠재 bug + cosmetic + 기록 정리 |
|
||||
| **보류 카테고리** | data-dependent (9건) + cross-cutting refactor (4건) |
|
||||
| **테스트 +** | 단위 710 → 약 720 (+10), typecheck 0 |
|
||||
| **schema 변경** | 없음 (m007 이후) |
|
||||
| **단일 PR** | v0.3.2 — 12 항목 = 7~8 commit (카테고리별 묶음) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 잠재 bug fix (4건)
|
||||
|
||||
### 3-1. `vocabSet` COLLATE NOCASE 정합 (#31)
|
||||
|
||||
**현재 코드** (`src/main/ai/AiWorker.ts` `processJob`):
|
||||
|
||||
```ts
|
||||
const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N);
|
||||
const vocabSet = new Set(vocab);
|
||||
// ...
|
||||
if (vocabSet.has(tagName)) { ... } // strict-eq, DB COLLATE NOCASE 와 충돌 가능
|
||||
```
|
||||
|
||||
**문제**: vocab pool 확장 시 (사용자가 `'Design'` 같은 capital case 추가하면) `getTagIdByName('Design')` 은 COLLATE NOCASE 로 매치되지만 `vocabSet.has('Design')` strict-eq 는 lowercase 만 등록된 set 에 miss → tagId 있는데 vocab hit 0 → silently skip.
|
||||
|
||||
**수정**:
|
||||
|
||||
```ts
|
||||
const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N);
|
||||
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
|
||||
// ...
|
||||
if (vocabSet.has(tagName.toLowerCase())) { ... }
|
||||
```
|
||||
|
||||
**테스트 (3건 신규)**:
|
||||
|
||||
- 대문자 vocab + lowercase AI tag → hit
|
||||
- lowercase vocab + 대문자 AI tag → hit
|
||||
- 동일 lowercase → hit (회귀)
|
||||
|
||||
### 3-2. Time-dependent test flake fix
|
||||
|
||||
**문제**: `NoteRevisions.test.ts` 의 v1 capture 가 `repo.create()` 호출 → `NoteRepository.create` 가 `new Date()` (NOW) 로 `created_at` / `edited_at` 박음. v2 는 fixed `2026-05-10T00:00:00Z` 명시 주입. 시스템 시계가 `2026-05-10T00:00:00Z` 초과 시 v1.edited_at > v2.edited_at → DESC ordering 깨짐. `upsertFromSync.test.ts` 도 동일 패턴.
|
||||
|
||||
**수정**: `NoteRepository.create(input, now?: Date)` 추가 (기존 `setStatus(id, status, reason, now: Date)` / `updateRawText(id, text, now: Date)` 패턴 정합).
|
||||
|
||||
```ts
|
||||
// src/main/repository/NoteRepository.ts
|
||||
create(input: CreateNoteInput, now: Date = new Date()): Note {
|
||||
const ts = now.toISOString();
|
||||
// 기존 INSERT 의 createdAt / updatedAt / edited_at 모두 ts 사용
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**기존 호출자 무영향** — production 코드는 `now` 생략 → 기본 `new Date()` 동일 동작.
|
||||
|
||||
**테스트 (5건 회복 + 2건 신규)**:
|
||||
|
||||
- `NoteRevisions.test.ts` 의 4 testcase v1 capture 도 fixed 시간 (`'2026-05-09T00:00:00Z'`) 주입 → v2 (`'2026-05-10T00:00:00Z'`) 이전 보장
|
||||
- `upsertFromSync.test.ts` 의 v1 capture 도 fixed 시간 주입
|
||||
- `NoteRepository.create` default 시간 (`now` 생략 시 `new Date()`) 단위 1
|
||||
- `NoteRepository.create` 명시 주입 단위 1
|
||||
|
||||
### 3-3. PII reason 마스킹 (#39)
|
||||
|
||||
**현재 코드** (`src/main/ai/LocalOllamaProvider.ts` `healthCheck`):
|
||||
|
||||
```ts
|
||||
} catch (e) {
|
||||
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable: ${(e as Error).message}` } });
|
||||
}
|
||||
```
|
||||
|
||||
**문제**: `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능 → telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN 사용 흔하게 만들어 노출 경로 확대.
|
||||
|
||||
**수정**: error class 분류 + host 마스킹.
|
||||
|
||||
```ts
|
||||
function classifyFetchError(e: unknown): 'timeout' | 'network' | 'dns' | 'other' {
|
||||
const msg = (e as Error).message?.toLowerCase() ?? '';
|
||||
if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
|
||||
if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
|
||||
if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// emit
|
||||
const reason = classifyFetchError(e);
|
||||
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason } });
|
||||
```
|
||||
|
||||
`AiFailedReason` zod enum (`'unreachable' | 'schema' | 'timeout' | 'other'`) 와 별개 — `ollama_unreachable.payload.reason` 만 신규 enum 도입 또는 기존 union 확장. spec 단계 결정: **기존 `'unreachable'` 그대로 유지**, 신규 enum 추가 X (단순화). reason 변환 후 prefix 만 변경:
|
||||
|
||||
```ts
|
||||
const cls = classifyFetchError(e);
|
||||
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable:${cls}` } });
|
||||
```
|
||||
|
||||
**테스트 (4건 신규)**:
|
||||
|
||||
- `ECONNREFUSED` → `unreachable:network`
|
||||
- `ETIMEDOUT` → `unreachable:timeout`
|
||||
- `ENOTFOUND` → `unreachable:dns`
|
||||
- 그 외 → `unreachable:other`
|
||||
|
||||
### 3-4. KST_OFFSET_MS inline duplication 5 callsite → import (#19)
|
||||
|
||||
**현재**: canonical `src/shared/util/kstDate.ts` 가 있는데 5 callsite inline duplicate.
|
||||
|
||||
| 파일 | 라인 | 처리 |
|
||||
|---|---|---|
|
||||
| `src/main/repository/NoteRepository.ts:1042` | inline `const KST_OFFSET_MS = ...` | `import { KST_OFFSET_MS } from '@shared/util/kstDate.js'` |
|
||||
| `src/main/repository/ftsHelpers.ts:18` | 동 | 동 |
|
||||
| `src/main/services/BackupService.ts:6` | 동 | 동 |
|
||||
| `src/main/services/ContinuityService.ts:4` | 동 | 동 |
|
||||
| `src/renderer/inbox/components/NoteCard.tsx:30` | 동 | 동 (renderer alias 경계 X — `@shared/...` 양쪽 import 가능) |
|
||||
|
||||
**테스트**: 단위 추가 없음 — 기존 회귀 검사. `kstDate.ts` 의 export 가 동일 값 (9 \* 60 \* 60 \* 1000) 이므로 동작 무변화.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cosmetic / readability (6건)
|
||||
|
||||
### 4-1. 탭 ARIA 패턴 정정 (#14)
|
||||
|
||||
`aria-pressed` 는 toggle 버튼용. 본 UI 의 탭 (Inbox / 휴지통 / 회고 등) 은 `role="tab"` + `aria-selected` canonical. screen reader 동작 OK 였지만 a11y audit canonical 정정.
|
||||
|
||||
**파일**: `src/renderer/inbox/App.tsx` (탭 컨테이너) — grep 으로 `aria-pressed` 위치 확정 후 수정.
|
||||
|
||||
**테스트**: 단위 추가 없음 (기존 RTL 단위가 selectable 검증). 단 `aria-selected="true"` 관련 assertion 1건 추가 검증.
|
||||
|
||||
### 4-2. `loadExpired()` 미사용 제거 (#18)
|
||||
|
||||
`store.ts` 의 `loadExpired()` action 이 `loadInitial`/`refreshMeta` 가 inline fetch 하면서 사용 안 함. App.tsx 호출 0건. test 만 exercise.
|
||||
|
||||
**처리**: dead-code 제거. 관련 test 도 제거.
|
||||
|
||||
### 4-3. AiWorker per-tag emit `Promise.all` 병렬화 (#32)
|
||||
|
||||
**현재**:
|
||||
|
||||
```ts
|
||||
for (const tag of new Set(...)) {
|
||||
await this.telemetry.emit({ kind: 'tag_vocab_hit' or 'miss', ... }); // serial
|
||||
}
|
||||
```
|
||||
|
||||
**수정**:
|
||||
|
||||
```ts
|
||||
await Promise.all(
|
||||
Array.from(new Set(...)).map((tag) => this.telemetry.emit({ ... }))
|
||||
);
|
||||
```
|
||||
|
||||
**Risk**: emit 순서 변경 — telemetry 파일 라인 순서 의존 단위 없음 확인. `ai_succeeded` emit 도 serial 이지만 본 cut 은 **per-tag emit 만** 변경 (tag_vocab_hit / tag_vocab_miss). `ai_succeeded` 는 serial 유지 (per-tag 와 다른 호출 시점).
|
||||
|
||||
**테스트**: 회귀 단위 PASS 확인. 신규 단위 추가 없음 (병렬 동작 자체 검증은 unit 무리).
|
||||
|
||||
### 4-4. `emitRecallShown` / `emitRecallSnoozed` `ipcMain.handle → on` (#36)
|
||||
|
||||
`fire-and-forget` 정책 호출 측 (RecallBanner) → return value 사용 안 함. canonical pattern: `ipcMain.on` (return value 없음).
|
||||
|
||||
**파일**: `src/main/ipc/inboxApi.ts` 또는 telemetry IPC 정의 위치. `emitRecallShown` / `emitRecallSnoozed` 만 `handle → on` migration. 호출 측 (`window.api.emitRecallShown` 등) 의 `Promise<void>` 시그니처 그대로 (preload 가 `ipcRenderer.send`).
|
||||
|
||||
**Risk**: `ipcMain.handle` 의 return value 의존 호출자 grep 으로 0 확인.
|
||||
|
||||
**테스트 (1건 신규)**: `vision-ipc.test.ts` 패턴 정합 — `ipcRenderer.send` 호출 검증.
|
||||
|
||||
### 4-5. OllamaSettingsModal 폐기 확인 (#41+#42)
|
||||
|
||||
**현재 audit**: `grep "OllamaSettingsModal" src/` → 0건. v0.2.7 cut 에서 이미 폐기됨 (memory: "OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup").
|
||||
|
||||
**처리**: backlog 항목 닫기만. **코드 변경 0**. v024-backlog.md 의 #41/#42 처리 이력 ✅ 추가.
|
||||
|
||||
### 4-6. Telemetry `.catch(() => {})` silent → debug log (#20)
|
||||
|
||||
**현재**: `CaptureService.listExpired` / `trashExpiredBatch` 가 `.catch(() => {})` 로 silent.
|
||||
|
||||
**수정**:
|
||||
|
||||
```ts
|
||||
this.telemetry.emit(...).catch((e) => {
|
||||
this.logger.debug('telemetry.emit.failed', { reason: String(e) });
|
||||
});
|
||||
```
|
||||
|
||||
`logger.debug` (project pattern) — production noise 0, 디버그 시 reproduce 가능.
|
||||
|
||||
**테스트**: 단위 추가 없음. 회귀 PASS.
|
||||
|
||||
---
|
||||
|
||||
## 5. 기록 정리 (2건)
|
||||
|
||||
### 5-1. v0.2.2 stale memory 폐기
|
||||
|
||||
`~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` — 6건 모두 v0.2.3~v0.2.9 cut 들에서 처리됨 (Ollama 회복 / AI 영속 큐 / 태그 vocab / 휴지통 / 만료 추천 / RecallBanner). 8일 stale.
|
||||
|
||||
**처리**:
|
||||
|
||||
- 파일 삭제
|
||||
- `MEMORY.md` 의 line 8 (`- [v0.2.2 feedback]...`) 제거
|
||||
|
||||
### 5-2. v024-backlog.md 갱신
|
||||
|
||||
처리 이력 table 에 신규 12건 entry 추가:
|
||||
|
||||
```markdown
|
||||
| #14 (탭 ARIA) | ✅ 처리 | v0.3.2 |
|
||||
| #18 (loadExpired 미사용) | ✅ 처리 | v0.3.2 |
|
||||
| #19 (KST inline 5 callsite 잔여) | ✅ 처리 | v0.3.2 |
|
||||
| #20 (.catch silent → debug log) | ✅ 처리 | v0.3.2 |
|
||||
| #31 (vocabSet COLLATE) | ✅ 처리 | v0.3.2 |
|
||||
| #32 (per-tag Promise.all) | ✅ 처리 | v0.3.2 |
|
||||
| #36 (recall IPC handle→on) | ✅ 처리 | v0.3.2 |
|
||||
| #39 (PII reason 마스킹) | ✅ 처리 | v0.3.2 |
|
||||
| #41+#42 (OllamaSettingsModal 폐기) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
|
||||
| time-dependent test flake | ✅ 처리 | v0.3.2 |
|
||||
```
|
||||
|
||||
총 처리 22 → 32, 잔여 23 → 11 (data-dependent 9 + future-proof 2).
|
||||
|
||||
---
|
||||
|
||||
## 6. 보류 항목 (dogfood 후 재평가)
|
||||
|
||||
### data-dependent (9건)
|
||||
|
||||
- #25 HealthChecker `inFlight` manual emit ordering — dogfood soak 결과 결정 (1초 윈도우 dedup 필요 여부)
|
||||
- #29 `getTopUsedTags(20)` magic number → `VOCAB_TOP_N` 모듈 상수 (이미 #29 처리, 튜닝 자체는 telemetry 후)
|
||||
- #30 `getTopUsedTags` SQL-side 필터 (overfetch+slice vs `GLOB`) — vocab pool 확장 결정
|
||||
- #33 `PROMPT_VERSION` telemetry payload 추가 — prompt 튜닝 후 hit-rate 추적 시
|
||||
- #35 `recall_shown` per-banner-lifetime dedup — telemetry 빈도 보고
|
||||
- #40 Settings 저장 vs HealthChecker race — visible 빈도 확인
|
||||
- #16 per-note 영구 삭제 telemetry 빈도 — 거의 0 이면 bulk emptyTrash 만 (UX 단순화)
|
||||
- #28 `unreachableBackoffStep` job-level — multi-provider 도입 시
|
||||
- recall_shown lifetime 영속 마커 (data-dependent #35 와 일부 중복)
|
||||
|
||||
### future-proof / cross-cutting (4건)
|
||||
|
||||
- #27 `refreshTrayFailedCount` exported singleton → TrayController class — multi-window 가설 검증 X
|
||||
- #24+#41 Banner CSS variables — modal 폐기로 일부 자연 소멸 + 잔여 4 banner 의 hardcode 색상은 단일 dogfood UX 영향 0
|
||||
- #37 NoteCard `id="note-${id}"` ref-forwarding — search 결과 scroll 등 신규 surface 등장 시
|
||||
- #28 단일 카운터 vs job-level — multi-provider 진입 시점
|
||||
|
||||
---
|
||||
|
||||
## 7. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| `vocabSet` lowercase normalize | 3 (대/소문자 vocab × AI tag matrix) |
|
||||
| `NoteRepository.create(now)` param | 2 (default / 명시 주입) |
|
||||
| `NoteRevisions.test.ts` flake fix | 4 testcase 회복 (v1 capture 시간 주입) |
|
||||
| `upsertFromSync.test.ts` flake fix | 2 testcase 회복 |
|
||||
| PII reason classification | 4 (network/timeout/dns/other) |
|
||||
| KST inline → import migration | 회귀 PASS 확인 (단위 +0) |
|
||||
| 탭 ARIA `aria-selected` | 1 신규 assertion |
|
||||
| `loadExpired` 제거 | 회귀 (test 같이 제거) |
|
||||
| AiWorker `Promise.all` 회귀 | 회귀 PASS |
|
||||
| recall IPC `on` migration | 1 신규 (`ipcRenderer.send` 검증) |
|
||||
| Telemetry `.catch` debug log | 회귀 PASS |
|
||||
|
||||
목표: 단위 710 → **약 720** (+10 신규, -2 제거 [`loadExpired` test], net +8). typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| `NoteRepository.create(now)` signature 변경 호출자 영향 | optional + default = `new Date()`. 기존 호출자 0 무영향. typecheck 가 누락 catch |
|
||||
| KST inline → import 회귀 (값 다른 정의) | canonical export 값 (9 \* 60 \* 60 \* 1000) 동일 검증. 기존 단위 회귀 PASS = 알고리즘 동일 |
|
||||
| `ipcMain.handle → on` migration 시 return value 의존 호출자 누락 | grep 으로 호출자 enumerated. preload 시그니처 그대로 (`Promise<void>`) — 호출 측 무수정 |
|
||||
| AiWorker `Promise.all` 으로 emit 순서 변경 | telemetry 파일 라인 순서 의존 단위 0 확인. file-append round-trip 만 줄어듦 |
|
||||
| PII 마스킹으로 디버그 어려움 | error class enum + production reproduce 시 dev 환경에서 stack 그대로 노출 (telemetry 만 마스킹) |
|
||||
| time-dependent fix 시 다른 시간 의존 단위 발견 | grep `new Date()` in `repo` methods → `create` 외 다른 메서드 audit. 발견 시 spec 갱신 X (cleanup cut 외 deferred) |
|
||||
|
||||
---
|
||||
|
||||
## 9. v0.3.2 후
|
||||
|
||||
**Cut G** (v0.3.3 또는 v0.4.0 — F25 사이드바 + notebook_id) 진입 전:
|
||||
|
||||
- v0.3.2 release → main → tag → Windows exe + Gitea release
|
||||
- macOS host 핸드오프: dist:mac dmg + dist:linux AppImage/deb (v0.3.0 + v0.3.1 + v0.3.2 누적 backlog)
|
||||
- **다기기 종합 dogfood ≥1주 soak**:
|
||||
- sync (Cut E): 충돌 빈도 / 인증 흐름 / interval 적정성 / NTP 단조 가정
|
||||
- vision (Cut F): 이미지 capture 빈도 / 한국어 정확도 / capability detection 정확도
|
||||
- cleanup (Cut 본 v0.3.2): 잠재 bug 회귀 X 확인
|
||||
- soak 후 신규 발견 + data-dependent 9건 일괄 triage → Cut G brainstorm 진입
|
||||
|
||||
**Cut G 가설** (재검증):
|
||||
|
||||
1. inbox 단일 view 의 정보 밀도 한계 (현재 노트 누적 N건 = 스크롤 부담)
|
||||
2. notebook 카테고리 = 분류 hint vs 단일 inbox + 태그 필터로 충분
|
||||
3. 사이드바 = 새 surface = 기존 정책 (트레이 deemphasis / SettingsPage 우선) 재고
|
||||
@@ -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 근거 명시
|
||||
@@ -1,9 +1,11 @@
|
||||
# Inkling — Dogfooding 전략
|
||||
|
||||
**작성일:** 2026-04-25
|
||||
**대상:** 김태현 (저자 본인) — 슬라이스 v0.4 dogfood 단계
|
||||
**스펙 의존:** `2026-04-24-inkling-vertical-slice-design.md` v0.4 §1.3 (종료 조건)
|
||||
**최종 갱신:** 2026-05-05 (v0.2.6 release 후 — environment step 갱신, 현재 단계 표기)
|
||||
**대상:** 김태현 (저자 본인) — 슬라이스 v0.4 → v0.2.6 dogfood 진행 중
|
||||
**스펙 의존:** `2026-04-24-inkling-vertical-slice-design.md` v0.4 §1.3 (종료 조건) + `2026-05-01-v023-feedback-roadmap-design.md` (v0.2.3 7항목 cut)
|
||||
**전략 의존:** `strategy.md` §1·§2·§5 (행동 정의, Capture→Clarify→Capitalize, 회복 친화 스트릭)
|
||||
**현재 binary:** v0.2.6 (`Inkling Setup 0.2.6.exe` — 2026-05-05 release)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,14 +29,24 @@ dogfood 첫 날 시작 전, 환경을 한 번에 정렬한다.
|
||||
|
||||
### 1.1 환경
|
||||
|
||||
- [ ] `.nvmrc` 의 Node 버전 (24.15.0) 활성화
|
||||
- [ ] `INKLING_OLLAMA_ENDPOINT` 가 LAN Ollama (`http://192.168.0.47:11434`) 를 가리킴
|
||||
- [ ] LAN Ollama 에 `gemma4:e4b` 가 pull 된 상태 확인 (`curl http://192.168.0.47:11434/api/tags`)
|
||||
- [ ] `npm run build` 후 `npm start` 로 정식 실행 (dev 모드 아님 — dogfood 는 프로덕션 빌드)
|
||||
- [ ] 윈도우 트레이에 Inkling 아이콘 떠 있음
|
||||
**v0.2.6 release 기준 (2026-05-05 갱신)**:
|
||||
|
||||
- [ ] **설치**: Gitea release 페이지 (`https://gitea.altair823.xyz/altair823-org/inkling/releases/tag/v0.2.6`) 에서 `Inkling-Setup-0.2.6.exe` 다운로드 + 설치
|
||||
- 또는 source 빌드: `npm run dist:win` (Windows) / `npm run dist:mac` (Mac arm64)
|
||||
- [ ] **Ollama 설정** (v0.2.3.1 PR #21 부터 in-app 가능):
|
||||
- 트레이 메뉴 → "Ollama 설정..." → endpoint + model 직접 입력
|
||||
- 또는 env var fallback: `INKLING_OLLAMA_ENDPOINT=http://192.168.0.47:11434` (LAN 서버)
|
||||
- 또는 default: `http://localhost:11434` (로컬 ollama serve 시)
|
||||
- **Windows 11434 reserved 머신 (Hyper-V/WSL2 사용 시)**: `OLLAMA_HOST=127.0.0.1:11942` setx + Inkling 설정 endpoint 도 11942 (자세한 내용 F8)
|
||||
- [ ] LAN Ollama 에 `gemma4:e4b` 가 pull 된 상태 확인 (`curl <endpoint>/api/tags`)
|
||||
- [ ] 윈도우 트레이에 Inkling 아이콘 떠 있음 (단일 instance — v0.2.5 PR #23 hotfix 로 multi-spawn 차단)
|
||||
- [ ] `Ctrl+Shift+J` 가 다른 앱(Chrome, Edge DevTools 등)에 충돌 없이 잡힘
|
||||
- [ ] OS 알림 권한 허용 — 첫 토스트 후 시스템 트레이에서 확인
|
||||
- [ ] `%APPDATA%\Inkling\default\inkling.db` 가 새로 생성됨 (이전 dogfood 데이터 분리하려면 이 파일을 백업·삭제)
|
||||
- [ ] **데이터 위치 확인** (v0.2.4 PR #22 트레이 "Inkling 정보..." → "데이터 위치 열기" 로 즉시 확인):
|
||||
- Windows: `%APPDATA%\Inkling\Inkling\profiles\default\inkling.sqlite`
|
||||
- macOS: `~/Library/Application Support/Inkling/Inkling/profiles/default/inkling.sqlite`
|
||||
- 이전 dogfood 데이터 분리하려면 이 디렉터리 백업·삭제
|
||||
- [ ] **autostart 확인** (v0.2.6 진단 fallback 적용 중, F12 dogfood verify 영역): 트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 → 체크박스 유지 여부 확인 (`autostart.state` 로그 같이 확인 = `<userData>/Inkling/logs/main-YYYY-MM-DD.log`)
|
||||
|
||||
### 1.2 dogfood 로그 파일 준비
|
||||
|
||||
|
||||
211
docs/superpowers/strategy/v028plus-roadmap.md
Normal file
211
docs/superpowers/strategy/v028plus-roadmap.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# v0.2.8+ Roadmap — F17~F25 cut 분할 + 우선순위
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**저자:** 김태현
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F17~F25 raw + chore 아이콘)
|
||||
- `docs/superpowers/v024-backlog.md` (잔여 23건 — v0.2.6 cut 후 deferred)
|
||||
- `docs/superpowers/strategy/strategy.md` (심리학 전략)
|
||||
|
||||
**목적:** v0.2.7 release 후 dogfood 9건 누적 + chore 1건 의 cut sequencing + 우선순위 + dependency 결정. v0.2.8 brainstorm 진입 직전 alignment 문서.
|
||||
|
||||
---
|
||||
|
||||
## 1. 항목 요약
|
||||
|
||||
| ID | 제목 | scope | 분류 |
|
||||
|---|---|---|---|
|
||||
| F17 | 휴지통 의미 분기 (완료/보관/버림) | 1주 (옵션 C 보관함만 별도) | 데이터 모델 |
|
||||
| F18 | 메모 이동 시 사유 입력 | 1일 (F17 묶음) | 데이터 모델 |
|
||||
| F19 | 획기적 recall (search/context/AI/회고/spaced/자연어) | A 단독 3-4일 / 묶음 1-2주 | UX 본질 |
|
||||
| F20 | 기존 메모 raw_text 수정 (load-bearing invariant 재검토) | 옵션 B 3-4일 | 데이터 모델 |
|
||||
| F21 | 다기기 git-based sync (양방향 + Configure + conflict) | 1-2주 | 인프라 |
|
||||
| F22 | NoteCard 이미지 회색 placeholder bug | 1-2일 | 명확한 bug |
|
||||
| F23 | 로컬 LLM 활성화 옵션 (Ollama-less 모드) | 3-4일 | 환경 대응 |
|
||||
| F24 | 이미지 멀티모달 vision AI | 1주 (F22 prerequisite) | AI 확장 |
|
||||
| F25 | 사이드바 + 메모 저장소 리스트 | 옵션 결정 후 1-3주 | UI 큰 변화 |
|
||||
| chore | 앱 아이콘 SVG → ICO/ICNS/PNG + builder 통합 | 0.5일 | release polish |
|
||||
|
||||
---
|
||||
|
||||
## 2. Dependency Graph
|
||||
|
||||
```dot
|
||||
digraph G {
|
||||
rankdir=LR;
|
||||
F22 -> F24 [label="prerequisite (이미지 렌더 → vision 결과 surface)"];
|
||||
F17 -> F18 [label="conceptual 강한 결합 (status + reason)"];
|
||||
F17 -> F19 [label="status 분기 데이터가 recall 입력"];
|
||||
F20 -> F21 [label="user_edited_text 가 sync 충돌 정책 입력"];
|
||||
F23 -> F19 [label="Ollama-less 시 recall 단순화 (tag 부재)"];
|
||||
F23 -> F17 [label="raw-only 모드에서 status 자동 분류 무력"];
|
||||
F25 -> F17 [label="저장소 + status + tag 분기 layer 정합 필요"];
|
||||
chore [shape=box, style=filled];
|
||||
F22 [shape=box, style=filled];
|
||||
chore -> "v0.2.8";
|
||||
F22 -> "v0.2.8";
|
||||
}
|
||||
```
|
||||
|
||||
**핵심 prerequisite chain:**
|
||||
|
||||
- F22 → F24 (이미지 보여야 vision 결과 surface 의미)
|
||||
- F20 → F21 (sync 충돌 정책 = `user_edited_text` 우선순위)
|
||||
- F17 + F23 → F19 (recall 알고리즘 입력은 status / Ollama-less 영향)
|
||||
|
||||
**독립 항목 (다른 항목 영향 받지 않음):**
|
||||
|
||||
- F22 (bug fix)
|
||||
- chore (icon)
|
||||
|
||||
---
|
||||
|
||||
## 3. Cut 분할 + 버전 매핑
|
||||
|
||||
### Cut A — v0.2.8 (1주 미만, 빠른 polish)
|
||||
|
||||
**테마:** dogfood UX 마찰 + release polish
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F22 (이미지 렌더링 fix) | 1-2일 — `inkling-media://` custom protocol + `<img>` |
|
||||
| chore (앱 아이콘) | 0.5일 — SVG → ICO/ICNS/PNG 다중 size + electron-builder config |
|
||||
|
||||
**합 2-3일.** 명확한 작업, 빠른 release. 의사결정 X (기술 detail 만).
|
||||
|
||||
### Cut B — v0.2.9 (2주, 데이터 모델 정비 1차)
|
||||
|
||||
**테마:** 휴지통의 의미 분기 + 사유 + Ollama-less
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F17 (status — 옵션 C 보관함만 별도) | 1주 — `archived_at` 컬럼 + UI 탭 + 마이그레이션 |
|
||||
| F18 (사유 입력 — preset + 자유 텍스트) | 1일 (F17 묶음) |
|
||||
| F23 (Ollama-less 토글) | 3-4일 — ai_status='disabled' enum + capture skip + UI fallback |
|
||||
|
||||
**합 1.5-2주.** F17/F18 같은 데이터 모델 변경 cut 안에 함께. F23 의 raw-only 모드가 F17 status 와 같은 schema 영역이라 효율.
|
||||
|
||||
**의사결정 필요 (brainstorm 단계)**:
|
||||
|
||||
- F17 옵션 A/B/C 중 — C 추천 (보관함만 별도) 가 가장 균형
|
||||
- F18 preset 항목 명세 ("완료" / "급하지 않음" / "잘못 적음" / "기타")
|
||||
- F23 ON↔OFF 전환 정책 (B1 추천 — 잔류)
|
||||
|
||||
### Cut C — v0.2.10 (1주, raw_text invariant)
|
||||
|
||||
**테마:** F20 단독 — load-bearing invariant 재검토
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F20 (raw_text 수정 — 옵션 B `user_edited_text`) | 3-4일 |
|
||||
|
||||
**합 1주.** Cut C 단독 cut 인 이유 = invariant 정책 변경 자체가 의사결정 큰 작업. 별도 PR 로 review focus 보장. 후속 Cut D (sync) 의 prerequisite.
|
||||
|
||||
**의사결정 필요**:
|
||||
|
||||
- 옵션 A (raw_text 직접 수정 + 원본 lost) vs B (`user_edited_text` 분기) — B 추천
|
||||
- AI 재실행 시 input — raw_text vs user_edited_text 우선순위
|
||||
|
||||
### Cut D — v0.2.11 (1.5-2주, recall 1차)
|
||||
|
||||
**테마:** F19 — search 진입 + 회고 view
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F19 옵션 A (FTS5 free text search) | 3-4일 |
|
||||
| F19 옵션 D (회고 view) | 1주 |
|
||||
|
||||
**합 1.5-2주.** F19 의 6 옵션 중 가장 작은 + 가치 큰 둘 (search + 회고). B/C/E/F 는 v0.3+ deferred.
|
||||
|
||||
**의사결정 필요**:
|
||||
|
||||
- search box 위치 (header / 사이드바 — F25 결정 영향)
|
||||
- 회고 view 트리거 (수동 라우트 / 월요일 자동 banner)
|
||||
|
||||
### Cut E — v0.3.0 (2주, 다기기 sync)
|
||||
|
||||
**테마:** F21 — 양방향 sync + Configure UI
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F21 옵션 A (양방향 sync — fetch+rebase+import) | 1주 |
|
||||
| F21 옵션 B (Configure UI) | 3-4일 |
|
||||
| F21 옵션 C (conflict UI) | 0.5주 |
|
||||
|
||||
**합 2주.** F20 의 user_edited_text 가 conflict 정책 입력 — 따라서 Cut C 후. v0.3.0 = MINOR bump (semver 엄밀히도 minor — 새 feature 큰 영역).
|
||||
|
||||
### Cut F — v0.3.1 (1-1.5주, 멀티모달 vision)
|
||||
|
||||
**테마:** F24 — Ollama vision 모델 활용
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F24 (capability detection + 멀티모달 prompt + InferenceProvider 확장) | 1주 |
|
||||
|
||||
**합 1주.** F22 prerequisite 충족 (Cut A) 이므로 진행 가능. F23 (Ollama-less) OFF 시 자동 OFF.
|
||||
|
||||
### Cut G — v0.3.2 (1-3주, 사이드바 + 저장소)
|
||||
|
||||
**테마:** F25 — UI 큰 변화
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F25 옵션 A (다중 profile) | 2-3주 — 큰 refactor |
|
||||
| F25 옵션 B (notebook_id) | 1주 |
|
||||
| F25 옵션 C (다중 sync remote) | 0.5주 |
|
||||
|
||||
**의사결정 필요 (직접 사용자 의도 확인)**:
|
||||
|
||||
- "메모 저장소" = 다중 DB 분리 (A) / 카테고리 폴더 (B) / sync remote (C) 어느 의미인가
|
||||
|
||||
---
|
||||
|
||||
## 4. 우선순위 + 시간선 추정
|
||||
|
||||
```
|
||||
2026-05-09 ~ 2026-05-15 Cut A (v0.2.8) ✦ 빠른 polish
|
||||
2026-05-15 ~ 2026-05-29 Cut B (v0.2.9) ✦ 데이터 모델 정비
|
||||
2026-05-29 ~ 2026-06-05 Cut C (v0.2.10) ✦ invariant 변경
|
||||
2026-06-05 ~ 2026-06-19 Cut D (v0.2.11) ✦ recall 1차
|
||||
2026-06-19 ~ 2026-07-03 Cut E (v0.3.0) ✦ 다기기 sync
|
||||
2026-07-03 ~ 2026-07-10 Cut F (v0.3.1) ✦ 멀티모달
|
||||
2026-07-10 ~ 2026-07-31 Cut G (v0.3.2) ✦ 사이드바 + 저장소
|
||||
```
|
||||
|
||||
**총 약 12주.** 본인 dogfood 2주 완주 종료 조건 (v0.4 slice §1.3) 은 Cut B 종료 시점 도달. 그 후 Cut C-G 는 외부 확장 영역.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk + Open Questions
|
||||
|
||||
| ID | 질문 |
|
||||
|---|---|
|
||||
| F17 | A/B/C 중 결정 — dogfood 1주 측정 후? |
|
||||
| F18 | preset 항목 정확 명세 |
|
||||
| F19 | recall 6 옵션 중 cut D 에 A+D 외 추가 여부 |
|
||||
| F20 | invariant 폐기 (옵션 A) 충분 vs B (`user_edited_text`) 분기 — B 균형 추천 |
|
||||
| F21 | conflict 처리 default (rebase / merge / 사용자 prompt) |
|
||||
| F23 | default ON / OFF — 본인 LAN Ollama 가정 시 ON, 외부 user 첫 실행 OFF? |
|
||||
| F24 | vision 모델 default 추천 (한국어 + 이미지) — dogfood 검증 필요 |
|
||||
| F25 | "메모 저장소" 정의 (A/B/C) — 직접 사용자 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 6. v0.2.8 brainstorm 진입 시 결정 사항
|
||||
|
||||
Cut A (v0.2.8) 는 의사결정 거의 없는 작업이라 brainstorm 가벼움. 그러나 절차상 진입.
|
||||
|
||||
**Cut A brainstorm focus:**
|
||||
|
||||
1. F22 — `inkling-media://` custom protocol 디테일 (path traversal 검사 / fallback / thumbnail vs full-size)
|
||||
2. chore — 아이콘 size 매트릭스 (16/32/64/128/256/512/1024) + electron-builder config (`build.win.icon`/`build.mac.icon`/`build.linux.icon`)
|
||||
3. v0.2.8 release notes 초안
|
||||
|
||||
이후 Cut B brainstorm 은 F17 옵션 결정 + F18 preset + F23 정책 등 의사결정 多. 별도 brainstorm 세션.
|
||||
|
||||
---
|
||||
|
||||
## 7. 변경 이력
|
||||
|
||||
- 2026-05-09: 작성. F17~F25 + chore 9+1 entry triage. Cut A~G 분할.
|
||||
227
docs/superpowers/v024-backlog.md
Normal file
227
docs/superpowers/v024-backlog.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# v0.2.x Backlog
|
||||
|
||||
> 누적 backlog. v0.2.3 cut (7항목 / PR #13~#19) 시점부터 PR review deferred + dogfood 발견 모두 합산. **파일명은 historic** (`v024-backlog.md`) — v0.2.4 ~ v0.2.6 cut 후에도 이어 사용. **v0.2.7 brainstorm 시** 신규 피드백 + 잔여 일괄 triage.
|
||||
|
||||
**누적 시작일:** 2026-05-01 (#7 telemetry skeleton 머지 시점)
|
||||
**최종 갱신:** 2026-05-10 (v0.3.2 cleanup cut — 잠재 bug 4 + cosmetic 5 + #20 deferred)
|
||||
**총 항목 수:** 46 (#1 stale 포함)
|
||||
**잔여:** 14건 (=46 − 처리 31 − stale 1)
|
||||
|
||||
## 처리 이력 / 진행 흐름
|
||||
|
||||
| 항목 | 상태 | Cut |
|
||||
|---|---|---|
|
||||
| #1 (`now()` 2번 호출) | ✅ 이미 fix (PR #13 round 1 — backlog stale) | - |
|
||||
| #2 (`DAY_MS` magic) | ✅ 처리 | v0.2.4 (commit `ef5d3da`) |
|
||||
| #6 (`media.gc.run()` `.catch`) | ✅ 처리 | v0.2.4 (commit `ef5d3da`) |
|
||||
| #13 (NoteCard `onDeleted` dead-code) | ✅ 처리 | v0.2.4 (commit `c87c248`) |
|
||||
| #44 (버전 정보 surface) | ✅ 처리 (트레이 "Inkling 정보..." + native dialog) | v0.2.4 (commit `d3dfe1e`) |
|
||||
| **out-of-backlog**: multi-instance bug (single-instance lock) | ✅ critical hotfix | v0.2.5 (PR #23, `7187aea`) |
|
||||
| #10 (restoreNote + pending_jobs) | ✅ 처리 (repo 메서드 + CaptureService production path) | v0.2.6 (commit `df27a96` + `a991008`) |
|
||||
| #12 (trashCount cap) | ✅ 이미 fix (v0.2.3 #4) — tests +2 추가 | v0.2.6 (commit `e2c53a2`) |
|
||||
| #45 (자동실행 풀림 버그) | ✅ 처리 (진단 노출 + 재등록 버튼) | v0.2.7 (commit `8a8652e` + `8368286`) |
|
||||
| #46 (hidden-start race) | ✅ 처리 (`additionalData` + handler hidden flag) | v0.2.6 (commit `e485b77`) |
|
||||
| #3+#19+#34 (KST helper 통합) | ✅ 처리 → `src/shared/util/kstDate.ts` (4 callsite migrate) | v0.2.6 (commit `3cfa60b`) |
|
||||
| #5 (AiFailedReason union) | ✅ 처리 (zod z.infer 단일 export) | v0.2.6 (commit `a2c17a8`) |
|
||||
| #21 (hasNoteId predicate) | ✅ 처리 (NO_NOTE_ID_KINDS Set + type predicate) | v0.2.6 (commit `05c45c1`) |
|
||||
| #22 (hydrate `as any[]`) | ✅ 처리 (`as Record<string, unknown>[]` 통일) | v0.2.6 (commit `983306e`) |
|
||||
| #8 (stats exhaustiveness) | ✅ 처리 (`else { _: never = ev }`) | v0.2.6 (commit `9230ebf`) |
|
||||
| #4+#23+#26+#27 (TrayCallbacks 객체화) | ✅ 처리 (1-arg + `Partial<TrayState>`) | v0.2.6 (commit `476a519`) |
|
||||
| #24+#41 (Banner shared component) | ✅ 처리 (`Banner severity=...` 4 callsite) | v0.2.6 (commit `0447b69`) |
|
||||
| #15 (IPC channel rename) | ✅ 처리 (`inbox:delete` → `inbox:trash`) | v0.2.6 (commit `8b2920f`) |
|
||||
| #29 (VOCAB_TOP_N const) | ✅ 처리 (튜닝 자체는 telemetry 후) | v0.2.6 (commit `8b2920f`) |
|
||||
| #42 (Modal URL pre-check) | ✅ 처리 (zod safeParse) | v0.2.6 (commit `8b2920f`) |
|
||||
| #9 (휴지통 회수율 ratio 코멘트) | ✅ 처리 (1줄 코멘트) | v0.2.6 (commit `8b2920f`) |
|
||||
|
||||
### v0.2.6 PR #24 round 1 발견 (Critical fix)
|
||||
|
||||
| 항목 | 상태 | Cut |
|
||||
|---|---|---|
|
||||
| **B1 production path** (CaptureService.restoreNote 가 옛 `repo.restore` 호출) | ✅ Critical fix (commit `a991008`) | v0.2.6 round 1 |
|
||||
|
||||
### v0.3.2 cleanup cut (2026-05-10)
|
||||
|
||||
| 항목 | 상태 | Cut |
|
||||
|---|---|---|
|
||||
| #14 (탭 ARIA `aria-pressed`→`role="tab"`) | ✅ 처리 | v0.3.2 |
|
||||
| #18 (`loadExpired` 미사용 제거) | ✅ 처리 | v0.3.2 |
|
||||
| #19 (KST inline 5 callsite 잔여 migrate) | ✅ 처리 | v0.3.2 |
|
||||
| #20 (telemetry `.catch` silent → debug log) | 🟡 deferred (CaptureService logger 미주입 — constructor 변경 회피) | v0.3.2 audit |
|
||||
| #31 (vocabSet COLLATE NOCASE 정합) | ✅ 처리 | v0.3.2 |
|
||||
| #32 (per-tag emit `Promise.all` 병렬화) | ✅ 처리 | v0.3.2 |
|
||||
| #36 (recall IPC `handle`→`on`) | ✅ 처리 | v0.3.2 |
|
||||
| #39 (`ollama_unreachable.reason` PII 마스킹) | ✅ 처리 | v0.3.2 |
|
||||
| #41+#42 (`OllamaSettingsModal` 폐기 audit) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
|
||||
| time-dependent test flake fix | ✅ 처리 | v0.3.2 |
|
||||
|
||||
### v0.2.6 final reviewer + round 1 minors (deferred)
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| NoteRepository.countToday inline KST_OFFSET_MS | v0.2.7 cleanup (C1 spec 외) |
|
||||
| BackupService / ContinuityService inline KST_OFFSET_MS | v0.2.7 cleanup |
|
||||
| NoteRepository.test.ts:125 `as any` | v0.2.7 (C6 spec 외) |
|
||||
| OllamaSettingsModal `#fce4e4` inline (C7 spec 5번째) | modal 컨텍스트라 보류 |
|
||||
| `kstDate(ts)` semantic naming (telemetryStats) | v0.2.7 |
|
||||
| store.ts:177 trashCount race on `trashExpiredBatch` | pre-existing, v0.2.7 |
|
||||
| ExpiryBanner useEffect 24h+ closure | edge case, defer |
|
||||
|
||||
**잔여 24건** (= 46 − 처리 21 − stale 1). v0.2.7 brainstorm 시 신규 dogfood 피드백 + #45 deeper fix + 위 deferred 항목 일괄 triage.
|
||||
|
||||
## 명명 노트
|
||||
|
||||
- v0.2.3.1 / v0.2.4 / v0.2.5 는 **dogfood unblock patch** (semver bump 강제 / hotfix)
|
||||
- v0.2.6 = 첫 정식 cut (16 backlog 항목 처리)
|
||||
- v0.2.7 = 다음 정식 feature cut (telemetry data-dependent 14건 + 신규 피드백 + 잔여 deferred)
|
||||
- 본 backlog 파일은 v0.2.7 cut 시점에 prune + rename 검토 (`v027-backlog.md` 또는 stable 한 `feature-backlog.md`)
|
||||
|
||||
## Defer 사유 카테고리
|
||||
|
||||
각 항목은 머지 전 inline fix 보다 v0.2.4 영역으로 미룬 명시적 사유 가짐:
|
||||
|
||||
1. **Cross-cutting refactor** — 한 PR 안에서 부분만 고치면 inconsistency. 일괄 cleanup task 영역. (예: KST helper 4 callsite 통합, `createTray` positional callbacks 전체 객체화)
|
||||
2. **Data-dependent** — dogfood telemetry 분포 보고 결정해야 의미. (예: top-N 튜닝, recall_shown lifetime dedup 정책)
|
||||
3. **Cosmetic / style** — 동작 영향 0, 다른 일괄 cleanup task. (예: `now()` 두 번 호출, `as any[]` 통합)
|
||||
|
||||
## How to apply
|
||||
|
||||
v0.2.6 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
|
||||
|
||||
- (a) 그대로 cleanup
|
||||
- (b) #4~#6 영향 받아 변형
|
||||
- (c) defer-further 결정
|
||||
- (d) drop (만에 하나 outdated 또는 v0.2.4/v0.2.5 patch 가 우회 처리)
|
||||
|
||||
## v0.2.3 #7 Telemetry skeleton 누적 (2026-05-01)
|
||||
|
||||
1. **`now()` 두 번 호출** — `TelemetryService.emit` (`src/main/services/TelemetryService.ts:58, :60`) 가 같은 emit 안에서 `this.now()` 두 번. 이론적 midnight straddle 가능 (ts vs filePath 다른 KST 일자), 실제 영향 cosmetic. cleanup: `const nowDate = this.now()` 한 번 추출.
|
||||
|
||||
2. **`DAY_MS = 24*60*60*1000` magic number** — `cleanupOldFiles:39` + `readAllRecent:78` (+ `KST_OFFSET_MS` 간접). 모듈 상단에 `const DAY_MS = 24 * 60 * 60 * 1000;` 추출.
|
||||
|
||||
3. **KST helper duplication** — `TelemetryService.todayKstIso` + `telemetryStats.kstDate` + `AiWorker.todayKstAsDate`/`todayKstAsIso`. 4번째 caller (예: 회상 schedule, 만료 batching) 등장 시 `src/main/util/kst.ts` 로 통합.
|
||||
|
||||
4. **`createTray` positional 폭주** — `tray.ts:51` 가 7 positional callbacks. #1 ollama 회복 / #4 휴지통 비우기 등 트레이 메뉴 추가 시 8+ 도달 → readability threshold 넘김. `TrayCallbacks` object 로 refactor.
|
||||
|
||||
5. **`AiFailedReason` union 3 곳 중복** — `'unreachable' | 'schema' | 'timeout' | 'other'` 가 `telemetryEvents.ts:15` (zod), `TelemetryService.ts:21` (EmitInput), `AiWorker.ts:19, :34` (classifier + emitter) 에 분산. `export type AiFailedReason` 하나로 통합. (단 zod enum + TS literal 의 inherent dual-define 은 어쩔 수 없음 — `z.infer` 통해 type 파생만)
|
||||
|
||||
6. **`media.gc.run()` 의 `.catch` 누락** — T11 에서 `telemetry.cleanupOldFiles` 의 `.catch` 일관성 처리 시 `media.gc` 도 같은 패턴 (`.catch` 없음) 발견. `backup.runDaily()` 와 컨벤션 통일 위해 `.catch((e) => logger.warn('media.gc.failed', { reason: String(e) }))` 추가.
|
||||
|
||||
7. **stats.md 의 reason 분포 미포함** — `telemetryStats.aggregateStats` 가 AI 성공률만 계산, `ai_failed.payload.reason` 의 분포 (unreachable/schema/timeout/other counts) 는 미집계. roadmap §6.2 의 "Ollama unreachable 빈도?" 질문이 부분적으로만 답해짐. v0.2.3 dogfood 후 실제 reason 분포 보고 결정.
|
||||
|
||||
## v0.2.3 #4 휴지통 누적 (2026-05-01)
|
||||
|
||||
8. **stats.md exhaustiveness check** — `telemetryStats.aggregateStats` 의 7-arm if/else if 가 union 확장 시 silent fall-through. `else { const _: never = ev; }` 추가로 컴파일 단계 가드.
|
||||
|
||||
9. **휴지통 회수율 ratio 의미 코멘트** — `restore / trash` 가 event-level ratio (한 노트 trash-restore 반복 시 100% 가능). spec §6.2 의 "회수 도구 동작?" 질문에는 충분, 단 unique-note 회수율로 오해할 여지. 코드 옆 1줄 코멘트.
|
||||
|
||||
10. **`restore` 시 AI 결과 보존 + pending_jobs 미재생성** — restore 가 `deleted_at = NULL` 만, pending_jobs 안 재생성. 사용자가 trash 도중 AI fail 한 노트를 restore 시 재처리 경로 부재. v0.2.3 dogfood 에서 빈도 보고 결정 — drop / per-note retry 버튼 / 자동 재처리 중.
|
||||
|
||||
11. **`restoreNote(id)` precondition 노출** — store 의 낙관적 갱신이 `trashNotes` 에 노트가 있어야 동작. 명령 팔레트 / 프로그래밍 호출 케이스 시 silently no-op. 현재는 trash view 한정이라 OK. main 이 trash/restore 시 `pushNoteUpdated` 보내도록 변경하면 더 견고.
|
||||
|
||||
12. **`inbox:trashCount` cap 200 silent undercount** — UI 만 200 cap, `repo.emptyTrash()` SQL 은 unbounded. 350 노트 trash 시 dialog "200개 영구 삭제" 표시되지만 실제 350 모두 삭제. `repo.countTrashed()` 추가로 둘 다 정확히. **(잠재 UX 버그 — pull-forward 후보)**
|
||||
|
||||
13. **NoteCard `mode='trash'` 의 `onDeleted` dead-code** — trash 카드는 `onPermanentDelete`/`onRestore` 만 사용. `onDeleted` prop 은 호출되지 않음 (App.tsx 가 pass-through). API 깔끔히 — `onDeleted?` optional + trash 분기 미전달.
|
||||
|
||||
14. **탭 ARIA 패턴** — `aria-pressed` 로 toggle 버튼 표현. canonical 은 `role="tab"` + `aria-selected`. screen reader 동작 OK 지만 a11y audit 시 정정 후보.
|
||||
|
||||
15. **`inbox:delete` 채널 rename** — semantic 이 hard → soft 인데 채널 이름 그대로. v0.2.4 에서 `inbox:trash` 로 rename 검토 (기존 호출 0건 보장 후).
|
||||
|
||||
16. **per-note 영구 삭제 telemetry 사용량** — v0.2.3 dogfood 에서 `permanent_delete` event 빈도 확인. 거의 0 이면 v0.2.4 에서 per-card "영구 삭제" 버튼 제거 + bulk emptyTrash 만 (UX 단순화). 빈번하면 유지.
|
||||
|
||||
## v0.2.3 #5 만료 추천 누적 (2026-05-01)
|
||||
|
||||
17. **dialog 버튼 순서 vs spec §5.3** — spec 은 `['취소','옮기기'], default=0`, 구현은 `['옮기기','취소'], defaultId=1, cancelId=1` (`inboxApi.ts:117`). 효과 동일 (default = cancel). v0.2.4 에서 spec 또는 impl 한쪽 통일.
|
||||
|
||||
18. **`loadExpired()` 미사용** — `loadInitial`/`refreshMeta` 가 inline fetch, App.tsx 도 호출 안 함 (test 만 exercise). v0.2.4 dogfood 후에도 consumer 미발생 시 제거 검토.
|
||||
|
||||
19. **store `KST_OFFSET_MS` inline duplication** — `store.ts:166` 의 `snoozeExpired` 가 inline KST 계산. `@main/util/kstDate.ts` 와 동일 알고리즘이지만 alias 경계 (main vs renderer) 로 import 불가. `src/shared/util/kstDate.ts` 로 lift 검토. (#3, #34 와 합산 가능)
|
||||
|
||||
20. **telemetry emit `.catch(() => {})` 가 silent** — `CaptureService.listExpired`/`trashExpiredBatch` 가 그대로. v0.2.4 telemetry 하드닝 시 debug log path (project pattern 통일) 추가 검토.
|
||||
|
||||
21. **TelemetryService.test.ts 의 noteId 가드 widening** — `e.kind !== 'empty_trash' && e.kind !== 'expired_banner_shown' && e.kind !== 'expired_batch_trash'` 체인이 #6 추가 시 더 길어짐. `hasNoteId(ev)` type predicate helper 추출 검토.
|
||||
|
||||
22. **NoteRepository hydrate 의 `as any[]` 일괄 cleanup** — `findExpiredCandidates` round 1 review 의 nit 가 단독 fix 시 다른 hydrate-using methods 와 inconsistency. `db.prepare().all()` 의 row type 을 `Record<string, unknown>[]` 또는 explicit row interface 로 통일하는 repo-wide refactor.
|
||||
|
||||
## v0.2.3 #1 ollama 회복 누적 (2026-05-01)
|
||||
|
||||
23. **`createTray` 8 positional callbacks** — #1 cut 에서 8개 도달, v0.2.4 backlog #4 와 정합 (TrayCallbacks object refactor 약속). #2 retry 또는 #6 reminder cut 에서 추가 항목 (예: "재시도 N건") 등장 시 9+ 회피 위해 본격 refactor.
|
||||
|
||||
24. **Banner CSS 스타일 inline 중복** — ExpiryBanner (`#fff7e6 / #d99500 / #946100` 황색) / OllamaBanner (동) / FailedBanner (`#fce4e4 / #a33` 적색) / RecallBanner (`#e8f0fe / #4a7ec0` 청색) 모두 색상 hardcode. v0.2.4 에서 CSS variables 또는 banner shared component (`<Banner severity="warning|error|info" />`) 추출 검토.
|
||||
|
||||
25. **HealthChecker `inFlight` 가드의 manual emit ordering** — manual emit 이 inFlight 체크 전 발생해 user 가 빠르게 N번 클릭하면 N개 manual telemetry. spec 의도 (1:1 보장) 와 정합이지만, 향후 dedup 정책 (예: 1초 윈도우) 으로 변형 가능성. v0.2.4 dogfood soak 결과로 결정.
|
||||
|
||||
## v0.2.3 #2 AI retry 누적 (2026-05-02)
|
||||
|
||||
26. **`createTray` 9 positional callbacks** — #2 cut 에서 9개 도달 (refreshTrayFailedCount 포함). #4 `TrayCallbacks` object refactor 가 이제 readability blocker. #3 / #6 cut 어느 쪽이든 추가 callback 더 들어오기 전에 우선 처리.
|
||||
|
||||
27. **`refreshTrayFailedCount` exported singleton state** — `tray.ts` 에 `_failedCount` module-scoped state + setter 패턴. 모듈 캡슐화로 작동하지만 multi-window 또는 multi-tray 시 broken. v0.2.4 refactor 시 TrayController class 또는 store-driven 으로 정리.
|
||||
|
||||
28. **`AiWorker.unreachableBackoffStep` 단일 카운터 vs job-level** — 모든 job 이 step counter 공유. 1 job timeout → step↑, 다른 job 정상 처리해도 step reset. 현재는 cross-job correlation 없으니 OK 가정 (Ollama daemon 단일이라 모든 job 이 같은 백엔드 의존). multi-provider 가 들어오면 provider-level step 으로 분리 필요.
|
||||
|
||||
## v0.2.3 #3 태그 vocab 누적 (2026-05-02)
|
||||
|
||||
29. **`getTopUsedTags(20)` magic number** — `AiWorker.processJob:137` 가 `repo.getTopUsedTags(20)` hardcoded. spec §7 Out 에 "top-N 튜닝" 명시. v0.2.4 dogfood telemetry (`tag_vocab_hit/miss` ratio) 보고 `VOCAB_TOP_N` 모듈 상수 추출 + 튜닝 결정.
|
||||
|
||||
30. **`getTopUsedTags` LIMIT-then-filter 의미** — SQL 가 limit 만큼 가져온 후 JS regex 가 후처리 → top-20 안에 한글/공백 태그 섞이면 결과 length < limit. dogfood 규모 OK 가정 + 테스트 lock-in (v0.2.3 round 1 m2 fix). v0.2.4 에서 vocab pool 확장 시 SQL `GLOB` 으로 SQL-side 필터 대안 검토 (또는 `LIMIT ?*2` overfetch+slice).
|
||||
|
||||
31. **`vocabSet` strict-eq vs DB COLLATE NOCASE 불일치** — `vocabSet = new Set(vocab)` 은 JS 대소문자 strict, `tags.name` 은 COLLATE NOCASE. 현재는 kebab-case 필터로 vocab 이 항상 lowercase + AI prompt 도 lowercase 강제라 충돌 없지만, vocab pool 확장 시 (예: `'Design'` 사용자 직접 추가) `getTagIdByName('Design')` 은 매치하지만 `vocabSet.has('Design')` 은 miss → tagId 없는 hit 가 silently skip. v0.2.4 에서 `vocabSet = new Set(vocab.map(v => v.toLowerCase()))` + `vocabSet.has(tagName.toLowerCase())` 로 normalize 검토.
|
||||
|
||||
32. **AiWorker per-tag emit serial await** — `for (const tag of new Set(...))` 안의 `await this.telemetry.emit(...)` 가 직렬. 3 태그 시 file-append 3 round-trip. `Promise.all` 로 병렬화 가능, 단 `ai_succeeded` emit 도 serial 이라 패턴 일관성 우선 skip. v0.2.4 telemetry 하드닝 시 일괄 변경 검토.
|
||||
|
||||
33. **`PROMPT_VERSION` telemetry payload 미포함** — v0.2.3 cut 에선 단일 버전 (4) 만 굴러가서 무의미. v0.2.4/v0.2.5 prompt 튜닝 후 어느 버전이 어떤 hit-rate 만든지 추적 시 `tag_vocab_hit/miss` payload 에 `promptVersion` 추가 검토. spec §7 Out 명시.
|
||||
|
||||
## v0.2.3 #6 RecallBanner 누적 (2026-05-02)
|
||||
|
||||
34. **KST midnight inline calc 4번째 복제** — `store.ts` 의 `snoozeRecall` (#6) + `snoozeExpired` (#5) + `NoteCard.todayKstIso` + 다른 1곳, 그리고 `kstDate.ts` util 도 별도 존재. 4 callsite 모두 동일 알고리즘. v0.2.4 에서 `nextKstMidnightMs()` / `kstTodayIso()` 단일 util 통합 + alias 경계 (main vs renderer) 해결책. backlog #3, #19 와 합산.
|
||||
|
||||
35. **`recall_shown` per-banner-lifetime emit 보장** — useState→useRef 로 race 차단했지만 RecallBanner 컴포넌트 unmount/remount 시 reset. 사용자가 페이지 이동 후 돌아오면 같은 노트가 재emit 가능. v0.2.4 dogfood telemetry 에서 동일 noteId 의 `recall_shown` 빈도 보고 결정 (per-noteId 24h dedup 또는 per-noteId 영속 마커).
|
||||
|
||||
36. **`emitRecallShown` / `emitRecallSnoozed` 가 fire-and-forget 인데 `ipcMain.handle` 사용** — 더 honest 한 패턴은 `ipcMain.on` (return value 없음). 현재는 다른 IPC 와 패턴 일관성 우선. v0.2.4 IPC 정리 시 `handle` vs `on` 구분 일괄 검토.
|
||||
|
||||
37. **NoteCard `id="note-${id}"` load-bearing** — RecallBanner 의 `scrollIntoView` target. 단순 DOM lookup 이라 shadow DOM / portal 미지원. v0.2.4 에서 다른 surface (예: 검색 결과에서 스크롤) 등장 시 ref-forwarding 패턴 검토.
|
||||
|
||||
## v0.2.3.1 Ollama Settings 누적 (2026-05-04)
|
||||
|
||||
39. **`ollama_unreachable.reason` 에 endpoint URL 노출 (PII 우회)** — `LocalOllamaProvider.healthCheck` 가 catch err 시 `reason: \`unreachable: ${err.message}\`` 로 emit. `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능. v0.2.3.1 의 in-app endpoint UI 가 LAN 사용을 흔하게 만들어 PII 우회 노출 경로 확대. v0.2.4 telemetry 하드닝 시: error class only (network/dns/timeout/...) 또는 host 마스킹 (`<host>:11434`) 정책. PR #21 round 1 m2 deferred.
|
||||
|
||||
40. **Settings 저장 vs HealthChecker 60s tick race** — `saveOllamaSettings` IPC 가 `health.runOnce()` 호출, 동시에 60s 주기 tick 도 `inFlight` 가드 통해 같이 실행 시도. 정확성 영향 0 (가드로 dedup), 단 modal 닫기 직전 banner flicker 가능. PR #21 round 1 i1 acknowledge only. v0.2.4 dogfood 에서 실제 빈도 확인 후 결정 (visible 빈도 낮으면 무시).
|
||||
|
||||
41. **`OllamaSettingsModal` 인라인 스타일** — 60+ 줄 inline style. backlog #24 (banner CSS 추출) 와 합산. v0.2.4 에서 CSS module / theme variables 추출 시 함께.
|
||||
|
||||
42. **Modal 의 client-side URL validation 부재** — endpoint freetext 가 잘못된 형식 (예: 빈 문자열, 한글) 일 때 server-side healthCheck 만 검증. zod URL error message 가 opaque ("Invalid url"). v0.2.4 에서 client-side z.string().url() pre-check + 친화적 에러 메시지.
|
||||
|
||||
43. **`createTray` 10번째 positional callback** — v0.2.3.1 cut 에서 10개 도달 (`runOpenOllamaSettings` 추가). backlog #4/#26 (TrayCallbacks object refactor) blocker 수준. v0.2.4 첫 cleanup 항목 후보.
|
||||
|
||||
## v0.2.3 / v0.2.3.1 dogfood 발견 (2026-05-05)
|
||||
|
||||
> 본 cut 들의 머지 후 사용자가 dogfood 중 발견한 항목. PR review deferred 와 달리 raw UX/bug 발견.
|
||||
|
||||
44. **버전 및 프로그램 정보 표시 방법 부재** — 현재 사용자가 설치된 Inkling 의 버전 (package.json `0.2.3.1`) 을 UI 에서 확인할 path 없음. 트레이 메뉴 / Inbox 푸터 / 별도 "About Inkling" 모달 어느 surface 에도 정보 없음. 핸드오프 후 다른 머신에서 같은 버전인지 사용자가 직접 검증 불가. v0.2.4 에서 트레이 메뉴 "Inkling 0.2.3.1 정보..." 또는 Inbox 우하단 footer 형태로 추가 검토. 곁들여: 빌드 commit SHA, electron/node 버전, OS, profileDir 경로 등 디버그 정보 노출 (사용자가 issue report 시 첨부 가능).
|
||||
|
||||
45. **윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그** — 트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 시 체크박스가 풀려서 표시됨. 코드 (`src/main/tray.ts:47-58`) 가 `app.setLoginItemSettings({ openAtLogin, args: ['--hidden'] })` 호출 후 다음 부팅 시 `app.getLoginItemSettings().openAtLogin` 이 false 반환. 추정 원인:
|
||||
- (a) Windows registry 에 쓴 exe path 와 현재 프로세스 path 가 다름 (NSIS 설치 위치 변경 / 버전 업데이트 시 새 디렉터리)
|
||||
- (b) Electron `setLoginItemSettings` Windows 구현 의 path canonicalization 이슈
|
||||
- (c) 우리 `args: ['--hidden']` 와 actual launch 시 args 비교 mismatch
|
||||
- 영향: dogfood UX 핵심 마찰 — autostart 가 핸드오프 시 매번 수동 재설정 필요. 자동 실행 의도 자체가 dogfood "잊지 않고 매일 사용" 목적인데 깨짐.
|
||||
- v0.2.6 에서 우선순위 높음. 진단 절차: (1) `app.getLoginItemSettings({ args: ['--hidden'] })` 형태로 args 전달해 비교 정확도 올리기, (2) registry 직접 inspect (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling`) 로 path/args 확인, (3) executable path canonicalization (electron 이 short path 변환 적용 여부).
|
||||
|
||||
## v0.2.5 critical hotfix 누적 (2026-05-05)
|
||||
|
||||
> v0.2.5 single-instance lock hotfix (PR #23) 의 reviewer deferred 항목.
|
||||
|
||||
46. **Hidden-start race (NSIS installer 자동 실행 + 사용자 클릭 충돌)** — NSIS installer 가 설치 직후 사용자가 시작메뉴 / 데스크톱 아이콘 클릭 (`inkling.exe`) + autostart entry (`inkling.exe --hidden`) 을 짧은 간격에 둘 다 시도 시 — 첫 lock 보유자에 따라 visible 여부 race. 본 cut 의 `second-instance` handler 는 무조건 inbox 창 띄움 (사용자 클릭 = 보고 싶다는 강한 시그널 가정). 매우 드문 시나리오 + lock 자체는 정상 동작 (한 쪽만 살아남음).
|
||||
- 영향: drm-edge 케이스만, 실 사용 거의 X
|
||||
- v0.2.6 에서: `app.requestSingleInstanceLock(additionalData)` 의 `additionalData: { hidden: startedHidden }` 전달 → `second-instance(event, argv, cwd, additionalData)` 에서 두 번째 호출이 hidden 이면 창 안 띄우는 정책. 첫 instance 가 자기 자신의 hidden 상태와 비교해 visible 결정.
|
||||
- PR #23 round 1 reviewer Important — acknowledge only, defer to v0.2.6.
|
||||
|
||||
## post-cut next-step (status, not backlog)
|
||||
|
||||
38. **빌드 / release 흐름 (status)** — v0.2.3 cut 7/7 (PR #13~#19) → binary v0.2.3 release → 11434 포트 reserved 발견 → v0.2.3.1 attempt (PR #21) → semver 거부 → v0.2.4 (PR #22, backlog 5건 + Ollama 설정 UI) → release → multi-instance bug 발견 → **v0.2.5 critical hotfix** (PR #23, single-instance lock) → release ✅ (2026-05-05). 다음: dogfood ≥1주 soak → telemetry export + 신규 피드백 → **v0.2.6 brainstorm 트리거** (잔여 backlog 40건 일괄 triage).
|
||||
|
||||
## v0.2.3 cut 후 final reviewer 가 칭찬한 부분
|
||||
|
||||
- 2-layer privacy invariant (zod outer + payload `.strict()`) 가 강한 defense
|
||||
- KST 처리 일관성 — 4 callsites 동일 패턴
|
||||
- backward compat — 기존 13 테스트 (Capture 4 + AiWorker 9) 무수정 통과
|
||||
- 신규 dep 0 (zip 회피로 폴더 + 2 file 정책)
|
||||
- TelemetryService surface 가 깔끔한 foundation — 다음 항목들이 (a) zod schema 추가, (b) EmitInput arm 추가, (c) emit 호출만 하면 됨
|
||||
4185
package-lock.json
generated
4185
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
49
package.json
49
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.3",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
@@ -28,7 +28,11 @@
|
||||
"predist:win": "npm run rebuild:electron && npm run build",
|
||||
"dist:win": "electron-builder --win --x64",
|
||||
"predist:mac": "npm run rebuild:electron && npm run build",
|
||||
"dist:mac": "electron-builder --mac --arm64"
|
||||
"dist:mac": "electron-builder --mac --arm64",
|
||||
"predist:linux": "npm run rebuild:electron && npm run build",
|
||||
"dist:linux": "electron-builder --linux --x64",
|
||||
"build:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024",
|
||||
"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten && node scripts/finalize-icons.mjs"
|
||||
},
|
||||
"build": {
|
||||
"appId": "xyz.altair823.inkling",
|
||||
@@ -42,8 +46,14 @@
|
||||
"**/*.node"
|
||||
],
|
||||
"win": {
|
||||
"icon": "build/icon.ico",
|
||||
"target": [
|
||||
{ "target": "nsis", "arch": ["x64"] }
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
@@ -54,11 +64,37 @@
|
||||
"shortcutName": "Inkling"
|
||||
},
|
||||
"mac": {
|
||||
"icon": "build/icon.icns",
|
||||
"target": [
|
||||
{ "target": "dmg", "arch": ["arm64"] }
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"category": "public.app-category.productivity",
|
||||
"identity": null
|
||||
},
|
||||
"linux": {
|
||||
"icon": "build/icon.png",
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"category": "Utility",
|
||||
"synopsis": "로컬 메모 캡처 + AI 태그",
|
||||
"description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구."
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -72,6 +108,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/better-sqlite3": "7.6.11",
|
||||
"@types/node": "24.0.0",
|
||||
"@types/react": "19.0.0",
|
||||
@@ -79,7 +117,10 @@
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"electron": "41.3.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "5.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "6.0.3",
|
||||
"undici": "8.1.0",
|
||||
"vite": "7.3.2",
|
||||
|
||||
35
scripts/finalize-icons.mjs
Normal file
35
scripts/finalize-icons.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import { copyFileSync, renameSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// electron-icon-builder --flatten 은 build/icons/ 안에 icon.ico, icon.icns, <size>x<size>.png
|
||||
// 들을 만든다. electron-builder 는 build/icon.ico, build/icon.icns, build/icon.png 를
|
||||
// 기대 — 정규 위치로 옮긴다.
|
||||
const buildDir = 'build';
|
||||
const iconsDir = join(buildDir, 'icons');
|
||||
|
||||
const moves = [
|
||||
['icon.ico', 'icon.ico'],
|
||||
['icon.icns', 'icon.icns'],
|
||||
];
|
||||
|
||||
for (const [src, dest] of moves) {
|
||||
const from = join(iconsDir, src);
|
||||
const to = join(buildDir, dest);
|
||||
if (existsSync(from)) {
|
||||
renameSync(from, to);
|
||||
console.log(`Moved: ${from} -> ${to}`);
|
||||
} else {
|
||||
console.error(`MISSING: ${from}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const png1024 = join(iconsDir, '1024x1024.png');
|
||||
const pngOut = join(buildDir, 'icon.png');
|
||||
if (existsSync(png1024)) {
|
||||
copyFileSync(png1024, pngOut);
|
||||
console.log(`Copied: ${png1024} -> ${pngOut}`);
|
||||
} else {
|
||||
console.error(`MISSING: ${png1024}`);
|
||||
process.exit(1);
|
||||
}
|
||||
14
scripts/svg-to-png.mjs
Normal file
14
scripts/svg-to-png.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import sharp from 'sharp';
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const [, , input, output, size = '1024'] = process.argv;
|
||||
if (!input || !output) {
|
||||
console.error('Usage: svg-to-png.mjs <input.svg> <output.png> [size]');
|
||||
process.exit(1);
|
||||
}
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
const svg = readFileSync(input);
|
||||
const png = await sharp(svg).resize(Number(size), Number(size)).png().toBuffer();
|
||||
writeFileSync(output, png);
|
||||
console.log(`OK: ${output} (${size}x${size})`);
|
||||
@@ -1,22 +1,19 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { InferenceProvider } from './InferenceProvider.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';
|
||||
import type { MediaStore } from '../services/MediaStore.js';
|
||||
import { ProviderHolder } from './ProviderHolder.js';
|
||||
import { parseAllCandidates } from '../services/dueDateParser.js';
|
||||
import { ZodError } from 'zod';
|
||||
import { kstTodayAsDate, kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
// v0.2.6 #29 — backlog 의 top-N 튜닝은 dogfood telemetry 후 (현재 magic 만 추출).
|
||||
const VOCAB_TOP_N = 20;
|
||||
|
||||
function todayKstAsDate(now: Date): Date {
|
||||
// Returns a Date object whose UTC year/month/day match KST today
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
|
||||
}
|
||||
|
||||
function todayKstAsIso(now: Date): string {
|
||||
return todayKstAsDate(now).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'other' {
|
||||
function classifyReason(err: unknown): AiFailedReason {
|
||||
if (err instanceof ZodError) return 'schema';
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) {
|
||||
@@ -31,7 +28,7 @@ function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'o
|
||||
export interface AiTelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
|
||||
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
|
||||
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
|
||||
): Promise<void>;
|
||||
@@ -48,6 +45,12 @@ export interface AiWorkerOptions {
|
||||
};
|
||||
now?: () => Date;
|
||||
telemetry?: AiTelemetryEmitter;
|
||||
/** v0.3.1 Cut F — vision 지원. 미전달 시 vision 비활성. */
|
||||
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; }
|
||||
@@ -63,10 +66,13 @@ export class AiWorker {
|
||||
private logger: NonNullable<AiWorkerOptions['logger']>;
|
||||
private now: () => Date;
|
||||
private telemetry?: AiTelemetryEmitter;
|
||||
private settings?: Pick<SettingsService, 'getVisionModel'>;
|
||||
private mediaStore?: Pick<MediaStore, 'absolutePath'>;
|
||||
private notebookRepo?: Pick<NotebookRepository, 'list' | 'findByName' | 'moveNote'>;
|
||||
|
||||
constructor(
|
||||
private repo: NoteRepository,
|
||||
private provider: InferenceProvider,
|
||||
private holder: ProviderHolder,
|
||||
opts: AiWorkerOptions = {}
|
||||
) {
|
||||
this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000];
|
||||
@@ -75,6 +81,9 @@ export class AiWorker {
|
||||
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
|
||||
this.now = opts.now ?? (() => new Date());
|
||||
this.telemetry = opts.telemetry;
|
||||
this.settings = opts.settings;
|
||||
this.mediaStore = opts.mediaStore;
|
||||
this.notebookRepo = opts.notebookRepo;
|
||||
}
|
||||
|
||||
async enqueue(noteId: string): Promise<void> {
|
||||
@@ -131,24 +140,77 @@ export class AiWorker {
|
||||
const note = this.repo.findById(job.noteId);
|
||||
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
|
||||
const nowDate = this.now();
|
||||
const todayDate = todayKstAsDate(nowDate);
|
||||
const todayIso = todayKstAsIso(nowDate);
|
||||
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(20);
|
||||
const res = await this.provider.generate({
|
||||
text: note.rawText,
|
||||
todayKst: todayIso,
|
||||
dueDateCandidates: candidates,
|
||||
vocab
|
||||
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
|
||||
// v0.3.1 Cut F — vision path: visionModel + note.media → base64 images
|
||||
// final review fix: note.media[].bytes 로 fast-fail (readFile/base64 비용 회피).
|
||||
// 5MB cap 초과 시 throw → AiWorker 의 'other' 분기 → markAiFailed 도달.
|
||||
const visionModel = this.settings ? await this.settings.getVisionModel() : null;
|
||||
let images: Array<{ base64: string; mime: string }> | undefined;
|
||||
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)`);
|
||||
}
|
||||
images = await Promise.all(
|
||||
note.media.map(async (m) => {
|
||||
const buf = await readFile(this.mediaStore!.absolutePath(m.relPath));
|
||||
return { base64: buf.toString('base64'), mime: m.mime };
|
||||
})
|
||||
);
|
||||
}
|
||||
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, notebooks: notebookNames },
|
||||
{ visionModel: visionModel ?? undefined }
|
||||
);
|
||||
// AI primary: AI's dueDate is final (no rule merge)
|
||||
this.repo.updateAiResult(job.noteId, {
|
||||
title: res.title,
|
||||
summary: res.summary,
|
||||
tags: res.tags,
|
||||
provider: this.provider.name,
|
||||
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,
|
||||
@@ -157,7 +219,8 @@ export class AiWorker {
|
||||
candidatesCount: candidates.length
|
||||
});
|
||||
if (this.telemetry) {
|
||||
await this.telemetry.emit({
|
||||
const telemetry = this.telemetry;
|
||||
await telemetry.emit({
|
||||
kind: 'ai_succeeded',
|
||||
payload: {
|
||||
noteId: job.noteId,
|
||||
@@ -167,23 +230,25 @@ export class AiWorker {
|
||||
}).catch(() => {});
|
||||
// v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장)
|
||||
// dedup: AI 응답에 같은 태그 중복 가능 — INSERT OR IGNORE 와 정합한 1-emit/태그 보장
|
||||
const vocabSet = new Set(vocab);
|
||||
for (const tagName of new Set(res.tags)) {
|
||||
if (vocabSet.has(tagName)) {
|
||||
const tagId = this.repo.getTagIdByName(tagName);
|
||||
if (tagId !== null) {
|
||||
await this.telemetry.emit({
|
||||
kind: 'tag_vocab_hit',
|
||||
payload: { tagId, vocabSize: vocab.length }
|
||||
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
|
||||
await Promise.all(
|
||||
Array.from(new Set(res.tags)).map(async (tagName) => {
|
||||
if (vocabSet.has(tagName.toLowerCase())) {
|
||||
const tagId = this.repo.getTagIdByName(tagName);
|
||||
if (tagId !== null) {
|
||||
await telemetry.emit({
|
||||
kind: 'tag_vocab_hit',
|
||||
payload: { tagId, vocabSize: vocab.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
await telemetry.emit({
|
||||
kind: 'tag_vocab_miss',
|
||||
payload: { vocabSize: vocab.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
await this.telemetry.emit({
|
||||
kind: 'tag_vocab_miss',
|
||||
payload: { vocabSize: vocab.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
this.emit(job.noteId);
|
||||
return;
|
||||
@@ -211,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',
|
||||
|
||||
@@ -6,12 +6,28 @@ export interface GenerateInput {
|
||||
todayKst: string; // ISO YYYY-MM-DD in KST
|
||||
dueDateCandidates: ParseResult[];
|
||||
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 {
|
||||
/** v0.3.1 Cut F — vision 전용 model 지정. null/미전달 시 기본 model 사용. */
|
||||
visionModel?: string | null;
|
||||
}
|
||||
|
||||
export interface HealthResult { ok: boolean; model?: string; reason?: string; }
|
||||
|
||||
export interface InferenceProvider {
|
||||
readonly name: string;
|
||||
generate(input: GenerateInput): Promise<AiResponse>;
|
||||
generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse>;
|
||||
healthCheck(): Promise<HealthResult>;
|
||||
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
|
||||
abort?: () => void;
|
||||
/**
|
||||
* v0.2.9 Cut B Task 9 — raw JSON 응답 호출. classifyStatus 같은 자체 prompt 호출용.
|
||||
* Ollama `/api/generate` 의 raw `response` 문자열을 그대로 반환한다 (보통 JSON 문자열).
|
||||
* 미구현 provider 는 undefined; classifyStatus 는 그 경우 안전 fallback 으로 동작.
|
||||
*/
|
||||
generateRaw?: (prompt: string) => Promise<string>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import { request } from 'undici';
|
||||
import { parseAiResponse, type AiResponse } from './schema.js';
|
||||
import { buildPrompt } from './prompt.js';
|
||||
import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js';
|
||||
import { buildVisionPrompt } from './visionPrompt.js';
|
||||
import type { GenerateInput, GenerateOptions, HealthResult, InferenceProvider } from './InferenceProvider.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js';
|
||||
|
||||
function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' {
|
||||
const msg = ((e as Error)?.message ?? '').toLowerCase();
|
||||
if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
|
||||
if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
|
||||
if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
|
||||
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;
|
||||
@@ -18,43 +51,99 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
private timeoutMs: number;
|
||||
private temperature: number;
|
||||
private numPredict: number;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
constructor(opts: LocalOllamaOptions = {}) {
|
||||
this.endpoint = opts.endpoint ?? 'http://localhost:11434';
|
||||
this.model = opts.model ?? 'gemma4:e4b';
|
||||
this.endpoint = opts.endpoint ?? DEFAULT_OLLAMA_ENDPOINT;
|
||||
this.model = opts.model ?? DEFAULT_OLLAMA_MODEL;
|
||||
this.timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
this.temperature = opts.temperature ?? 0.2;
|
||||
this.numPredict = opts.numPredict ?? 512;
|
||||
this.name = `local-ollama/${this.model}`;
|
||||
}
|
||||
|
||||
async generate(input: GenerateInput): Promise<AiResponse> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
async generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse> {
|
||||
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
|
||||
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 ?? [], 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(), effectiveTimeout);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
prompt,
|
||||
format: 'json',
|
||||
stream: false,
|
||||
// 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);
|
||||
}
|
||||
const res = await request(`${this.endpoint}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw new Error(`ollama http ${res.statusCode}`);
|
||||
}
|
||||
const responseBody = (await res.body.json()) as { response?: string };
|
||||
if (!responseBody.response) throw new Error('missing response field');
|
||||
// v0.3.11 — vision model 응답이 markdown fence / prose 섞인 경우 fallback 추출.
|
||||
const parsed = parseJsonLoose(responseBody.response);
|
||||
return parseAiResponse(parsed);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
|
||||
abort(): void {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 9 — raw JSON 호출 (classifyStatus 등 자체 prompt 용).
|
||||
* `format: 'json'` + `stream: false` 로 Ollama 가 valid JSON 문자열을 반환하도록 강제.
|
||||
* abortController / timeout 은 generate() 와 동일 패턴.
|
||||
*/
|
||||
async generateRaw(prompt: string): Promise<string> {
|
||||
this.abortController = new AbortController();
|
||||
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
|
||||
try {
|
||||
const res = await request(`${this.endpoint}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
|
||||
prompt,
|
||||
format: 'json',
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
}),
|
||||
signal: controller.signal
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw new Error(`ollama http ${res.statusCode}`);
|
||||
}
|
||||
const body = (await res.body.json()) as { response?: string };
|
||||
if (!body.response) throw new Error('missing response field');
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(body.response); }
|
||||
catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
|
||||
return parseAiResponse(parsed);
|
||||
return body.response;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +156,8 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
return found ? { ok: true, model: this.model }
|
||||
: { ok: false, reason: `${this.model} not installed` };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `unreachable: ${(err as Error).message}` };
|
||||
const cls = classifyFetchError(err);
|
||||
return { ok: false, reason: `unreachable:${cls}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
src/main/ai/ProviderHolder.ts
Normal file
36
src/main/ai/ProviderHolder.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { InferenceProvider } from './InferenceProvider.js';
|
||||
|
||||
/**
|
||||
* v0.2.3.1 — Mutable provider holder. AiWorker / HealthChecker 가 endpoint 변경 시
|
||||
* 새 LocalOllamaProvider 인스턴스를 받도록 indirection layer.
|
||||
*
|
||||
* 사용 패턴:
|
||||
* const holder = new ProviderHolder(initialProvider);
|
||||
* aiWorker = new AiWorker(repo, holder, opts);
|
||||
* health = new HealthChecker(holder, opts);
|
||||
*
|
||||
* // 사용자가 Settings 저장 시:
|
||||
* holder.get().abort?.(); // in-flight 중단 (LocalOllamaProvider 전용)
|
||||
* holder.replace(newProvider); // 모든 consumer 가 새 인스턴스 사용
|
||||
*/
|
||||
export class ProviderHolder {
|
||||
private current: InferenceProvider;
|
||||
private listeners: Array<(p: InferenceProvider) => void> = [];
|
||||
|
||||
constructor(initial: InferenceProvider) {
|
||||
this.current = initial;
|
||||
}
|
||||
|
||||
get(): InferenceProvider {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
replace(next: InferenceProvider): void {
|
||||
this.current = next;
|
||||
for (const fn of this.listeners) fn(next);
|
||||
}
|
||||
|
||||
onReplace(fn: (p: InferenceProvider) => void): void {
|
||||
this.listeners.push(fn);
|
||||
}
|
||||
}
|
||||
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 };
|
||||
}
|
||||
85
src/main/ai/classifyStatus.ts
Normal file
85
src/main/ai/classifyStatus.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { InferenceProvider } from './InferenceProvider.js';
|
||||
import type { NoteStatus } from '@shared/types';
|
||||
|
||||
export interface ClassifyStatusInput {
|
||||
provider: InferenceProvider;
|
||||
rawText: string;
|
||||
summary: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ClassifyStatusOutput {
|
||||
recommended: NoteStatus;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
// v0.4 Task 16 — 'archived' 제거. completed / trashed 만 유효 추천값.
|
||||
// AI 응답이 'archived' 를 반환해도 VALID 에 없으므로 FALLBACK(completed) 로 coerce됨.
|
||||
const VALID: readonly NoteStatus[] = ['completed', 'trashed'];
|
||||
|
||||
const PROMPT_TEMPLATE = `다음 메모를 분류하세요.
|
||||
가능한 status:
|
||||
- completed: 작업이 끝났고 더 이상 행동 불필요
|
||||
- trashed: 불필요, 의미 없는 메모
|
||||
|
||||
JSON 출력만 하세요: { "recommended": "completed|trashed", "rationale": "<한 문장 한국어>" }
|
||||
|
||||
메모 본문:
|
||||
{{rawText}}
|
||||
|
||||
메모 요약:
|
||||
{{summary}}
|
||||
|
||||
사용자 이동 사유:
|
||||
{{reason}}`;
|
||||
|
||||
// v0.4 Task 16 — fallback 을 'completed' 로 변경 ('archived' 제거).
|
||||
const FALLBACK: ClassifyStatusOutput = {
|
||||
recommended: 'completed',
|
||||
rationale: '판단 실패 — 완료 처리 추천'
|
||||
};
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
||||
*
|
||||
* provider.generateRaw 가 있으면 raw JSON 응답 사용, 없으면 generate() 재사용 시도
|
||||
* (그 경우 응답 형태 불일치로 보통 fallback). 에러/parse 실패 시 'completed' fallback
|
||||
* (v0.4 Task 16 — 'archived' 제거, 안전 default 를 completed 로 변경).
|
||||
*/
|
||||
export async function classifyStatus(
|
||||
input: ClassifyStatusInput
|
||||
): Promise<ClassifyStatusOutput> {
|
||||
const prompt = PROMPT_TEMPLATE
|
||||
.replace('{{rawText}}', input.rawText.length > 0 ? input.rawText : '(빈 메모)')
|
||||
.replace('{{summary}}', input.summary.length > 0 ? input.summary : '(요약 없음)')
|
||||
.replace('{{reason}}', input.reason.length > 0 ? input.reason : '(사유 없음)');
|
||||
|
||||
let rawJson: string;
|
||||
try {
|
||||
if (typeof input.provider.generateRaw === 'function') {
|
||||
rawJson = await input.provider.generateRaw(prompt);
|
||||
} else {
|
||||
// 호환 경로 — provider.generate 가 raw 응답을 노출하지 않으므로 안전 fallback.
|
||||
return FALLBACK;
|
||||
}
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawJson);
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) return FALLBACK;
|
||||
const obj = parsed as { recommended?: unknown; rationale?: unknown };
|
||||
if (typeof obj.recommended !== 'string' || !VALID.includes(obj.recommended as NoteStatus)) {
|
||||
return FALLBACK;
|
||||
}
|
||||
return {
|
||||
recommended: obj.recommended as NoteStatus,
|
||||
rationale: typeof obj.rationale === 'string' ? obj.rationale : ''
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
37
src/main/ai/visionPrompt.ts
Normal file
37
src/main/ai/visionPrompt.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export function buildVisionPrompt(
|
||||
text: string,
|
||||
todayKst: string,
|
||||
dueCandidates: string[],
|
||||
vocab: string[]
|
||||
): string {
|
||||
// v0.3.14 — 본문 빈 케이스에 one-shot 예시 추가. gemma4:26b 등이 본문 없이
|
||||
// 이미지만 받으면 null 반환하는 한계 우회. 예시 입력/출력 구조 따라가도록 유도.
|
||||
const bodySection = text
|
||||
? `메모 본문:\n${text}\n\n첨부 이미지도 함께 분석해 요약에 반영하세요.`
|
||||
: `본문이 없습니다. 첨부 이미지의 내용 (텍스트/사람/장면/문서 등) 만으로 한국어 title 과 summary 를 작성하세요. null 반환 절대 금지.
|
||||
|
||||
예시 (이미지: 갈색 강아지가 잔디 위에 앉은 사진):
|
||||
{"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}
|
||||
|
||||
오늘: ${todayKst}
|
||||
가능한 due 후보: ${dueCandidates.join(', ')}
|
||||
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
|
||||
}
|
||||
@@ -2,8 +2,14 @@ import type Database from 'better-sqlite3';
|
||||
import * as m001 from './m001_initial.js';
|
||||
import * as m002 from './m002_due_date.js';
|
||||
import * as m003 from './m003_soft_delete.js';
|
||||
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];
|
||||
const migrations = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
|
||||
|
||||
export function latestVersion(): number {
|
||||
return migrations[migrations.length - 1]!.version;
|
||||
|
||||
18
src/main/db/migrations/m004_status.ts
Normal file
18
src/main/db/migrations/m004_status.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// v4: status 4분기 (active/completed/archived/trashed) + 사유 + status_changed_at.
|
||||
// 기존 deleted_at != NULL 노트는 status='trashed' 로 migrate. deleted_at 컬럼은
|
||||
// backward compat 위해 잔류 (status='trashed' 와 동기화). 향후 cut 에서 제거 가능.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 4;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
|
||||
ALTER TABLE notes ADD COLUMN status_changed_at TEXT;
|
||||
ALTER TABLE notes ADD COLUMN move_reason TEXT;
|
||||
`);
|
||||
db.prepare(
|
||||
`UPDATE notes SET status='trashed', status_changed_at=deleted_at
|
||||
WHERE deleted_at IS NOT NULL`
|
||||
).run();
|
||||
}
|
||||
65
src/main/db/migrations/m005_ai_disabled.ts
Normal file
65
src/main/db/migrations/m005_ai_disabled.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// v5: ai_status enum 에 'disabled' 추가 (v0.2.9 Cut B). settings.ai_enabled=false 일 때
|
||||
// CaptureService 가 새 노트를 ai_status='disabled' 로 insert + pending_jobs enqueue skip.
|
||||
//
|
||||
// SQLite 는 ALTER COLUMN ... CHECK 미지원 → table recreate 패턴.
|
||||
// 외래키 (note_tags / media / pending_jobs) 는 notes.id 를 참조 + ON DELETE CASCADE 라
|
||||
// FK off + DROP/RENAME 시 데이터 보존 위해 새 테이블 생성 → INSERT SELECT → DROP old → RENAME new.
|
||||
// PRAGMA foreign_keys=OFF 안에서 single transaction (runMigrations 가 transaction 으로 감쌈).
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 5;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
// 기존 인덱스/CHECK 제약을 그대로 유지하되 ai_status 만 'disabled' 추가.
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE notes_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
raw_text TEXT NOT NULL,
|
||||
ai_title TEXT,
|
||||
ai_summary TEXT,
|
||||
ai_status TEXT NOT NULL
|
||||
CHECK (ai_status IN ('pending','done','failed','disabled')),
|
||||
ai_error TEXT,
|
||||
ai_provider TEXT,
|
||||
ai_generated_at TEXT,
|
||||
title_edited_by_user INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (title_edited_by_user IN (0,1)),
|
||||
summary_edited_by_user INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (summary_edited_by_user IN (0,1)),
|
||||
user_intent TEXT,
|
||||
intent_prompted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
due_date TEXT,
|
||||
due_date_edited_by_user INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (due_date_edited_by_user IN (0,1)),
|
||||
deleted_at TEXT,
|
||||
last_recalled_at TEXT,
|
||||
recall_dismissed_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
status_changed_at TEXT,
|
||||
move_reason TEXT
|
||||
);
|
||||
INSERT INTO notes_new (
|
||||
id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at,
|
||||
created_at, updated_at, due_date, due_date_edited_by_user,
|
||||
deleted_at, last_recalled_at, recall_dismissed_at,
|
||||
status, status_changed_at, move_reason
|
||||
)
|
||||
SELECT
|
||||
id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at,
|
||||
created_at, updated_at, due_date, due_date_edited_by_user,
|
||||
deleted_at, last_recalled_at, recall_dismissed_at,
|
||||
status, status_changed_at, move_reason
|
||||
FROM notes;
|
||||
DROP TABLE notes;
|
||||
ALTER TABLE notes_new RENAME TO notes;
|
||||
CREATE INDEX idx_notes_created_at ON notes(created_at DESC);
|
||||
CREATE INDEX idx_notes_ai_status ON notes(ai_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
|
||||
PRAGMA foreign_keys=ON;
|
||||
`);
|
||||
}
|
||||
23
src/main/db/migrations/m006_revisions.ts
Normal file
23
src/main/db/migrations/m006_revisions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// v6: note_revisions 테이블 + 기존 notes 의 raw_text 를 edited_by='capture' revision 으로 backfill.
|
||||
// FK ON DELETE CASCADE — notes 영구 삭제 시 revision 도 함께 삭제.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 6;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE note_revisions (
|
||||
rev_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL,
|
||||
edited_at TEXT NOT NULL,
|
||||
edited_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (edited_by IN ('user','capture')),
|
||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC);
|
||||
|
||||
INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
SELECT id, raw_text, created_at, 'capture' FROM notes;
|
||||
`);
|
||||
}
|
||||
48
src/main/db/migrations/m007_fts.ts
Normal file
48
src/main/db/migrations/m007_fts.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// v7: notes_fts FTS5 virtual table + trigger 3개 + 기존 notes (status != 'trashed') backfill.
|
||||
// raw_text/ai_title/ai_summary 는 trigger 자동 sync. tags 는 note_tags JOIN 결과를
|
||||
// NoteRepository 의 명시 헬퍼 (rebuildFtsTagsForNote) 로 갱신 — Cut D 의 single write path.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 7;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE notes_fts USING fts5(
|
||||
note_id UNINDEXED,
|
||||
raw_text,
|
||||
ai_title,
|
||||
ai_summary,
|
||||
tags,
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
CREATE TRIGGER notes_fts_ai AFTER INSERT ON notes BEGIN
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), '');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_fts_ad AFTER DELETE ON notes BEGIN
|
||||
DELETE FROM notes_fts WHERE note_id = OLD.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_fts_au AFTER UPDATE ON notes BEGIN
|
||||
UPDATE notes_fts
|
||||
SET raw_text = NEW.raw_text,
|
||||
ai_title = COALESCE(NEW.ai_title, ''),
|
||||
ai_summary = COALESCE(NEW.ai_summary, '')
|
||||
WHERE note_id = NEW.id;
|
||||
END;
|
||||
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
SELECT
|
||||
n.id,
|
||||
n.raw_text,
|
||||
COALESCE(n.ai_title, ''),
|
||||
COALESCE(n.ai_summary, ''),
|
||||
COALESCE((SELECT GROUP_CONCAT(t.name, ' ')
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = n.id), '')
|
||||
FROM notes n
|
||||
WHERE n.status != 'trashed';
|
||||
`);
|
||||
}
|
||||
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));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import electron from 'electron';
|
||||
const { app, BrowserWindow, Notification, dialog } = electron;
|
||||
const { app, Notification, dialog } = electron;
|
||||
import '@shared/types';
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -15,24 +15,63 @@ import { HotkeyService } from './services/HotkeyService.js';
|
||||
import { IntentService } from './services/IntentService.js';
|
||||
import { HealthChecker } from './services/HealthChecker.js';
|
||||
import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js';
|
||||
import { ProviderHolder } from './ai/ProviderHolder.js';
|
||||
import { AiWorker } from './ai/AiWorker.js';
|
||||
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
|
||||
} from './windows/quickCaptureWindow.js';
|
||||
import { createTray, refreshTray, refreshTrayOllama, refreshTrayFailedCount } from './tray.js';
|
||||
import { createTray, refreshTray } from './tray.js';
|
||||
import { MediaGc } from './services/MediaGc.js';
|
||||
import { BackupService } from './services/BackupService.js';
|
||||
import { ExportService } from './services/ExportService.js';
|
||||
import { ImportService } from './services/ImportService.js';
|
||||
import { SyncService } from './services/SyncService.js';
|
||||
import { SyncTimer } from './services/SyncTimer.js';
|
||||
import { TelemetryService } from './services/TelemetryService.js';
|
||||
import { SettingsService } from './services/SettingsService.js';
|
||||
import { collectAutostartState } from './services/AutostartDiagnostic.js';
|
||||
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
|
||||
|
||||
const HIDDEN_ARG = '--hidden';
|
||||
const startedHidden = process.argv.includes(HIDDEN_ARG);
|
||||
|
||||
// v0.2.8 Cut A — `inkling-media://` custom protocol 스킴은 app.whenReady() 전에
|
||||
// privileged 등록 필수 (Electron 표준). 이미지 asset 을 main process 가 직접
|
||||
// 서빙해 file:// hack 없이 작동.
|
||||
registerSchemesAsPrivileged();
|
||||
|
||||
// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46).
|
||||
// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창
|
||||
// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로.
|
||||
const additionalData = { hidden: startedHidden };
|
||||
const gotLock = app.requestSingleInstanceLock(additionalData);
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', (_e, _argv, _cwd, secondData) => {
|
||||
const data = secondData as { hidden?: boolean } | undefined;
|
||||
// 두 번째가 hidden 으로 spawn (autostart 등) — UI 띄우지 않음
|
||||
if (data?.hidden === true) return;
|
||||
// 사용자가 명시적으로 .exe / 단축키 / 트레이로 띄움 → inbox 창 보이게
|
||||
const win = getInboxWindow();
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
if (!win.isVisible()) win.show();
|
||||
win.focus();
|
||||
} else {
|
||||
createInboxWindow();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
initLogger();
|
||||
logger.info('app.start', {
|
||||
@@ -44,6 +83,9 @@ app.whenReady().then(async () => {
|
||||
|
||||
const paths = resolveProfilePaths('default');
|
||||
|
||||
// v0.2.8 Cut A — `inkling-media://` request handler 등록 (profileDir 결정 후).
|
||||
registerInklingMediaProtocol(paths.profileDir);
|
||||
|
||||
const telemetry = new TelemetryService(join(paths.profileDir, 'telemetry'), () => new Date(), 14, { silent: true });
|
||||
void telemetry.cleanupOldFiles()
|
||||
.then((r) => logger.info('telemetry.cleanup', { removed: r.removed.length }))
|
||||
@@ -56,24 +98,46 @@ app.whenReady().then(async () => {
|
||||
writeFileSync(initFlag, new Date().toISOString());
|
||||
logger.info('autostart.enabled.firstRun');
|
||||
}
|
||||
// v0.2.6 #45 진단 — startup 로그. 같은 정보가 SettingsPage 진단 패널에도 surface (collectAutostartState single source of truth).
|
||||
void collectAutostartState().then((state) => logger.info('autostart.state', { ...state }));
|
||||
}
|
||||
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);
|
||||
|
||||
const resolvedEndpoint = process.env.INKLING_OLLAMA_ENDPOINT ?? 'http://localhost:11434';
|
||||
const settingsSvc = new SettingsService(paths.profileDir);
|
||||
const settings = await settingsSvc.load();
|
||||
|
||||
const resolvedEndpoint = settings.ollama?.endpoint
|
||||
?? process.env.INKLING_OLLAMA_ENDPOINT
|
||||
?? DEFAULT_OLLAMA_ENDPOINT;
|
||||
const resolvedModel = settings.ollama?.model ?? DEFAULT_OLLAMA_MODEL;
|
||||
|
||||
logger.info('ai.endpoint', {
|
||||
endpoint: resolvedEndpoint,
|
||||
fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined
|
||||
model: resolvedModel,
|
||||
source: settings.ollama?.endpoint
|
||||
? 'settings'
|
||||
: (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default')
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint });
|
||||
const health = new HealthChecker(provider, {
|
||||
|
||||
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
|
||||
const providerHolder = new ProviderHolder(provider);
|
||||
|
||||
// v0.3.1 Cut F — app launch 시 vision capability cache 갱신 (fire-and-forget).
|
||||
// 실패 silent (cache 유지). 사용자가 설정 페이지에서 "다시 감지" manual trigger 가능.
|
||||
void refreshVisionCache({ settings: settingsSvc, endpoint: resolvedEndpoint }).catch(() => {});
|
||||
|
||||
const health = new HealthChecker(providerHolder, {
|
||||
// v0.2.9 Cut B Task 14 — AI 비활성 시 health polling skip (Ollama 미설치 환경 무영향).
|
||||
isAiEnabled: () => settingsSvc.isAiEnabled(),
|
||||
onUpdate: (status) => {
|
||||
logger.info('ai.health', { ...status } as Record<string, unknown>);
|
||||
pushOllamaStatus(getInboxWindow, status);
|
||||
refreshTrayOllama(status.ok);
|
||||
},
|
||||
onTelemetry: (ev) => {
|
||||
if (ev.kind === 'ollama_unreachable') {
|
||||
@@ -87,15 +151,20 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
health.start();
|
||||
|
||||
const worker = new AiWorker(repo, provider, {
|
||||
const worker = new AiWorker(repo, providerHolder, {
|
||||
onUpdate: (note) => {
|
||||
pushNoteUpdated(getInboxWindow, note);
|
||||
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
|
||||
refreshTray(repo.countToday());
|
||||
refreshTrayFailedCount(repo.countFailed());
|
||||
// v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신.
|
||||
refreshTray({ todayCount: repo.countToday() });
|
||||
},
|
||||
logger,
|
||||
telemetry
|
||||
telemetry,
|
||||
// v0.3.1 Cut F — vision 지원
|
||||
settings: settingsSvc,
|
||||
mediaStore: store,
|
||||
// v0.4 — notebook_match 자동 이동
|
||||
notebookRepo: notebookRepo
|
||||
});
|
||||
|
||||
const notify = new NotificationService({
|
||||
@@ -108,14 +177,22 @@ app.whenReady().then(async () => {
|
||||
const capture = new CaptureService(repo, store, {
|
||||
enqueue: (id) => worker.enqueue(id),
|
||||
celebrate: (id) => notify.celebrate(id),
|
||||
telemetry
|
||||
telemetry,
|
||||
settings: settingsSvc
|
||||
});
|
||||
|
||||
registerCaptureApi(capture, getQuickCaptureWindow);
|
||||
registerInboxApi({
|
||||
repo, continuity, capture, health, intent,
|
||||
getInboxWindow
|
||||
getInboxWindow, settings: settingsSvc, providerHolder,
|
||||
paths: { profileDir: paths.profileDir },
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신.
|
||||
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/... 초기화 직후로 이동.
|
||||
|
||||
const hotkeys = new HotkeyService();
|
||||
const reg = hotkeys.register({
|
||||
@@ -124,29 +201,43 @@ 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();
|
||||
|
||||
const gc = new MediaGc(db, store);
|
||||
void gc.run().then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>));
|
||||
void gc.run()
|
||||
.then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>))
|
||||
.catch((e) => logger.warn('media.gc.failed', { reason: String(e) }));
|
||||
|
||||
const exportSvc = new ExportService(repo, store);
|
||||
const importSvc = new ImportService(repo, store);
|
||||
const syncSvc = new SyncService(paths.profileDir, exportSvc);
|
||||
const syncSvc = new SyncService(paths.profileDir, exportSvc, importSvc);
|
||||
const syncTimer = new SyncTimer(syncSvc, settingsSvc);
|
||||
|
||||
const backup = new BackupService(db, join(paths.profileDir, 'backups'));
|
||||
void backup.runDaily()
|
||||
.then((r) => logger.info('backup.daily', { ...r } as Record<string, unknown>))
|
||||
.catch((e) => logger.warn('backup.daily.failed', { reason: String(e) }));
|
||||
|
||||
// v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry).
|
||||
// backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록.
|
||||
registerSettingsApi({
|
||||
backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow,
|
||||
syncTimer
|
||||
});
|
||||
|
||||
void syncTimer.start();
|
||||
|
||||
let backupOnQuitDone = false;
|
||||
let trayInterval: NodeJS.Timeout | null = null;
|
||||
app.on('before-quit', (e) => {
|
||||
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
|
||||
health.stop();
|
||||
syncTimer.stop();
|
||||
if (trayInterval !== null) {
|
||||
clearInterval(trayInterval);
|
||||
trayInterval = null;
|
||||
@@ -174,190 +265,34 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
createTray(
|
||||
() => createInboxWindow(),
|
||||
() => showQuickCapture(),
|
||||
async () => {
|
||||
try {
|
||||
const r = await backup.runDaily();
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: r.snapshotted
|
||||
? `백업 완료 — ${r.removed?.length ?? 0}개 정리`
|
||||
: `오늘 백업이 이미 있습니다`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('backup.manual.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업을 만들지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.',
|
||||
buttonLabel: '여기에 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return;
|
||||
try {
|
||||
const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
|
||||
logger.info('export.done', {
|
||||
outDir: r.outDir,
|
||||
noteCount: r.noteCount,
|
||||
mediaCount: r.mediaCount,
|
||||
bytes: r.bytes
|
||||
});
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}개`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
const win = getInboxWindow();
|
||||
const dirOpts: Electron.OpenDialogOptions = {
|
||||
title: '복원할 백업 폴더 선택',
|
||||
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.',
|
||||
buttonLabel: '여기서 복원',
|
||||
properties: ['openDirectory']
|
||||
};
|
||||
const dirResult = win
|
||||
? await dialog.showOpenDialog(win, dirOpts)
|
||||
: await dialog.showOpenDialog(dirOpts);
|
||||
if (dirResult.canceled || dirResult.filePaths.length === 0) return;
|
||||
const sourceDir = dirResult.filePaths[0]!;
|
||||
let plan;
|
||||
try {
|
||||
plan = await importSvc.preview(sourceDir);
|
||||
} catch (e) {
|
||||
logger.warn('import.preview.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업 폴더를 읽지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
return;
|
||||
}
|
||||
const detail = `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`;
|
||||
const confirmOpts: Electron.MessageBoxOptions = {
|
||||
type: 'question',
|
||||
buttons: ['복원', '취소'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: 'Inkling 복원',
|
||||
message: '복원 미리보기',
|
||||
detail
|
||||
};
|
||||
const confirm = win
|
||||
? await dialog.showMessageBox(win, confirmOpts)
|
||||
: await dialog.showMessageBox(confirmOpts);
|
||||
if (confirm.response !== 0) return;
|
||||
try {
|
||||
const r = await importSvc.run(sourceDir);
|
||||
logger.info('import.done', {
|
||||
total: r.total,
|
||||
new: r.newCount,
|
||||
unchanged: r.unchangedCount,
|
||||
forked: r.forkedCount,
|
||||
media: r.mediaCount
|
||||
});
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}개`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('import.run.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '복원을 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
// runSync — 트레이 "지금 동기화"
|
||||
try {
|
||||
const r = await syncSvc.sync();
|
||||
if (!r.ok) {
|
||||
logger.warn('sync.failed', { reason: r.reason });
|
||||
const body = r.reason === 'not_configured'
|
||||
? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.`
|
||||
: '동기화를 완료하지 못했습니다.';
|
||||
new Notification({ title: 'Inkling', body, silent: true }).show();
|
||||
return;
|
||||
}
|
||||
if (r.changed) {
|
||||
logger.info('sync.done', { sha: r.sha, pushed: r.pushed });
|
||||
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
|
||||
} else {
|
||||
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('sync.exception', { reason: String(e) });
|
||||
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
|
||||
}
|
||||
},
|
||||
/* runExportTelemetry */ async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '사용 로그를 내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.',
|
||||
buttonLabel: '여기로 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return;
|
||||
try {
|
||||
const r = await telemetry.exportTo(result.filePaths[0]!);
|
||||
logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('telemetry.export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '사용 로그 내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
/* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); },
|
||||
/* runRetryAllFailed */ () => { void capture.retryAllFailed(); }
|
||||
);
|
||||
// v0.2.7 Phase 3 (Task 16) — TrayCallbacks 슬림: 10 → 3.
|
||||
// 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/정보 →
|
||||
// 모두 설정 페이지로 이전 (registerSettingsApi 의 IPC 핸들러가 본문 보유).
|
||||
createTray({
|
||||
showInbox: () => createInboxWindow(),
|
||||
showCapture: () => showQuickCapture(),
|
||||
showSettings: () => navigateInbox('settings')
|
||||
});
|
||||
|
||||
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
|
||||
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
|
||||
// cleanup 은 위 통합 before-quit 핸들러에서 처리.
|
||||
refreshTray(repo.countToday());
|
||||
refreshTrayFailedCount(repo.countFailed());
|
||||
// v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신.
|
||||
refreshTray({ todayCount: repo.countToday() });
|
||||
trayInterval = setInterval(() => {
|
||||
refreshTray(repo.countToday());
|
||||
refreshTray({ todayCount: repo.countToday() });
|
||||
}, 60_000);
|
||||
|
||||
// F14 (v0.2.7) — macOS dock 클릭 시 hidden inbox 창 show/focus.
|
||||
// 기존: BrowserWindow.getAllWindows().length === 0 만 검사 → quickCapture 등이
|
||||
// 떠 있으면 inbox 창이 hidden 인 채로 남았음.
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
|
||||
const win = getInboxWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (!win.isVisible()) win.show();
|
||||
win.focus();
|
||||
} else {
|
||||
createInboxWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
const { ipcMain, dialog } = 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';
|
||||
import type { IntentService } from '../services/IntentService.js';
|
||||
import type { Note } from '@shared/types';
|
||||
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';
|
||||
|
||||
export interface InboxIpcDeps {
|
||||
repo: NoteRepository;
|
||||
@@ -16,10 +23,21 @@ export interface InboxIpcDeps {
|
||||
health: HealthChecker;
|
||||
intent: IntentService;
|
||||
getInboxWindow: () => BrowserWindow | null;
|
||||
settings: SettingsService;
|
||||
providerHolder: ProviderHolder;
|
||||
// v0.2.8 Cut A — `inbox:open-media` 의 path traversal 검사 baseline.
|
||||
paths: { profileDir: string };
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 처리 시 in-memory worker queue 갱신.
|
||||
// 미주입 시 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)
|
||||
);
|
||||
|
||||
@@ -34,7 +52,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
deps.repo.setDueDate(arg.noteId, arg.date);
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:delete', async (_e, noteId: string) => {
|
||||
ipcMain.handle('inbox:trash', async (_e, noteId: string) => {
|
||||
await deps.capture.deleteNote(noteId);
|
||||
});
|
||||
|
||||
@@ -137,11 +155,236 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed());
|
||||
ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed());
|
||||
|
||||
// v0.3.9 — per-note retry/cancel. failed/pending 사용자 막힘 해소 path.
|
||||
// status 변경 후 pushNoteUpdated 로 renderer counts/list 자동 sync.
|
||||
ipcMain.handle('inbox:retry-one-failed', async (_e, id: string) => {
|
||||
const r = await deps.capture.retryOneFailed(id);
|
||||
if (r.ok) {
|
||||
const updated = deps.repo.findById(id);
|
||||
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
|
||||
}
|
||||
return r;
|
||||
});
|
||||
ipcMain.handle('inbox:cancel-pending', (_e, id: string) => {
|
||||
const r = deps.capture.cancelPending(id);
|
||||
if (r.ok) {
|
||||
const updated = deps.repo.findById(id);
|
||||
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
|
||||
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
|
||||
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
|
||||
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
|
||||
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
|
||||
ipcMain.on('inbox:emitRecallShown', (_e, id: string) => { void deps.capture.emitRecallShown(id); });
|
||||
ipcMain.on('inbox:emitRecallSnoozed', (_e, id: string) => { void deps.capture.emitRecallSnoozed(id); });
|
||||
|
||||
ipcMain.handle('inbox:loadOllamaSettings', async () => {
|
||||
const s = await deps.settings.load();
|
||||
return s.ollama ?? null;
|
||||
});
|
||||
|
||||
// v0.2.8 Cut A — 첨부 이미지 클릭 시 OS 기본 뷰어로 열기 (Task 3).
|
||||
// path traversal 검사는 inkling-media:// protocol handler 와 동일한 패턴 (Task 1).
|
||||
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
|
||||
if (typeof relPath !== 'string' || relPath.length === 0) {
|
||||
return { ok: false as const, reason: 'invalid path' as const };
|
||||
}
|
||||
const profileDir = deps.paths.profileDir;
|
||||
const mediaRoot = join(profileDir, 'media');
|
||||
const target = normalize(join(profileDir, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
|
||||
return { ok: false as const, reason: 'invalid path' as const };
|
||||
}
|
||||
await shell.openPath(target);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// 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; 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 — 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분기 직접 전이 (사유 포함).
|
||||
// Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는
|
||||
// NoteRepository.setStatus 내부에서 처리 (deleted_at sync).
|
||||
// v0.3.8 — setStatus 도 pushNoteUpdated emit. 이전엔 emit 안 해서 renderer 의 store
|
||||
// (counts/search/list) 가 stale 되어 NoteCard 호출처마다 명시적 refreshMeta 호출이
|
||||
// 필요했음. push 화 시 onNoteUpdated 콜백 1개 path 로 일관 갱신.
|
||||
ipcMain.handle(
|
||||
'inbox:set-status',
|
||||
async (_e, id: string, status: NoteStatus, reason: string | null) => {
|
||||
const VALID: readonly NoteStatus[] = ['active', 'completed', 'trashed'];
|
||||
if (!VALID.includes(status)) {
|
||||
return { ok: false as const, reason: 'invalid status' as const };
|
||||
}
|
||||
deps.repo.setStatus(id, status, reason);
|
||||
const updated = deps.repo.findById(id);
|
||||
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
|
||||
return { ok: true as const };
|
||||
}
|
||||
);
|
||||
|
||||
// v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
||||
// 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: 'completed' as const,
|
||||
rationale: '메모를 찾을 수 없음 — 완료 처리 추천'
|
||||
};
|
||||
}
|
||||
const provider = deps.providerHolder.get();
|
||||
return classifyStatus({
|
||||
provider,
|
||||
rawText: note.rawText,
|
||||
summary: note.aiSummary ?? '',
|
||||
reason: typeof reason === 'string' ? reason : ''
|
||||
});
|
||||
});
|
||||
|
||||
// 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 갱신.
|
||||
ipcMain.handle('inbox:enqueue-disabled', async () => {
|
||||
// requeue 전 대상 id 수집 — UPDATE 가 status 바꾸므로 select 후 update 필요 없이
|
||||
// requeueDisabled 가 처리한 다음 pending_jobs 에서 다시 가져와 enqueue.
|
||||
const targets = deps.repo.getAllPendingJobs().map((j) => j.noteId);
|
||||
const before = new Set(targets);
|
||||
const count = deps.repo.requeueDisabled();
|
||||
if (count > 0 && deps.enqueue) {
|
||||
const after = deps.repo.getAllPendingJobs();
|
||||
// requeue 직후 새로 들어온 pending_jobs row 만 enqueue (기존 row 는 이미 in-memory queue 에).
|
||||
for (const j of after) {
|
||||
if (!before.has(j.noteId)) {
|
||||
await deps.enqueue(j.noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { count };
|
||||
});
|
||||
|
||||
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 });
|
||||
const r = await trial.healthCheck();
|
||||
if (!r.ok) return { ok: false, reason: r.reason ?? 'unknown' };
|
||||
try {
|
||||
await deps.settings.setOllama(value);
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `persist failed: ${(e as Error).message}` };
|
||||
}
|
||||
deps.providerHolder.get().abort?.();
|
||||
deps.providerHolder.replace(trial);
|
||||
// 즉시 health 재확인 → onUpdate callback 통해 OllamaBanner 자동 갱신
|
||||
await deps.health.runOnce();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
|
||||
// updateRawText: 빈 문자열 reject (trim 후 length===0). 그 외엔 그대로 (newline/space 보존).
|
||||
// listRevisions: 그대로 반환 (camelCase 이미 hydrate 됨).
|
||||
// restoreRevision: repo throw → { ok: false } (UI 가 에러 표시).
|
||||
ipcMain.handle('inbox:update-raw-text', async (_e, id: string, newText: string) => {
|
||||
if (typeof newText !== 'string' || newText.trim().length === 0) {
|
||||
return { ok: false as const, reason: 'empty' as const };
|
||||
}
|
||||
deps.repo.updateRawText(id, newText);
|
||||
await reprocessAi(deps, id);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:list-revisions', (_e, id: string) => deps.repo.listRevisions(id));
|
||||
|
||||
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 };
|
||||
}
|
||||
});
|
||||
|
||||
// v0.2.11 Cut D — FTS5 검색 + 회고 aggregate.
|
||||
// v0.4 — notebookId 옵션 추가.
|
||||
ipcMain.handle(
|
||||
'inbox:search',
|
||||
(_e, query: string, opts: { limit?: number; status?: NoteStatus; notebookId?: string } = {}) =>
|
||||
deps.repo.search(query, opts)
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:review-aggregate', (_e, period: 'daily' | 'weekly' | 'monthly') => {
|
||||
const VALID = ['daily', 'weekly', 'monthly'] as const;
|
||||
if (!(VALID as readonly string[]).includes(period)) {
|
||||
return {
|
||||
totalCount: 0,
|
||||
recentNotes: [],
|
||||
tagCounts: [],
|
||||
dueProgress: { total: 0, passed: 0, pending: 0 }
|
||||
};
|
||||
}
|
||||
return deps.repo.reviewAggregate(period);
|
||||
});
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
@@ -150,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));
|
||||
}
|
||||
425
src/main/ipc/settingsApi.ts
Normal file
425
src/main/ipc/settingsApi.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { platform, release, EOL } from 'node:os';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
const { ipcMain, app, dialog, Notification, shell, clipboard } = electron;
|
||||
import { logger } from '../logger.js';
|
||||
import type { BackupService } from '../services/BackupService.js';
|
||||
import type { ExportService } from '../services/ExportService.js';
|
||||
import type { ImportService } from '../services/ImportService.js';
|
||||
import type { SyncService } from '../services/SyncService.js';
|
||||
import { GitClient } from '../services/GitClient.js';
|
||||
import type { TelemetryService } from '../services/TelemetryService.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
import type { SyncTimer } from '../services/SyncTimer.js';
|
||||
import { collectAutostartState } from '../services/AutostartDiagnostic.js';
|
||||
import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js';
|
||||
import { refreshVisionCache } from '../services/VisionDetect.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT } from '../../shared/constants.js';
|
||||
|
||||
/**
|
||||
* 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을
|
||||
* 요청하는 진입점. 창이 숨겨져 있으면 show + focus 후 'inbox:navigate' IPC 이벤트를
|
||||
* renderer 로 전달.
|
||||
*
|
||||
* Task 13 (v0.2.7) — 트레이 "설정..." 메뉴 wiring 은 Task 16 에서 본 함수 호출.
|
||||
*/
|
||||
export function navigateInbox(view: 'inbox' | 'trash' | 'settings'): void {
|
||||
const win = getInboxWindowSingleton();
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (!win.isVisible()) win.show();
|
||||
win.focus();
|
||||
win.webContents.send('inbox:navigate', view);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsIpcDeps {
|
||||
backup: BackupService;
|
||||
exportSvc: ExportService;
|
||||
importSvc: ImportService;
|
||||
syncSvc: SyncService;
|
||||
telemetry: TelemetryService;
|
||||
settings: SettingsService;
|
||||
getInboxWindow: () => BrowserWindow | null;
|
||||
syncTimer?: SyncTimer;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.7 설정 페이지 IPC 핸들러.
|
||||
*
|
||||
* - 자동 실행 (Task 22 통일): `settings:autostart-state` (조회) / `settings:autostart-set` (변경).
|
||||
* 둘 다 `{ openAtLogin, diagnostic }` 반환 — diagnostic 은 withArgs/noArgs/execPath/registry 진단.
|
||||
* args=['--hidden'] 명시 — 자동 실행 시 백그라운드 모드로 시작 (Quick Capture only).
|
||||
*
|
||||
* - 백업/내보내기/복원/동기화/사용 로그 (Task 10): 기존 `src/main/index.ts` 트레이 callback
|
||||
* (runBackup, runExport, runImport, runSync, runExportTelemetry) 본문을 그대로 IPC 핸들러로
|
||||
* 복사. 트레이 callback 자체 제거는 Task 16 (Phase 3) — 본 task 에선 잔류 (의도적 중복).
|
||||
*/
|
||||
export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
// v0.2.7 F12 deeper fix (Task 21~22) — 진단 정보 포함된 autostart 상태 조회/변경.
|
||||
// 옛 'settings:get-autostart' / 'settings:set-autostart' 채널은 본 통일에서 제거됨.
|
||||
ipcMain.handle('settings:autostart-state', async () => {
|
||||
const diag = await collectAutostartState();
|
||||
return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:autostart-set', async (_e, open: boolean) => {
|
||||
app.setLoginItemSettings({ openAtLogin: open, args: ['--hidden'] });
|
||||
const diag = await collectAutostartState();
|
||||
return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag };
|
||||
});
|
||||
|
||||
// v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 detail 형식 그대로 (clipboard 일관성).
|
||||
// 트레이 showAboutDialog 자체 제거는 Task 25 (Phase 6 cleanup) — 본 task 는 추가만.
|
||||
ipcMain.handle('settings:get-app-info', () => ({
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron ?? '?',
|
||||
node: process.versions.node ?? '?',
|
||||
os: `${platform()} ${release()}`,
|
||||
profileDir: app.getPath('userData')
|
||||
}));
|
||||
|
||||
ipcMain.handle('settings:open-profile-dir', async () => {
|
||||
await shell.openPath(app.getPath('userData'));
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:copy-app-info', () => {
|
||||
const v = app.getVersion();
|
||||
const detail = [
|
||||
`버전: ${v}`,
|
||||
`Electron: ${process.versions.electron ?? '?'}`,
|
||||
`Node: ${process.versions.node ?? '?'}`,
|
||||
`OS: ${platform()} ${release()}`,
|
||||
`데이터 위치: ${app.getPath('userData')}`
|
||||
].join(EOL);
|
||||
clipboard.writeText(`Inkling ${v}${EOL}${detail}`);
|
||||
});
|
||||
|
||||
if (!deps) return;
|
||||
const { backup, exportSvc, importSvc, syncSvc, telemetry, settings, getInboxWindow } = deps;
|
||||
|
||||
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글.
|
||||
// 첫 launch 시 OnboardingWizard 분기 (App.tsx) 와 SettingsPage 의 ai_enabled 토글 통합.
|
||||
ipcMain.handle('settings:get', async () => settings.getAll());
|
||||
|
||||
ipcMain.handle('settings:set-ai-enabled', async (_e, enabled: boolean) => {
|
||||
await settings.setAiEnabled(enabled);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-onboarding-completed', async (_e, completed: boolean) => {
|
||||
await settings.setOnboardingCompleted(completed);
|
||||
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();
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-sync-interval-min', async (_e, value: number) => {
|
||||
try {
|
||||
await deps.settings.setSyncIntervalMin(value);
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-backup', async () => {
|
||||
try {
|
||||
const r = await backup.runDaily();
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: r.snapshotted
|
||||
? `백업 완료 — ${r.removed?.length ?? 0}개 정리`
|
||||
: `오늘 백업이 이미 있습니다`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('backup.manual.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업을 만들지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-export', async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.',
|
||||
buttonLabel: '여기에 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return { ok: true } as const;
|
||||
try {
|
||||
const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
|
||||
logger.info('export.done', {
|
||||
outDir: r.outDir,
|
||||
noteCount: r.noteCount,
|
||||
mediaCount: r.mediaCount,
|
||||
bytes: r.bytes
|
||||
});
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}개`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-import', async () => {
|
||||
const win = getInboxWindow();
|
||||
const dirOpts: Electron.OpenDialogOptions = {
|
||||
title: '복원할 백업 폴더 선택',
|
||||
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.',
|
||||
buttonLabel: '여기서 복원',
|
||||
properties: ['openDirectory']
|
||||
};
|
||||
const dirResult = win
|
||||
? await dialog.showOpenDialog(win, dirOpts)
|
||||
: await dialog.showOpenDialog(dirOpts);
|
||||
if (dirResult.canceled || dirResult.filePaths.length === 0) return { ok: true } as const;
|
||||
const sourceDir = dirResult.filePaths[0]!;
|
||||
let plan;
|
||||
try {
|
||||
plan = await importSvc.preview(sourceDir);
|
||||
} catch (e) {
|
||||
logger.warn('import.preview.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업 폴더를 읽지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
return { ok: true } as const;
|
||||
}
|
||||
const detail = `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`;
|
||||
const confirmOpts: Electron.MessageBoxOptions = {
|
||||
type: 'question',
|
||||
buttons: ['복원', '취소'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: 'Inkling 복원',
|
||||
message: '복원 미리보기',
|
||||
detail
|
||||
};
|
||||
const confirm = win
|
||||
? await dialog.showMessageBox(win, confirmOpts)
|
||||
: await dialog.showMessageBox(confirmOpts);
|
||||
if (confirm.response !== 0) return { ok: true } as const;
|
||||
try {
|
||||
const r = await importSvc.run(sourceDir);
|
||||
logger.info('import.done', {
|
||||
total: r.total,
|
||||
new: r.newCount,
|
||||
unchanged: r.unchangedCount,
|
||||
forked: r.forkedCount,
|
||||
media: r.mediaCount
|
||||
});
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}개`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('import.run.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '복원을 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-sync', async () => {
|
||||
try {
|
||||
const r = await syncSvc.sync();
|
||||
if (!r.ok) {
|
||||
logger.warn('sync.failed', { reason: r.reason });
|
||||
const body = r.reason === 'not_configured'
|
||||
? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.`
|
||||
: '동기화를 완료하지 못했습니다.';
|
||||
new Notification({ title: 'Inkling', body, silent: true }).show();
|
||||
return { ok: true } as const;
|
||||
}
|
||||
if (r.changed) {
|
||||
logger.info('sync.done', { sha: r.localSha, pushed: r.pushed });
|
||||
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
|
||||
} else {
|
||||
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('sync.exception', { reason: String(e) });
|
||||
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-export-telemetry', async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '사용 로그를 내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.',
|
||||
buttonLabel: '여기로 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return { ok: true } as const;
|
||||
try {
|
||||
const r = await telemetry.exportTo(result.filePaths[0]!);
|
||||
logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('telemetry.export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '사용 로그 내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
// v0.3.0 Cut E — sync IPC.
|
||||
|
||||
// settings:configure-sync — URL 저장 + git init + remote add (없으면).
|
||||
// null URL → 저장만 (init 안 함). 빈 문자열도 null 처리.
|
||||
ipcMain.handle('settings:configure-sync', async (_e, url: string | null) => {
|
||||
const trimmed = typeof url === 'string' ? url.trim() : '';
|
||||
const finalUrl = trimmed.length === 0 ? null : trimmed;
|
||||
|
||||
try {
|
||||
await deps.settings.setSyncRepoUrl(finalUrl);
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: `persist failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
if (finalUrl === null) {
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
// git init + remote add origin
|
||||
const syncDir = deps.syncSvc.getSyncDir();
|
||||
try {
|
||||
await mkdir(syncDir, { recursive: true });
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: `mkdir failed: ${(e as Error).message}` };
|
||||
}
|
||||
const git = new GitClient(syncDir);
|
||||
|
||||
if (!(await git.isRepo())) {
|
||||
const init = await git.run(['init']);
|
||||
if (init.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `git init failed: ${init.stderr}` };
|
||||
}
|
||||
}
|
||||
if (!(await git.hasRemote())) {
|
||||
const add = await git.run(['remote', 'add', 'origin', finalUrl]);
|
||||
if (add.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `remote add failed: ${add.stderr}` };
|
||||
}
|
||||
} else {
|
||||
// remote exists — update URL
|
||||
const set = await git.run(['remote', 'set-url', 'origin', finalUrl]);
|
||||
if (set.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` };
|
||||
}
|
||||
}
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// settings:test-sync-connection — git ls-remote 결과
|
||||
ipcMain.handle('settings:test-sync-connection', async () => {
|
||||
const syncDir = deps.syncSvc.getSyncDir();
|
||||
const git = new GitClient(syncDir);
|
||||
if (!(await git.isRepo())) return { ok: false as const, reason: 'not_initialized' };
|
||||
const r = await git.run(['ls-remote', 'origin']);
|
||||
if (r.exitCode !== 0) return { ok: false as const, reason: r.stderr || 'connection failed' };
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// sync:list-conflicts — SyncService 캐시 결과
|
||||
ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts());
|
||||
|
||||
// sync:resolve-conflict — local/remote 2 choice. path = git index conflict 경로.
|
||||
ipcMain.handle('sync:resolve-conflict', async (_e, path: string, choice: 'local' | 'remote') => {
|
||||
if (choice !== 'local' && choice !== 'remote') {
|
||||
return { ok: false as const, reason: 'invalid choice' };
|
||||
}
|
||||
return deps.syncSvc.resolveConflict(path, choice);
|
||||
});
|
||||
|
||||
// sync:get-status — lastAt + lastResult + nextAt 계산
|
||||
ipcMain.handle('sync:get-status', async () => {
|
||||
const last = deps.syncSvc.getLastStatus();
|
||||
let nextAt: string | null = null;
|
||||
if (await deps.settings.isAutoSyncEnabled()) {
|
||||
const intervalMin = await deps.settings.getSyncIntervalMin();
|
||||
const baseMs = last.lastAt ? new Date(last.lastAt).getTime() : Date.now();
|
||||
nextAt = new Date(baseMs + intervalMin * 60 * 1000).toISOString();
|
||||
}
|
||||
return { lastAt: last.lastAt, lastResult: last.lastResult, nextAt };
|
||||
});
|
||||
|
||||
// v0.3.1 Cut F — vision IPC
|
||||
|
||||
ipcMain.handle('settings:get-vision-models', async () => {
|
||||
const cache = await deps.settings.getVisionCapableCache();
|
||||
const selected = await deps.settings.getVisionModel();
|
||||
return { models: cache.models, at: cache.at, selected };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-vision-model', async (_e, value: string | null) => {
|
||||
const sanitized = typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
await deps.settings.setVisionModel(sanitized);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:refresh-vision-cache', async () => {
|
||||
// Cut F final review fix — index.ts 의 resolvedEndpoint (settings → env → default)
|
||||
// 와 동일한 fallback 체인 사용. settings.ollama 미설정 + env / default 만 있는 dev
|
||||
// 환경에서도 manual "다시 감지" 가 동작하도록.
|
||||
const all = await deps.settings.getAll();
|
||||
const endpoint = all.ollama?.endpoint
|
||||
?? process.env.INKLING_OLLAMA_ENDPOINT
|
||||
?? DEFAULT_OLLAMA_ENDPOINT;
|
||||
return refreshVisionCache({ settings: deps.settings, endpoint });
|
||||
});
|
||||
}
|
||||
54
src/main/protocol/inklingMedia.ts
Normal file
54
src/main/protocol/inklingMedia.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import electron from 'electron';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, normalize, sep, extname } from 'node:path';
|
||||
|
||||
const { protocol } = electron;
|
||||
|
||||
export function registerSchemesAsPrivileged(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
|
||||
]);
|
||||
}
|
||||
|
||||
export function inferMime(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.png': return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.gif': return 'image/gif';
|
||||
case '.webp': return 'image/webp';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerInklingMediaProtocol(profileDir: string): void {
|
||||
const mediaRoot = join(profileDir, 'media');
|
||||
protocol.handle('inkling-media', async (req) => {
|
||||
// Raw URL 에서 `..` 세그먼트 (URL-encoded 포함) 검출 — `new URL()` 이 normalize 하기 전에 차단.
|
||||
const rawLower = req.url.toLowerCase();
|
||||
if (
|
||||
rawLower.includes('/../') ||
|
||||
rawLower.endsWith('/..') ||
|
||||
rawLower.includes('/%2e%2e/') ||
|
||||
rawLower.endsWith('/%2e%2e')
|
||||
) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
const url = new URL(req.url);
|
||||
// inkling-media://media/<noteId>/<file> → host='media', pathname='/<noteId>/<file>'
|
||||
const relPath = decodeURIComponent(`${url.host}${url.pathname}`);
|
||||
const target = normalize(join(profileDir, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const data = await readFile(target);
|
||||
return new Response(new Uint8Array(data), {
|
||||
headers: { 'content-type': inferMime(target) }
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
|
||||
import type { Note, NoteMedia, NoteTag } from '@shared/types';
|
||||
import { todayInKstString } from '../util/kstDate.js';
|
||||
import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types';
|
||||
import { kstTodayIso, KST_OFFSET_MS } from '../../shared/util/kstDate.js';
|
||||
import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js';
|
||||
|
||||
export interface CreateNoteInput { rawText: string; }
|
||||
export interface CreateNoteInput {
|
||||
rawText: string;
|
||||
/**
|
||||
* v0.2.9 Cut B — settings.ai_enabled=false 일 때 'disabled' 로 insert + pending_jobs skip.
|
||||
* 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat).
|
||||
*/
|
||||
aiStatus?: AiStatus;
|
||||
/**
|
||||
* v0.4 — 미지정 시 가장 오래된 notebook (default notebook) 의 id 자동 사용.
|
||||
*/
|
||||
notebookId?: string;
|
||||
}
|
||||
|
||||
export interface NewMediaRow {
|
||||
noteId: string;
|
||||
@@ -15,7 +27,10 @@ export interface NewMediaRow {
|
||||
|
||||
export interface ImportNoteInput {
|
||||
/** Proposed id from the export file. May be replaced if it collides with
|
||||
* an existing row whose `raw_text` differs (raw_text invariant guard). */
|
||||
* an existing row whose `raw_text` differs — fork-on-conflict so a single
|
||||
* id never resolves to two distinct historical baselines (v0.2.10 Cut C
|
||||
* changed `raw_text 불변` policy → `raw_text 가변` + revision history; the
|
||||
* baseline distinction is now preserved per-id, edit history per-note). */
|
||||
id: string;
|
||||
rawText: string;
|
||||
createdAt: string;
|
||||
@@ -40,28 +55,70 @@ export interface ImportNoteResult {
|
||||
status: ImportNoteStatus;
|
||||
}
|
||||
|
||||
export interface UpsertFromSyncInput {
|
||||
id: string;
|
||||
rawText: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
aiTitle: string | null;
|
||||
aiSummary: string | null;
|
||||
titleEditedByUser: boolean;
|
||||
summaryEditedByUser: boolean;
|
||||
aiProvider: string | null;
|
||||
aiGeneratedAt: string | null;
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
tags: { name: string; source: 'ai' | 'user' }[];
|
||||
status: NoteStatus;
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
}
|
||||
|
||||
export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped';
|
||||
|
||||
const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
export class NoteRepository {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
create(input: CreateNoteInput): { id: string } {
|
||||
create(input: CreateNoteInput, now: Date = new Date()): { id: string } {
|
||||
const id = uuidv7();
|
||||
const now = new Date().toISOString();
|
||||
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 (?, ?, 'pending', ?, ?)`)
|
||||
.run(id, input.rawText, now, now);
|
||||
.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 pending_jobs (note_id, attempts, next_run_at)
|
||||
VALUES (?, 0, ?)`)
|
||||
.run(id, now);
|
||||
.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`)
|
||||
.run(id, input.rawText, ts);
|
||||
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
|
||||
if (aiStatus === 'pending') {
|
||||
this.db
|
||||
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
|
||||
VALUES (?, 0, ?)`)
|
||||
.run(id, ts);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
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();
|
||||
@@ -78,35 +135,27 @@ export class NoteRepository {
|
||||
}
|
||||
|
||||
findById(id: string): Note | null {
|
||||
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any;
|
||||
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as Record<string, unknown>;
|
||||
if (!row) return null;
|
||||
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 any[])
|
||||
: (this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(limit) as any[]);
|
||||
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));
|
||||
}
|
||||
|
||||
listAll(): Note[] {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
|
||||
.all() as any[];
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
@@ -143,6 +192,7 @@ export class NoteRepository {
|
||||
linkTag.run(id, tagRow.id);
|
||||
}
|
||||
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
|
||||
this.rebuildFtsTagsForNote(id);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
@@ -205,6 +255,114 @@ export class NoteRepository {
|
||||
return { ids };
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.9 — 단일 failed 노트 재시도. retryAllFailed 의 per-row 로직 동일.
|
||||
* NoteCard 의 per-note "재시도" 버튼 path. failed 가 아닌 status 면 no-op.
|
||||
*/
|
||||
retryOneFailed(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 !== 'failed') 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 IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`)
|
||||
.run(id, now);
|
||||
});
|
||||
tx();
|
||||
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.
|
||||
* pending 외 status 면 no-op.
|
||||
*/
|
||||
cancelPending(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 !== 'pending') return { ok: false };
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET ai_status='disabled', ai_error=NULL, updated_at=? WHERE id=?`)
|
||||
.run(now, id);
|
||||
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
|
||||
});
|
||||
tx();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고
|
||||
* pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리"
|
||||
* 버튼을 누른 path. 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
|
||||
*
|
||||
* INSERT OR IGNORE — race 안전 (이미 pending_jobs row 존재 시 skip).
|
||||
* 반환값 = 처리된 노트 수 (UI 가 "N건 처리됨" 토스트 등 표시용).
|
||||
*/
|
||||
requeueDisabled(now: Date = new Date()): number {
|
||||
const tx = this.db.transaction(() => {
|
||||
const ts = now.toISOString();
|
||||
const targets = this.db
|
||||
.prepare(`SELECT id FROM notes WHERE ai_status='disabled'`)
|
||||
.all() as Array<{ id: string }>;
|
||||
for (const { id } of targets) {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`)
|
||||
.run(ts, id);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
)
|
||||
.run(id, ts);
|
||||
}
|
||||
return targets.length;
|
||||
});
|
||||
return tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 16 — ai_status 별 row count.
|
||||
* 설정 페이지의 "원문만 저장된 메모 N건" 표기용 (status='disabled' 카운트).
|
||||
* deleted_at 필터 없음 — disabled 메모도 trash 갈 수 있는데 사용자 의도는
|
||||
* "AI 처리할 게 얼마나 남았나?" 라 trashed 까지 포함되면 안 됨. → deleted_at IS NULL 추가.
|
||||
*/
|
||||
countByAiStatus(status: AiStatus): number {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS c FROM notes WHERE ai_status=? AND deleted_at IS NULL`
|
||||
)
|
||||
.get(status) as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
|
||||
/**
|
||||
* pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음.
|
||||
* v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로).
|
||||
@@ -328,6 +486,7 @@ export class NoteRepository {
|
||||
const row = getOrInsert.get(t) as { id: number };
|
||||
link.run(id, row.id);
|
||||
}
|
||||
this.rebuildFtsTagsForNote(id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
@@ -410,6 +569,257 @@ export class NoteRepository {
|
||||
.run(now, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.10 Cut C — 사용자가 raw_text 정정. notes.raw_text 갱신 + note_revisions 에
|
||||
* edited_by='user' 새 row INSERT. 단일 transaction. 호출자 `now` 주입 가능 (테스트성).
|
||||
*
|
||||
* 옛 raw_text 는 backfill (m006) 으로 capture revision 에 이미 보존됨.
|
||||
*/
|
||||
updateRawText(id: string, newText: string, now: Date = new Date()): void {
|
||||
const ts = now.toISOString();
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`)
|
||||
.run(newText, ts, id);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'user')`
|
||||
)
|
||||
.run(id, newText, ts);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.10 Cut C — 노트의 모든 revision (capture + user) 을 최신순 반환.
|
||||
* NoteCard 의 "이력" modal 에서 사용. edited_at DESC + rev_id DESC tiebreak.
|
||||
*/
|
||||
listRevisions(id: string): NoteRevision[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT rev_id, note_id, raw_text, edited_at, edited_by
|
||||
FROM note_revisions
|
||||
WHERE note_id = ?
|
||||
ORDER BY edited_at DESC, rev_id DESC`
|
||||
)
|
||||
.all(id) as Array<{
|
||||
rev_id: number;
|
||||
note_id: string;
|
||||
raw_text: string;
|
||||
edited_at: string;
|
||||
edited_by: 'user' | 'capture';
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
revId: r.rev_id,
|
||||
noteId: r.note_id,
|
||||
rawText: r.raw_text,
|
||||
editedAt: r.edited_at,
|
||||
editedBy: r.edited_by
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.10 Cut C — 옛 revision 의 raw_text 를 latest 로 복원. chain 끊지 않고
|
||||
* 새 user revision 으로 INSERT (linear history 유지). revId 가 해당 note 의 것이
|
||||
* 아니면 throw — restore 대상 잘못 매칭 방지.
|
||||
*/
|
||||
restoreRevision(id: string, revId: number, now: Date = new Date()): void {
|
||||
const rev = this.db
|
||||
.prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`)
|
||||
.get(revId, id) as { raw_text: string } | undefined;
|
||||
if (!rev) throw new Error(`revision ${revId} not found for note ${id}`);
|
||||
this.updateRawText(id, rev.raw_text, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed).
|
||||
* status + status_changed_at + move_reason + updated_at 갱신 + deleted_at
|
||||
* backward-compat 동기화 (status='trashed' → deleted_at=ts, 그 외 → NULL).
|
||||
*
|
||||
* 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
|
||||
*/
|
||||
setStatus(
|
||||
id: string,
|
||||
status: NoteStatus,
|
||||
reason: string | null,
|
||||
now: Date = new Date()
|
||||
): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
const ts = now.toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE notes
|
||||
SET status = ?,
|
||||
move_reason = ?,
|
||||
status_changed_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(status, reason, ts, ts, id);
|
||||
// backward compat: deleted_at 컬럼은 m004 이후로도 status='trashed' 와 동기화.
|
||||
if (status === 'trashed') {
|
||||
this.db.prepare(`UPDATE notes SET deleted_at = ? WHERE id = ?`).run(ts, id);
|
||||
} else {
|
||||
this.db.prepare(`UPDATE notes SET deleted_at = NULL WHERE id = ?`).run(id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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; notebookId?: string } = {}): Note[] {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit ?? 200));
|
||||
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; 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} ${notebookClause}
|
||||
ORDER BY rank
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.11 Cut D — 회고 view aggregate. period 별 KST 자정 cutoff 이후 노트
|
||||
* (status != 'trashed') 의 totalCount / recentNotes(50) / tagCounts(DESC) /
|
||||
* dueProgress(passed/pending KST today 기준).
|
||||
*/
|
||||
reviewAggregate(period: ReviewPeriod, now: Date = new Date()): {
|
||||
totalCount: number;
|
||||
recentNotes: Note[];
|
||||
tagCounts: Array<{ tag: string; count: number }>;
|
||||
dueProgress: { total: number; passed: number; pending: number };
|
||||
} {
|
||||
const cutoff = computeCutoff(period, now);
|
||||
const todayIso = kstTodayIso(now);
|
||||
|
||||
const totalCount = (this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND status != 'trashed'`)
|
||||
.get(cutoff) as { c: number }).c;
|
||||
|
||||
const recentRows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE created_at >= ? AND status != 'trashed'
|
||||
ORDER BY created_at DESC, id DESC LIMIT 50`
|
||||
)
|
||||
.all(cutoff) as Record<string, unknown>[];
|
||||
const recentNotes = recentRows.map((r) => this.hydrate(r));
|
||||
|
||||
const tagCounts = this.db
|
||||
.prepare(
|
||||
`SELECT t.name AS tag, COUNT(*) AS count
|
||||
FROM note_tags nt
|
||||
JOIN notes n ON n.id = nt.note_id
|
||||
JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE n.created_at >= ? AND n.status != 'trashed'
|
||||
GROUP BY t.id
|
||||
ORDER BY count DESC, t.name ASC`
|
||||
)
|
||||
.all(cutoff) as Array<{ tag: string; count: number }>;
|
||||
|
||||
const dueRow = this.db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed,
|
||||
SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending
|
||||
FROM notes
|
||||
WHERE created_at >= ?
|
||||
AND status != 'trashed'
|
||||
AND due_date IS NOT NULL`
|
||||
)
|
||||
.get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null };
|
||||
const dueProgress = {
|
||||
total: dueRow.total,
|
||||
passed: dueRow.passed ?? 0,
|
||||
pending: dueRow.pending ?? 0
|
||||
};
|
||||
|
||||
return { totalCount, recentNotes, tagCounts, dueProgress };
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 +
|
||||
* v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입).
|
||||
*/
|
||||
restoreNote(id: string): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
const before = this.db
|
||||
.prepare(`SELECT ai_status FROM notes WHERE id = ?`)
|
||||
.get(id) as { ai_status: string } | undefined;
|
||||
// setStatus('active', null) — reason clear + deleted_at NULL + updated_at 갱신.
|
||||
this.setStatus(id, 'active', null);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성
|
||||
if (before?.ai_status === 'failed') {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
|
||||
)
|
||||
.run(now, id);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
)
|
||||
.run(id, now);
|
||||
} else if (before?.ai_status === 'pending') {
|
||||
// pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
)
|
||||
.run(id, now);
|
||||
}
|
||||
// done 노트는 재처리 안 함 (이미 결과 있음)
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
permanentDelete(id: string): void {
|
||||
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
|
||||
}
|
||||
@@ -428,7 +838,7 @@ export class NoteRepository {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit));
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
|
||||
.all(limit) as any[];
|
||||
.all(limit) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
@@ -458,11 +868,22 @@ export class NoteRepository {
|
||||
|
||||
/**
|
||||
* Import a note from an external source (F5 export tree).
|
||||
* Conflict policy:
|
||||
* Conflict policy (fork-on-id-collision):
|
||||
* - id missing in DB → INSERT (status: 'inserted')
|
||||
* - id present + raw_text identical → no-op (status: 'skipped')
|
||||
* - id present + raw_text differs → INSERT under fresh uuidv7
|
||||
* to preserve the raw_text-immutable invariant (status: 'forked')
|
||||
* - id present + raw_text differs → INSERT under fresh uuidv7 so the same id
|
||||
* never points at two different baselines (status: 'forked'). v0.2.10 Cut C
|
||||
* relaxed the `raw_text 불변` policy → `raw_text 가변 + note_revisions 보존`,
|
||||
* but per-id baseline distinction is still required for sync determinism.
|
||||
*
|
||||
* v0.2.10 Cut C — INSERT/fork 시 동일 transaction 안에서 note_revisions 에
|
||||
* 'capture' 첫 revision INSERT (createdAt = edited_at). 미수행 시 first user
|
||||
* edit 직후 import 시점 본문이 history 에서 사라지는 회귀 (final review 발견).
|
||||
*
|
||||
* v0.2.11 Cut D — INSERT/fork 시 tags 추가 후 rebuildFtsTagsForNote(finalId)
|
||||
* 호출 — m007 trigger 가 빈 tags='' 로 FTS row 만들고, note_tags INSERT 만으로는
|
||||
* notes_fts.tags 갱신 안 됨. 미수행 시 import 한 노트가 tag keyword 검색에서
|
||||
* 매칭 안 되는 회귀 (final review 발견).
|
||||
*
|
||||
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
|
||||
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
|
||||
@@ -489,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,
|
||||
@@ -511,8 +933,15 @@ export class NoteRepository {
|
||||
input.intentPromptedAt,
|
||||
input.deletedAt ?? null,
|
||||
input.createdAt,
|
||||
input.updatedAt
|
||||
input.updatedAt,
|
||||
notebookId
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`
|
||||
)
|
||||
.run(finalId, input.rawText, input.createdAt);
|
||||
if (input.tags.length > 0) {
|
||||
const getOrInsertTag = this.db.prepare(
|
||||
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
||||
@@ -528,12 +957,153 @@ export class NoteRepository {
|
||||
if (t.source === 'ai') linkAi.run(finalId, row.id);
|
||||
else linkUser.run(finalId, row.id);
|
||||
}
|
||||
// v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 동기화 (single write path).
|
||||
this.rebuildFtsTagsForNote(finalId);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
return { id: finalId, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은
|
||||
* sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가).
|
||||
*
|
||||
* 3 분기:
|
||||
* - id 없음 → INSERT (capture revision + tags FTS sync)
|
||||
* - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신
|
||||
* - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision),
|
||||
* local 이 더 최신이면 skip
|
||||
*
|
||||
* tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용.
|
||||
* raw_text 변경 시 updateRawText 호출 — Cut C single write path 재사용.
|
||||
*/
|
||||
upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } {
|
||||
const existing = this.db
|
||||
.prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`)
|
||||
.get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined;
|
||||
|
||||
if (!existing) {
|
||||
// INSERT path
|
||||
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,
|
||||
created_at, updated_at,
|
||||
due_date, due_date_edited_by_user,
|
||||
status, status_changed_at, move_reason, notebook_id)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.id,
|
||||
input.rawText,
|
||||
input.aiTitle,
|
||||
input.aiSummary,
|
||||
input.aiProvider,
|
||||
input.aiGeneratedAt,
|
||||
input.titleEditedByUser ? 1 : 0,
|
||||
input.summaryEditedByUser ? 1 : 0,
|
||||
input.userIntent,
|
||||
input.intentPromptedAt,
|
||||
input.createdAt,
|
||||
input.updatedAt,
|
||||
input.dueDate,
|
||||
input.dueDateEditedByUser ? 1 : 0,
|
||||
input.status,
|
||||
input.statusChangedAt,
|
||||
input.moveReason,
|
||||
notebookId
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`
|
||||
)
|
||||
.run(input.id, input.rawText, input.createdAt);
|
||||
if (input.tags.length > 0) {
|
||||
const getOrInsertTag = this.db.prepare(
|
||||
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
||||
);
|
||||
const linkAi = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
|
||||
);
|
||||
const linkUser = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
|
||||
);
|
||||
for (const t of input.tags) {
|
||||
const row = getOrInsertTag.get(t.name) as { id: number };
|
||||
if (t.source === 'ai') linkAi.run(input.id, row.id);
|
||||
else linkUser.run(input.id, row.id);
|
||||
}
|
||||
this.rebuildFtsTagsForNote(input.id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
return { id: input.id, status: 'inserted' };
|
||||
}
|
||||
|
||||
if (input.updatedAt <= existing.updated_at) {
|
||||
return { id: input.id, status: 'skipped' };
|
||||
}
|
||||
|
||||
if (existing.raw_text !== input.rawText) {
|
||||
this.updateRawText(input.id, input.rawText, new Date(input.updatedAt));
|
||||
}
|
||||
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE notes
|
||||
SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END,
|
||||
ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END,
|
||||
ai_provider = ?,
|
||||
ai_generated_at = ?,
|
||||
due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END,
|
||||
status = ?,
|
||||
status_changed_at = ?,
|
||||
move_reason = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
input.aiTitle,
|
||||
input.aiSummary,
|
||||
input.aiProvider,
|
||||
input.aiGeneratedAt,
|
||||
input.dueDate,
|
||||
input.status,
|
||||
input.statusChangedAt,
|
||||
input.moveReason,
|
||||
input.updatedAt,
|
||||
input.id
|
||||
);
|
||||
this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id);
|
||||
if (input.tags.length > 0) {
|
||||
const getOrInsertTag = this.db.prepare(
|
||||
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
||||
);
|
||||
const linkAi = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
|
||||
);
|
||||
const linkUser = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
|
||||
);
|
||||
for (const t of input.tags) {
|
||||
const row = getOrInsertTag.get(t.name) as { id: number };
|
||||
if (t.source === 'ai') linkAi.run(input.id, row.id);
|
||||
else linkUser.run(input.id, row.id);
|
||||
}
|
||||
}
|
||||
this.rebuildFtsTagsForNote(input.id);
|
||||
});
|
||||
tx();
|
||||
return { id: input.id, status: 'updated' };
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
@@ -550,7 +1120,6 @@ export class NoteRepository {
|
||||
* and count rows whose UTC ISO `created_at` lies inside.
|
||||
*/
|
||||
countToday(now: Date = new Date()): number {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
const kstYear = kstNow.getUTCFullYear();
|
||||
const kstMonth = kstNow.getUTCMonth();
|
||||
@@ -569,35 +1138,61 @@ 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()`.
|
||||
*/
|
||||
findExpiredCandidates(now: Date = new Date()): Note[] {
|
||||
const today = todayInKstString(now);
|
||||
const today = kstTodayIso(now);
|
||||
const rows = this.db
|
||||
.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 any[];
|
||||
.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`)
|
||||
.all() as any[];
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map((r) => ({
|
||||
noteId: r.note_id,
|
||||
attempts: r.attempts,
|
||||
nextRunAt: r.next_run_at
|
||||
noteId: r.note_id as string,
|
||||
attempts: r.attempts as number,
|
||||
nextRunAt: r.next_run_at as string
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -613,41 +1208,62 @@ export class NoteRepository {
|
||||
.run(nextRunAt, lastError.slice(0, 500), noteId);
|
||||
}
|
||||
|
||||
private hydrate(row: any): Note {
|
||||
/**
|
||||
* v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 컬럼 (csv) 재구성.
|
||||
* 단일 write path 패턴: tags 변경하는 모든 메서드가 같은 transaction 끝에서 호출.
|
||||
*/
|
||||
private rebuildFtsTagsForNote(noteId: string): void {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT COALESCE(GROUP_CONCAT(t.name, ' '), '') AS csv
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = ?`
|
||||
)
|
||||
.get(noteId) as { csv: string };
|
||||
this.db
|
||||
.prepare(`UPDATE notes_fts SET tags = ? WHERE note_id = ?`)
|
||||
.run(row.csv, noteId);
|
||||
}
|
||||
|
||||
private hydrate(row: Record<string, unknown>): Note {
|
||||
const tags = this.db
|
||||
.prepare(
|
||||
`SELECT t.name, nt.source
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = ? ORDER BY t.name`
|
||||
)
|
||||
.all(row.id) as Array<{ name: string; source: 'ai' | 'user' }>;
|
||||
.all(row.id as string) as Array<{ name: string; source: 'ai' | 'user' }>;
|
||||
const media = this.db
|
||||
.prepare(
|
||||
`SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?`
|
||||
)
|
||||
.all(row.id) as NoteMedia[];
|
||||
.all(row.id as string) as NoteMedia[];
|
||||
return {
|
||||
id: row.id,
|
||||
rawText: row.raw_text,
|
||||
aiTitle: row.ai_title,
|
||||
aiSummary: row.ai_summary,
|
||||
aiStatus: row.ai_status,
|
||||
aiError: row.ai_error,
|
||||
aiProvider: row.ai_provider,
|
||||
aiGeneratedAt: row.ai_generated_at,
|
||||
titleEditedByUser: row.title_edited_by_user === 1,
|
||||
summaryEditedByUser: row.summary_edited_by_user === 1,
|
||||
userIntent: row.user_intent,
|
||||
intentPromptedAt: row.intent_prompted_at,
|
||||
dueDate: row.due_date ?? null,
|
||||
dueDateEditedByUser: row.due_date_edited_by_user === 1,
|
||||
deletedAt: row.deleted_at ?? null,
|
||||
lastRecalledAt: row.last_recalled_at ?? null,
|
||||
recallDismissedAt: row.recall_dismissed_at ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
id: row.id as string,
|
||||
rawText: row.raw_text as string,
|
||||
aiTitle: row.ai_title as string | null,
|
||||
aiSummary: row.ai_summary as string | null,
|
||||
aiStatus: row.ai_status as AiStatus,
|
||||
aiError: row.ai_error as string | null,
|
||||
aiProvider: row.ai_provider as string | null,
|
||||
aiGeneratedAt: row.ai_generated_at as string | null,
|
||||
titleEditedByUser: (row.title_edited_by_user as number) === 1,
|
||||
summaryEditedByUser: (row.summary_edited_by_user as number) === 1,
|
||||
userIntent: row.user_intent as string | null,
|
||||
intentPromptedAt: row.intent_prompted_at as string | null,
|
||||
dueDate: (row.due_date as string | null) ?? null,
|
||||
dueDateEditedByUser: (row.due_date_edited_by_user as number) === 1,
|
||||
deletedAt: (row.deleted_at as string | null) ?? null,
|
||||
lastRecalledAt: (row.last_recalled_at as string | null) ?? null,
|
||||
recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null,
|
||||
status: ((row.status as NoteStatus | undefined) ?? 'active'),
|
||||
statusChangedAt: (row.status_changed_at as string | null) ?? null,
|
||||
moveReason: (row.move_reason as string | null) ?? null,
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
35
src/main/repository/ftsHelpers.ts
Normal file
35
src/main/repository/ftsHelpers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* v0.2.11 Cut D — FTS5 검색 + 회고 view 의 순수 함수 헬퍼.
|
||||
*/
|
||||
|
||||
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
|
||||
|
||||
// FTS5 special chars: " * ( ) : 외에도 - (NOT 연산자), ^ (column prefix), ` (escape),
|
||||
// AND/OR/NOT keyword 도 query parser 가 special 처리. v0.3.9 — backtick/dash/caret 추가.
|
||||
// 한국어 사용자가 의도 없이 입력할 가능성 가장 높은 punctuation 까지 sanitize.
|
||||
const FTS5_SPECIAL_CHARS_RE = /["*():`^\-]/g;
|
||||
const WS_COLLAPSE_RE = /\s+/g;
|
||||
|
||||
/**
|
||||
* FTS5 MATCH 쿼리에 안전한 형태로 변환. special chars 공백 치환 + 공백 정리.
|
||||
* 다중 토큰은 그대로 두어 FTS5 implicit AND 활용.
|
||||
*/
|
||||
export function sanitizeFtsQuery(input: string): string {
|
||||
return input.replace(FTS5_SPECIAL_CHARS_RE, ' ').replace(WS_COLLAPSE_RE, ' ').trim();
|
||||
}
|
||||
|
||||
export type ReviewPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
/**
|
||||
* 회고 cutoff = period 시작점의 KST 자정 (UTC ISO).
|
||||
* daily = 오늘 0시, weekly = 7일 전 0시, monthly = 30일 전 0시.
|
||||
*/
|
||||
export function computeCutoff(period: ReviewPeriod, now: Date): string {
|
||||
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
const y = kstNow.getUTCFullYear();
|
||||
const m = kstNow.getUTCMonth();
|
||||
const d = kstNow.getUTCDate();
|
||||
const todayMidKstUtc = Date.UTC(y, m, d) - KST_OFFSET_MS;
|
||||
const days = period === 'daily' ? 0 : period === 'weekly' ? 7 : 30;
|
||||
return new Date(todayMidKstUtc - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
63
src/main/services/AutostartDiagnostic.ts
Normal file
63
src/main/services/AutostartDiagnostic.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import electron from 'electron';
|
||||
import { execFile } from 'node:child_process';
|
||||
const { app } = electron;
|
||||
|
||||
/**
|
||||
* v0.2.7 F12 deeper fix — 자동 실행 진단 정보 수집.
|
||||
*
|
||||
* Electron 의 `app.getLoginItemSettings()` 는 args 가 매칭돼야만 정확한 상태를 반환 →
|
||||
* `args: ['--hidden']` 으로 등록 vs `args: undefined` 로 조회하면 mismatch 가 발생할 수 있다.
|
||||
* 그래서 두 호출 결과를 모두 노출 (withArgs / noArgs) + Win 에서는 registry 직접 조회까지.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
const WIN_REGISTRY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
||||
const WIN_REGISTRY_KEY = 'Inkling';
|
||||
|
||||
export async function collectAutostartState(): Promise<AutostartState> {
|
||||
const w = app.getLoginItemSettings({ args: ['--hidden'] });
|
||||
const n = app.getLoginItemSettings();
|
||||
const state: AutostartState = {
|
||||
withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin },
|
||||
noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin },
|
||||
execPath: process.execPath,
|
||||
platform: process.platform
|
||||
};
|
||||
if (process.platform === 'win32') {
|
||||
state.registryPath = `${WIN_REGISTRY_PATH}\\${WIN_REGISTRY_KEY}`;
|
||||
state.registryValue = await readRegistrySilent();
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* `reg query` 로 HKCU\\...\\Run\\Inkling 의 값을 조회.
|
||||
* 키가 없으면 reg.exe 가 exit 1 → silent fallback (null).
|
||||
*
|
||||
* promisify(execFile) 대신 직접 Promise 로 wrapping — 테스트에서 vi.mock 이
|
||||
* `util.promisify.custom` symbol 을 보전하지 못해 stdout 이 undefined 가 되는 이슈 회피.
|
||||
*/
|
||||
function readRegistrySilent(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile('reg', ['query', WIN_REGISTRY_PATH, '/v', WIN_REGISTRY_KEY], (err, stdout) => {
|
||||
if (err) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const m = stdout.match(/REG_SZ\s+(.+)/);
|
||||
resolve(m && m[1] ? m[1].trim() : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import type Database from 'better-sqlite3';
|
||||
import { mkdir, rename, stat, readdir, unlink, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { applyGfsRetention } from './backupRotation.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
|
||||
const MARKER_FILENAME = '.last-snapshot';
|
||||
|
||||
function toKstDateKey(d: Date): string {
|
||||
|
||||
@@ -2,6 +2,14 @@ import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { MediaStore } from './MediaStore.js';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — CaptureService 가 ai_enabled 를 조회할 때만 의존하는 좁은 인터페이스.
|
||||
* SettingsService 직접 의존을 피해 테스트 mock 이 단순해짐 (entire SettingsService 면 불필요).
|
||||
*/
|
||||
export interface AiEnabledSource {
|
||||
isAiEnabled(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface TelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
||||
@@ -23,6 +31,9 @@ export interface CaptureDeps {
|
||||
enqueue: (noteId: string) => Promise<void>;
|
||||
celebrate: (noteId: string) => void;
|
||||
telemetry?: TelemetryEmitter;
|
||||
// v0.2.9 Cut B — settings.ai_enabled=false 면 새 노트는 ai_status='disabled' + enqueue skip.
|
||||
// 미주입 시 기존 동작 (항상 enabled) 보존 — 기존 caller 무영향.
|
||||
settings?: AiEnabledSource;
|
||||
}
|
||||
|
||||
export interface SubmitInput {
|
||||
@@ -44,7 +55,12 @@ export class CaptureService {
|
||||
if (trimmed.length === 0 && input.images.length === 0) {
|
||||
throw new Error('empty submission');
|
||||
}
|
||||
const { id } = this.repo.create({ rawText: input.text });
|
||||
// v0.2.9 Cut B — settings 미주입 시 기본 enabled (backward compat).
|
||||
const aiEnabled = this.deps.settings ? await this.deps.settings.isAiEnabled() : true;
|
||||
const { id } = this.repo.create({
|
||||
rawText: input.text,
|
||||
aiStatus: aiEnabled ? 'pending' : 'disabled'
|
||||
});
|
||||
if (input.images.length > 0) {
|
||||
const rows = [];
|
||||
for (const img of input.images) {
|
||||
@@ -70,7 +86,9 @@ export class CaptureService {
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
await this.deps.enqueue(id);
|
||||
if (aiEnabled) {
|
||||
await this.deps.enqueue(id);
|
||||
}
|
||||
this.deps.celebrate(id);
|
||||
return { noteId: id };
|
||||
}
|
||||
@@ -88,9 +106,14 @@ export class CaptureService {
|
||||
|
||||
async restoreNote(noteId: string): Promise<void> {
|
||||
// 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note || note.deletedAt === null) return;
|
||||
this.repo.restore(noteId);
|
||||
const before = this.repo.findById(noteId);
|
||||
if (!before || before.deletedAt === null) return;
|
||||
// v0.2.6 #10 — production path: repo.restoreNote (ai_status reset + pending_jobs 재생성)
|
||||
this.repo.restoreNote(noteId);
|
||||
// v0.2.6 #10 — in-memory AiWorker queue 갱신: DB 갱신만으로는 다음 앱 실행 시까지 처리 X
|
||||
if (before.aiStatus === 'failed' || before.aiStatus === 'pending') {
|
||||
await this.deps.enqueue(noteId);
|
||||
}
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
@@ -123,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);
|
||||
@@ -184,6 +207,24 @@ export class CaptureService {
|
||||
return { count: ids.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.9 — 단일 failed 노트 재시도. NoteCard 의 per-note "재시도" 버튼 path.
|
||||
* repo.retryOneFailed 후 worker.enqueue 재투입.
|
||||
*/
|
||||
async retryOneFailed(id: string): Promise<{ ok: boolean }> {
|
||||
const r = this.repo.retryOneFailed(id, new Date().toISOString());
|
||||
if (r.ok) await this.deps.enqueue(id);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.9 — pending 노트 cancel. ai_status='disabled' + pending_jobs 삭제.
|
||||
* 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path.
|
||||
*/
|
||||
cancelPending(id: string): { ok: boolean } {
|
||||
return this.repo.cancelPending(id, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 후보 1건 fetch. */
|
||||
async listRecallCandidate(): Promise<Note | null> {
|
||||
return this.repo.findRecallCandidate();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { WeeklyContinuity } from '@shared/types';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const WEEK_TARGET = 7;
|
||||
const RECOVERY_GAP_DAYS = 7;
|
||||
|
||||
@@ -64,6 +64,11 @@ function noteToExportNote(n: Note): ExportNote {
|
||||
aiGeneratedAt: n.aiGeneratedAt,
|
||||
userIntent: n.userIntent,
|
||||
intentPromptedAt: n.intentPromptedAt,
|
||||
status: n.status,
|
||||
statusChangedAt: n.statusChangedAt,
|
||||
moveReason: n.moveReason,
|
||||
dueDate: n.dueDate,
|
||||
dueDateEditedByUser: n.dueDateEditedByUser,
|
||||
tags: n.tags.map((t) => ({ name: t.name, source: t.source })),
|
||||
media: n.media.map((m, idx) => ({
|
||||
rel: `media/${n.id.slice(0, 8)}__${idx + 1}.${inferExt(m.mime)}`,
|
||||
@@ -131,7 +136,6 @@ export class ExportService {
|
||||
totalBytes += Buffer.byteLength(indexJsonl, 'utf8');
|
||||
|
||||
const manifest = composeManifest({
|
||||
exportedAt: this.now().toISOString(),
|
||||
noteCount: notes.length,
|
||||
mediaCount
|
||||
});
|
||||
|
||||
@@ -89,4 +89,33 @@ export class GitClient {
|
||||
if (r.exitCode !== 0) throw new Error(`git rev-parse failed: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
async fetch(remote: string = 'origin'): Promise<GitExecResult> {
|
||||
return this.run(['fetch', remote]);
|
||||
}
|
||||
|
||||
async rebaseOnto(ref: string): Promise<GitExecResult> {
|
||||
return this.run(['rebase', ref]);
|
||||
}
|
||||
|
||||
async rebaseAbort(): Promise<GitExecResult> {
|
||||
return this.run(['rebase', '--abort']);
|
||||
}
|
||||
|
||||
async hasUncommittedChanges(): Promise<boolean> {
|
||||
const r = await this.run(['status', '--porcelain']);
|
||||
return r.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
/** ref (branch, tag, remote branch) 존재 여부 확인. `git rev-parse --verify`. */
|
||||
async refExists(ref: string): Promise<boolean> {
|
||||
const r = await this.run(['rev-parse', '--verify', ref]);
|
||||
return r.exitCode === 0;
|
||||
}
|
||||
|
||||
/** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */
|
||||
async listConflicts(): Promise<string[]> {
|
||||
const r = await this.run(['diff', '--name-only', '--diff-filter=U']);
|
||||
return r.stdout.split('\n').map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js';
|
||||
import type { HealthResult } from '../ai/InferenceProvider.js';
|
||||
import { ProviderHolder } from '../ai/ProviderHolder.js';
|
||||
|
||||
export type HealthTelemetryEvent =
|
||||
| { kind: 'ollama_unreachable'; reason: string }
|
||||
@@ -10,6 +11,9 @@ export interface HealthCheckerOptions {
|
||||
onUpdate?: (status: HealthResult) => void;
|
||||
onTelemetry?: (event: HealthTelemetryEvent) => void;
|
||||
now?: () => number;
|
||||
// v0.2.9 Cut B Task 14 — settings.ai_enabled=false 면 polling skip.
|
||||
// 미설정 시 항상 enabled (backward-compat).
|
||||
isAiEnabled?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60_000;
|
||||
@@ -28,7 +32,7 @@ export class HealthChecker {
|
||||
private now: () => number;
|
||||
|
||||
constructor(
|
||||
private provider: InferenceProvider,
|
||||
private holder: ProviderHolder,
|
||||
private opts: HealthCheckerOptions = {}
|
||||
) {
|
||||
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
@@ -48,7 +52,7 @@ export class HealthChecker {
|
||||
}
|
||||
|
||||
private async doRunOnce(): Promise<HealthResult> {
|
||||
const next = await this.provider.healthCheck();
|
||||
const next = await this.holder.get().healthCheck();
|
||||
const prev = this.last;
|
||||
const okChanged = prev.ok !== next.ok;
|
||||
const reasonChanged = prev.reason !== next.reason;
|
||||
@@ -71,8 +75,22 @@ export class HealthChecker {
|
||||
|
||||
start(): void {
|
||||
if (this.timer !== null) return;
|
||||
void this.runOnce();
|
||||
this.timer = setInterval(() => { void this.runOnce(); }, this.intervalMs);
|
||||
void this.tickIfEnabled();
|
||||
this.timer = setInterval(() => { void this.tickIfEnabled(); }, this.intervalMs);
|
||||
}
|
||||
|
||||
// v0.2.9 Cut B Task 14 — polling tick. settings.ai_enabled=false 면 skip.
|
||||
// 수동 runOnce({ manual: true }) 는 이 게이트와 무관하게 항상 실행 (사용자 의도).
|
||||
private async tickIfEnabled(): Promise<void> {
|
||||
if (this.opts.isAiEnabled !== undefined) {
|
||||
try {
|
||||
const enabled = await this.opts.isAiEnabled();
|
||||
if (!enabled) return;
|
||||
} catch {
|
||||
// settings 로드 실패 시 안전 측면 — polling 진행 (기존 동작 유지).
|
||||
}
|
||||
}
|
||||
await this.runOnce();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
|
||||
@@ -130,6 +130,39 @@ export class ImportService {
|
||||
};
|
||||
}
|
||||
|
||||
async applySyncFromDir(dir: string): Promise<{ changedCount: number }> {
|
||||
const files = await this.scanNotes(dir);
|
||||
let changedCount = 0;
|
||||
for (const f of files) {
|
||||
const content = await readFile(f, 'utf8');
|
||||
const parsed = parseExportNote(content);
|
||||
const r = this.repo.upsertFromSync({
|
||||
id: parsed.id,
|
||||
rawText: parsed.rawText,
|
||||
createdAt: parsed.createdAt,
|
||||
updatedAt: parsed.updatedAt,
|
||||
aiTitle: parsed.aiTitle,
|
||||
aiSummary: parsed.aiSummary,
|
||||
titleEditedByUser: parsed.titleEditedByUser,
|
||||
summaryEditedByUser: parsed.summaryEditedByUser,
|
||||
aiProvider: parsed.aiProvider,
|
||||
aiGeneratedAt: parsed.aiGeneratedAt,
|
||||
userIntent: parsed.userIntent,
|
||||
intentPromptedAt: parsed.intentPromptedAt,
|
||||
tags: parsed.tags,
|
||||
// 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,
|
||||
dueDateEditedByUser: parsed.dueDateEditedByUser
|
||||
});
|
||||
if (r.status !== 'skipped') changedCount += 1;
|
||||
}
|
||||
return { changedCount };
|
||||
}
|
||||
|
||||
private async scanNotes(sourceDir: string): Promise<string[]> {
|
||||
const notesDir = join(sourceDir, 'notes');
|
||||
let entries: string[];
|
||||
|
||||
214
src/main/services/SettingsService.ts
Normal file
214
src/main/services/SettingsService.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const OllamaSettingsSchema = z.object({
|
||||
endpoint: z.string().url(),
|
||||
model: z.string().min(1)
|
||||
}).strict();
|
||||
|
||||
const SettingsSchema = z.object({
|
||||
ollama: OllamaSettingsSchema.optional(),
|
||||
// v0.2.9 Cut B — AI-less mode toggle. 기존 settings 파일에 없으면 isAiEnabled() 가
|
||||
// true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 —
|
||||
// load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동.
|
||||
ai_enabled: z.boolean().optional(),
|
||||
onboarding_completed: z.boolean().optional(),
|
||||
// v0.3.0 Cut E — 양방향 git sync 설정. 모두 optional — 미구성 시 sync 비활성.
|
||||
sync_repo_url: z.string().nullable().optional(),
|
||||
sync_auto_enabled: z.boolean().optional(),
|
||||
sync_interval_min: z.number().int().min(5).optional(),
|
||||
// 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(),
|
||||
// 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>;
|
||||
export type OllamaSettings = z.infer<typeof OllamaSettingsSchema>;
|
||||
|
||||
export class SettingsService {
|
||||
private filePath: string;
|
||||
private cache: Settings | null = null;
|
||||
|
||||
constructor(profileDir: string) {
|
||||
this.filePath = join(profileDir, 'settings.json');
|
||||
}
|
||||
|
||||
async load(): Promise<Settings> {
|
||||
if (this.cache !== null) return this.cache;
|
||||
try {
|
||||
const raw = await readFile(this.filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
this.cache = SettingsSchema.parse(parsed);
|
||||
} catch {
|
||||
this.cache = {};
|
||||
}
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 12 — settings:get IPC 핸들러용 read-only accessor.
|
||||
* 첫 launch onboarding 분기에서 onboarding_completed 키 확인.
|
||||
*/
|
||||
async getAll(): Promise<Settings> {
|
||||
return this.load();
|
||||
}
|
||||
|
||||
async setOllama(value: OllamaSettings): Promise<void> {
|
||||
const validated = OllamaSettingsSchema.parse(value);
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, ollama: validated };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — AI-less mode 의 기본값은 enabled (true). 기존 settings 파일을
|
||||
* 가진 사용자 (ai_enabled 키 부재) 도 무영향.
|
||||
*/
|
||||
async isAiEnabled(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.ai_enabled ?? true;
|
||||
}
|
||||
|
||||
async setAiEnabled(value: boolean): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, ai_enabled: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.2.9 Cut B — 첫 실행 onboarding completion 표지. 기본 false. */
|
||||
async isOnboardingCompleted(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.onboarding_completed ?? false;
|
||||
}
|
||||
|
||||
async setOnboardingCompleted(value: boolean): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, onboarding_completed: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — sync 저장소 URL. null/빈 문자열 = sync 비활성. 본 메서드는 값만 저장,
|
||||
* git init/remote add 는 별도 호출자 (settings:configure-sync IPC) 가 담당.
|
||||
*/
|
||||
async getSyncRepoUrl(): Promise<string | null> {
|
||||
const s = await this.load();
|
||||
return s.sync_repo_url ?? null;
|
||||
}
|
||||
|
||||
async setSyncRepoUrl(value: string | null): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, sync_repo_url: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.3.0 Cut E — 자동 주기 sync 활성. configured 일 때만 의미 있음. 기본 true. */
|
||||
async isAutoSyncEnabled(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.sync_auto_enabled ?? true;
|
||||
}
|
||||
|
||||
async setAutoSyncEnabled(value: boolean): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, sync_auto_enabled: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.3.0 Cut E — 자동 주기 sync interval (분). 기본 30, min 5. */
|
||||
async getSyncIntervalMin(): Promise<number> {
|
||||
const s = await this.load();
|
||||
return s.sync_interval_min ?? 30;
|
||||
}
|
||||
|
||||
async setSyncIntervalMin(value: number): Promise<void> {
|
||||
if (!Number.isInteger(value) || value < 5) {
|
||||
throw new Error(`sync_interval_min must be an integer >= 5 (got ${value})`);
|
||||
}
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, sync_interval_min: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.3.1 Cut F — 선택된 vision model. null = 미선택. */
|
||||
async getVisionModel(): Promise<string | null> {
|
||||
const s = await this.load();
|
||||
return s.vision_model ?? null;
|
||||
}
|
||||
|
||||
async setVisionModel(value: string | null): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, vision_model: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.3.1 Cut F — /api/tags 조회 결과 캐시. 기본 빈 배열 + null timestamp. */
|
||||
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
|
||||
const s = await this.load();
|
||||
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
|
||||
}
|
||||
|
||||
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
|
||||
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';
|
||||
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
|
||||
await rename(tmpPath, this.filePath);
|
||||
this.cache = next;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,41 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import type { ExportService } from './ExportService.js';
|
||||
import type { ImportService } from './ImportService.js';
|
||||
import { GitClient } from './GitClient.js';
|
||||
|
||||
/**
|
||||
* Cut E final review fix: 'noteId' was misleading — F5 export filenames are
|
||||
* `<date>-<id8>-<slug>.md` (composeFilename), not `<uuid>.md`. The git checkout /
|
||||
* resolve operations use the FULL relative path (e.g., `notes/2026-05-09-abc12345-회의.md`).
|
||||
* `path` matches what we actually pass to `git checkout --ours/theirs`.
|
||||
*/
|
||||
export interface SyncConflict {
|
||||
path: string;
|
||||
localText: string;
|
||||
remoteText: string;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
ok: boolean;
|
||||
reason?: string; // why the sync was skipped or failed
|
||||
changed?: boolean; // true if a new commit was created
|
||||
sha?: string | null;
|
||||
reason?: string;
|
||||
changed?: boolean;
|
||||
localSha?: string | null;
|
||||
pushed?: boolean;
|
||||
importedCount?: number;
|
||||
conflicts?: SyncConflict[];
|
||||
}
|
||||
|
||||
export class SyncService {
|
||||
private syncDir: string;
|
||||
private lastConflicts: SyncConflict[] = [];
|
||||
private lastResult: SyncStatus | null = null;
|
||||
private lastAt: string | null = null;
|
||||
|
||||
constructor(
|
||||
private profileDir: string,
|
||||
private exportSvc: ExportService,
|
||||
private importSvc: ImportService,
|
||||
private now: () => Date = () => new Date()
|
||||
) {
|
||||
this.syncDir = join(profileDir, 'sync');
|
||||
@@ -33,31 +52,151 @@ export class SyncService {
|
||||
return true;
|
||||
}
|
||||
|
||||
getLastStatus(): { lastAt: string | null; lastResult: SyncStatus | null } {
|
||||
return { lastAt: this.lastAt, lastResult: this.lastResult };
|
||||
}
|
||||
|
||||
listConflicts(): SyncConflict[] {
|
||||
return this.lastConflicts;
|
||||
}
|
||||
|
||||
async sync(): Promise<SyncStatus> {
|
||||
if (!(await this.isConfigured())) {
|
||||
return { ok: false, reason: 'not_configured' };
|
||||
const result = await this.runSync();
|
||||
this.lastResult = result;
|
||||
this.lastAt = this.now().toISOString();
|
||||
if (result.reason === 'conflict' && result.conflicts) {
|
||||
this.lastConflicts = result.conflicts;
|
||||
} else if (result.ok) {
|
||||
this.lastConflicts = [];
|
||||
}
|
||||
// 1. Re-export the full tree into syncDir (idempotent).
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — conflict 해결. local/remote 2 choice (both deferred to v0.3.1+).
|
||||
* 사용자가 ConflictModal 에서 선택 → IPC → 본 메서드. 각 conflict 의 path 별 호출.
|
||||
*
|
||||
* - 'local' = 내 것 사용 (origin 변경 폐기) → git checkout --ours
|
||||
* - 'remote' = 원격 사용 → git checkout --theirs + applySyncFromDir (local DB 갱신)
|
||||
*
|
||||
* 모든 conflict 해결 후 rebase --continue 가 성공 → push.
|
||||
* UI 가 여러 conflict 를 loop 호출하면 마지막 호출에서 push 까지 완료.
|
||||
*
|
||||
* Cut E final review fix: 파라미터를 path 로 변경 (옛 noteId 는 export filename slug,
|
||||
* UUID 아님 — 혼동 회피).
|
||||
*/
|
||||
async resolveConflict(
|
||||
path: string,
|
||||
choice: 'local' | 'remote'
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
const git = new GitClient(this.syncDir);
|
||||
const flag = choice === 'local' ? '--ours' : '--theirs';
|
||||
|
||||
const checkout = await git.run(['checkout', flag, path]);
|
||||
if (checkout.exitCode !== 0) {
|
||||
return { ok: false, reason: `checkout failed: ${checkout.stderr}` };
|
||||
}
|
||||
|
||||
await git.addAll();
|
||||
|
||||
const cont = await git.run(['rebase', '--continue']);
|
||||
if (cont.exitCode !== 0) {
|
||||
// Likely other unresolved files — UI will call resolveConflict for them.
|
||||
return { ok: false, reason: `rebase --continue failed: ${cont.stderr}` };
|
||||
}
|
||||
|
||||
if (choice === 'remote') {
|
||||
try {
|
||||
await this.importSvc.applySyncFromDir(this.syncDir);
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `re-import failed: ${(e as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await git.push();
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `push failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// Remove this path from cached conflicts list
|
||||
this.lastConflicts = this.lastConflicts.filter((c) => c.path !== path);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async runSync(): Promise<SyncStatus> {
|
||||
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
|
||||
|
||||
const git = new GitClient(this.syncDir);
|
||||
|
||||
// 1. local export
|
||||
try {
|
||||
await mkdir(this.syncDir, { recursive: true });
|
||||
await this.exportSvc.export(this.syncDir, { includeMedia: true });
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `export failed: ${(e as Error).message}` };
|
||||
}
|
||||
// 2. git add + commit + push
|
||||
const git = new GitClient(this.syncDir);
|
||||
|
||||
// 2. local commit (only if changed)
|
||||
let localSha: string | null = null;
|
||||
let localChanged = false;
|
||||
try {
|
||||
await git.addAll();
|
||||
const ts = this.now().toISOString();
|
||||
const message = `chore(notes): sync ${ts}`;
|
||||
const commit = await git.commit(message);
|
||||
if (!commit.changed) {
|
||||
return { ok: true, changed: false, pushed: false };
|
||||
localChanged = await git.hasUncommittedChanges();
|
||||
if (localChanged) {
|
||||
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
|
||||
localSha = c.sha;
|
||||
}
|
||||
await git.push();
|
||||
return { ok: true, changed: true, sha: commit.sha, pushed: true };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: (e as Error).message };
|
||||
return { ok: false, reason: `local commit failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// 3. fetch
|
||||
const fetchR = await git.fetch();
|
||||
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
|
||||
|
||||
// 4. rebase — skip if origin/main doesn't exist yet (first-push, empty remote)
|
||||
const hasOriginMain = await git.refExists('origin/main');
|
||||
if (hasOriginMain) {
|
||||
const rebaseR = await git.rebaseOnto('origin/main');
|
||||
if (rebaseR.exitCode !== 0) {
|
||||
const files = await git.listConflicts();
|
||||
// Cut E final review fix — populate localText/remoteText from rebase index
|
||||
// BEFORE aborting. `git show :2:<path>` = ours (local during rebase),
|
||||
// `:3:<path>` = theirs (remote being applied). UI shows side-by-side diff.
|
||||
const conflicts: SyncConflict[] = [];
|
||||
for (const path of files) {
|
||||
const ours = await git.run(['show', `:2:${path}`]);
|
||||
const theirs = await git.run(['show', `:3:${path}`]);
|
||||
conflicts.push({
|
||||
path,
|
||||
localText: ours.exitCode === 0 ? ours.stdout : '',
|
||||
remoteText: theirs.exitCode === 0 ? theirs.stdout : ''
|
||||
});
|
||||
}
|
||||
await git.rebaseAbort();
|
||||
return { ok: false, reason: 'conflict', conflicts };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. re-import
|
||||
let importedCount = 0;
|
||||
try {
|
||||
const r = await this.importSvc.applySyncFromDir(this.syncDir);
|
||||
importedCount = r.changedCount;
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `re-import failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// 6. push
|
||||
try {
|
||||
await git.push();
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `push failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
return { ok: true, changed: localChanged || importedCount > 0, localSha, importedCount, pushed: true };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
49
src/main/services/SyncTimer.ts
Normal file
49
src/main/services/SyncTimer.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { SyncService } from './SyncService.js';
|
||||
import type { SettingsService } from './SettingsService.js';
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — 자동 주기 sync timer.
|
||||
*
|
||||
* - start: settings 의 auto enabled + repo URL 모두 갖춰져야 시작
|
||||
* - reconfigure: settings 변경 시 stop + start (새 interval 적용)
|
||||
* - stop: clearInterval (idempotent)
|
||||
*
|
||||
* sync 결과는 무시 (interval mode = silent). conflict 발생 시 다음 manual sync /
|
||||
* 충돌 UI 진입 시 처리됨 — 사용자가 settings 페이지의 SyncSection 에서 확인 가능.
|
||||
*/
|
||||
export class SyncTimer {
|
||||
private handle: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private syncSvc: SyncService,
|
||||
private settings: SettingsService
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.handle !== null) return; // idempotent
|
||||
const enabled = await this.settings.isAutoSyncEnabled();
|
||||
if (!enabled) return;
|
||||
const url = await this.settings.getSyncRepoUrl();
|
||||
if (url === null || url.trim().length === 0) return;
|
||||
const intervalMin = await this.settings.getSyncIntervalMin();
|
||||
const ms = Math.max(5, intervalMin) * 60 * 1000;
|
||||
this.handle = setInterval(() => {
|
||||
void this.syncSvc.sync().catch(() => {
|
||||
// silent — interval mode 의 실패는 다음 attempt 또는 사용자 manual 호출이 처리
|
||||
});
|
||||
}, ms);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.handle !== null) {
|
||||
clearInterval(this.handle);
|
||||
this.handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** settings 변경 시 호출 — 현재 interval stop 후 새 값으로 start. */
|
||||
async reconfigure(): Promise<void> {
|
||||
this.stop();
|
||||
await this.start();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { validateEvent, TelemetryEvent } from './telemetryEvents.js';
|
||||
import type { AiFailedReason } from './telemetryEvents.js';
|
||||
import { aggregateStats } from './telemetryStats.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
function todayKstIso(now: Date): string {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
||||
.toISOString().slice(0, 10);
|
||||
}
|
||||
import { kstTodayIso, DAY_MS } from '../../shared/util/kstDate.js';
|
||||
|
||||
export interface TelemetryServiceOptions {
|
||||
silent?: boolean;
|
||||
@@ -18,7 +12,7 @@ export interface TelemetryServiceOptions {
|
||||
export type EmitInput =
|
||||
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
||||
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
|
||||
| { kind: 'trash'; payload: { noteId: string } }
|
||||
| { kind: 'restore'; payload: { noteId: string } }
|
||||
| { kind: 'permanent_delete'; payload: { noteId: string } }
|
||||
@@ -52,8 +46,8 @@ export class TelemetryService {
|
||||
} catch {
|
||||
return { removed };
|
||||
}
|
||||
const cutoff = new Date(this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000);
|
||||
const cutoffIso = todayKstIso(cutoff); // KST 일자 비교
|
||||
const cutoff = new Date(this.now().getTime() - this.retentionDays * DAY_MS);
|
||||
const cutoffIso = kstTodayIso(cutoff); // KST 일자 비교
|
||||
for (const name of entries) {
|
||||
const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
|
||||
if (!m) continue;
|
||||
@@ -76,7 +70,7 @@ export class TelemetryService {
|
||||
const nowDate = this.now();
|
||||
const ts = nowDate.toISOString();
|
||||
const event = validateEvent({ ts, kind: input.kind, payload: input.payload });
|
||||
const filePath = join(this.dir, `events-${todayKstIso(nowDate)}.jsonl`);
|
||||
const filePath = join(this.dir, `events-${kstTodayIso(nowDate)}.jsonl`);
|
||||
try {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8');
|
||||
@@ -94,8 +88,8 @@ export class TelemetryService {
|
||||
} catch {
|
||||
return events;
|
||||
}
|
||||
const cutoffMs = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
|
||||
const cutoffIso = todayKstIso(new Date(cutoffMs));
|
||||
const cutoffMs = this.now().getTime() - this.retentionDays * DAY_MS;
|
||||
const cutoffIso = kstTodayIso(new Date(cutoffMs));
|
||||
// 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로
|
||||
// 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨.
|
||||
const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/;
|
||||
|
||||
47
src/main/services/VisionDetect.ts
Normal file
47
src/main/services/VisionDetect.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { SettingsService } from './SettingsService.js';
|
||||
|
||||
// v0.3.1 Cut F final fix — gemma 시리즈 default 정정. 본인 dogfood 환경 = gemma4:e4b
|
||||
// (텍스트). vision 변종은 gemma3 (현재 vision-capable) 또는 gemma4 (향후 출시 시).
|
||||
// 양 family 모두 hint 에 포함 — capability detection 이 future-proof.
|
||||
const VISION_FAMILIES = new Set(['gemma3', 'gemma4', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
|
||||
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3', 'gemma4'];
|
||||
|
||||
export interface OllamaModel {
|
||||
name: string;
|
||||
details?: { family?: string; families?: string[] };
|
||||
}
|
||||
|
||||
export function isVisionCapable(model: OllamaModel): boolean {
|
||||
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
|
||||
if (model.details?.families?.some((f) => VISION_FAMILIES.has(f))) return true;
|
||||
const lower = model.name.toLowerCase();
|
||||
return VISION_NAME_HINTS.some((h) => lower.includes(h));
|
||||
}
|
||||
|
||||
export interface RefreshDeps {
|
||||
settings: SettingsService;
|
||||
endpoint: string;
|
||||
now?: () => Date;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function refreshVisionCache(
|
||||
deps: RefreshDeps
|
||||
): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
|
||||
if (!(await deps.settings.isAiEnabled())) {
|
||||
return { ok: false, reason: 'ai_disabled' };
|
||||
}
|
||||
const fetchFn = deps.fetchImpl ?? fetch;
|
||||
let body: { models?: OllamaModel[] };
|
||||
try {
|
||||
const r = await fetchFn(`${deps.endpoint}/api/tags`);
|
||||
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
|
||||
body = (await r.json()) as { models?: OllamaModel[] };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
|
||||
}
|
||||
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
|
||||
const now = deps.now ? deps.now() : new Date();
|
||||
await deps.settings.setVisionCapableCache(capable, now);
|
||||
return { ok: true, models: capable };
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export interface ExportNote {
|
||||
aiGeneratedAt: string | null;
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
// v0.3.0 Cut E — Cut B (status), Cut C (dueDate via m002), and dueDate user-edited flag
|
||||
// need to round-trip through F5 export and Cut E sync.
|
||||
status: 'active' | 'completed' | 'archived' | 'trashed';
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
tags: ExportNoteTag[];
|
||||
media: ExportNoteMedia[];
|
||||
}
|
||||
@@ -155,6 +162,18 @@ export function composeFrontmatter(note: ExportNote): string {
|
||||
lines.push(`ai_generated_at: ${note.aiGeneratedAt}`);
|
||||
}
|
||||
|
||||
lines.push(`status: ${note.status}`);
|
||||
if (note.statusChangedAt !== null) {
|
||||
lines.push(`status_changed_at: ${note.statusChangedAt}`);
|
||||
}
|
||||
if (note.moveReason !== null) {
|
||||
lines.push(`move_reason: ${formatScalar(note.moveReason)}`);
|
||||
}
|
||||
if (note.dueDate !== null) {
|
||||
lines.push(`due_date: ${note.dueDate}`);
|
||||
lines.push(`due_date_source: ${note.dueDateEditedByUser ? 'user' : 'ai'}`);
|
||||
}
|
||||
|
||||
if (note.media.length > 0) {
|
||||
lines.push('images:');
|
||||
for (const m of note.media) {
|
||||
@@ -234,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
|
||||
},
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface ParsedNote {
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
deletedAt: string | null; // 신규 v0.2.3 #4
|
||||
// v0.3.0 Cut E — round-trip status / due_date / move_reason from frontmatter.
|
||||
// Default to 'active' / null / false when absent (older exports pre-Cut E).
|
||||
status: 'active' | 'completed' | 'archived' | 'trashed';
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
tags: ParsedNoteTag[];
|
||||
images: ParsedNoteImage[];
|
||||
exportVersion: number;
|
||||
@@ -335,6 +342,13 @@ export function parseExportNote(markdown: string): ParsedNote {
|
||||
const versionRaw = get('inkling_export_version');
|
||||
const exportVersion = versionRaw === null ? 0 : Number.parseInt(versionRaw, 10) || 0;
|
||||
|
||||
const statusRaw = get('status');
|
||||
const validStatuses = ['active', 'completed', 'archived', 'trashed'] as const;
|
||||
const status = (validStatuses as readonly string[]).includes(statusRaw ?? 'active')
|
||||
? ((statusRaw ?? 'active') as ParsedNote['status'])
|
||||
: 'active';
|
||||
const dueDateSource = get('due_date_source');
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
@@ -349,6 +363,11 @@ export function parseExportNote(markdown: string): ParsedNote {
|
||||
userIntent: get('user_intent'),
|
||||
intentPromptedAt: get('intent_prompted_at'),
|
||||
deletedAt: get('deleted_at'),
|
||||
status,
|
||||
statusChangedAt: get('status_changed_at'),
|
||||
moveReason: get('move_reason'),
|
||||
dueDate: get('due_date'),
|
||||
dueDateEditedByUser: dueDateSource === 'user',
|
||||
tags: fm.tags,
|
||||
images: fm.images,
|
||||
exportVersion
|
||||
|
||||
@@ -12,11 +12,12 @@ const AiSucceededPayload = z.object({
|
||||
attempts: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const AiFailedReason = z.enum(['unreachable', 'schema', 'timeout', 'other']);
|
||||
export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']);
|
||||
export type AiFailedReason = z.infer<typeof AiFailedReasonSchema>;
|
||||
|
||||
const AiFailedPayload = z.object({
|
||||
noteId: z.string().min(1),
|
||||
reason: AiFailedReason,
|
||||
reason: AiFailedReasonSchema,
|
||||
attempts: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
@@ -92,3 +93,23 @@ export type TelemetryKind = TelemetryEvent['kind'];
|
||||
export function validateEvent(raw: unknown): TelemetryEvent {
|
||||
return TelemetryEventSchema.parse(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.6 #21 — type predicate helper. payload.noteId 가 있는 event kind 만 narrow.
|
||||
* union 확장 시 NO_NOTE_ID_KINDS Set 한 곳만 갱신.
|
||||
*/
|
||||
const NO_NOTE_ID_KINDS = new Set<TelemetryKind>([
|
||||
'empty_trash',
|
||||
'expired_banner_shown',
|
||||
'expired_batch_trash',
|
||||
'ollama_unreachable',
|
||||
'ollama_recovered',
|
||||
'ollama_recheck_manual',
|
||||
'ai_retry_manual',
|
||||
'tag_vocab_hit',
|
||||
'tag_vocab_miss'
|
||||
]);
|
||||
|
||||
export function hasNoteId(ev: TelemetryEvent): ev is Extract<TelemetryEvent, { payload: { noteId: string } }> {
|
||||
return !NO_NOTE_ID_KINDS.has(ev.kind);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { TelemetryEvent } from './telemetryEvents.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
import { kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
|
||||
function kstDate(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
const k = new Date(d.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
||||
.toISOString().slice(0, 10);
|
||||
return kstTodayIso(new Date(ts));
|
||||
}
|
||||
|
||||
interface DailyRow {
|
||||
@@ -133,12 +129,18 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
} else if (ev.kind === 'recall_snoozed') {
|
||||
row.recall_snoozed += 1;
|
||||
recallSnoozedCount += 1;
|
||||
} else {
|
||||
// v0.2.6 #8 — 새 telemetry kind 추가 시 본 함수 분기 누락을 컴파일 단계에서 catch.
|
||||
const _exhaustive: never = ev;
|
||||
void _exhaustive;
|
||||
}
|
||||
}
|
||||
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
const aiTotal = aiSucceeded + aiFailed;
|
||||
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
|
||||
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
|
||||
// v0.2.6 #9 — 회수율 = restore / trash event 비율 (event-level — 한 노트 trash-restore 반복 시
|
||||
// 100% 가능, unique-note 회수율 아님. spec §6.2 "회수 도구 동작?" 질문에 충분).
|
||||
const trashRecoveryRate = trashCount === 0
|
||||
? 'N/A'
|
||||
: `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;
|
||||
|
||||
152
src/main/tray.ts
152
src/main/tray.ts
@@ -2,120 +2,80 @@ import electron from 'electron';
|
||||
import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron';
|
||||
const { app, Tray, Menu, nativeImage } = electron;
|
||||
|
||||
let tray: TrayType | null = null;
|
||||
let _showInbox: () => void = () => {};
|
||||
let _showCapture: () => void = () => {};
|
||||
let _runBackup: () => void = () => {};
|
||||
let _runExport: () => void = () => {};
|
||||
let _runImport: () => void = () => {};
|
||||
let _runSync: () => void = () => {};
|
||||
let _runExportTelemetry: () => void = () => {};
|
||||
let _runOllamaRecheck: () => void = () => {};
|
||||
let _ollamaOk = true;
|
||||
let _todayCount = 0;
|
||||
let _runRetryAllFailed: () => void = () => {};
|
||||
let _failedCount = 0;
|
||||
// v0.2.7 Phase 3 (Task 15) — showAboutDialog 제거됨.
|
||||
// "Inkling 정보..." 트레이 항목이 사라짐 → 동일 기능은 설정 페이지의 InfoSection 이 담당.
|
||||
// settings:get-app-info / settings:copy-app-info IPC 핸들러 (settingsApi.ts) 가 역할 인계.
|
||||
|
||||
function buildMenu() {
|
||||
/**
|
||||
* v0.2.7 Phase 3 (Task 14) — 트레이 메뉴 슬림. 13 → 4 항목.
|
||||
*
|
||||
* 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/자동실행/정보 →
|
||||
* 모두 설정 페이지로 이전. 트레이는 4 항목만 노출:
|
||||
* 1. 한 줄 적기 (showCapture)
|
||||
* 2. 보관한 메모 보기 (showInbox)
|
||||
* 3. 설정... (showSettings — 설정 페이지로 navigate)
|
||||
* 4. 종료
|
||||
*/
|
||||
export interface TrayCallbacks {
|
||||
showInbox: () => void;
|
||||
showCapture: () => void;
|
||||
showSettings: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.7 Phase 3 (Task 14) — TrayState 슬림. todayCount 만 잔류 (오늘 N번 잡아둠 라벨).
|
||||
* ollamaOk / failedCount 메뉴 항목이 사라져 더 이상 필요 없음.
|
||||
*/
|
||||
export interface TrayState {
|
||||
todayCount: number;
|
||||
}
|
||||
|
||||
let tray: TrayType | null = null;
|
||||
let _callbacks: TrayCallbacks | null = null;
|
||||
let _state: TrayState = { todayCount: 0 };
|
||||
|
||||
function buildMenu(): electron.Menu {
|
||||
const items: MenuItemConstructorOptions[] = [];
|
||||
const cb = _callbacks;
|
||||
if (!cb) {
|
||||
// createTray 호출 전이면 빈 메뉴 (defensive)
|
||||
return Menu.buildFromTemplate([{ label: '로딩 중...', enabled: false }]);
|
||||
}
|
||||
// F4-C: count > 0 시 비활성 라벨로 정체성 신호 노출. count = 0 시 메뉴를 자연스럽게 시작.
|
||||
if (_todayCount > 0) {
|
||||
items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false });
|
||||
if (_state.todayCount > 0) {
|
||||
items.push({ label: `오늘 ${_state.todayCount}번 잡아둠`, enabled: false });
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
items.push({ label: '보관한 메모 보기', click: _showInbox });
|
||||
items.push({ label: '한 줄 적기', click: _showCapture });
|
||||
items.push({ label: '한 줄 적기', click: cb.showCapture });
|
||||
items.push({ label: '보관한 메모 보기', click: cb.showInbox });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: '설정...', click: cb.showSettings });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: '지금 백업', click: _runBackup });
|
||||
items.push({ label: '내보내기...', click: _runExport });
|
||||
items.push({ label: '백업에서 복원...', click: _runImport });
|
||||
items.push({ label: '지금 동기화', click: _runSync });
|
||||
items.push({ label: '사용 로그 내보내기...', click: _runExportTelemetry });
|
||||
items.push({
|
||||
label: 'Ollama 재확인',
|
||||
enabled: !_ollamaOk,
|
||||
click: _runOllamaRecheck
|
||||
});
|
||||
items.push({
|
||||
label: `지금 AI 처리 (실패 ${_failedCount}건)`,
|
||||
enabled: _failedCount > 0,
|
||||
click: _runRetryAllFailed
|
||||
});
|
||||
if (app.isPackaged) {
|
||||
const { openAtLogin } = app.getLoginItemSettings();
|
||||
items.push({
|
||||
label: '윈도우 시작 시 자동 실행',
|
||||
type: 'checkbox',
|
||||
checked: openAtLogin,
|
||||
click: (item) => {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: item.checked,
|
||||
args: ['--hidden']
|
||||
});
|
||||
}
|
||||
});
|
||||
items.push({ type: 'separator' });
|
||||
} else {
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
|
||||
return Menu.buildFromTemplate(items);
|
||||
}
|
||||
|
||||
export function createTray(
|
||||
showInbox: () => void,
|
||||
showCapture: () => void,
|
||||
runBackup: () => void,
|
||||
runExport: () => void,
|
||||
runImport: () => void,
|
||||
runSync: () => void,
|
||||
runExportTelemetry: () => void,
|
||||
runOllamaRecheck: () => void,
|
||||
runRetryAllFailed: () => void
|
||||
): TrayType {
|
||||
_showInbox = showInbox;
|
||||
_showCapture = showCapture;
|
||||
_runBackup = runBackup;
|
||||
_runExport = runExport;
|
||||
_runImport = runImport;
|
||||
_runSync = runSync;
|
||||
_runExportTelemetry = runExportTelemetry;
|
||||
_runOllamaRecheck = runOllamaRecheck;
|
||||
_runRetryAllFailed = runRetryAllFailed;
|
||||
/**
|
||||
* v0.2.6 C2 — 1-arg createTray. 기존 10 positional 폐기.
|
||||
* v0.2.7 Phase 3 — TrayCallbacks 3-필드로 슬림.
|
||||
*/
|
||||
export function createTray(callbacks: TrayCallbacks): TrayType {
|
||||
_callbacks = callbacks;
|
||||
const icon = nativeImage.createEmpty();
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);
|
||||
tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
tray.on('click', showInbox);
|
||||
tray.on('click', callbacks.showInbox);
|
||||
return tray;
|
||||
}
|
||||
|
||||
/**
|
||||
* F4-C 환경 앵커 — tooltip + 메뉴 첫 항목을 오늘 캡처 수로 갱신.
|
||||
* `src/main/index.ts` 가 60s interval / AiWorker onUpdate 시점에 호출.
|
||||
* v0.2.6 C3 — 통합 state 갱신. partial 으로 받아 _state merge + 메뉴 재빌드.
|
||||
* v0.2.7 Phase 3 — TrayState 가 todayCount 만 갖도록 슬림.
|
||||
*/
|
||||
export function refreshTray(todayCount: number): void {
|
||||
_todayCount = todayCount;
|
||||
if (tray === null) return;
|
||||
tray.setToolTip(`Inkling — 오늘 ${todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #1 — Ollama 상태가 변할 때 main 의 health.onUpdate 가 호출.
|
||||
* 메뉴의 "Ollama 재확인" 활성/비활성 상태 갱신.
|
||||
*/
|
||||
export function refreshTrayOllama(ok: boolean): void {
|
||||
_ollamaOk = ok;
|
||||
if (tray === null) return;
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #2 — AiWorker.onUpdate 시 실패 카운트 변하면 메뉴 라벨 + enabled 갱신.
|
||||
*/
|
||||
export function refreshTrayFailedCount(count: number): void {
|
||||
_failedCount = count;
|
||||
export function refreshTray(state: Partial<TrayState>): void {
|
||||
_state = { ..._state, ...state };
|
||||
if (tray === null) return;
|
||||
tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Calendar date (YYYY-MM-DD) in Asia/Seoul timezone for the given instant.
|
||||
*
|
||||
* v0.2.3 #5 — used by NoteRepository.findExpiredCandidates to compare against
|
||||
* notes.due_date (also stored as YYYY-MM-DD per slice §F1).
|
||||
*/
|
||||
export function todayInKstString(now: Date): string {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(
|
||||
Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())
|
||||
).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms of the next 00:00 KST strictly after `now`.
|
||||
*
|
||||
* v0.2.3 #5 — used by store.snoozeExpired to compute the in-memory snooze
|
||||
* deadline ("오늘 그만").
|
||||
*/
|
||||
export function nextKstMidnightMs(now: number): number {
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
// Floor to KST midnight, then add one day.
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
return nextKstMidnight - KST_OFFSET_MS;
|
||||
}
|
||||
@@ -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,11 +8,11 @@ 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 }),
|
||||
deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
|
||||
deleteNote: (noteId) => ipcRenderer.invoke('inbox:trash', noteId),
|
||||
setIntent: (noteId, text) => ipcRenderer.invoke('inbox:setIntent', { noteId, text }),
|
||||
dismissIntent: (noteId) => ipcRenderer.invoke('inbox:dismissIntent', noteId),
|
||||
getContinuity: () => ipcRenderer.invoke('inbox:continuity'),
|
||||
@@ -40,11 +40,92 @@ const api: InklingApi = {
|
||||
},
|
||||
retryAllFailed: () => ipcRenderer.invoke('inbox:retryAllFailed'),
|
||||
getFailedCount: () => ipcRenderer.invoke('inbox:failedCount'),
|
||||
retryOneFailed: (id: string) => ipcRenderer.invoke('inbox:retry-one-failed', id),
|
||||
cancelPending: (id: string) => ipcRenderer.invoke('inbox:cancel-pending', id),
|
||||
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
|
||||
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
|
||||
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
|
||||
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
|
||||
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id)
|
||||
emitRecallShown: (id: string) => { ipcRenderer.send('inbox:emitRecallShown', id); },
|
||||
emitRecallSnoozed: (id: string) => { ipcRenderer.send('inbox:emitRecallSnoozed', id); },
|
||||
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
|
||||
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
|
||||
// v0.2.7 Task 13 — 외부 (트레이) 에서 view 전환 요청 listener.
|
||||
onNavigate: (cb: (view: 'inbox' | 'trash' | 'settings') => void) => {
|
||||
const listener = (_e: unknown, view: 'inbox' | 'trash' | 'settings') => cb(view);
|
||||
ipcRenderer.on('inbox:navigate', listener);
|
||||
return () => ipcRenderer.off('inbox:navigate', listener);
|
||||
},
|
||||
// v0.2.7 자동 실행 (Task 22 통일) — 진단 정보 포함 응답
|
||||
getAutostart: () => ipcRenderer.invoke('settings:autostart-state'),
|
||||
setAutostart: (open: boolean) => ipcRenderer.invoke('settings:autostart-set', open),
|
||||
// v0.2.7 백업/복원/동기화/텔레메트리 (Task 10) — 트레이 callback 의 IPC 대응
|
||||
runBackup: () => ipcRenderer.invoke('settings:run-backup'),
|
||||
runExport: () => ipcRenderer.invoke('settings:run-export'),
|
||||
runImport: () => ipcRenderer.invoke('settings:run-import'),
|
||||
runSync: () => ipcRenderer.invoke('settings:run-sync'),
|
||||
runExportTelemetry: () => ipcRenderer.invoke('settings:run-export-telemetry'),
|
||||
// v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 IPC 대응
|
||||
getAppInfo: () => ipcRenderer.invoke('settings:get-app-info'),
|
||||
openProfileDir: () => ipcRenderer.invoke('settings:open-profile-dir'),
|
||||
copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'),
|
||||
// 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: (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),
|
||||
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글 (첫 launch wizard 분기 포함).
|
||||
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'),
|
||||
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
|
||||
updateRawText: (noteId: string, newText: string) => ipcRenderer.invoke('inbox:update-raw-text', noteId, newText),
|
||||
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.
|
||||
configureSync: (url: string | null) => ipcRenderer.invoke('settings:configure-sync', url),
|
||||
testSyncConnection: () => ipcRenderer.invoke('settings:test-sync-connection'),
|
||||
listConflicts: () => ipcRenderer.invoke('sync:list-conflicts'),
|
||||
resolveConflict: (path: string, choice: 'local' | 'remote') =>
|
||||
ipcRenderer.invoke('sync:resolve-conflict', path, choice),
|
||||
getSyncStatus: () => ipcRenderer.invoke('sync:get-status'),
|
||||
setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value),
|
||||
setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value),
|
||||
// v0.3.1 Cut F — vision capability + 모델 선택
|
||||
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)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,17 @@ import { TagUndoToast } from './components/TagUndoToast.js';
|
||||
import { ExpiryBanner } from './components/ExpiryBanner.js';
|
||||
import { FailedBanner } from './components/FailedBanner.js';
|
||||
import { RecallBanner } from './components/RecallBanner.js';
|
||||
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 {
|
||||
@@ -20,7 +31,49 @@ export function App(): React.ReactElement {
|
||||
continuity, tagFilter, setTagFilter,
|
||||
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
|
||||
} = useInbox();
|
||||
const showSettings = useInbox((s) => s.showSettings);
|
||||
const setShowSettings = useInbox((s) => s.setShowSettings);
|
||||
// v0.2.9 Cut B Task 5 — 4탭 (Inbox/완료/보관/휴지통).
|
||||
const view = useInbox((s) => s.view);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const settings = await inboxApi.getSettings();
|
||||
setShowOnboarding(!settings.onboarding_completed);
|
||||
} catch {
|
||||
// 안전한 fallback — settings 읽기 실패 시 wizard 미표시 (기존 사용자 무영향).
|
||||
setShowOnboarding(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
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();
|
||||
@@ -31,15 +84,32 @@ export function App(): React.ReactElement {
|
||||
const unsubOllama = inboxApi.onOllamaStatus((status) => {
|
||||
useInbox.setState({ ollamaStatus: status });
|
||||
});
|
||||
const unsubNav = inboxApi.onNavigate((view) => {
|
||||
// v0.2.9 Cut B Task 4 — setView 가 mirror state (showTrash/showSettings) 동기 갱신.
|
||||
useInbox.getState().setView(view);
|
||||
});
|
||||
const onFocus = () => { void refreshMeta(); };
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); };
|
||||
return () => { unsubNote(); unsubOllama(); unsubNav(); window.removeEventListener('focus', onFocus); };
|
||||
// onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라
|
||||
// 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)} />;
|
||||
|
||||
if (view === 'review-daily') return <ReviewView period="daily" />;
|
||||
if (view === 'review-weekly') return <ReviewView period="weekly" />;
|
||||
if (view === 'review-monthly') return <ReviewView period="monthly" />;
|
||||
|
||||
if (showSettings) return <SettingsPage />;
|
||||
|
||||
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
|
||||
const filtered = selectFilteredNotes({ notes, tagFilter });
|
||||
const displayed = searchResults !== null ? searchResults : filtered;
|
||||
|
||||
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
|
||||
background: active ? '#0a4b80' : 'transparent',
|
||||
@@ -52,42 +122,108 @@ 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 }}>
|
||||
<button
|
||||
onClick={() => { if (showTrash) void toggleShowTrash(); }}
|
||||
aria-pressed={!showTrash}
|
||||
style={tabBtnStyle(!showTrash)}
|
||||
>
|
||||
Inbox({notes.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (!showTrash) void toggleShowTrash(); }}
|
||||
aria-pressed={showTrash}
|
||||
style={tabBtnStyle(showTrash)}
|
||||
>
|
||||
휴지통({trashCount})
|
||||
</button>
|
||||
{(
|
||||
[
|
||||
{ key: 'inbox', label: 'Inbox', count: counts.active },
|
||||
{ key: 'completed', label: '완료', count: counts.completed },
|
||||
{ key: 'trash', label: '휴지통', count: counts.trashed }
|
||||
] as const
|
||||
).map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === t.key}
|
||||
onClick={() => setView(t.key)}
|
||||
style={tabBtnStyle(view === t.key)}
|
||||
>
|
||||
{t.label}({t.count})
|
||||
</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-', '') : ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === 'daily' || v === 'weekly' || v === 'monthly') setView(`review-${v}` as InboxView);
|
||||
}}
|
||||
style={{ marginLeft: 8, fontSize: 12, padding: '4px 6px', border: '1px solid #0a4b80', borderRadius: 4, color: '#0a4b80', background: 'transparent' }}
|
||||
>
|
||||
<option value="">📅 회고…</option>
|
||||
<option value="daily">일간</option>
|
||||
<option value="weekly">주간</option>
|
||||
<option value="monthly">월간</option>
|
||||
</select>
|
||||
<SearchBox />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
|
||||
<ContinuityBadge />
|
||||
<IdentityCounter />
|
||||
</div>
|
||||
<button
|
||||
aria-label="설정 열기"
|
||||
onClick={() => setShowSettings(true)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 4,
|
||||
fontSize: 16,
|
||||
marginLeft: 8
|
||||
}}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
<main className="main">
|
||||
<div className="main">
|
||||
{!showTrash && (
|
||||
<>
|
||||
<OllamaBanner />
|
||||
<RecoveryToast
|
||||
show={showRecovery}
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
/>
|
||||
<PendingBanner />
|
||||
<FailedBanner />
|
||||
<ExpiryBanner />
|
||||
<RecallBanner />
|
||||
{/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출.
|
||||
completed/archived 에서는 무관 컨텐츠라 숨김. */}
|
||||
{view === 'inbox' && (
|
||||
<>
|
||||
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
|
||||
<RecoveryToast
|
||||
show={showRecovery}
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
/>
|
||||
<PendingBanner />
|
||||
<FailedBanner />
|
||||
<ExpiryBanner />
|
||||
<RecallBanner />
|
||||
<PromotionBanner />
|
||||
</>
|
||||
)}
|
||||
{tagFilter !== null && (
|
||||
<div style={{
|
||||
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
|
||||
@@ -107,12 +243,14 @@ export function App(): React.ReactElement {
|
||||
)}
|
||||
{loading && notes.length === 0 ? (
|
||||
<div className="empty">불러오는 중…</div>
|
||||
) : searchResults !== null && displayed.length === 0 ? (
|
||||
<div className="empty">검색 결과가 없습니다.</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>{MOD_KEY}+Shift+J</code></div>
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
displayed.map((n) => (
|
||||
<NoteCard
|
||||
key={n.id} note={n} mode="inbox"
|
||||
onDeleted={() => removeNote(n.id)}
|
||||
@@ -144,7 +282,6 @@ export function App(): React.ReactElement {
|
||||
trashNotes.map((n) => (
|
||||
<NoteCard
|
||||
key={n.id} note={n} mode="trash"
|
||||
onDeleted={() => removeNote(n.id)}
|
||||
onUpdated={(u) => upsertNote(u)}
|
||||
onRestore={() => void restoreNote(n.id)}
|
||||
onPermanentDelete={() => void permanentDeleteNote(n.id)}
|
||||
@@ -153,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;
|
||||
|
||||
27
src/renderer/inbox/components/Banner.tsx
Normal file
27
src/renderer/inbox/components/Banner.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* v0.2.6 #24+#41 — 4 banner 의 inline style 중복 제거. severity 별 theme map.
|
||||
*/
|
||||
const THEMES = {
|
||||
warning: { bg: '#fff7e6', border: '#d99500', text: '#946100' },
|
||||
error: { bg: '#fce4e4', border: '#a33', text: '#a33' },
|
||||
info: { bg: '#e8f0fe', border: '#4a7ec0', text: '#234' }
|
||||
} as const;
|
||||
|
||||
interface Props {
|
||||
severity: 'warning' | 'error' | 'info';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Banner({ severity, children }: Props): React.ReactElement {
|
||||
const t = THEMES[severity];
|
||||
return (
|
||||
<div style={{
|
||||
background: t.bg, border: `1px solid ${t.border}`, color: t.text,
|
||||
borderRadius: 6, padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
135
src/renderer/inbox/components/ConflictModal.tsx
Normal file
135
src/renderer/inbox/components/ConflictModal.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { SyncConflict } from '@shared/types';
|
||||
import { inboxApi } from '../api.js';
|
||||
import type { SyncHelpAnchor } from './SyncHelpModal.js';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onResolved: () => void;
|
||||
onOpenHelp?: (anchor: SyncHelpAnchor) => void;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 100
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff', borderRadius: 8, padding: 20, width: 600,
|
||||
maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
|
||||
};
|
||||
|
||||
export function ConflictModal({ onClose, onResolved, onOpenHelp }: Props): React.ReactElement {
|
||||
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const c = await inboxApi.listConflicts();
|
||||
if (!cancelled) setConflicts(c);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Escape key 로 닫기.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
async function onChoose(path: string, choice: 'local' | 'remote') {
|
||||
setBusy(path);
|
||||
setError(null);
|
||||
const r = await inboxApi.resolveConflict(path, choice);
|
||||
setBusy(null);
|
||||
if (!r.ok) {
|
||||
setError(`해결 실패: ${r.reason}`);
|
||||
return;
|
||||
}
|
||||
const next = conflicts.filter((c) => c.path !== path);
|
||||
setConflicts(next);
|
||||
if (next.length === 0) {
|
||||
onResolved();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>충돌 ({conflicts.length}건)</h3>
|
||||
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
|
||||
</div>
|
||||
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.path} style={rowStyle}>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>{c.path}</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>내 기기</div>
|
||||
<pre style={preStyle()}>{c.localText || '(미리보기 없음)'}</pre>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>다른 기기</div>
|
||||
<pre style={preStyle()}>{c.remoteText || '(미리보기 없음)'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#666', lineHeight: 1.5 }}>
|
||||
<div><b>내 것 사용</b>: 이 기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.</div>
|
||||
<div><b>원격 사용</b>: 원격의 변경을 가져오고 내 변경을 폐기.</div>
|
||||
{onOpenHelp && (
|
||||
<button
|
||||
onClick={() => onOpenHelp('main-conflict')}
|
||||
style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: 0, marginTop: 2, textDecoration: 'underline' }}
|
||||
>
|
||||
자세히 보기 →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { void onChoose(c.path, 'local'); }}
|
||||
disabled={busy === c.path}
|
||||
style={chooseBtnStyle('#0a4b80')}
|
||||
>
|
||||
{busy === c.path ? '처리 중…' : '내 것 사용'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void onChoose(c.path, 'remote'); }}
|
||||
disabled={busy === c.path}
|
||||
style={chooseBtnStyle('#236b1a')}
|
||||
>
|
||||
{busy === c.path ? '처리 중…' : '원격 사용'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function preStyle(): React.CSSProperties {
|
||||
return {
|
||||
margin: 0, whiteSpace: 'pre-wrap', fontSize: 11, color: '#444',
|
||||
background: '#fafafa', padding: 6, borderRadius: 3, maxHeight: 120, overflow: 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
function chooseBtnStyle(color: string): React.CSSProperties {
|
||||
return {
|
||||
background: 'none', border: `1px solid ${color}`, color, cursor: 'pointer',
|
||||
fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,36 @@
|
||||
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);
|
||||
@@ -57,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)));
|
||||
@@ -72,12 +106,9 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff7e6', border: '1px solid #d99500', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
<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' }}
|
||||
@@ -109,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))}
|
||||
@@ -152,6 +201,6 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
export function FailedBanner(): React.ReactElement | null {
|
||||
const aiEnabled = useInbox((s) => s.ai_enabled);
|
||||
const count = useInbox((s) => s.failedCount);
|
||||
const retryAllFailed = useInbox((s) => s.retryAllFailed);
|
||||
// v0.2.9 Cut B Task 14 — AI-less mode 에서는 banner 자체 비활성.
|
||||
if (!aiEnabled) return null;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fce4e4', border: '1px solid #a33', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13,
|
||||
display: 'flex', alignItems: 'center', gap: 8
|
||||
}}>
|
||||
<span style={{ flex: 1 }}>❌ AI 처리 실패 <b>{count}</b>건</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
retryAllFailed().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('retryAllFailed failed', e);
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
background: '#a33', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
<Banner severity="error">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1 }}>❌ AI 처리 실패 <b>{count}</b>건</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
retryAllFailed().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('retryAllFailed failed', e);
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
background: '#a33', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
170
src/renderer/inbox/components/MoveStatusModal.tsx
Normal file
170
src/renderer/inbox/components/MoveStatusModal.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../api.js';
|
||||
import type { NoteStatus } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
rawText: string;
|
||||
summary: string;
|
||||
/** 현재 노트 status. 이 값을 제외한 나머지 status 가 이동 버튼으로 노출. */
|
||||
currentStatus: NoteStatus;
|
||||
onClose: () => void;
|
||||
onMoved: (status: NoteStatus, reason: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모 이동 Modal.
|
||||
*
|
||||
* 사유 입력 + 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', 'trashed'];
|
||||
|
||||
export function MoveStatusModal({
|
||||
noteId,
|
||||
currentStatus,
|
||||
onClose,
|
||||
onMoved
|
||||
}: Props): React.ReactElement {
|
||||
const [reason, setReason] = useState('');
|
||||
const [recommendation, setRecommendation] = useState<{
|
||||
status: NoteStatus;
|
||||
rationale: string;
|
||||
} | null>(null);
|
||||
const [classifying, setClassifying] = useState(false);
|
||||
|
||||
// Escape key 로 modal 닫기. mount 동안만 listener 활성.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
async function move(status: NoteStatus): Promise<void> {
|
||||
const trimmedReason = reason.trim() === '' ? null : reason.trim();
|
||||
await inboxApi.setStatus(noteId, status, trimmedReason);
|
||||
onMoved(status, trimmedReason);
|
||||
}
|
||||
|
||||
async function classify(): Promise<void> {
|
||||
setClassifying(true);
|
||||
setRecommendation(null);
|
||||
try {
|
||||
const r = await inboxApi.classifyStatus(noteId, reason);
|
||||
setRecommendation({ status: r.recommended, rationale: r.rationale });
|
||||
} finally {
|
||||
setClassifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="이동"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 100
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
minWidth: 400,
|
||||
maxWidth: 520
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 16, margin: '0 0 12px' }}>메모 이동</h2>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="이동 사유 (선택사항)"
|
||||
rows={2}
|
||||
style={{ width: '100%', padding: 6, fontSize: 13, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<button onClick={() => void classify()} disabled={classifying}>
|
||||
{classifying ? '분류 중...' : 'AI 자동 분류'}
|
||||
</button>
|
||||
{ALL_STATUSES.filter((s) => s !== currentStatus).map((s) => (
|
||||
<button key={s} onClick={() => void move(s)}>
|
||||
{statusLabel(s)}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={onClose} style={{ marginLeft: 'auto' }}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
{recommendation !== null && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 8,
|
||||
background: '#f0f8ff',
|
||||
borderRadius: 4,
|
||||
fontSize: 12
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
AI 추천: <strong>{statusLabel(recommendation.status)}</strong>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>이유: {recommendation.rationale}</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={() => void move(recommendation.status)}>
|
||||
확정 ({statusLabel(recommendation.status)})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function statusLabel(s: NoteStatus): string {
|
||||
switch (s) {
|
||||
case 'active':
|
||||
// 헤더 탭 표기 ('Inbox') 와 일치. UI 전반에서 active = Inbox 동의어.
|
||||
return 'Inbox';
|
||||
case 'completed':
|
||||
return '완료';
|
||||
case 'trashed':
|
||||
return '휴지통';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* status 의 한글 라벨에 적절한 조사를 붙여 반환. 받침 있으면 "으로", 없으면 "로".
|
||||
* 예: '완료로' / '보관으로' / '휴지통으로' / '활성으로'.
|
||||
*/
|
||||
export function statusLabelWithParticle(s: NoteStatus): string {
|
||||
const label = statusLabel(s);
|
||||
const last = label.charCodeAt(label.length - 1);
|
||||
// 한글 syllable block 외 → "로" default
|
||||
if (last < 0xAC00 || last > 0xD7A3) return `${label}로`;
|
||||
const jongseong = (last - 0xAC00) % 28;
|
||||
return jongseong === 0 ? `${label}로` : `${label}으로`;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
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';
|
||||
import { EditableField } from './EditableField.js';
|
||||
import { IntentBanner } from './IntentBanner.js';
|
||||
import { pushTagUndo } from './TagUndoToast.js';
|
||||
import { MoveStatusModal } from './MoveStatusModal.js';
|
||||
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
onDeleted: () => void;
|
||||
onDeleted?: () => void; // inbox mode 전용 (trash mode 에서 미사용)
|
||||
onUpdated: (n: Note) => void;
|
||||
mode?: 'inbox' | 'trash'; // default 'inbox'
|
||||
onRestore?: () => void;
|
||||
@@ -25,7 +28,6 @@ function isPastDue(iso: string, today: string): boolean {
|
||||
}
|
||||
|
||||
function todayKstIso(): string {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const k = new Date(Date.now() + KST_OFFSET_MS);
|
||||
return k.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -107,21 +109,85 @@ 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 켬.
|
||||
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
|
||||
const [local, setLocal] = useState(note);
|
||||
const isAiDisabled = local.aiStatus === 'disabled';
|
||||
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
|
||||
// 이동 modal 열림 여부. 클릭 시 MoveStatusModal 에서 사유 + AI 분류 + 수동 분류 선택.
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
const [editingRaw, setEditingRaw] = useState(false);
|
||||
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');
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return;
|
||||
await inboxApi.deleteNote(note.id);
|
||||
onDeleted();
|
||||
}
|
||||
|
||||
async function saveTitle(next: string) {
|
||||
await inboxApi.updateAiFields(note.id, { title: next });
|
||||
const updated = { ...local, aiTitle: next, titleEditedByUser: true };
|
||||
@@ -145,6 +211,25 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
setLocal(updated); onUpdated(updated);
|
||||
}
|
||||
|
||||
async function saveRaw() {
|
||||
const next = draftRaw;
|
||||
if (next.trim().length === 0) return;
|
||||
const r = await inboxApi.updateRawText(note.id, next);
|
||||
if (!r.ok) return;
|
||||
// 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);
|
||||
}
|
||||
|
||||
async function removeTag(tagName: string) {
|
||||
const removed = local.tags.find((t) => t.name === tagName);
|
||||
const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
|
||||
@@ -200,13 +285,68 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
)}
|
||||
|
||||
{local.aiStatus === 'pending' && (
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#666', marginTop: 4 }}>
|
||||
Inkling이 정리하는 중…
|
||||
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#666' }}>
|
||||
Inkling이 정리하는 중…
|
||||
</div>
|
||||
{/* v0.3.9 — pending cancel UI. Ollama 끊김 / 무한 pending 시 사용자 unblock path. */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
await inboxApi.cancelPending(local.id);
|
||||
// push 기반 — onNoteUpdated 가 store 자동 갱신.
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #ccc', color: '#666',
|
||||
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
|
||||
}}
|
||||
title="AI 자동 처리를 건너뛰고 원문만 보관"
|
||||
>
|
||||
건너뛰기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{local.aiStatus === 'failed' && (
|
||||
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55', marginTop: 4 }}>
|
||||
정리 보류 — 원문은 안전합니다
|
||||
<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.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.
|
||||
summary/tags 는 hide. 원문은 아래 "원문 보기" 영역에서 항상 표시. */}
|
||||
{isAiDisabled && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{fallbackTitle}</h3>
|
||||
</div>
|
||||
)}
|
||||
{local.aiStatus === 'done' && (
|
||||
@@ -332,9 +472,24 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
)}
|
||||
|
||||
{local.media.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
|
||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{local.media.map((m) => (
|
||||
<div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
|
||||
// alt="" — decorative (relPath 는 사용자 의미 X). title 이 hover tooltip.
|
||||
<img
|
||||
key={m.id}
|
||||
src={`inkling-media://${m.relPath}`}
|
||||
alt=""
|
||||
title={m.relPath}
|
||||
onClick={() => { void inboxApi.openMedia(m.relPath); }}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -344,40 +499,130 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
{rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
|
||||
</button>
|
||||
{rawOpen && (
|
||||
<pre style={{ marginTop: 6, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{editingRaw ? (
|
||||
<div>
|
||||
<textarea
|
||||
aria-label="원문 편집"
|
||||
value={draftRaw}
|
||||
onChange={(e) => setDraftRaw(e.target.value)}
|
||||
style={{ width: '100%', minHeight: 80, fontSize: 12, fontFamily: 'inherit', padding: 8, border: '1px solid #ddd', borderRadius: 4, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setEditingRaw(false)} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>취소</button>
|
||||
<button onClick={() => { void saveRaw(); }} style={{ background: '#0a4b80', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setShowRevisions(true)} style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12, padding: 0 }}>이력</button>
|
||||
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>편집</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, textAlign: 'right' }}>
|
||||
{isTrash ? (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onRestore}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'flex-end',
|
||||
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 });
|
||||
}}
|
||||
>
|
||||
🔄 복구
|
||||
</button>
|
||||
<button
|
||||
onClick={onPermanentDelete}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #c93030', color: '#c93030',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🗑 영구 삭제
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
|
||||
🗑 삭제
|
||||
/>
|
||||
)}
|
||||
{/* 이동 버튼 — 클릭 시 MoveStatusModal 진입.
|
||||
사유 입력 + AI 자동 분류 + 수동 status 선택 한 곳에서 처리. */}
|
||||
<button
|
||||
onClick={() => setMoveOpen(true)}
|
||||
aria-label="이동"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid #ccc',
|
||||
color: '#444',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 4
|
||||
}}
|
||||
>
|
||||
이동
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */}
|
||||
{isTrash && (
|
||||
<>
|
||||
<button
|
||||
onClick={onRestore}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🔄 복구
|
||||
</button>
|
||||
<button
|
||||
onClick={onPermanentDelete}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #c93030', color: '#c93030',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🗑 영구 삭제
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{moveOpen && (
|
||||
<MoveStatusModal
|
||||
noteId={local.id}
|
||||
rawText={local.rawText}
|
||||
summary={local.aiSummary ?? ''}
|
||||
currentStatus={local.status}
|
||||
onClose={() => setMoveOpen(false)}
|
||||
onMoved={(newStatus, reason) => {
|
||||
const updated = { ...local, status: newStatus, moveReason: reason };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
// inbox/완료/보관/휴지통 view 의 list 가 status 별로 필터되므로 status 변경 시 onDeleted 호출.
|
||||
if (newStatus !== local.status) onDeleted?.();
|
||||
// setStatus IPC 는 pushNoteUpdated emit 안 함 → 헤더 탭 counts 가 stale.
|
||||
// refreshMeta 로 server-authoritative counts 재로드.
|
||||
void useInbox.getState().refreshMeta();
|
||||
setMoveOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showRevisions && (
|
||||
<RevisionHistoryModal
|
||||
noteId={local.id}
|
||||
onClose={() => setShowRevisions(false)}
|
||||
onRestored={(newRawText) => {
|
||||
const updated = { ...local, rawText: newRawText, updatedAt: new Date().toISOString() };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
export function OllamaBanner(): React.ReactElement | null {
|
||||
interface OllamaBannerProps {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.ReactElement | null {
|
||||
const aiEnabled = useInbox((s) => s.ai_enabled);
|
||||
const status = useInbox((s) => s.ollamaStatus);
|
||||
const recheckOllama = useInbox((s) => s.recheckOllama);
|
||||
// v0.2.9 Cut B Task 14 — AI-less mode 에서는 banner 자체 비활성.
|
||||
if (!aiEnabled) return null;
|
||||
if (status.ok) return null;
|
||||
const isMissing = status.reason?.includes('not installed');
|
||||
const message = isMissing
|
||||
? '`ollama pull gemma4:e4b` 실행 후 앱을 재시작해주세요.'
|
||||
: 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.';
|
||||
return (
|
||||
<div className="banner warn" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<Banner severity="warning">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
|
||||
<span style={{ flex: 1 }}>⚠ {message}</span>
|
||||
<button
|
||||
@@ -28,12 +37,26 @@ export function OllamaBanner(): React.ReactElement | null {
|
||||
>
|
||||
재확인
|
||||
</button>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
style={{
|
||||
background: 'transparent', color: 'inherit',
|
||||
border: '1px solid currentColor', borderRadius: 4,
|
||||
padding: '2px 8px', fontSize: 12, cursor: 'pointer',
|
||||
marginLeft: 6
|
||||
}}
|
||||
>
|
||||
설정
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{status.reason ? (
|
||||
<span style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
|
||||
진단: {status.reason}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
78
src/renderer/inbox/components/OnboardingWizard.tsx
Normal file
78
src/renderer/inbox/components/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 11 — 첫 launch onboarding 위저드.
|
||||
*
|
||||
* 3 옵션 (AI 사용 / 원문만 / 나중에) 중 하나를 선택. AI 옵션 (true/false) 은
|
||||
* setAiEnabled 로 settings 에 저장, 모든 옵션은 setOnboardingCompleted(true) 로
|
||||
* 두 번째 launch 부터 미노출. "나중에" 는 ai_enabled 기본값 (true) 유지 — 사용자
|
||||
* 가 SettingsPage 에서 추후 선택 가능.
|
||||
*/
|
||||
export function OnboardingWizard({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function choose(aiEnabled: boolean | null): Promise<void> {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled);
|
||||
await inboxApi.setOnboardingCompleted(true);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
// IPC 실패 (예: settings 저장 throw) 시 modal stuck 방지. 사용자에게 메시지 표시 +
|
||||
// "지금 건너뛰기" 로 fallback 길 제공. choose() 가 throw 하지 않고 무한 wizard 잠금
|
||||
// 회피.
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function skip(): void {
|
||||
// IPC 자체가 실패하는 상태 → ai_enabled 변경/onboarding flag 저장 모두 포기하고 wizard 만 닫기.
|
||||
// 다음 launch 에 다시 wizard 가 뜸 (onboarding_completed=false 상태). 그래도 사용자가
|
||||
// 진입 자체는 가능.
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Escape key 로 wizard 종료 (skip 동일 — onboarding flag 미저장).
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') skip();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-label="시작 안내" style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
|
||||
}}>
|
||||
<div style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 520 }}>
|
||||
<h2 style={{ margin: '0 0 12px' }}>Inkling 사용 시작</h2>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, marginBottom: 12 }}>
|
||||
Inkling 은 로컬 LLM (Ollama) 으로 메모를 자동 정리합니다.
|
||||
Ollama 가 설치되어 있고 한국어 지원 모델 (gemma3, gemma2 등) 이 pull 되어 있어야 최적의 경험이 가능합니다.
|
||||
</p>
|
||||
<p style={{ fontSize: 13, marginBottom: 16 }}>
|
||||
설치 가이드:
|
||||
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">ollama.com/download</a>
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button onClick={() => choose(true)} disabled={busy}>AI 자동 처리 사용 (Ollama 필요)</button>
|
||||
<button onClick={() => choose(false)} disabled={busy}>원문만 저장 (AI 처리 안 함)</button>
|
||||
<button onClick={() => choose(null)} disabled={busy} style={{ marginTop: 4 }}>나중에 설정</button>
|
||||
</div>
|
||||
{error !== null && (
|
||||
<div style={{ marginTop: 12, padding: 8, background: '#fdecea', color: '#a3261c', fontSize: 12, borderRadius: 4 }}>
|
||||
<div>설정 저장 실패: {error}</div>
|
||||
<button onClick={skip} style={{ marginTop: 8 }}>지금 건너뛰기</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user